有个同行去年接了个农产品交易系统的活儿,源码拉下来十分钟就跑起来了,业务对完脸色直接变了:商品表里就price和stock两个字段,下单直接扣库存。这套东西搁在普通零售上勉强能转,挪到农产品上,第一批货还没出库就得乱套。后来我帮他重做了数据模型和订单链路,前后折腾两个多月,中间踩的坑比写代码的时间还长。
这里说的"农产品交易系统",指的是把产地供货方和采购方(批发商、商超、食堂、社区团购)之间的"看货—下单—称重—结算—发货—对账"这条链路搬到线上的一套平台。它适合两类人看:一类是正在做这类毕业设计、手里握着源码和开题材料却不知道怎么讲清楚的同学;另一类是接手了类似项目、想知道哪里最容易埋雷的开发者。下面所有内容都基于一个可运行的 Spring Boot + Vue 单体架构展开,讲取舍、讲原理、讲代码怎么落,不掺水。
1. 先划清系统边界:不做什么比做什么更重要
1.1 三类角色背后的真实诉求
农产品交易系统里角色看着一堆,真正要落地做深的就三类:供货方(产地合作社、种植户、批发档口)、采购方(餐饮采购、商超、社区团购团长)、平台运营。毕设里很多人喜欢一口气塞进去"游客、买家、卖家、管理员、配送员、财务"六个角色,结果每个角色的权限都只写了个 CRUD 页面,答辩老师随口一问"配送员和采购员的权限边界到底在哪儿",基本就答不上来了。
我给的建议是别按"页面菜单"分角色,按"谁能改哪张表的哪些字段"来分,这样权限设计才站得住脚。角色划分清楚了,后面的接口鉴权和菜单渲染都能直接从这张表推导出来,不会出现前后端各写一套、越写越对不上的情况。
| 角色 | 核心动作 | 数据可见范围 | 常见误设计 |
|---|---|---|---|
| 供货方 | 上架商品、维护批次、改价、确认发货、上传质检报告 | 仅自己的商品、批次、订单 | 能看到同行销量和库存 |
| 采购方 | 搜索、下单、支付、确认收货、申请售后 | 自己的订单与收货信息 | 能查到批次原始进价 |
| 平台运营 | 审核资质、处理纠纷、配置运费模板、看大盘报表 | 全量,但成本价脱敏 | 直接改订单金额且不记日志 |
运营这个角色最容易被做残。很多人给运营一个"订单管理"页面,能改金额、能改状态,改完数据库一刷新就完事了。真出纠纷的时候,谁来改的、什么时候改的、改之前是多少,一个都查不到。我的做法是所有涉及金额和状态的变更,一律走"写状态日志表 + 记录操作人"的路径,宁可多一张表,也别让运营直接裸改主表。
1.2 农产品交易绕不开的三个特殊约束
第一个约束是非标。同样叫"赣南脐橙",68mm 果和 75mm 果不是一个价,糖度 12 和 14 也不是一个价,甚至同一批货里大小果混装的比例不同,报价都不同。这意味着商品不能只有一个名字加一个价格,必须是"商品—规格—批次"三层结构。只做两层,后面一旦要按批次算损耗、按批次退货,整个模型就得推倒重来。
第二个约束是称重。蔬菜、水果、水产几乎都是按斤卖,买家下单时只能报个意向重量,实际出库要过秤。这就直接导致订单金额在下单那一刻只是"预估",发货之后才"结算",支付、退款、对账全都要围绕这个差额做设计。普通商城的"下单即定金额"那套逻辑,在这里是走不通的。
第三个约束是损耗与季节。农产品有保鲜期,库存不是简单的"卖出减一",还有报损、退货回库、批次先进先出。价格也随季节和产地行情走,今天 3.2 元一斤,过三天可能 4.1 元。所以订单里必须存价格快照,否则商品一改价,历史订单显示的全是错的。这三条是普通商城模板里没有的东西,也正是这类系统真正值得写进开题、值得在答辩上讲的部分。
1.3 功能模块的取舍清单
我把功能按"必做、可选、别碰"分三档。必做的有:账号与角色权限、商品与规格、批次与产地、购物车与直接下单、订单状态流转、库存扣减、支付对接(毕设可用模拟支付)、售后申请、基础报表。这些是撑起整个业务闭环的骨架,缺一个链路就断。
可选的是:溯源扫码页、供货方入驻审核、优惠券、拼团、直播带货。这些属于加分项,做完能明显提升答辩观感,但前提是主干已经跑通。
不建议在毕设里碰的有三样:多商户结算分账、对接真实第三方物流接口、复杂的会员等级体系。任何一个都够吃掉半个月时间,而且很难做深,最后变成调接口调不通、数据对不上的半成品。我见过有人为了做一个"自动分账"功能,把订单金额拆成七八个字段,结果退款逻辑彻底乱掉,得不偿失。
2. 数据模型:把"农产品"的特性折进表结构
2.1 商品与规格为什么必须拆开
商品主表负责"是什么",规格表负责"卖哪种"。主表里放名称、类目、产地、保质期、主图、上架状态;规格表里放规格描述、计量单位、当前售价、起批量、库存。这样拆的好处是显而易见的:一个"赣南脐橙"商品下挂十个规格,改主图只动一处,调价只动对应规格,互不干扰。
字段设计上有个细节值得说:计量单位要存成字符串而不是枚举。农产品单位太杂了——斤、公斤、箱、件、筐、袋,一箱可能是 10 斤也可能是 20 斤。用字符串加一个"换算系数"字段,比硬编枚举灵活得多。换算系数指的是"1 个销售单位等于多少个标准计量单位",比如一箱 = 20 斤,系数就是 20。后续算运费、算总重量,全靠这个系数统一折算,不然订单里一半是斤一半是箱,运费根本没法算。
create table product ( id bigint primary key auto_increment, name varchar(100) not null, category_id bigint not null, origin_id bigint, shelf_life int comment '保质期天数', main_image varchar(255), status tinyint not null default 1 comment '1上架 0下架', created_at datetime not null ); create table product_sku ( id bigint primary key auto_increment, product_id bigint not null, spec_desc varchar(100) not null comment '如 75mm精品果', unit varchar(20) not null comment '斤/箱/件', convert_rate decimal(10,2) not null default 1 comment '折合标准重量', sale_price decimal(10,2) not null, min_qty decimal(10,2) not null default 1, status tinyint not null default 1, key idx_product (product_id) );2.2 批次表是整个系统的算账底座
批次表是很多人做毕设会直接省掉的一张表,也是最不该省的一张。它的作用是回答"这批货从哪来、进了多少、出了多少、损耗多少、还剩多少"。没有它,损耗率算不出来,溯源页做不出来,退货回库也不知道该回到哪个批次。
批次表的关键字段是入库重量、出库重量、损耗重量、采收日期、质检等级。出库重量不要用字段实时累加,而是从订单明细里聚合出来,这样数据永远只有一份真相,不会出现两处对不上的情况。损耗重量则通过盘点录入,每次盘点生成一条盘点记录,批次表里的损耗是这些记录的和。
create table product_batch ( id bigint primary key auto_increment, sku_id bigint not null, batch_no varchar(64) not null comment '批次号,如 20250320-A01', harvest_date date comment '采收日期', in_weight decimal(12,2) not null comment '入库重量', quality_grade varchar(20) comment '特级/一级/二级', status tinyint not null default 1 comment '1在售 2售罄 3报损', created_at datetime not null, unique key uk_batch (batch_no) );批次号我习惯于用"日期 + 产地代码 + 序号"生成,比如20250320-A01。这种编号的好处是肉眼可读,运营看单号就能知道是哪天的货,客服处理售后时不用反查数据库。生成逻辑放在服务层,用 Redis 的当日计数器自增,避免并发下单时撞号。
2.3 产地、质检、溯源码的字段落法
产地单独建表,存省市区、产地名称、经纬度。经纬度不是给用户看的,是给溯源页的地图组件用的,有它溯源页才有"可视化"的说服力。质检信息挂在批次上而不是商品上,因为同一商品的每批货质检结果不同,挂在商品上等于自欺欺人。
溯源码的本质是一个唯一标识,扫码后跳到只读页面,展示商品名、产地、采收日期、质检等级、出库时间。这里有个安全细节要注意:溯源码不要直接用批次主键 id,否则别人把 id 减一就能翻到别人的批次。用一个随机生成的 UUID 或者雪花 ID,并且查询接口只返回脱敏后的字段,不返回成本和供货方信息。我在一个项目里就见过溯源码直接写batchId=1024的情况,改个数字就能看到所有批次的进价,这属于典型的低门槛信息泄露。
3. 订单状态机:别用一个 status 字段硬扛
3.1 状态机画不出来的项目后期一定返工
我判断一个交易类项目能不能做下去,有个很土的办法:让对方在纸上画出订单的所有状态和流转箭头。画得出来,项目基本能成;画不出来,或者画到一半开始犹豫"退款后还能不能再发货",那后面一定要返工。
农产品订单的状态比普通商城多一截,因为中间插了个"称重结算"。完整链路是这样的:待支付 → 已支付 → 待称重 → 待补差(可选)→ 待发货 → 已发货 → 已完成。旁支有:已取消、退款中、已退款。称重商品和标品走的是两条分支,标品支付完直接进待发货,称重商品必须过一遍称重环节。
这套状态设计解决了普通商城模板里的一个硬伤:金额不确定。普通订单支付完金额就锁死了,农产品订单支付完只是付了个预估价,实际结算要等过秤。所以必须在支付和发货之间插入称重节点,否则退款金额永远算不清。
3.2 状态流转表与代码组织
状态变更不要散落在各个 Service 里各写各的,集中到一个OrderStateMachine类里,入参是"订单 id + 目标状态 + 操作人",内部先校验当前状态是否允许流转到目标状态,允许才更新并写日志。这样后期排查"这单怎么变成已完成的了"的时候,日志表一拉就清楚。
public enum OrderStatus { PENDING_PAY, PAID, WEIGHING, SUPPLEMENT_WAIT, SHIPPED, COMPLETED, CANCELLED, REFUNDING, REFUNDED } public void transition(Long orderId, OrderStatus target, Long operatorId, String remark) { Order order = orderMapper.selectById(orderId); if (!ALLOW_MAP.get(order.getStatus()).contains(target)) { throw new BizException("当前状态不允许流转到 " + target); } orderStatusLogMapper.insert(new OrderStatusLog(orderId, order.getStatus(), target, operatorId, remark)); order.setStatus(target); orderMapper.updateById(order); }日志表的价值在答辩时特别明显。老师问"你怎么保证订单数据可追溯",你直接把状态日志表的截图放上去,从下单到完成每一步谁操作的、什么时间、备注是什么,一应俱全。这比嘴上说"我做了日志"有说服力得多。
3.3 称重商品的"预估—结算"两段式金额
这块是整套系统的技术核心,也是最能体现设计水平的地方。下单时买家填意向重量,系统按规格单价算出预估价并冻结这笔钱;出库过秤后录入实际重量,系统算出实际价,两者做差。差额在阈值内直接忽略,比如 5% 以内不补不退,避免为一两二两反复走退款流程;超出阈值就走补差或退差。
差额处理有个坑必须提前想清楚:补差单和原订单的关系。我的做法是差额不生成新订单,而是挂在原订单上开一个settle_amount字段,支付补差走原订单的补支付接口。这样对账的时候一个订单号能对完全部流水,财务不会问"这笔两块钱是谁付的"。
alter table orders add estimated_amount decimal(12,2) comment '预估金额', add settle_amount decimal(12,2) comment '称重结算金额', add diff_amount decimal(12,2) comment '差额,正数补、负数退', add actual_weight decimal(12,2) comment '实际出库重量';阈值配置不要写死在代码里,放配置表或者配置文件,方便按品类调整。叶菜损耗大,阈值可以放到 8%;水果规整,3% 就够。写死的话,每调一次就得改代码重新打包,运营那边早就骂街了。
4. 库存扣减:超卖往往发生在一瞬间
4.1 三种扣减方案的取舍
库存扣减这块我试过三种写法,各有各的适用场景,不能一概而论。
第一种是数据库条件更新,直接update ... set qty = qty - n where qty >= n,看返回的影响行数。返回 0 说明不够,抛异常回滚。这种写法简单、可靠、不引入额外组件,在并发量不高(比如毕设演示、日单量几千)的场景下完全够用,而且事务边界清晰,不会出现数据和缓存不一致。
第二种是悲观锁,select ... for update。它的问题是锁的粒度大,热点商品一抢就排队,接口响应时间直接飙上去。而且一旦事务里有远程调用或者慢查询,锁持有时间会非常长,容易拖垮整个连接池。我基本不推荐在交易主链路上用它。
第三种是Redis 预扣 + 异步落库。把库存预热到 Redis,下单时用decrement原子扣减,扣成功再异步写数据库,定时任务做对账补偿。这种方案吞吐量最高,但引入了一致性问题:Redis 扣了、数据库没落成功怎么办?进程挂了怎么办?必须配一套对账机制兜底。
| 方案 | 适用并发 | 实现复杂度 | 一致性风险 | 我的建议 |
|---|---|---|---|---|
| 条件更新 | 低到中 | 低 | 无 | 毕设和中小项目首选 |
| 悲观锁 | 低 | 低 | 无 | 仅用于极热点单品的兜底 |
| Redis 预扣 | 高 | 高 | 有,需对账 | 有真实流量压力再上 |
4.2 条件更新加缓存的组合写法
实际落地我一般用"条件更新为主 + Redis 缓存为辅"。缓存只用来扛读请求(商品详情页展示剩余库存),扣减还是走数据库。这样既减轻了数据库压力,又不用承担一致性风险,属于性价比最高的组合。
update product_sku_stock set available_qty = available_qty - #{qty}, version = version + 1 where sku_id = #{skuId} and available_qty >= #{qty}int rows = stockMapper.deduct(skuId, qty); if (rows == 0) { throw new BizException("库存不足,请调整购买数量"); }这里有个小经验:扣减和生成订单明细必须放在同一个事务里,且扣减放在前面。放后面的话,订单明细已经插入了才去扣库存,扣失败回滚,虽然数据没错,但自增主键已经消耗掉了,订单号会出现大量空洞,对账的时候运营看着一堆跳号会以为丢单。
4.3 取消、超时、退款三条回滚路径必须收敛
库存回滚最怕什么?怕同一个订单回滚两次。用户手动取消回滚了一次,超时任务又扫到它回滚一次,库存凭空多出来一份,最后发货的时候发现账实不符。
解决办法是给回滚动作加幂等。具体做法是在订单表上记一个stock_released标记,回滚前先判断,已经释放过的直接返回,不再执行。同时把回滚入口收敛到一个方法里,手动取消、超时取消、退款成功三条路径全部调它,不要各写各的。
超时取消用定时任务扫,扫描条件是"状态=待支付 且 创建时间 < 当前时间 - 30分钟"。任务执行频率不用太高,一分钟一次足够。这里要注意多实例部署的情况,如果服务起了两个节点,定时任务会重复执行。毕设单机部署可以不管,但只要上多实例,就得加分布式锁或者用数据库的乐观标记抢任务。我见过有人本地跑得好好的,一部署到两台机器上,库存每天多出几十件,查了三天才定位到是定时任务重复执行。
5. 价格、运费与结算:把钱的规则写死
5.1 订单里的价格快照
订单明细表里必须冗余存商品名、规格描述、单价、单位。这不是数据库范式里的冗余,而是业务上的必需。因为商品随时可能改名、改价、下架,历史订单必须显示下单那一刻的信息,否则用户翻三个月前的订单,看到的商品名和价格全是错乱的,客服解释都解释不清。
public class OrderItem { private Long skuId; private String productName; // 快照 private String specDesc; // 快照 private String unit; // 快照 private BigDecimal unitPrice; // 快照 private BigDecimal estWeight; private BigDecimal actualWeight; private BigDecimal amount; }快照字段的写入时机是下单那一刻,从商品服务里查出来直接塞进去,之后再也不更新。这个设计看着简单,但很多模板项目为了"省字段",订单明细只存一个 skuId,展示的时候再去关联查商品表,结果商品一改价,历史订单金额全变了,用户截图投诉都拿不出证据。
5.2 阶梯价与运费模板
农产品批发天然是阶梯价,买 10 斤一个价,买 100 斤另一个价。实现上我不建议在代码里写 if-else,而是建一张价格区间表,按sku_id + 最小数量 + 最大数量 + 单价存,下单时按数量查对应区间。这样运营改价只改数据,不用发版。
运费同理,用模板表。字段包括计费方式(按重量/按件数/固定)、首重首价、续重续价、偏远地区加价、包邮门槛。农产品运费有个特殊点:生鲜要走冷链,运费单价明显高于普通快递,而且很多地区不发货。所以运费模板里要有一个"不配送地区"的黑名单字段,下单时校验收货地址,命中就直接拦掉,别等到发货了才发现送不到。
create table freight_template ( id bigint primary key auto_increment, name varchar(64) not null, charge_type tinyint not null comment '1按重量 2按件数 3固定', first_unit decimal(10,2) comment '首重/首件', first_price decimal(10,2), extra_unit decimal(10,2), extra_price decimal(10,2), free_amount decimal(10,2) comment '满额包邮', exclude_area varchar(500) comment '不配送地区,逗号分隔' );5.3 金额精度:double 是绝对不能碰的
这条我单独拎出来说,因为踩过太多次。Java 里用double算钱,0.1 + 0.2会得到0.30000000000000004,几笔订单累加下来,对账能差出好几毛。金额字段一律用BigDecimal,数据库用decimal(12,2),比较大小用compareTo而不是equals(equals会比较精度,1.0和1.00会返回 false)。
设置小数位的时候用setScale(2, RoundingMode.HALF_UP),明确指定舍入模式。不指定的话默认抛异常,线上跑着跑着突然报错,查起来很费劲。还有个细节:除法一定要指定精度,divide不传参数在除不尽的时候会抛ArithmeticException,算均摊运费的时候特别容易中招。
6. 源码结构与跑通顺序
6.1 目录分层
拿到一套源码,先别急着跑,花十分钟看目录结构,能省下后面两小时的瞎折腾。我习惯的分层是这样的:
agri-trade ├── src/main/java/com/demo/agri │ ├── common # 统一返回、全局异常、常量、工具 │ ├── config # 数据源、跨域、拦截器、Redis │ ├── controller # 接口层,只做参数校验和转发 │ ├── service # 业务层,接口 + impl │ ├── mapper # MyBatis 接口 │ ├── entity # 数据库实体 │ ├── dto # 入参对象 │ ├── vo # 出参对象 │ └── task # 定时任务 ├── src/main/resources │ ├── mapper # XML │ ├── application.yml │ └── sql/init.sql # 建表 + 初始数据 └── web # 前端 Vue 工程判断一套源码质量高不高,看两处就够:一是controller里有没有塞业务逻辑,二是entity有没有直接当接口出参用。前者说明分层是假的,后者说明没做数据脱敏,用户密码、成本价很容易跟着实体一起返回给前端。
6.2 从拉取代码到能下单的最小步骤
第一步,环境对齐。JDK 17、MySQL 8、Redis 6、Node 18,版本别乱跳。我见过用 JDK 8 跑 Spring Boot 3 的项目,启动直接报Unsupported class file major version,卡半天。
第二步,建库导数据。先建一个空库,字符集用utf8mb4,然后执行sql/init.sql。这一步最容易出问题的是时间字段默认值和 SQL 模式,如果报Invalid default value for 'created_at',多半是 MySQL 的sql_mode里开了严格模式而建表语句写得不规范。
第三步,改配置。application.yml里改数据库地址、账号密码、Redis 地址。注意密码如果是纯数字,YAML 里要加引号,不然会被解析成数字,连库直接认证失败。这个坑我踩过,报错信息还很隐晦,只提示认证失败,不看配置根本想不到。
第四步,起后端。mvn clean package -DskipTests打完包直接java -jar跑,比在 IDE 里点绿三角更容易暴露问题。
第五步,起前端。npm install然后npm run dev。如果页面能打开但接口全 404,先看vite.config.js里的代理配置,再确认后端context-path有没有配前缀,两边对不上就全 404。
6.3 二次开发最常改的五处
接手别人的源码做二次开发,改动集中在五个地方,按改动频率排:一是登录鉴权(很多模板还是 session,要改成 JWT);二是订单状态流转(业务不同状态就不同);三是库存扣减逻辑(模板里十有八九是直接减);四是文件上传(本地存储改成对象存储或者至少加个访问前缀);五是分页查询(模板里经常用内存分页,数据一多就崩)。
改的时候有个原则:先跑通再改,别一上来就重构。我见过有人拿到源码第一天就把包名全改了、分层全调了,改到第三天项目起不来,原来的代码也没了备份,只能从头再来。稳妥的做法是先建一个 git 分支,原样跑通,再一个一个模块替换,每改一处跑一次。
7. 开题报告怎么写才不被问住
7.1 四个必答题
开题答辩本质上就四个问题:为什么做、做什么、怎么做、做成什么样。很多人把八成篇幅花在"做什么"上,罗列一堆功能模块,结果老师一问"你这个和现有的电商系统有什么区别",就答不上来了。
"为什么做"要落到具体痛点上。不要写"农产品流通效率低"这种空话,要写"产地供货方报价靠微信、订单靠手写、对账靠 Excel,一笔订单从询价到发货平均要来回沟通七八次"。痛点写得越具体,后面做的东西就越显得有必要。
"做什么"要画边界。明确写出本期做哪些模块、不做哪些、为什么不做。敢于写"不做"反而是加分项,说明你想清楚了。
"怎么做"讲技术路线,重点讲清楚关键难点怎么解决,比如称重结算怎么处理、库存怎么防超卖。
"做成什么样"给出可验证的成果,比如"完成 XX 个接口、XX 张表、跑通从下单到完成的全链路"。
7.2 技术选型论证的写法
技术选型别写成"因为 Spring Boot 很流行所以选它"。这种话在答辩现场是减分的。要结合项目特点讲理由:单体架构是因为项目规模不大、部署环境简单、团队就一个人,上微服务纯属给自己找麻烦;MySQL 是因为交易数据强一致要求高,需要事务支持;Redis 只用在缓存和计数器上,不承担核心数据存储。
我一般会准备一张对比表,把候选方案和最终选择放一起,写清楚取舍理由。老师看到你考虑过其他方案并且能说出为什么不选,印象分会明显不一样。
| 维度 | 备选方案 | 最终选择 | 理由 |
|---|---|---|---|
| 架构 | 微服务 / 单体 | 单体 | 单人开发,部署简单,事务好控 |
| 持久层 | JPA / MyBatis | MyBatis | 复杂查询多,SQL 可控 |
| 缓存 | 本地缓存 / Redis | Redis | 需要跨节点共享计数器和会话 |
| 前端 | 原生 / Vue | Vue3 | 组件化,表单和表格开发效率高 |
7.3 创新点与工作量的平衡
创新点这块,我的建议是别硬造。老老实实把"农产品特性"做扎实,本身就是亮点:批次溯源、称重两段式结算、损耗统计、阶梯批发价,这四点任何一个做透了都比生造一个"基于大数据的智能推荐"强。
工作量要能拿得出手。我通常按"表数量、接口数量、页面数量、核心链路条数"四个维度报:20 张左右的表、60 个左右的接口、15 个左右的页面、3 条核心链路(下单支付、称重结算、售后处理)。这个量级做扎实了,答辩是完全够用的。别贪多报 40 张表,最后一半是空表,老师随便点开一个问字段含义就露馅了。
8. 上线前最容易翻车的地方
8.1 时区与时间字段
服务器时区、数据库时区、JVM 时区三者不一致,是交易系统里最隐蔽的问题之一。表现是订单创建时间比实际早或晚 8 小时,超时取消任务按错误的时间判断,导致订单刚下就被取消。解决办法很简单:数据库连接串里显式指定serverTimezone=Asia/Shanghai,JVM 启动参数加-Duser.timezone=Asia/Shanghai,两边都写死,别依赖操作系统默认值。时间字段我一般用datetime而不是timestamp,后者会受时区影响自动转换,排查问题时容易看花眼。
8.2 前端路由刷新 404
Vue 用 history 模式,本地开发一切正常,打包部署到服务器上,刷新任意非首页路由直接 404。原因是前端路由是浏览器行为,服务器上没有对应的物理文件。解决方式是在 Nginx 里加一条try_files $uri $uri/ /index.html;,把所有未命中的请求交给 index.html 处理。这个坑几乎每个新手都会踩一次,配置加上就再也不犯了。
8.3 图片上传的路径问题
本地开发时图片存D:/upload,代码里写死这个路径,部署到服务器上直接报错。正确做法是把存储根路径配到配置文件里,代码里只拼接相对路径,数据库里存相对路径而不是绝对路径。这样换环境只改一行配置,不用全局搜代码。更进一步的话,把上传目录挂到 Nginx 的静态资源路径下,通过 URL 直接访问,比走后端接口读文件流效率高得多。
8.4 分页查询的 total 不准
一对多关联查询做分页,total经常偏大。原因是主表和明细表 join 之后行数膨胀,count(*)数的是 join 后的行数。解决办法是先对主表做分页拿到 id 列表,再用 id 列表去查明细,或者用count(distinct 主表id)。这个问题在订单列表页特别明显,用户看到"共 100 条",翻到第二页就没了,体验很差。
8.5 多实例下的定时任务
前面提过一次,这里再说透一点。定时任务在单机上是"每分钟执行一次",到了两台机器上就变成"每分钟执行两次"。库存回滚、超时取消、对账补偿这类任务重复执行都是灾难。最简单的兜底是加一张任务锁表,执行前先insert一条带唯一键的记录,插入成功才执行,失败直接跳过。复杂一点的用分布式锁,但对毕设来说,一张锁表足够解决问题,还不用额外部署组件。
8.6 接口没有幂等,重复提交就多一单
用户手抖点两下提交,或者网络慢点了重试,很容易生成两笔一模一样的订单。前端加防抖只能挡住一部分,后端必须做幂等。我的做法是下单接口要求前端传一个requestId(前端生成 UUID),后端在 Redis 里setnx这个 key,过期时间设 5 分钟,设置失败就说明是重复请求,直接返回上一次的结果。这个成本极低,收益极大,属于必做项。
8.7 数据库连接池配得太大
很多人觉得连接池越大越好,直接配 100。实际上数据库的连接数是有上限的,多个服务一起抢,很容易出现"连接等待超时"。合理的配置是最大连接数 = CPU核数 * 2 + 磁盘数,一般十几到二十几就够了。配大了不但不提升性能,反而会因为线程上下文切换变慢。这个参数调优我建议先压测再定,别拍脑袋。
8.8 日志打得太随意
System.out.println打日志,上线之后日志文件几十个 G,磁盘直接写满。用 SLF4J 加 Logback,按天滚动,保留 30 天,异常日志单独一个文件。关键节点必须打日志:下单、支付回调、库存扣减、状态流转,每个都带上订单号和关键参数。出问题的时候,一份结构清晰的日志能省下大半天的排查时间。我真的见过线上出问题,日志里只有一行"出错了",连哪个接口都不知道的场面。
8.9 支付回调没做验签和幂等
即使用的是模拟支付,回调接口的两个基本动作也不能省:验签和幂等。验签是防伪造,幂等是防重复通知。支付平台的回调天生会重试,同一笔订单可能通知你三次五次,没做幂等就会重复加余额、重复改状态。做法是把支付流水号作为唯一键存一张支付记录表,回调进来先查,存在就直接返回成功,不存在才处理业务。
这些坑散落在各个模块里,单看每一条都不算大问题,凑在一起就是上线第一周天天救火。我的习惯是做完一个模块就把对应的异常场景列一遍,能想到的全写进测试用例,比上线后被动修要省心得多。农产品交易系统这套东西,难点从来不在技术栈有多新,而在有没有把"非标、称重、损耗"这三件事真正想明白,想明白了,代码自然就顺了。