ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

高并发下优惠券系统架构设计:从防超发到削峰填谷的完整实践

高并发下优惠券系统架构设计:从防超发到削峰填谷的完整实践

先问一个问题:你有没有在某个大促节点蹲点抢过券?零点一过,按钮一点,转圈、报错、再点,最后券没了——这个过程的背后,是一套要在几秒钟内扛住十万级 QPS 的系统。优惠券系统看起来只是"发券+核销"两个动作,但它同时踩中了高并发、分布式一致性、防超发、幂等、限流、削峰这一串大坑。这篇文章我就从零开始,把搭建一套10万级 QPS 大流量、高并发优惠券系统的完整思路、技术选型、核心代码和踩坑实录都盘一遍。不管你是刚接触高并发不久的中级开发,还是正准备在公司里做类似中台系统的负责人,这篇东西都能给你一个可以直接对标参考的底稿。

先说清楚,我这里讨论的"优惠券系统",指的是电商/外卖/出行这类场景下的通用营销券中台,核心流程包括:创建活动、发券、查券、核销、对账。流量压力集中在两个地方:一是活动开启那一刻的集中领券,二是核销高峰期的券状态校验。这两条链路如果设计不好,轻则领券超发导致资损,重则把数据库打死拖垮整个订单服务。所以本文的重点会放在库存扣减与防超发、缓存架构、异步削峰、限流降级这四个硬骨头上,附带完整的代码示例和压测数据。下面直接开始。

1. 需求拆解与技术选型:先想明白难点在哪

1.1 拆解业务链路:领券、核销、对账分别考验什么

很多人一上来就谈技术选型,这是顺序搞反了。做高并发系统,第一步永远是把业务链路拆到底,找出真正的压力点和一致性要求。

优惠券系统的核心链路可以拆成四条:

  • 发券链路:用户点击"领取"→校验活动规则(时间、用户等级、限领数量)→校验库存→扣减库存→记录用户券→异步通知。这条链路的压力特征是瞬时峰值极高,活动一开,百万用户同时进来,QPS 曲线直接拉满。
  • 查券链路:用户进"我的卡券"页、下单页勾选可用券、收银台展示优惠明细,都会查券。这条链路是典型的读多写少,QPS 通常比发券高一个数量级,但压力可以通过缓存解决。
  • 核销链路:下单时锁定券、支付成功后核销、退款时回退券。这条链路最考验数据一致性,因为核销动作往往不在优惠券系统内部,而是在订单系统里通过远程调用或 MQ 触发。
  • 对账链路:晚间跑批,核对"发出多少张、核销多少张、退款回退多少张",涉及和财务系统的数据对齐,要求的是最终一致性和完整的流水记录。

把这四条链路摆出来,技术难点就清晰了。发券链路的难点是瞬时高并发下的防超发,查券链路的难点是缓存结构与缓存一致性,核销链路的难点是分布式环境下的状态流转,对账链路则要求前面的每一步都留下可靠痕迹。后面所有方案设计,都围绕这四个难点展开。

1.2 技术栈选型:为什么是 Redis + MQ + MySQL,而不是别的

先看一下我最终采用的方案,再解释为什么这么选。

层级选型核心职责
接入层Nginx + 网关黑白名单、基础限流、路由转发
本地缓存Caffeine热点活动信息和券模板的毫秒级读取
分布式缓存Redis Cluster库存预扣、用户领券记录、活动配置缓存
异步削峰消息队列(RocketMQ)发券结果落库、核销通知、对账流水
数据库MySQL(分库分表)券实例最终存储、对账数据
限流组件Sentinel 等接口限流、热点参数限流、系统自适应限流

这套选型是"高并发场景的经典答案",不是拍脑袋定的。逐个说理由:

为什么缓存用 Redis Cluster 而不是单机 Redis?10万 QPS 的读流量单机 Redis 勉强能顶,但写流量一上来,单机 CPU 和带宽都会成瓶颈。Cluster 模式可以横向扩容,把 key 分散到多台机器上。更重要的是,库存扣减需要使用Lua 脚本保证原子性,而 Redis 的 Lua 执行是单线程串行的,天然适合做库存这类强一致操作,这是 MySQL 行锁方案给不了的性能优势。

为什么引入消息队列?发券链路里,"扣减库存"和"写库落账"其实可以解耦。库存扣减必须在请求线程内同步完成,因为它直接决定用户能不能领到券;但"生成券实例记录""发送核销通知"这些操作完全可以异步,等 MQ 慢慢消费。这样做的直接好处是:数据库的写入压力被摊平了,而不是集中在活动开启的那几秒里。

为什么数据库最终选了 MySQL 分库分表?券实例的量级非常大——一次大型活动发几千万张券很正常,一年的累计量轻松上亿。单表肯定扛不住,按用户 ID 或券模板 ID 做水平拆分是必经之路。选 MySQL 而不是 NoSQL,是因为券数据有强事务和复杂查询需求(比如对账系统要按时间+状态聚合查询),关系型数据库在这一块依然不可替代。

本地缓存 Caffeine 是不是多余?不多余。活动配置、券模板信息这类数据特点是"读多、写极少、允许短暂不一致",把它们放在应用本地内存里,一次查询耗时可以压到微秒级,几乎不占网络开销。我见过不少团队忽略这一层,结果 Redis 集群 QPS 高得吓人,实际上 70% 的查询是重复读同一份模板配置。本地缓存这几个热点 key,能为 Redis 分担大量压力。

注意:本地缓存一定要控制好 key 数量和过期时间,并且要监听配置变更主动失效,否则会出现"活动配置改了,用户端还显示旧规则"的问题。

2. 整体架构与缓存设计:流量怎么分层扛住

2.1 从接入到存储:每一层分别挡掉什么流量

10万级 QPS 的系统,绝不是一台应用服务器硬扛。流量必须一层一层被削掉,越靠近用户,处理要越快越"轻"。

完整链路是这样的:用户请求先到 Nginx,Nginx 做 IP 黑白名单和最简单的连接数限制,然后进入网关层。网关层做接口级限流、用户身份识别、风控拦截,过了网关的请求才到达优惠券系统的应用服务。应用服务先查本地缓存(Caffeine),查不到再查 Redis,Redis 也没有才回源到 MySQL。

从流量的角度看,每一层都在"过滤"请求:Nginx 挡掉恶意流量和连接风暴,网关挡掉超速请求,本地缓存挡掉重复查询,Redis 挡掉绝大部分数据库读压力。真正打到 MySQL 的,只有那些缓存 miss 的读请求和异步写入请求。

这里有一个关键思路:** 10万 QPS 指的是入口流量,而不是数据库流量。** 如果设计合理,MySQL 每秒处理的请求应该只有几千甚至几百。衡量架构好不好的标准,就是看"最终打到数据库的流量被削掉了几个数量级"。

2.2 三级缓存方案:本地、分布式、数据库各自管什么

我把缓存设计拆成三级,每一级管不同类型的数据,互不干扰。

  • 一级:本地缓存(Caffeine),存放活动配置、券模板信息、限领规则。这类数据的特点是:量小、读极频繁、变更极少。我把过期时间设为 5 分钟,同时监听配置中心的变更事件,收到变更后立即失效对应 key。
  • 二级:Redis Cluster,存放库存余量、用户已领券记录、发放流水号。这几个数据是并发写的主力,也是整个系统防超发的核心所在,后面会专门讲。
  • 三级:MySQL,存放券实例最终数据、对账流水。应用层只在 cache miss 时回源,而且回源会加一个"防止缓存击穿"的互斥锁。

这里要专门说一个高频踩坑点:缓存击穿。活动开启时,如果活动配置刚好在那一秒过期,大量请求同时发现缓存 miss,就会同时打到数据库——这就是"击穿"。我的处理方式是在回源逻辑里加锁,同一个 key 只允许一个线程回源,其他线程自旋等待。示例代码如下:

public CouponTemplate getTemplateFromCache(Long templateId) { String key = "coupon:template:" + templateId; // 先查本地缓存 Object localVal = localCache.getIfPresent(key); if (localVal != null) { return (CouponTemplate) localVal; } // 查 Redis String json = redis.get(key); if (json != null) { CouponTemplate template = JSON.parseObject(json, CouponTemplate.class); localCache.put(key, template); return template; } // 防止击穿:分布式锁,只允许一个请求回源 String lockKey = "lock:template:" + templateId; boolean locked = redis.set(lockKey, "1", "NX", "EX", 3); if (locked) { try { CouponTemplate template = loadFromDb(templateId); redis.set(key, JSON.toJSONString(template), 300); localCache.put(key, template); return template; } finally { redis.delete(lockKey); } } else { // 没抢到锁就短暂等待,然后重新走缓存 Thread.sleep(50); return getTemplateFromCache(templateId); } }

这个方法实测下来,能把数据库的读压力压掉 90% 以上。记住一个原则:缓存 miss 回源必须加锁,否则高并发下数据库必死。不过要注意,上面的代码里自旋等待用的是线程 sleep,在高并发下容易浪费线程资源。更优的做法是使用 CompletableFuture 或内部缓存框架自带的"单个 key 回源"机制,效果类似,但线程利用率更高。

2.3 热点 Key 识别与处理:避免单个 Redis 节点被打挂

10万级 QPS 下,还有一个非常隐蔽的坑——热点 Key。某个爆款活动,可能 80% 的请求都集中在同一个券模板 ID 上。Redis Cluster 会把这个 key 路由到某个固定节点,于是所有流量都打在同一台机器上,导致该节点 CPU 飙升、带宽打满,而其他节点空闲。

热点 Key 的常规解法有两个方向。一是本地缓存提前挡流量:既然热点 key 的读流量巨大,就把它主动放进本地缓存,用 JVM 内存扛这部分流量。二是给 key 加随机后缀做复制,比如把一个热点 key 变成hot_0到hot_9十个 key,分散到不同节点,读取时随机读其中一个。写操作则需要把数据同时写到十个副本,逻辑复杂一些,但这是对读热点最有效的兜底。

我做过的做法是:在应用启动时加一个"热点发现"定时任务,统计最近 5 分钟内 Redis 各 key 的访问次数,超过阈值的 key 自动加入本地缓存热点名单,动态调整本地缓存过期时间。这个机制上线之后,单个 Redis 节点的带宽峰值下降了约 40%。

3. 库存扣减与防超发:秒级并发下怎么保证一张不多发

3.1 三种库存方案对比:数据库乐观锁为什么不够用

防超发是整个优惠券系统最核心的一致性难题。先看三种主流方案,再告诉你我为什么最终选 Lua。

方案一:数据库乐观锁。在 MySQL 里执行update coupon_stock set stock = stock - 1 where stock > 0。这个写法本身没问题,但问题是数据库行锁的性能上限摆在那里:单表每秒能承受的 UPDATE 操作通常只有几千到一万多。10万 QPS 的领券请求全部走这条路,数据库直接被打爆。而且库存操作会被锁阻塞,延迟飙升,用户体验会很差。

方案二:Redis 原子自减。用DECR命令扣库存,如果返回值小于 0 就回补。问题在于,扣减成功之后还需要判断"用户是否已领过券",这是两个操作,需要额外的 Lua 脚本才能保证原子性。如果单纯用DECR做扣减,会出现"库存扣了,但用户重复领取"的问题。

方案三:Redis Lua 脚本,把库存判断、扣减、记录用户放在一个脚本里原子执行。Redis 执行 Lua 脚本是单线程串行的,意味着同一个脚本在同一时刻只有一个实例在执行,从根本上消灭了并发竞争。这是目前业界做秒杀类库存扣减的主流方案。

结论很明确:上 Redis Lua 脚本,把库存校验、扣减、限量判断三步合并为一次原子操作。

3.2 核心 Lua 脚本代码:扣减、限领、记录一次完成

下面这段 Lua 脚本,是我经过多次迭代后沉淀下来的核心代码:

-- 入参:KEYS[1] 库存key,KEYS[2] 用户领取记录key,ARGV[1] 用户ID,ARGV[2] 限领数量 -- 1. 判断库存 local stock = tonumber(redis.call('GET', KEYS[1]) or '0') if stock <= 0 then return -1 end -- 2. 判断用户是否已领 (SISMEMBER 用户已领集合) local hasClaimed = redis.call('SISMEMBER', KEYS[2], ARGV[1]) if hasClaimed == 1 then return -2 -- 用户已领过,直接返回 end -- 3. 判断是否达到个人限领数量 local claimedCount = tonumber(redis.call('SCARD', KEYS[2]) or '0') local limitCount = tonumber(ARGV[2] or '1') if claimedCount >= limitCount then return -3 end -- 4. 扣库存 + 记录用户 redis.call('DECR', KEYS[1]) redis.call('SADD', KEYS[2], ARGV[1]) -- 5. 设置库存key过期时间(防止key永不失效) redis.call('EXPIRE', KEYS[1], 86400) redis.call('EXPIRE', KEYS[2], 86400) return 1

这段脚本的关键在于:库存扣减和用户领取记录是在同一个原子操作里完成的,因此不会出现"库存扣了但记录没写"或"记录写了但库存没扣"的中间状态。

调用方(Java 侧)的代码也要注意细节:

public Result claimCoupon(Long userId, Long templateId, int limitCount) { String stockKey = "coupon:stock:" + templateId; String userKey = "coupon:user:" + templateId; // Lua 脚本,通过 Jedis 或 Lettuce 执行 Long result = redis.eval( STOCK_LUA_SCRIPT, Arrays.asList(stockKey, userKey), Arrays.asList(String.valueOf(userId), String.valueOf(limitCount)) ); if (result == 1) { // 异步发送 MQ,让券实例落库、记录流水 sendClaimEvent(userId, templateId); return Result.success(); } else if (result == -1) { return Result.error("库存不足"); } else if (result == -2) { return Result.error("您已领取过该券"); } else { return Result.error("已达到领取上限"); } }

这里有个关键技巧:Lua 脚本不必每次调用都重复传整个脚本内容。Redis 支持SCRIPT LOAD预先加载脚本,拿到一个 SHA 签名,后续用EVALSHA执行,可以减少网络传输开销。高并发场景下,这个优化能省下不少带宽。我实测过,同样的一万次调用,用 EVALSHA 比直接 EVAL 延迟低约 15%。

注意:Lua 脚本里的 KEYS 和 ARGV 必须严格区分开。动态的 key 名(比如用户 ID)必须放在 KEYS 里由客户端传入,绝不能在脚本内部拼接字符串来拼 key,否则 Redis Cluster 模式下会因为 key 不在同一个 slot 上而报错。上面脚本里 KEYS[2] 的构造会在 Java 侧完成,确保两个 key 都带相同的 hash tag,使它们被路由到同一个节点。

3.3 幂等设计:重复点击和重试机制怎么防

防超发不能仅仅依赖 Lua 脚本,还要防止重复请求。用户在活动页疯狂点击领取按钮,或者上游系统因超时自动重试,都可能导致同一用户同一活动产生多个领券请求。Lua 脚本里用 Set 判断用户是否已领取,能挡住大部分重复,但挡不住"同一请求被重试"的情况——比如请求在网络上延迟,客户端超时重发,服务端实际上已经处理成功了。

解决方案是引入请求幂等键。每次领券请求在进入系统时生成一个全局唯一的 requestId(可以用 UUID,也可以用雪花算法),在处理之前先写一个"幂等键"到 Redis:

public Result claimWithIdempotent(String requestId, Long userId, Long templateId, int limitCount) { String idemKey = "coupon:idempotent:" + requestId; // 尝试写入幂等键,只有第一次能成功 boolean first = redis.set(idemKey, "1", "NX", "EX", 600); if (!first) { // 已经有相同 requestId 处理过了,直接返回处理中或成功 return Result.success("重复请求,幂等拦截"); } // 继续走 Lua 扣库存逻辑 return claimCoupon(userId, templateId, limitCount); }

这样处理之后,不管是用户手动狂点按钮,还是 RPC 框架的重试机制,同一笔请求只会被真正处理一次。代价仅仅是在 Redis 里多存了一个 10 分钟的短命 key,非常划算。幂等是防止资损的最后一道防线,宁可多写几行代码,也不能漏掉这道判断。

4. 限流与削峰:流量太大时先保可用性再保准确性

4.1 三层限流配置:用户级、接口级、总量级分别怎么设

即使缓存和 Lua 脚本能扛住大部分流量,也必须在系统入口做限流。限流的意义不在于"拒绝用户",而在于牺牲一部分请求,保住整个系统的可用性。如果系统被冲垮了,所有人都会领不到券——那才是真正的灾难。

我一般做三层限流:

第一层:用户维度限流。同一个用户在单位时间内最多领取多少次,或者一分钟内最多访问多少次接口。这层主要防"黄牛脚本",可以用 Sentinel 的热点参数限流实现,根据用户 ID 做维度限制。比如设置 QPS 为 1,一个用户一秒只能领一次,点再快也没用。

第二层:接口维度限流。对整个领券接口设置总 QPS 阈值,比如 10万 QPS。这层防的是流量整体超过系统承载能力。使用 Sentinel 的集群流控或网关层的限流规则都可以实现。

第三层:总量维度限流。在 Redis 里维护一个全局计数器,总领取次数达到活动设定的总库存后,直接拒绝所有领券请求。这个判断可以在 Lua 脚本里完成,返回"已抢光"。

三层限流的配置规则示例:

# Sentinel 规则配置示例 { "resources": [ { "resource": "coupon:claim", "grade": 1, # 1- QPS 模式 "count": 100000, # 总 QPS 上限 "strategy": 0 # 直接拒绝 }, { "resource": "coupon:claim:userId", "grade": 1, "count": 1, # 单用户 QPS 上限 "strategy": 0, "controlBehavior": 0, # 直接拒绝 "parseParameter": ["userId"] } ] }

有同学可能会问,限流的阈值设多少合适?我的经验是:先按压测得到的系统最大承载值乘以 0.8 来设置,保留 20% 的余量。比如压测显示单集群最大能扛 12万 QPS,那么线上就限 10万。留这 20% 的余量是为了应对流量毛刺和垃圾回收造成的抖动,避免限流器自己也成为瓶颈。

4.2 基于 MQ 的削峰落库:数据库写入为什么要异步化

限流之后,真正进入业务逻辑的流量会稳定在一个系统能承受的范围内。但接下来的问题是:即使只有 10万 QPS 进入了业务逻辑,数据库也扛不住这么高频的插入。假设领券成功后要给用户生成一张券实例记录,一秒 10万条 INSERT,任何 MySQL 单库都顶不住。

解决思路是削峰填谷:把同步写入变成异步写入。领券成功后,应用层只向 MQ 里扔一条消息,然后就返回用户"领取成功"。消费端每秒从 MQ 拉一批数据,批量写入数据库。

这里有一个非常重要的认知:用户感知的"成功"和数据库里的"落库"不必是同时发生的。只要消息不丢,券最终一定会计入用户账号。对用户来说,领券动作的响应时间反而更快了。

实际消费端的伪代码:

@Component public class CouponEventConsumer { @Autowired private CouponInstanceMapper couponInstanceMapper; @RocketMQMessageListener(topic = "COUPON_CLAIM_TOPIC", consumerGroup = "coupon-claim-group") public void onMessage(List<Message> messages) { // 攒够一定数量或一定时间后批量落库 List<CouponInstanceDO> batch = convert(messages); couponInstanceMapper.batchInsert(batch); // 一次插几百条 // 确认消息已消费 } }

批量插入的优化效果非常明显。一条一条插入每条大概 1-2 毫秒,一个线程一秒最多插几百条;改成批量插入后,一次插入 500 条可能只需要 20-30 毫秒,单线程每秒可以处理一万五千条以上。如果开 10 个消费者线程,每秒就能写入 15万条,数据库完全来得及。

这里有个常被忽略的细节:批量插入一定要控制单批的大小和总量。插太多,单条 SQL 的网络包太大,MySQL 的 undo log 和 binlog 压力也会剧增。我一般把单批控制在 200-500 条,同时用多线程分片消费,每个线程独立一个数据库连接,避免一把大锁堵死。

5. 压测数据与性能调优:从每秒一万到十万,我做了什么

5.1 压测环境与工具:没有真实数据就没法做调优

说这么多设计,很多人关心"10万 QPS 到底是怎么测出来的"。这里分享一下我的压测方法和环境配置。

压测工具我用的是开源的压测平台,配合脚本生成并发请求。模拟环境规格如下:6 台应用服务器(8 核 16G)、3 台 Redis 节点(16G 内存)、3 台 MySQL 节点(一主两从)。压测场景是模拟 500 万用户同时抢 100 万张券。

关键压测参数:

  • 并发线程总数:5000(模拟高并发用户数)
  • 压测时长:10分钟(需超过活动瞬间峰值时长,才能暴露慢 SQL 和连接池问题)
  • 请求分布:领券请求 80%,查券请求 20%
  • 核心关注指标:QPS、响应时间 TP99、错误率、Redis CPU、MySQL 线程数

这里要提个醒:压测不能只看平均响应时间,一定要看 TP99 和错误率。平均响应时间 50 毫秒看着不错,但 TP99 是 800 毫秒、错误率 5%,那就是用户体验灾难。我在调优过程中最关注的就是这两个指标。

5.2 三轮调优的对比数据:每一步优化带来了什么

我把调优过程分成三轮,每一轮解决一个层面的问题,数据变化很有代表性。

指标第一轮(原始)第二轮(加缓存+Lua)第三轮(加MQ+调参)
入口 QPS3万8万10万+
TP99 响应时间480ms210ms90ms
数据库 QPS2.8万6000800
错误率8%1.5%0.2%
Redis CPU 峰值45%60%55%

第一轮的写法是最"朴素"的直连数据库方案,吞吐量很快见顶,数据库连接池被占满,大量请求排队超时。第二轮的改动主要是引入 Caffeine 本地缓存、Redis 缓存和 Lua 脚本扣库存,数据库流量立刻降了一个数量级。第三轮的改动重心是异步落库和参数调优——调整线程池大小、批量写入系数、连接池上限之后,系统吞吐量突破了 10万 QPS,而且 TP99 稳定在 100 毫秒以内。

这个数据说明一个道理:高并发系统的瓶颈往往不在单点算力,而在"流量分层"是否合理。数据库被保护住了,系统就活下来了;数据库被打爆,性能再好的应用服务器也白搭。

5.3 上线前的最后检查:连接池、GC、线程池调参清单

除了架构层面的优化,上线前还必须过一遍基础参数。这些参数看似不起眼,但在高并发下任何一个配置失误都会引发雪崩。我把自己的检查清单列在这里:

  • 数据库连接池:最大连接数不能太小,否则高并发下线程会排队等连接。我的配置是initialSize=50, maxActive=300,连接池满时快速失败而不是无限等待。
  • HTTP 线程池:Tomcat 或 Vert.x 的线程数要大于理论上限并发,默认 200 在 10万 QPS 下远远不够,我调整到 500,同时配合工作队列,避免线程直接打满。
  • Redis 连接池:Jedis 连接池 maxTotal 设到 500 以上,空闲检测打开。Redis 连接数过少在高并发下会大量出现JedisException: Could not get a resource from pool,或者是连接建立风暴。
  • JVM 参数:堆内存至少给到 8G,使用 G1 收集器,-XX:MaxGCPauseMillis=100控制 GC 停顿。我在压测中发现 G1 的停顿时间远低于 CMS,对 TP99 的改善非常明显。

还有一个很重要的经验是连接池大小的"反向直觉":连接池不是越大越好。数据库连接数过多会导致 MySQL 内部上下文切换开销变大,反而拉低整体吞吐。我的经验值是"应用线程数的一半到三分之一"就够了。在第三轮优化里,我把数据库连接池从 500 降到 200,吞吐量不降反升,数据库负载也显著下降。这一点,新人在调优时最容易踩坑。

6. 线上事故与排查经验:真实场景里踩过的坑

6.1 缓存穿透导致数据库被打满

第一件真实的教训,就是缓存穿透。当时有一个查询券详情的接口,传入一个根本不存在的券模板 ID。每个请求都直接绕过缓存查数据库,数据库根本扛不住。排查时发现 Redis 的命中率从 95% 掉到了 60%,但查看代码逻辑,发现缓存 miss 之后直接查库,完全没有兜底。

修复方法是空值缓存:当数据库查不到数据时,在 Redis 里也写入一个空值,并设置 60 秒过期时间。这样后续请求在过期前都会命中"空缓存",不会穿透到数据库。同时加布隆过滤器挡掉明显不存在的 key。从这次事故里学到的教训是:任何查询接口都必须假设"缓存可能 miss",提前设计好 miss 后的行为。

6.2 网络超时导致的重试风暴

第二件事发生在一次大促前。领券网关调下游会员服务时,由于下游服务抖动导致大量超时,网关开启了自动重试,结果一次简单的领券请求,在下游抖动期间被放大了 5 倍,形成重试风暴,直接把会员服务打挂了。

这个问题的根因不在会员服务,而在调用方的重试策略。现在的做法是:只对"幂等且非致命错误"的重试设置整体次数上限,比如最多重试 1 次;同时开启"快速失败",超时时间从 1 秒降低到 300 毫秒,宁可这次失败也不给系统添乱。另外配合了熔断器,下游持续错误时主动熔断,不再发起调用,让下游有时间恢复。高并发系统里,最怕的不是某一台机器挂了,而是故障被"重试"这个动作无限放大。

6.3 消息积压导致用户领券状态不一致

还有一次事故源于消费端消费速度下降。某个时间段内,活动运营为了冲量临时加大了发券数量,消息生产者速度飙升,但消费端的批量插入逻辑没有跟着扩容,导致 MQ 积压了几十万条消息。用户在页面看到"领取成功",但券实际到账延迟了十几分钟。

排查思路是:先看消费端日志,发现批量插入偶尔报主键冲突;再看数据库,发现批量插入的批次里因为包含重复的 requestId,导致 insert 失败。修复方式是消费端做"批量插入时忽略冲突",同时在消费逻辑里增加动态扩缩容机制,当积压数量超过阈值时自动增加消费者线程。这次事故之后,我还给消费端加了一个监控页面,实时展示"待消费数量"和"消费延迟",已经积压就立刻报警。消息队列不是万能保险箱,消费速度上不去,积压只是时间问题。

6.4 常见问题速查表

问题现象可能原因排查方法解决方案
数据库连接池耗尽慢 SQL 占用连接、缓存穿透查看数据库慢日志、连接池监控加缓存、优化 SQL、限制并发
Redis CPU 飙升热点 key 集中请求用redis-cli --hotkeys或监控命令查看本地缓存热点 key、多 key 复制分散
领券提示库存不足但页面还有数量Redis 库存和 DB 库存不同步检查 Lua 脚本和回补逻辑定期比对库存、设置库存预热任务
消息消费延迟大消费者线程不够、批量插入慢查看 MQ 积压数、消费端日志扩容消费者、优化批量插入
活动刚开始就报"已抢光"库存 key 不存在或预热失败检查定时预热任务活动开始前强制预热 Redis 库存

这张表是我在多个项目里反复沉淀下来的,遇到同类问题可以直接对照。有几次排查,我甚至直接靠"现象+解决方案"两列就能定位问题,省了不少时间。

7. 写在最后:关于高并发系统设计,我给你三句实话

项目做完了,压测达标了,也有几件事是我想对你说的。

第一句:系统不可能一步到位,调优是一个"测量-定位-修改"的循环。10万 QPS 不是设计出来的,是压测压出来的。每次压测都会暴露出一个新的瓶颈,可能是连接池、可能是缓存命中率、可能是 GC 停顿。不要指望画一张完美的架构图就能上线,真实的高并发系统是在一次次的故障和压测中被"打磨"出来的。

第二句:稳定性永远优于新功能。高并发场景下,宁可"少发券"也不能"多发券"。少发一张,用户可以理解,补发通道可以做;多发一张,资损对账就是事故。在代码层面,防超发、幂等、限流这三件事优先级最高。先保证不出事,再谈用户体验。

第三句:监控是最后一道防线,报警比优化更重要。我见过太多团队埋头调优参数,却忘了加监控。等线上出问题时,连"从哪里开始看"都不知道。至少要把 Redis 命中率、数据库连接池使用率、MQ 积压量、TP99 这四个指标做到可视化,配合告警。任何一次线上事故,从监控发现问题到定位问题,隔的时间越短,损失就越小。

这套系统运行了几个月之后,我又陆续加了几个锦上添花的能力,比如活动灰度发布、券模板预热的自动化、分地区限流等。但核心骨架始终没有变,还是那三板斧:Redis Lua 保一致性、本地缓存挡热点、MQ 削峰填谷。如果你也在设计类似的高并发系统,不妨从这三个支撑点开始,先跑通主线再逐步完善。踩过几个坑之后你会发现,高并发这回事,没有那么多玄学,就是把每一步都做扎实。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进