零工日结 3 万条结算单 40 分钟跑不完:从逐条 UPDATE 到分批批量提交
xllg 的日结任务每天凌晨跑:把昨天打过卡、考勤有效的零工,按工时算出应结金额,批量生成结算单,再逐条把金额打进钱包余额。上线第一个月没事,工单量上来之后,凌晨 3 点开始的任务到 4 点还没跑完,直接挤压第二天的打卡数据导入。我看了眼日志,三万条结算,一条条 INSERT + UPDATE,跑完刚好四十多分钟,问题就出在这。
先看原始实现,问题一目了然
第一版代码逻辑很直白:循环里先查考勤汇总,再插结算单,再更新钱包。三层循环嵌套,每层都跟数据库交互一次:
publicvoiddailySettle(LocalDateworkDate){Listlist=attendanceMapper.summaryByDate(workDate);for(AttendanceSummarya:list){SettleOrderorder=buildOrder(a,workDate);settleMapper.insert(order);// 第 1 次 IO:插结算单Walletwallet=walletMapper.selectForUpdate(a.getUserId());// 第 2 次 IO:查钱包加锁wallet.setBalance(wallet.getBalance().add(order.getAmount()));walletMapper.updateById(wallet);// 第 3 次 IO:更新钱包}}三万条 × 3 次 IO = 九万次数据库往返。每次就算 5ms,光网络开销就 450 秒,再加上事务、锁等待,40 分钟一点不冤。逐条 IO 是批量任务的第一大敌人,这个教训后来刻在团队规范里了。
拆解优化:批处理 + 批量 UPDATE + 减少锁范围
第一刀,INSERT 改成 MyBatis-Plus 的批量插入。三万条分 50 条一批,一批一条 SQL:
Listbatch=newArrayList<>(50);for(AttendanceSummarya:list){batch.add(buildOrder(a,workDate));if(batch.size()>=50){settleMapper.batchInsert(batch);// foreach 拼多值 INSERTbatch.clear();}}if(!batch.isEmpty()){settleMapper.batchInsert(batch);}batchInsert就是 XML 里的 `` 多值插入,不是一条条调 insert。注意 batch 里不能有重复的唯一键——结算单用(user_id, work_date)做唯一约束,同一天一个人只能有一张单,重复会导致整批失败回滚。
第二刀,钱包余额累加改成一条 SQL 搞定,去掉查锁 + 更新的两跳:
@Update("UPDATE jx_wallet SET balance = balance + #{amount}, update_time = NOW() "+"WHERE user_id = #{userId} AND status = 1")intincrBalance(@Param("userId")LonguserId,@Param("amount")BigDecimalamount);balance = balance + amount是原子操作,数据库行锁天然保证并发安全,比先 selectForUpdate 再 update 少一半 IO,也不需要应用层锁。
第三刀,也是最重要的一刀:不要在事务里做大批量。最初整个循环包在一个@Transactional里,三万条数据一个事务,undo log 撑爆、锁持有时间超长,最后一条失败全部回滚重来。改成"一批一个事务",每批 50 条独立提交,失败只回滚当前批:
for(inti=0;i<batchCount;i++){try{settleService.settleBatch(batchList.get(i));// 每批一个 REQUIRES_NEW 事务}catch(Exceptione){log.error("结算批次失败 batchIndex={}, workDate={}",i,workDate,e);// 记录失败批次,后续对账补偿,不中断整个任务}}结算单状态机:别让异常流程失控
优化性能只是第一步,结算的正确性比速度重要一百倍。结算单从生成到入账,我用一个状态字段控制,四个状态足够用:0 待入账 / 1 已入账 / 2 入账失败 / 3 已对账。批量任务只处理状态为 0 的单子,入账失败的置为 2,由独立的补偿任务捞出来重跑,状态流转收敛,才不会出现"钱发了但单子状态还是待入账"的糊涂账:
publicvoidsettleBatch(Listorders){// 事务方法:批量插入 + 批量入账settleMapper.batchInsert(orders);for(SettleOrderorder:orders){introws=walletMapper.incrBalance(order.getUserId(),order.getAmount());if(rows==1){settleMapper.updateStatus(order.getId(),1);// 已入账}else{settleMapper.updateStatus(order.getId(),2);// 入账失败log.warn("入账失败 userId={}, amount={}",order.getUserId(),order.getAmount());}}}对账兜底:跑批任务必须有的最后一道保险
批量任务再优化也有漏网之鱼——某个批次超时、某条数据被并发改坏。我加了一个对账任务,跑批结束十分钟后执行:统计当日应结算金额,跟钱包实际入账金额对比,不一致就生成对账差异单人工处理。
-- 对账:当日应结 vs 实际入账SELECTa.work_date,SUM(a.amount)ASshould_amount,COALESCE(w.actual_amount,0)ASactual_amount,SUM(a.amount)-COALESCE(w.actual_amount,0)ASdiffFROMjx_settle_order aLEFTJOIN(SELECTwork_date,SUM(amount)ASactual_amountFROMjx_wallet_flowWHEREbiz_type='SETTLE'GROUPBYwork_date)wONa.work_date=w.work_dateWHEREa.work_date=#{workDate}GROUPBYa.work_date;diff 不为 0 就告警。这套跑了一个月,抓出过两笔漏入账——都是批处理超时被中断导致的,金额不大但流程站得住。性能优化能让你跑得快,对账才能让你睡得着。
踩坑记录:批量 INSERT 一次插入 3 万条直接炸了
问题现象:第一次改完,把三万条一次性塞进一个 ``,执行后 MySQL 报错PacketTooBigException: Packet for query is too large,同时主库 CPU 飙到 95%,连接数打满。
排查过程:先看报错,这是 MySQL 的max_allowed_packet限制,一条 SQL 太大被拒。再看慢日志,那条巨型 INSERT 光拼 SQL 就花了 300ms,解析执行又是一大坨。然后看监控,主库磁盘 IO 写放大明显——一个 3 万行的 INSERT 生成一个超大 undo 段,回滚段竞争严重。
定位思路:批量插入不是越大越好,SQL 长度、参数个数、事务大小三者的平衡点在 50~100 条一批。我直接查了线上参数SHOW VARIABLES LIKE 'max_allowed_packet',默认 16MB,看起来很大,但 MySQL 对单条 SQL 的内存占用还有别的限制,加上主从同步要把整条 SQL 传到从库,过大就是给自己埋雷。
最终解决:改成 50 条一批,每条 SQL 控制在 20KB 以内,任务整体耗时从 40 分钟降到 3 分半。同时把唯一键冲突单独处理——结算单重复生成时Duplicate entry不再打断整批,改成INSERT ... ON DUPLICATE KEY UPDATE跳过,幂等性顺带解决了:
INSERTINTOjx_settle_order(user_id,work_date,amount,status,create_time)VALUES(#{userId}, #{workDate}, #{amount}, 0, NOW())ONDUPLICATEKEYUPDATEupdate_time=NOW()优化后对比,数据说话
| 指标 | 优化前 | 优化后 |
|---|---|---|
| 总耗时 | 42 分钟 | 3 分半 |
| 数据库 IO 次数 | 9 万次 | 约 1200 次 |
| 事务大小 | 单事务 3 万条 | 单事务 50 条 |
| 失败影响面 | 全量回滚 | 单批回滚可补偿 |
直接可复用的清单
- 批量任务禁止逐条 IO,INSERT 用 `` 多值,50~100 条一批
- 余额累加用
UPDATE ... SET balance = balance + #{amount},别 selectForUpdate - 大批量循环绝对不要包在一个大事务里,一批一个事务,失败只回滚当前批
- 唯一约束 +
ON DUPLICATE KEY UPDATE实现任务幂等,重复跑不产生脏数据 - 任务跑完做对账:结算单总数 vs 考勤明细数,不一致的批次捞出来重跑
项目源码:https://gitee.com/gzqkl/xllg