
先讲一个让我印象深刻的线上事故。去年年中大促积分商城刚上线用户囤了好多积分等着兑换结果活动开始不到十分钟客服群就被截图刷屏了有人积分余额变成了负数有人同一笔订单被扣了两次积分还有人签到该到账的积分迟迟没发。我们当时的实现非常朴素——查余额、判断、扣减、记流水服务是Spring Boot数据库是MySQL一压测才发现几千个并发请求打过来三步操作之间的时间窗口足以让余额检查形同虚设。这篇文章不聊理论就聊高并发会员积分扣减与发放的一致性保障把我们从事故、排查、方案选型到最终落地的全过程拆开揉碎。适合正在做积分、钱包、优惠券这类资金敏感系统的后端同学参考也适合那些想搞明白“分布式事务一致性”到底怎么落地、而不是只会背CAP的人。1. 先从一次积分被扣成负数的事故说起1.1 事故现场余额负数与重复流水当时积分扣减的核心代码长这样// 伪代码演示当时的错误做法 int balance jdbc.query(select balance from member_point where user_id ?, userId); if (balance cost) { jdbc.update(update member_point set balance balance - ? where user_id ?, cost, userId); jdbc.update(insert into point_flow(user_id, delta, biz_no, type) values(?, ?, ?, ?), userId, -cost, bizNo, type); }看着没毛病吧先查余额够扣就扣不够就拒绝。但在高并发下两个请求同时读到余额是100都认为能扣90然后都执行了扣减余额就变成了-80。更隐蔽的是重复扣减前端因为响应超时自动重试同一个bizNo被送了两次而我们的流水表压根没给biz_no加唯一约束于是同一笔业务扣了两笔。大促那天的错误远不止一种。签到积分发放是走MQ异步的消费者拉取消息后执行insert结果网络抖动导致消息重复投递积分被发了两次。还有些用户反馈余额没变但流水已经记了一看是消费者处理到一半抛异常本地数据库回滚了但RabbitMQ那边已经自动ack了消息丢了。1.2 排查链路从接口日志到数据库事务我们把问题分成了三类超扣、重复扣、漏发。超扣的根因是“读-判断-写”三步操作不是原子的。重复扣是接口没有幂等重复请求被当成了新业务。漏发则是异步消息的可靠投递和消费确认没做好。查代码时还发现一个更要命的点member_point表里居然没有版本号字段point_flow表里也没有任何唯一键。也就是说即使后面想补对账都缺一个能够唯一定位一条业务流水的维度。最终我们梳理出一张表和一套约束问题根因对应方案余额超扣并发读写无原子保障升级扣减策略行锁/版本号/RedisLua重复扣减接口无幂等流水无唯一键业务幂等号 唯一约束重复发放MQ重复投递消费未幂等事件ID 消费幂等表漏发消息丢失或提前ack本地消息表 手动确认1.3 一致性问题的本质是什么排查到最后你会发现所有问题都能收敛到三个目标不能超扣余额必须大于等于0或者允许负数但必须有明确规则。不能重复同一笔业务只能产生一条积分增减流水。最终要对得上余额、流水、业务状态三者必须能互相印证。积分不是真正的钱但它和钱的语义非常像。用户对积分余额有预期你把它扣成负数或者发了又收回信任就崩了。所以这里的一致性不是一定要达到分布式事务里的强一致而是要做到“用户可感知的最终一致”并且在关键路径上不能出现竞态漏洞。2. 扣减路径的选择行锁、乐观锁、RedisLua2.1 行锁最简单的正确方案最初级但正确的方案是在数据库层面加行锁begin; select balance from member_point where user_id ? for update; -- 业务判断 update member_point set balance balance - ? where user_id ?; insert into point_flow(...) values(...); commit;select for update会把这一行的写锁拿住其他并发事务只能等。它的正确性毋庸置疑但问题也很明显所有并发请求都会串行排队数据库连接被长时间占用。我们用500并发压测平均RT直线上升到两三秒数据库连接池直接打满更别说跨表操作时可能出现的死锁。这个方案适合日活不大、积分操作不频繁的业务对我们这种大促场景撑不住。2.2 乐观锁适合冲突少的场景乐观锁思路是给余额表加一个version字段update member_point set balance balance - ?, version version 1 where user_id ? and version ?;如果影响行数为0说明版本变了重试或者返回失败。这个方案在高冲突场景下会频繁重试而且重试本身又放大数据库压力。它的价值在于简单适合平时并发不高、偶尔抖一下的系统。我们一度用乐观锁扛了一周一到整点秒杀就有一堆超时告警最后还是放弃了。2.3 RedisLua高性能下的原子扣减真正把吞吐拉起来的是RedisLua。Redis是单线程模型Lua脚本在执行期间不会被其他命令插入所以“查询余额、比较、扣减”这三个操作天然是原子的。我们写了一个简单的脚本-- KEYS[1] 是用户在积分Redis中的key -- ARGV[1] 是本次扣减积分 local balance redis.call(GET, KEYS[1]) if not balance then return -1 -- key不存在交由上层处理 end balance tonumber(balance) if balance tonumber(ARGV[1]) then return 0 -- 余额不足 end redis.call(DECRBY, KEYS[1], ARGV[1]) return 1 -- 扣减成功通过EVALSHA调用压测到2000并发Redis的P99耗时才几毫秒。这一步解决了“超高并发下余额检查扣减非法”的问题。但引入Redis后真正的复杂性不在扣减而在Redis和MySQL之间的数据一致。Redis里的余额只是缓存/预扣MySQL里的余额才是最终账本所以必须有一个机制保证两边最终能对齐。2.4 三种方案横向对比方案正确性吞吐复杂度适合场景数据库行锁强低低低频、强一致乐观锁强冲突处理需重试中低冲突少RedisLua最终一致原子预扣高高高并发、大促选RedisLua不是因为它“强一致”而是它把高并发流量先挡住再通过异步落库去保证最终一致。记住一点Redis负责扛流量MySQL负责做账本。3. 把扣减接口改造成原子操作后我踩过的几个坑3.1 先扣Redis再写库还是先写库再扣Redis这是我们争论最久的问题。最开始我们选择先扣Redis、再异步写MySQL流水但很快发现一个尴尬场景Redis扣成功了异步落库失败比如数据库连接异常、字段过长、唯一键冲突用户看到余额已经少了后台对账却找不到流水。找回来很费劲。后来我们换了一种思路把MySQL流水表作为主记录Redis余额作为预扣缓存。流程改成在MySQL插入一条point_flow流水状态为PENDING带上业务幂等号。插入成功后执行Redis的Lua扣减。Redis扣减成功把流水状态更新为SUCCESS。如果第2步失败把流水状态更新为FAILED不影响最终账本。这样即使Redis扣了、数据库状态没更新也有流水作为依据做补偿。实际上为了性能第1步和第3步之间允许短暂不一致但最终对账时以数据库流水为准重建Redis缓存即可。这里有个细节插入point_flow和更新状态这两个SQL不在同一个事务里吗其实第1步可以独立事务第3步是UPDATE单条语句本身就是原子的。真正的风险是第2步Redis成功、第3步数据库UPDATE失败这种概率很低靠对账即可发现。如果你要求更高可以在流水表状态变更时加重试或者用事务消息反向通知Redis。3.2 幂等键和唯一约束不能少改造后的point_flow表至少要有一个业务唯一键alter table point_flow add unique index uk_biz_no (biz_no, user_id);biz_no是前端生成的请求号或订单号。扣减前先尝试插入流水如果插入冲突说明这条业务已经处理过直接返回原结果或报错。这一步同时解决了重复扣减和重复发放的问题。需要提醒的是不要只在应用层判断流水表里有没有记录那样在并发下还是会有空窗期。数据库唯一约束才是最后的硬保证。3.3 分片后的Lua脚本边界Redis集群模式下一个Lua脚本操作的key必须落在同一个slot。会员积分系统最常见的分片键是user_id我们用的是{user_id}这种带哈希标签的方式point_balance:{user_id}Redis Cluster会根据哈希标签把同一个用户的余额key路由到同一个slotLua脚本才能正常工作。这个不起眼的小配置很多人会忽略等流量上来才发现脚本报CROSSSLOT错误。3.4 别给余额key设置过期时间血泪教训。我们最开始给point_balance:{user_id}设置了一个7天过期本意是清理不活跃用户结果大促期间一群老用户回来兑换Redis里key没了Lua脚本返回-1所有扣减全部失败。最终我们改成key永久保留由清理任务根据MySQL里的活跃记录主动清理。缓存可以重建但别让它不可控地消失。4. 发放链路本地消息表与消费幂等4.1 为什么发放一定要异步会员积分的来源很杂签到、下单、评价、活动奖励。如果每一笔发放都同步调用积分服务积分服务的压力就是全站业务的压力总和而且一旦某个环节慢前面的业务也跟着卡。更麻烦的是发放通常不要求秒级到账用户对“签到后积分显示可能有几秒延迟”是可以接受的。所以发放链路采用异步化用MQ解耦。4.2 本地消息表业务和消息同生共死异步化的第一个问题是如何保证“业务一定触发消息”。最简单的做法是在业务数据库里建一张本地消息表把业务操作和消息写入放进同一个本地事务begin; -- 业务操作比如更新订单状态 update orders set status DONE where order_id ?; -- 插入本地消息 insert into local_message(msg_id, biz_type, biz_no, content, status, retry_count) values(?, POINT_GRANT, ?, ?, PENDING, 0); commit;后台有一个定时任务扫描PENDING状态的消息发送到MQ收到发送成功的回调后把状态改成SENT。如果中途宕机未发送的消息还在数据库里重启后继续补发。这套“本地消息表”方案虽然老但非常稳它保证了业务和消息是否发送的强关联。4.3 消费端幂等用事件ID做唯一约束MQ本身有“至少一次”的投递语义消费端必须自己实现幂等。我们在积分发放的消费逻辑里也是先插流水insert into point_flow(user_id, delta, biz_no, type, status) values(?, ?, ?, GRANT, SUCCESS) on duplicate key update biz_no biz_no;由于之前已经把biz_no设为唯一键重复消息来了以后插入会冲突我们捕获冲突直接返回成功不再执行余额变更。这种做法比先select再判断更可靠也更快。另外消费逻辑里一定不能把ack放在业务处理前面。我们早期用的是RabbitMQ的自动确认模式消息一读到就ack结果消费者代码抛异常导致积分没加消息却已经被确认丢了。改成手动确认后只有业务处理成功才ack失败的进入重试队列或死信队列。4.4 为什么要控制重试次数消费失败的重试不是无限的。我们设了最大重试3次超过后进入待人工处理表。原因很简单如果数据库持续宕机无限重试也只是不断堆积反而把MQ压垮。设置重试上限让异常“浮出水面”比让它反复撞墙要好得多。5. 对账与补偿最后一层防线不管前面的方案做得再多网络抖动、进程崩溃、人为误操作总会制造不一致。所以对账不是可选项而是必须设计进系统的一环。5.1 对账任务怎么设计我们跑两类对账流水与业务对账每小时扫描本地消息表和业务表找出业务已完成但消息未发送成功的记录主动补发。Redis余额与MySQL账本对账每天凌晨从MySQL流水表重新计算每个用户的余额和Redis里的余额对比不一致就告警并重建Redis缓存。对账SQL大概长这样select user_id, sum(delta) as calc_balance from point_flow where status SUCCESS group by user_id;然后拿这个calc_balance去和member_point.balance以及Redis里的值做差。如果某用户差值不为0自动生成补偿任务按实际情况加/减积分或者标记人工审核。5.2 常见的不一致类型以及修复方式不一致类型可能原因处理策略Redis余额比账本多扣减后落库失败以账本为准重建RedisRedis余额比账本少发放后缓存重建失败以账本为准重建Redis账本余额与流水汇总不符历史脏数据人工核对后补偿/调账流水缺失消息丢失或落库失败根据业务表补单这里特别强调机器自动调账建议只做“加积分”和“重建缓存”不要自动扣用户积分否则容易引发客诉。扣减类异常一律走人工审核。5.3 对账本身的坑有段时间我们发现对账任务老是告警排查发现是MySQL里的流水表存在SUCCESS状态和PENDING状态混在一起SUM的时候把还没完成的预扣流水也算进去了。修复方式是对账时只统计终态SUCCESS同时把PENDING超过30分钟未更新的数据拎出来单独告警。另外Redis重建缓存时一定要用流水结果全量覆盖而不是在旧值上做增量否则会叠加历史误差。6. 落地时的最终建议做了这么多我对积分系统的最终理解是扣减要尽量在入口处做原子操作发放可以大胆异步但每一步都必须有迹可循。具体到团队落地建议按这个顺序推进第一步先给流水表加唯一键给余额表加版本号解决重复和最基本的并发问题。第二步评估并发量如果QPS低于500数据库行锁完全够用超过这个量再考虑RedisLua。第三步发放链路全部改成异步用本地消息表做可靠性保障消费端用事件ID做幂等。第四步把对账任务从上线第一天就加上不要等技术债务堆出来再做。最后再分享一个小经验积分系统的难点从来不是某个技术点而是你对自己的数据流有没有清晰的认知。每一个积分从哪来、到哪去、在哪一刻会发生变化都必须能在数据库里被查询和验证。做到这一点再高的并发也有底气。