先讲一个让我印象深刻的线上事故。去年年中大促,积分商城刚上线,用户囤了好多积分等着兑换,结果活动开始不到十分钟,客服群就被截图刷屏了:有人积分余额变成了负数,有人同一笔订单被扣了两次积分,还有人签到该到账的积分迟迟没发。我们当时的实现非常朴素——查余额、判断、扣减、记流水,服务是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表里也没有任何唯一键。也就是说,即使后面想补对账,都缺一个能够唯一定位一条业务流水的维度。最终我们梳理出一张表和一套约束:
| 问题 | 根因 | 对应方案 |
|---|---|---|
| 余额超扣 | 并发读写无原子保障 | 升级扣减策略(行锁/版本号/Redis+Lua) |
| 重复扣减 | 接口无幂等,流水无唯一键 | 业务幂等号 + 唯一约束 |
| 重复发放 | MQ重复投递,消费未幂等 | 事件ID + 消费幂等表 |
| 漏发 | 消息丢失或提前ack | 本地消息表 + 手动确认 |
1.3 一致性问题的本质是什么
排查到最后,你会发现所有问题都能收敛到三个目标:
- 不能超扣:余额必须大于等于0(或者允许负数但必须有明确规则)。
- 不能重复:同一笔业务只能产生一条积分增减流水。
- 最终要对得上:余额、流水、业务状态三者必须能互相印证。
积分不是真正的钱,但它和钱的语义非常像。用户对积分余额有预期,你把它扣成负数,或者发了又收回,信任就崩了。所以这里的一致性,不是一定要达到分布式事务里的强一致,而是要做到“用户可感知的最终一致”,并且在关键路径上不能出现竞态漏洞。
2. 扣减路径的选择:行锁、乐观锁、Redis+Lua
2.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 Redis+Lua:高性能下的原子扣减
真正把吞吐拉起来的是Redis+Lua。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 三种方案横向对比
| 方案 | 正确性 | 吞吐 | 复杂度 | 适合场景 |
|---|---|---|---|---|
| 数据库行锁 | 强 | 低 | 低 | 低频、强一致 |
| 乐观锁 | 强(冲突处理需重试) | 中 | 低 | 冲突少 |
| Redis+Lua | 最终一致+原子预扣 | 高 | 高 | 高并发、大促 |
选Redis+Lua不是因为它“强一致”,而是它把高并发流量先挡住,再通过异步落库去保证最终一致。记住一点: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路由到同一个slot,Lua脚本才能正常工作。这个不起眼的小配置,很多人会忽略,等流量上来才发现脚本报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余额比账本多 | 扣减后落库失败 | 以账本为准,重建Redis |
| Redis余额比账本少 | 发放后缓存重建失败 | 以账本为准,重建Redis |
| 账本余额与流水汇总不符 | 历史脏数据 | 人工核对后补偿/调账 |
| 流水缺失 | 消息丢失或落库失败 | 根据业务表补单 |
这里特别强调:机器自动调账建议只做“加积分”和“重建缓存”,不要自动扣用户积分,否则容易引发客诉。扣减类异常一律走人工审核。
5.3 对账本身的坑
有段时间我们发现对账任务老是告警,排查发现是MySQL里的流水表存在SUCCESS状态和PENDING状态混在一起,SUM的时候把还没完成的预扣流水也算进去了。修复方式是对账时只统计终态SUCCESS,同时把PENDING超过30分钟未更新的数据拎出来单独告警。另外,Redis重建缓存时一定要用流水结果全量覆盖,而不是在旧值上做增量,否则会叠加历史误差。
6. 落地时的最终建议
做了这么多,我对积分系统的最终理解是:扣减要尽量在入口处做原子操作,发放可以大胆异步,但每一步都必须有迹可循。具体到团队落地,建议按这个顺序推进:
- 第一步,先给流水表加唯一键,给余额表加版本号,解决重复和最基本的并发问题。
- 第二步,评估并发量,如果QPS低于500,数据库行锁完全够用;超过这个量再考虑Redis+Lua。
- 第三步,发放链路全部改成异步,用本地消息表做可靠性保障,消费端用事件ID做幂等。
- 第四步,把对账任务从上线第一天就加上,不要等技术债务堆出来再做。
最后再分享一个小经验:积分系统的难点从来不是某个技术点,而是你对自己的数据流有没有清晰的认知。每一个积分从哪来、到哪去、在哪一刻会发生变化,都必须能在数据库里被查询和验证。做到这一点,再高的并发也有底气。