资讯动态

返利系统高并发防御实践:分布式锁、限流与削峰

发布时间:2026/10/21 5:14:28 来源:尧图企业网站定制
返利系统的并发问题做过的同学都懂平时看着没几个人一到活动节点或运营推广瞬时流量能翻几十倍。更棘手的是返利计算涉及订单状态、用户账户、入账流水多个环节同一个订单如果被多个请求重复处理用户可能被返两次钱。我说的高并发核心不在于“把系统写得快”而在于在并发冲击下还能保持各环节一致。这也是为什么标题里要把分布式锁、限流、削峰放在一起——它们是解决同一问题的三条互补路线。这篇文章就围绕我在实际项目里的方案取舍、代码落地和踩坑过程展开适合正在设计电商返利系统、或者准备应对活动大流的后端开发参考。1. 返利系统的压力模型先搞清楚瓶颈出在哪1.1 三类最容易打满请求量的场景很多团队一上来就讨论 Redis 锁怎么写、MQ 用什么这其实是本末倒置。返利系统的并发压力来源是有规律可循的先分清楚属于哪类压力才能决定后续要做什么。第一类是运营动作引发的集中流量。比如发布一个“下单返50%”的活动用户在活动开启瞬间集中下单。这个量通常集中在秒级请求数是平时的几十倍。这类场景下的关键词是“瞬时”对网关层和下单接口的压力最大。第二类是返利结算的批量触发。返利系统一般有定时任务扫订单或者订单系统回调触发返利。如果系统设定在某个时间点跑批量结算比如凌晨两点统一计算前一天的订单这时候任务并发一旦写得不严谨会出现大量重复计算。这类场景典型特征是“高吞吐、低延迟要求不高”但一致性要求极高。第三类是回调风暴。现在很多返利系统接第三方订单数据平台侧会以 webhook 或者定时拉单的方式回调。回调经常是短时间数据大量到达而且同一笔订单可能重复回调多次。如果没有幂等控制返利记录的异常会立刻体现到账面上。把压力来源梳理清楚之后结论其实很直接第三类回调风暴依赖幂等和分布式锁第一类依赖限流和削峰第二类依赖任务分片加幂等。不同场景的解法权重完全不同不要拿一套方案硬套。1.2 哪些模块该上锁、限流还是削峰在实际构架中我习惯先把系统切成几条链路再决定每条链路上的治理手段。下单链路用户下单实时触发返利资格记录。这条链路对响应时间敏感不适合在同步路径上做重量级结算所以只需要做轻量校验和限流。结算链路订单完结后计算返利金额。这条链路数据量大、计算复杂适合异步处理用削峰方案消化流量峰值。入账链路返利金额写入用户账户。该环节对数据一致性要求最高必须在写账户前加分布式锁防止重复入账。对账链路定期核对返利流水与订单流水。对账本身不需要实时但对准确率要求高往往靠离线任务不受高峰流量影响。三条链路当中最容易出问题的其实是结算链路和入账链路之间的衔接。用一个表格看会更清楚链路主要压力核心治理手段一致性要求下单链路瞬时高流量限流、削峰弱一致允许延迟到账结算链路大量批量任务异步队列、分片最终一致入账链路重复回调、重复结算分布式锁、幂等表强一致对账链路高吞吐离线任务最终一致这个表格的价值在于帮助你定位如果只需要在入账链路上加锁就千万不要给下单接口也加锁那只会白白放大延迟。我开始做这个项目时就是没分清链路结果在接口层加了分布式锁活动高峰期锁竞争严重RTT 直接翻了几倍。2. 分布式锁返利金额防重复的核心防线2.1 锁粒度比锁实现更关键分布式锁的代码示例到处都是真正考验功底的是锁粒度设计。返利系统里最容易被重复处理的不是“用户”维度而是“订单”维度。同一个订单可能被第三方回调两次可能被定时任务重复扫描也可能被人工重试触发。所以锁的 key 一定是 订单号 返利类型 的组合而不是简单地把整个用户 ID 作为锁 key。String lockKey rebate:lock:order: orderId :type: rebateType;这里尤其要注意如果把锁粒度放在用户维度一个用户同时结算多个订单这些订单会被强制串行严重降低吞吐。放在订单维度的好处是天然隔离不同订单的结算互不干扰同一个订单即使并发进来也不会有重复计算。另一个容易踩坑的地方是锁的持有时间。返利计算涉及查询订单、计算金额、写流水、写账户多个操作耗时波动极大。一次完整计算在正常情况几十毫秒慢的时候可能到两秒。如果锁的过期时间定得太短比如固定 500ms那么一次慢计算还没结束锁就自动释放了。此时其他线程拿到锁进来重复计算就会出现问题。解决思路有两种一种是把过期时间设得很长比如 30 秒缺点是如果持有锁的线程突然崩溃锁要等很久才能释放另一种是引入看门狗机制业务执行期间不断续期。后一种更推荐不理解的话可以把它理解成“锁的存活时间在动态续费业务没结束锁就不死”。2.2 用 Redis 实现分布式锁的常见踩坑点第一版我写锁用的是 Redis 的 setnx expire 两条指令当时觉得没问题后来复盘发现了好几处隐患。第一条指令设置锁第二条指令设置过期时间。如果第二步执行前进程崩溃锁就永远不会过期。后来我改成一条原子指令Boolean locked stringRedisTemplate.opsForValue().setIfAbsent(lockKey, requestId, Duration.ofSeconds(30));通过setIfAbsent和过期时间放在同一个调用里避免了非原子操作问题。第二个坑是误删锁。代码里很容易写拿到锁之后做计算计算完直接delete(lockKey)。但假如当前线程的锁已经过期失效了另一个线程重新获取到了锁这时第一个线程执行完删除锁会把第二个线程的锁删除。解决方式是删除前判断 value 是否仍是自己的唯一标识if (stringRedisTemplate.opsForValue().get(lockKey).equals(requestId)) { stringRedisTemplate.delete(lockKey); }不过即便是判断后删除判断和删除之间也有时间差。严谨一点要用 Lua 脚本保证原子性Redis 在执行 Lua 脚本时不会穿插其他命令if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end第三个坑是主从切换导致的锁丢失。Redis 主节点刚 set 锁还没来得及同步给从节点主节点宕机从节点晋升为主新主节点上没有这把锁。这个时候别的线程可以拿到同样的锁分布式锁在极端情况下就失效了。如果业务对一致性要求极高建议使用 Redisson 的 RedLock 方案或者引入 ZooKeeper / etcd 实现锁。RedLock 的原理是向多个独立 Redis 节点同时申请锁超过半数成功才算加锁成功可以把单点故障的影响降到最低。但 RedLock 的代价是加锁耗时显著增加所以大多数业务场景其实用不到。我最终的选择是常规业务用 Redis 锁涉及到高金额返利结算才走一个带有数据库唯一约束的幂等表做兜底效果比纠结 RedLock 更务实。2.3 生产环境选 Redisson 还是手动写 Lua这个问题我实战下来比较有发言权。Redisson 框架已经实现了锁的自动续期功能并且封装了可重入锁如果直接用RLock lock redissonClient.getLock(lockKey); boolean locked lock.tryLock(3, 30, TimeUnit.SECONDS);tryLock 的第一个参数是等待时间第二个参数是持锁时间。如果设置持锁时间为 30 秒Redisson 的看门狗会默认在锁还剩 1/3 时间时自动续期也就是大约第 10 秒续一次直到业务执行完毕或者进程崩溃。这个机制能避免长期占用锁的问题也能避免持锁时间太短导致锁提前失效。不过 Redisson 也有一个细节容易让人迷惑如果你自己在 tryLock 里指定了 30 秒看门狗会按照 30 秒来续期如果你指定的是 -1 或者不指定 leaseTimeRedisson 才会启用默认的 30 秒看门狗逻辑。实际项目里我建议业务方统一把持锁时间设置成足够大比如 60 秒靠看门狗续期保证业务安全即可。手动写 Lua 的好处是可控也没有框架依赖适合锁逻辑极简的场景。但一旦涉及可重入、看门狗、公平锁这些复杂需求手写成本反而更高。生产环境的返利系统我推荐用 Redisson 做业务锁同时保留手写 Lua 脚本做幂等校验。两者职责不同并不冲突。3. 限流让流量在网关层先接受一轮筛选3.1 四种限流算法怎么选很多开发把限流简单理解成“请求数太多就拒绝”但这只是结果。核心要解决的是限流阈值怎么设定限流算法在突发下表现如何。常用的限流算法有四种我整理过它们各自的适用场景算法核心逻辑优点缺点适合场景固定窗口计数单位时间窗口内计数实现简单窗口临界点可能突增两倍流量对突发容忍度低的内部任务滑动窗口计数基于时间片精细分片解决了临界突增比固定窗口消耗更多内存常规 API 限流令牌桶匀速放入令牌桶内积攒允许一定突发突发太猛下游依旧扛不住面向外部用户的接口限流漏桶匀速流出请求平滑流量波形瞬间高并发时大量请求排队等待对下游保护要求极高的链路在返利系统里下单接口用的令牌桶限流允许用户在活动开始时有一定突发流量冲进来然后通过全局限流和用户维度限流共同控制整体水位。结算回调链路用的则是漏桶思路它不怕排队但要求处理平滑不能让第三方回调把系统打穿。3.2 一个简单可靠的 Redis Lua 限流实现网上有各种限流框架比如 Sentinel、Hystrix 或者网关自带的限流模块但很多团队不想为此引入整套框架那就用 Redis 自己实现一套。这里给出一个基于固定窗口计数加 Lua 脚本的实现local key KEYS[1] local limit tonumber(ARGV[1]) local now tonumber(ARGV[2]) local windowSize tonumber(ARGV[3]) local windowStart now - windowSize local count redis.call(zremrangebyscore, key, 0, windowStart) local current redis.call(zcard, key) if current 1 limit then return 0 end redis.call(zadd, key, now, now .. - .. math.random(1000000)) redis.call(expire, key, windowSize 10) return 1这段代码里我用 ZSet 而不是简单的 INCR。原因是固定窗口按秒计数超出窗口的请求无法精准剔除滑动窗口通过记录每次请求的时间戳能计算窗口内真实请求量并且能准确删掉过期的记录。每次窗口大小为 60 秒保存每个请求的时间戳和随机唯一值窗口滑过时自动清理。实际调用时只需要在业务代码外层套一层判断Long allowed redisTemplate.execute( new DefaultRedisScript(LUA_SCRIPT, Long.class), Arrays.asList(limitKey), String.valueOf(threshold), String.valueOf(System.currentTimeMillis()), String.valueOf(windowMs) ); if (allowed null || allowed 0L) { throw new BizException(请求过于频繁请稍后再试); }这个方案的好处是无需新增中间件Redis 本身承载了原先的缓存职责不用单独部署一套限流器。当然要注意一个问题如果 Redis 本身挂掉限流组件会跟着不可用。理论上限流不应该成为系统的单点瓶颈生产上最好配合本地限流做降级当 Redis 调用失败时退化为进程内 RateLimiter。3.3 限流维度不只是接口级别做返利系统初期我给所有返利相关 API 设置了一个全局限流阈值结果运营反馈“明明全局限流没触发但接口还是经常超时”。后来查日志才发现问题出在单个用户维度上。某一个用户短时间内大量请求占满了工作线程其他正常用户全部排队。所以限流必须分层第一层是网关或 Nginx 层的 IP 限流防止单个来源打爆。第二层是接口维度限流按 API 的预估承载能力设置阈值比如下单返利接口 2000 QPS。第三层是用户维度限流。同一用户每秒不超过 5 次请求防止异常流量堵住线程池。最后一层才是业务限流比如同一订单只能返利一次这个一般靠幂等表实现。有同学会问用户维度限流会不会误伤正常用户比如用户手滑连续点了好几下。所以设置阈值时需要结合前端交互判断。下单场景本身用户点击频率很低5 次/秒明显异常但查询返利进度的接口用户刷新频率可能较高这类接口按用户维度限流阈值要适当放宽。4. 削峰把瞬时击穿的请求迁到异步队列里4.1 为什么削峰要放在限流之后设计限流解决的是“不让系统被瞬时流量打穿”削峰解决的是“把超过系统处理能力的请求先接住等待系统慢慢消化”。两者的最大区别是限流会直接拒绝部分请求削峰则让请求先进入队列后续再处理。返利场景里完全不拒绝用户下单这一点很重要。用户下单是业务诉求如果因为系统能力不足直接拒绝下单用户体验和业务量都会受损。所以在下单返利链路中采用削峰方案先接住请求立即给用户返回“下单成功返利到账预计稍后到账”的结果。真正的返利计算步骤放进消息队列由消费者异步处理。这里有个业内讨论很多的伪命题是不是所有系统都应该引入削峰我的看法是削峰只该用在“允许延迟处理”的链路里。比如返利计算可以延迟但用户支付密码校验绝不能延迟。判断标准很简单用户能否接受结果晚几秒钟甚至几分钟出现。能接受就考虑削峰不能接受只能优化同步链路。4.2 从同步返利到异步返利的流程重构重构前的返利计算链路是这样的用户下单成功 - 订单状态变为已支付 - 同步计算返利金额 - 更新返利记录 - 写入用户账户余额 - 返回结果。这些步骤里同步计算和更新记录是耗时大头整体链路常常超过 800ms大促时涨到 3 秒直接拖垮下单接口。重构之后链路变成用户下单成功 - 生成消息发送到 MQ - 立即返回下单成功。MQ 的消费者接续处理查询订单信息 - 计算返利金额 - 唯一键写入返利记录 - 更新用户账户余额 - 标记消息完成。这个过程中最关键的改造是消息结构设计。消息里不要只带一个订单号让消费者再去查一堆关联信息那样消费速度慢还会反复查询数据库。更好的做法是消息体里带上订单号、用户ID、商品金额、下单时间等计算返利所需的全部基础信息{ orderId: 20241201001, userId: 100234, orderAmount: 199.00, rebateType: NEW_USER_20, orderTime: 1733040000000, source: order_callback }携带必要数据有两个好处一是消费者拿到消息直接按信息计算减少对订单系统的依赖二是如果订单系统当时压力大无法查询消息也能独立处理。我用的是 RabbitMQ 作为消息中间件队列配置如下Bean public Queue rebateQueue() { return QueueBuilder.durable(rebate.calculate.queue) .withArgument(x-max-priority, 10) .withArgument(x-queue-mode, lazy) .build(); }加入 priority 是为了让高金额订单优先处理加入 lazy 模式是为了防止消息大量积压时全部存在内存里导致 GC 压力。生产环境有同学直接裸用默认队列消息积压一旦超过几十万内存直接报警这个参数细节值得抄作业。4.3 消息重复消费的兜底逻辑异步化之后又引入了新的一致性问题MQ 消息可能重复投递消费者处理失败后重新入队也会带来重复消费。我在架构里用了唯一键约束做落库兜底。给返利流水表设置一个业务唯一键字段结构包含order_id和rebate_typeALTER TABLE rebate_record ADD UNIQUE KEY uk_order_rebate (order_id, rebate_type);当重复消息进入消费者插入记录时数据库会因为唯一键冲突直接失败消费者的幂等逻辑捕获冲突之后把消息标记为已处理即可try { rebateRecordService.create(record); } catch (DuplicateKeyException e) { log.info(重复返利消息忽略处理。orderId{}, rebateType{}, record.getOrderId(), record.getRebateType()); return; }这套兜底要做到万无一失需要消息消费的提交时机正确。我的处理方式是先插入记录并提交随后再 ack 消息。如果消息先 ack 再处理业务处理中途应用宕机消息就丢了如果消息处理完但不 ack会反复消费但有唯一键保护重复消费只是多一次数据库冲突不会造成经济损失。这就是最终一致性的常见实现思路。5. 压测验证和上线后的长效观察5.1 制定合理的压测基线方案上线前必须做压测这个我不能偷懒。我的做法是搭建一个独立压测环境数据量按照预估订单量的三倍准备用 JMeter 或 wrk 模拟真实请求。压测的核心指标不是单纯看 QPS 能跑到多少而是看系统在目标负载下的响应时间和成功率。返利下单接口我当时定的目标QPS 1500P99 响应时间小于 800ms成功率 99.99%。如果达不到预期逐层排查是哪个环节成为瓶颈。排查顺序通常是先看网关层再看业务层的线程池和数据库连接池再看 Redis 的操作耗时最后看 MQ 的消费积压。我的经验是很多性能问题出在数据库的慢查询上比如订单表缺索引、返利流水表查询没走唯一索引这些问题在压测中会非常明显地放大。5.2 上线后盯紧哪几个指标上线后的观察和压测同样重要。主要有几个指标值得盯第一是 MQ 积压量。如果积压持续上涨说明消费速度跟不上生产速度此时扩容消费者实例是立竿见影的但如果消息结构设计不合理或者消费者里存在重活扩容效果也会受限。第二是 Redis 的操作耗时和内存使用。分布式锁和限流都强依赖 Redis一旦 Redis 的耗时从 1ms 涨到 5ms整个链路都会感知。我看到过一个案例Redis 开启了持久化大促时写操作变多AOF 重写导致短暂阻塞结果所有锁操作超时。后来调整了持久化策略才解决。第三是分布式锁的竞争次数。我在代码里加了一行埋点把加锁等待耗时记录下来。正常情况下同订单并发很少锁竞争次数不高。如果某个时段锁等待超过 100ms 的次数突增说明存在重复结算风暴比如某个订单回调重复触发这时候去查回调方的逻辑比继续优化锁实现更有价值。最后是数据库层面的唯一键冲突量。正常情况冲突几乎为零如果冲突量突然升高说明重复消息的比例在上升虽然不会造成经济损失但也反映了上游系统的异常状态。5.3 每次大促之后如何复盘大促结束后我会把阶段性压测数据、线上监控数据和业务数据进行一次对照。对照的核心是限流阈值是否定得合理削峰队列深度够不够锁的等待时间符不符合预期有一次复盘发现某个活动的返利限流阈值设得太低导致大量接口返回“请求过于频繁”用户使用体验很差。后来把接口维度的限流阈值提高了 30%Redis 的内存压力并没有显著增加说明阈值还有余量。这种调整只靠刻板估算是不准的必须拿线上真实流量来微调。我觉得这个项目的顺利交付很大程度上得益于把分布式锁、限流、削峰这三件事分清了边界。锁负责微观的一致性限流负责宏观的流量边界削峰负责把超出的流量平滑到时间轴上三者不乱、彼此配合系统自然能在并发冲击下站稳。最后再说一个小建议分布式锁、限流、削峰都是通用方案真正值钱的是对返利业务本身的理解。订单的重复回调解耦、金额计算的重试边界、入账流水和账户余额的对账逻辑这些细节才是这个方案的灵魂。先把业务想透再谈技术方案才不会在高并发浪潮里迷失方向。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑