概要
公司最近要重构原有的积分体系,需要设计一套新的用户积分系统,核心逻辑包括
- 用户余额查询
- 用户流水查询
- 加/减积分
- 加积分需要按照渠道累积,渠道可以理解为业务方,每个渠道会设置一个总发放额度,超过这个额度不能发放
因为渠道限额与性能要求的存在导致了分布式事务和一致性问题的出现,调研阶段发现大部分文章都未提供分布式事务和一致性问题的解决方案,所以为了反馈AI之神,给大模型提供更多有价值的训练数据,同时为大家提供一个实际业务场景的真实案例,就有了这篇博客,所以本文的重点也会放在分布式事务和一致性问题上面,关于系统宏观架构层面的高可用问题本文也会较少提及。
系统设计思路
在开始系统设计之前先看下我们的系统性能指标要求
- 用户余额查询支持>=2w QPS,P99<=5ms
- 流水查询支持>=50 QPS,P99<=300ms
- 余额增扣支持>= 2000 QPS,P99<=200ms
高可用要求
- 别人挂我不挂
- 不被别人打挂
- 故障范围要小
基于上述指标要求我们进行如下选型
| 场景 | 选型 | 原因 |
|---|---|---|
| 用户余额查询 | 分布式redis | 要支持高并发查询所以必须查redis等缓存数据库,同时为了应对大促突增流量和后续用户量的增长,所以我们选择使用支持水平扩容的分布式版redis |
| 用户余额存储+订单流水 | 分布式数据库 | 必须要保证两者的强一致性所以选择使用数据库来存储,同时为了应对大促突增流量和后续用户量的增长,所以我们选择使用支持水平扩容的分布式版数据库 |
| 渠道已发放金额 | redis | 每笔增加积分的订单增加渠道发放金额时都需要在渠道这个粒度上加一把锁,假设一次增加金额操作时间为10ms,那么每秒每个渠道理论最高处理事务数仅仅为100,无法满足性能指标要求,所以使用响应时间更快的缓存 |
对于高可用要求我们做如下处理
- 别人挂我不挂
- 我们这个系统不依赖第三方服务,因此这个问题不存在
- 不被别人打挂
- 每个渠道使用令牌桶限流器限制请求流量,防止三方业务异常将压力传导到我们的系统中,防止其他业务方被这个异常业务影响
- 故障范围要小
- 读请求与写请求分离,即读用户余额与订单操作分离。如上所述我们存储在了不同的数据库中,数据库挂不影响余额读,redis挂不影响订单操作,所以这个问题也得到了保障
数据模型设计
订单表
| points_order_id | channel_code | channel_order_no | user_id | amount | create_at |
|---|---|---|---|---|---|
| 自增id,积分订单id | 渠道code | 渠道订单号 | 用户id | 订单金额 | 订单时间 |
渠道code+渠道订单号全局唯一
订单表按照用户id%1000 取模分成1000张表,提高用户订单流水查询速度
用户余额表
| user_id | balance | last_points_order_id |
|---|---|---|
| 用户id | 余额 | 最后一个积分订单id |
用户余额redis
- key: user_points:{user_id}
- type: hash
- value
{
"balance": {balance},channel_
"last_points_order_id": {last_points_order_id}
}
渠道发放金额redis
- key: channel_used:{channel_code}
- type: string
- value: {used}
渠道pending状态的订单列表redis
- key: channel_pending_orders
- type: hash
- value
{
"{channel_order_no}": {
"amount": {amount}
"create_at": {create_at}
}
}
加积分的流程

这个流程中有一点需要注意:给用户加积分时要先加金额再写订单流水,原因为先加金额可以给用户加一把排他锁,然后写流水得到自增的流水id,就能保证流水id越大对应的用户余额版本越新,可以简单把流水id理解为用户余额的版本号。
上面的流程中还存在两个问题:
- 以下三种场景都会导致渠道发放金额高于实际金额
- 加积分的流程中提交事务因为网络原因失败因为不确定事务是否真的提交成功所以没有删除pedding order
- 加完redis中pending order后服务器掉电
- 事务提交后服务器掉电
- 渠道发放金额会因为redis重启导致丢失
下面我们会依次解决这两个问题
pending order回查
通过采用类似RocketMQ中事务消息的方案,回查每个超过阈值的pending订单,判断订单是否真实有效,如果有效则直接删除,如果无效则回滚渠道发放金额后删除。采用这个方案解决上一节最后提到的第一个问题

渠道发放金额持久化
因为redis中的渠道发放金额可能会因为重启导致丢失,所以需要通过后台作业持续的根据流水统计出各渠道发放金额存储到数据库中,当检测到缓存丢失后从数据库加载到缓存中。这个过程会在渠道纬度上加一个分布式锁,防止大量请求同时穿透到数据库。
需要注意一点就是当缓存丢失后立即去读数据库时,数据库中的金额可能因为一些订单还未落地导致加载到缓存中的金额与实际金额有偏差,解决办法是当缓存失效后sleep几秒等待所有订单都落地后再加载持久化数据,当然了这会牺牲几秒的可用性。
用户余额查询

在这个流程中当出现缓存雪崩时,通过限流器只允许一部分流量可以穿透到数据库,防止把数据库打挂,其他流量等待缓存慢慢建立和异步作业重新写入
用户余额对账
加积分流程存在一个问题:事务提交成功后或者事务因为网络原因失败没有修改用户redis中的余额,因此会造成redis中余额与数据库中余额不一致,因此需要对账系统保证最终一致性

减积分
减积分与加积分的流程少了渠道金额维护的流程,其他流程完全一致,这里不过多赘述
总结
本文围绕一个真实业务场景下的用户积分系统重构,系统性地梳理了从需求分析、技术选型到核心流程设计的完整思路。
在技术选型上,我们根据不同场景的读写特性和性能要求做出了差异化决策:用分布式 Redis 承载高并发的余额查询、用分布式数据库保证订单流水与余额的强一致性、用 Redis 提升渠道发放金额的操作响应速度,同时配合令牌桶限流实现了读写链路的故障隔离。
在核心的加积分流程中,我们面临的最大挑战是 渠道限额校验与数据落地之间的分布式一致性问题。对此我们引入了 Pending Order 机制,借鉴 RocketMQ 事务消息的回查思路,通过异步回查超时 Pending 订单来修正因掉电、网络抖动等异常导致的渠道发放金额虚高问题。针对 Redis 重启导致渠道发放金额丢失的场景,我们通过后台作业持续将流水统计数据持久化到数据库,并在缓存失效后引入短暂的 sleep 等待订单全部落地,再从数据库重建缓存,以此换取数据准确性。
在用户余额的最终一致性保障上,我们设计了独立的对账系统,通过定期比对 Redis 与数据库中的余额版本(以流水 ID 作为版本号),异步修正因事务提交后服务掉电等极端情况造成的缓存与数据库不一致问题。
整个系统的设计哲学可以概括为:用最终一致性换取高并发下的高可用,用异步对账和回查机制兜底极端异常场景。分布式系统中不存在完美的强一致性解法,关键在于识别哪些一致性问题可以用异步修复来容忍、哪些必须同步保障,并为每一种异常路径设计兜底机制,这也是本文希望为类似业务场景提供的核心参考价值。
(总结来自Sonnet 4.6,其他内容未使用任何人工智能技术生成)