一次大促压测把优惠券系统打到崩溃之后,我才彻底想明白一个道理:这类系统的难度从来不在“券”上,而在“流量”上。优惠券业务逻辑本身无非是发券、领券、核销、管理四条线,可一旦遇到零点秒杀式的抢券场景,瞬间灌进来的10万级 QPS 会同时砸向网关、缓存、数据库和消息队列,任何一处短板都会被放大成肉眼可见的事故。这篇文章我就把从零搭建一套高并发优惠券系统的完整过程复盘出来:业务怎么拆、容量怎么估、防超发怎么做、缓存怎么分层、写流量怎么削峰、压测怎么过关。既适合正在设计同类系统的后端工程师,也适合想系统了解高并发架构套路的读者。
1. 先算账再动手:把“10万QPS”翻译成工程指标
很多人一听到“10万 QPS”就急着堆机器、上各种中间件,这是典型的本末倒置。高并发系统设计的第一步永远是拆业务和算容量,你得先知道这10万 QPS 打在哪些环节上、每个环节各自承受多大压力,否则后面所有技术选型都是拍脑袋。
1.1 一次领券请求在系统里要过哪些关卡
拿一个典型的限量抢券活动来说,用户点击“立即领取”之后,请求不会直接“发券”完事,而是要依次穿过这些关卡:
| 环节 | 主要职责 | 压力特征 |
|---|---|---|
| 接入层 | 解析、负载均衡、基础的IP维度防护 | 带宽占用最高 |
| 网关层 | 登录态鉴权、全局限流、灰度路由 | 纯CPU计算型压力 |
| 业务层 | 活动校验、用户资格校验、风控规则、库存预检 | 需要查多处Redis和本地缓存 |
| 数据层 | Redis库存扣减、异步消息、MySQL落库 | 热点集中,最容易成为瓶颈 |
网关和业务层是无状态计算,可以通过加机器线性扩展;真正的危险集中在数据层——尤其是同一个活动的库存key被所有抢券请求同时访问,这就是典型的热点。你设计的核心工作,就是让热点不要击穿任何一层。
1.2 容量账:带宽、应用机器、Redis、DB写入量一起算
不要只看QPS,容量估算必须围绕一整条链路来做。以10万 QPS 的入口流量为基准,我习惯这样估算:
- 带宽:假设单个请求响应体平均1KB,10万 QPS 意味着每秒要传100MB左右的数据,约合800Mbps。这还没算日志、HTTP头、网络损耗,所以接入层至少要预留1Gbps以上的带宽。
- 应用层:理想情况下单实例能抗2-3万QPS,但考虑到CPU还要做鉴权、限流、拼装响应,我通常按单机1.5万到2万来规划,至少需要6至8个实例,并且必须是无状态服务。
- Redis:一次领券请求平均要读2~3次Redis(活动配置、资格预检、扣减库存),所以Redis侧真实压力是20万到30万QPS。单机Redis虽然能到10万+,但热点key会导致单分片过载,后面必须做分片和本地缓存。
- MySQL写入:假设10万QPS中最终成功领到券的只有十分之一,也就是1万TPS。每笔成功要写用户券记录、领取流水,有些还要写活动统计,大约2-3条写操作,合计每秒2-3万次写入。这个量级单库根本撑不住,必须分库分表或者先削峰。
很多人一开始只盯着“10万 QPS”这个数字,忽略了成功领取率、读写放大倍数和带宽,结果压测时应用CPU不紧张,Redis或网卡先爆了。先算账,再动手,能让后面的每一步都有据可依。
2. 整体架构分层:流量如何逐层被削薄
容量账算清楚之后,架构轮廓其实已经浮出来了。我不会为了“高并发”把系统搞得很花哨,而是坚持一条朴素原则:每一层只做自己该做的事,把流量逐层削薄。
2.1 从单机到四层架构的演进逻辑
最原始的系统只有一台应用服务器加一台MySQL,券表、库存、流水全在一个库里。这种结构在日活几千时没问题,但在抢券场景下,数据库很快会成为死穴:单条UPDATE乐观锁扣库存,随着行锁竞争,数据库的TPS会断崖式下跌。
所以10万级QPS必须从一开始就按四层来规划:
- 接入层:用云负载均衡或Nginx做入口,负责TLS卸载、基础限流、连接管理;
- 网关层:集中处理鉴权、全局限流、风控前置判断,避免无效请求打到业务层;
- 业务层:无状态服务,只做活动规则、资格校验和编排;
- 数据与中间件层:Redis扛高频读写,消息队列做削峰,MySQL只承接异步落库。
这四层的核心思想是“能挡在外面的流量就不要放进来,能用异步解决的不要同步等待,能走内存的不要走磁盘”。每往下一层,流量都应该减少一个数量级。
2.2 为什么数据要先打到Redis而不是直接写库
这是整个系统最反直觉的地方。很多新手觉得“库存是重要数据,必须放MySQL里才安心”,但现实是MySQL单机在4万到5万QPS纯读、几千TPS写入时已经捉襟见肘,抢券的瞬时写入量远超这个量级。
压测数据很直观:MySQL行级锁在超高并发扣库存时,TPS可能从3000掉到几百;而Redis单线程执行Lua脚本,原子扣减能稳定支撑几万QPS级别。所以我倾向于把“当前剩余库存”这一个状态完全放在Redis中,用Lua保证原子性,MySQL只负责记录最终的事实——用户最终拿到了哪张券、券码是多少、领取流水是什么。Redis的数据万一有偏差,靠对账任务在低峰期修正。这不是不负责任,而是把“实时扣减”和“最终记账”两件事拆给了最适合它们的组件。
2.3 业务层必须无状态
应用层最大的杀手是状态。如果会话信息、库存副本只存在本地内存,扩容和重启都会出问题。我的做法很粗暴:业务层任何需要跨请求共享的数据都放Redis或本地缓存这种明确标注了“可丢失”的组件;业务实例只保留代码、配置和可重建的本地缓存。这样压测时发现问题,直接加几台实例就能分摊流量,不需要迁移任何数据。
3. 抢券防超发:库存扣减与幂等控制是命门
高并发优惠券系统所有技术花活,最终都要回归到两个硬指标:不能超发,不能重复发。这两个问题看起来简单,做到正确却需要仔细权衡。
3.1 库存扣减的三种方案与取舍
我实际对比过三种扣减方案,各有适用场景。
方案A:数据库乐观锁SQL大概是UPDATE coupon_stock SET stock = stock - 1 WHERE campaign_id = ? AND stock > 0。优点是绝对准确,不依赖Redis;缺点是行锁竞争。实测单表超过每秒2000次扣减时,数据库锁等待就会急剧上升。
方案B:Redis Lua原子扣减这是主流做法,推荐优先考虑。核心逻辑是:读库存、判断是否足够、扣减,三步在一个Lua脚本里原子完成。脚本长这样:
-- KEYS[1]: 库存key,ARGV[1]: 本次扣减数量 local stock = tonumber(redis.call('GET', KEYS[1])) if not stock or stock <= 0 then return -1 -- 库存不足或未初始化 end local num = tonumber(ARGV[1]) if stock < num then return -2 -- 扣减后为负 end redis.call('DECRBY', KEYS[1], num) return stock - num注意,Redis是单线程模型,整个Lua脚本执行期间不会有其他命令插入,因此不会出现“两个请求同时读到剩余1张库存,然后都扣减成功”的经典超卖问题。
方案C:纯本地内存扣减把库存预加载到每台业务实例本地内存,直接在本地扣减,性能最高,但一致性维护很麻烦。我一般只把它当作“一级缓存”来用,与Redis配合,而不是完全脱离Redis。
三者并不冲突,实际系统里我经常是B做最终裁决,C做前置加速,A做settlement兜底。
3.2 幂等:同一用户不能重复领
防超发解决的是“总量”问题,幂等解决的是“同一个人”问题。用户在抢券场景下的行为是非常狂躁的:连点、失败重试、多端并发,同一个请求可能以多份副本同时到达服务端。
最稳妥的方案是数据库唯一索引:(user_id, campaign_id)建唯一约束,后写入的请求会因唯一键冲突失败。但问题是,如果每次都等落库再判断幂等,用户要等很久。所以我会在Redis里提前放一道哨兵:
SET lock:user_campaign:{userId}:{campaignId} 1 NX EX 10只有SETNX成功才能进入后续流程,领取完成后再释放。考虑到抢券窗口很短,10秒过期绰绰有余。Redis哨兵挡住绝大多数重复请求,MySQL唯一索引兜底处理极端并发。双保险下来,重复发放基本不可能。
3.3 限流与风控:别让恶意流量吃掉真实用户的机会
优惠券系统天然吸引羊毛党和脚本刷量,如果不对流量做分级,正常用户根本抢不到券。我通常在两层做限流:
- 网关层全局限流:按活动配置总入口QPS,比如目标10万QPS就限制在11万,超过的直接返回“排队中”,保护下游;
- 业务层用户维度限流:单个用户对同一活动每秒最多放行1到2次请求,基于Redis的令牌桶实现。用户维度的限流数据量不大,每活动几百万用户,Redis内存足以支撑。
风控的成熟方案可以后续接入专业系统,但基础的黑名单、设备指纹、IP异常频次,我建议在业务层做一个轻量前置,因为风控判定一旦后置,失败的请求已经打到了Redis,浪费了宝贵的处理能力。
4. 缓存分层与热点key治理:别让Redis变成第二个瓶颈
流量过了网关和业务层,真正的硬仗在Redis。很多人想当然地认为Redis能扛10万QPS就够了,放到优惠券场景里完全不是这么回事——因为流量不是平均分布在所有key上,而是几乎全部打向同一个活动库存key。
4.1 热点key问题:单分片的吞吐天花板
Redis Cluster会把key按hash槽分散到多个节点,但同一个key注定只属于一个分片。当一场大促活动只有一个库存key时,10万QPS会全部压到一个分片上:单分片的CPU和网络先到瓶颈,其他分片还在空转。更危险的是,一旦这个分片节点抖动,活动直接整体不可用。
解决思路只有一个:把热点拆开,让流量均匀散布到多个分片上。
4.2 分片库存key与两级缓存设计
实践中最有效的组合拳是“分片key + 本地缓存”。
分片key的做法是:把活动的总库存拆成N份,分别存到coupon_stock:{campaignId}:{0..N-1}这N个key里。用户请求根据如用户ID的hashcode分片,只扣自己那一片。
我常用的N是10到20,兼顾打散效果和查询成本。扣减时,如果本分片库存不足,再尝试下一个分片。库存的汇总查询需要遍历所有分片累加,但由于这个查询频次很低,成本可接受。
本地缓存放在业务实例里,与Redis构成两级结构。每次扣减先走本地内存判断,本地没有余量才回源Redis。但这里必须小心一个坑:本地缓存如果长期持有库存副本,会与实际库存产生偏差。我的做法是设定极短TTL(比如1秒到2秒),并且本地库存只是“预判”,真正扣减始终以Redis为准。这样错误率可控,性能还能大幅提升。
4.3 缓存击穿、穿透与雪崩的兜底
热点key过期瞬间,大量请求同时回源数据库,这是最经典的“缓存击穿”。对优惠券系统,我的补救措施有三条:
- 活动配置这类热点数据,不给过期时间,靠后台定时刷新;
- 库存key本身不允许过期,活动结束后由任务删除;
- 如果Redis异常,业务层做一个本地熔断开关,一段时间内直接返回“活动火爆”,避免请求穿透到MySQL。
缓存穿透则是恶意用户用不存在的活动ID反复请求,我的办法是布隆过滤器前置拦截。缓存雪崩则靠给不同配置项加随机过期时间错峰过期。这些措施不复杂,但缺一个都会在压测高峰期变成事故。
5. 异步削峰与最终一致性:写流程的终极解法
即时扣减库存之后,还有一堆写操作:生成用户券、写领取流水、更新活动统计。如果这些全在请求线程里同步完成,MySQL扛不住,用户响应也会变慢。我的原则是:用户侧必须很快看到结果,落库的事放进队列慢慢做。
5.1 同步转异步:把写峰值削成平滑曲线
同步链路里,一次请求要做完扣库存、发券、写库、返回,耗时不可控。异步化之后,“领取”这个动作在Redis扣减成功后即可返回“领取成功”,后面的券记录写入、流水记录、活动统计全部发布到消息队列,由消费者异步处理。
消息队列在这里就像一个大坝,能把瞬时洪峰改造成平稳水流。比如10万QPS的突发流量写入队列,消费者按自己能力的2万TPS慢慢消费,积压就积压,不会压垮数据库。对于用户来说,反正他拿到的券已经通过Redis扣减留住了,晚几秒看到卡包里的券完全无感。
5.2 消息不丢不重:生产者、消费者与对账机制
异步化最怕“消息丢了”,用户明明抢到券,库里却没有记录。我会在三个环节分别加固:
- 生产者可靠发送:消息发出后等待Broker确认,确认失败就重试。发送动作与业务操作放在同一个本地事务表里,先写“待发送消息”表,再异步发送,避免业务成功而消息没发出的情况。
- 消费者手动确认:消费成功再提交位点,处理失败就重回队列或进入死信队列。这里要接受“重复消费”的存在,因为网络重试必然可能导致重复,消费端必须用业务幂等去兜底——用户券记录有唯一索引,重复消费不会生成两张券。
- 对账任务兜底:定时任务对比Redis里“已扣减成功”的明细和MySQL里“实际已发放”的记录,找出差异,补偿发券或回收。我对这套方案的定义是:“大部分情况下靠队列,特殊情况下靠对账,最后一定能把账做平。”
5.3 消息乱序的处理
同一个用户的多个操作(领取、核销、退款归还库存)如果乱序,状态就会错乱。我的做法是给同一用户的消息都带上用户ID作为分区键,让同一用户的消息进入同一个分区,保证消费顺序。这里顺便提醒一句:分区有序不是全局限序,如果业务上要求全局强一致,那就不适合完全交给MQ解决,而应该在落库时加版本号校验。
6. 压测达标与上线实战:从1万QPS一路推到10万
架构设计得再漂亮,最终都要用压测数据说话。我把这阶段单独拿出来讲,是因为太多系统“设计时天下第一,压测时原形毕露”了。
6.1 分阶段压测:小步快跑逼近目标
我不会一上来就用压测工具狂灌10万QPS,那只会得到一份毫无区分度的失败报告。我的推进节奏是四步:
- 单实例1万QPS:验证核心链路功能、Redis脚本正确性、基本响应时间;
- 集群3万QPS:验证负载均衡是否均匀、Redis分片是否发挥了作用;
- 全链路8万QPS:补齐数据库、MQ、日志、监控等环节,观察瓶颈开始出现在哪里;
- 冲刺10万QPS:做最后的资源调整、线程池参数优化和预案演练。
每个阶段我都盯着几个关键指标:
| 指标 | 健康线 | 危险线 |
|---|---|---|
| P99响应时间 | 小于500ms | 超过1s |
| 错误率 | 小于0.1% | 超过1% |
| Redis CPU | 低于60% | 超过80% |
| 应用GC停顿 | 未触发Full GC | 频繁Full GC为危险信号 |
| 数据库连接池 | 占用低于50% | 占用超过80%且有等待 |
压测时最容易踩的坑是用了线上活动来压,导致真实库存被消耗。我都是创建专门的“压测专用活动”,使用独立库存和用户数据,压完直接下线,避免污染线上数据。
6.2 上线前还要做的三件事
压测过了不代表上线稳了,我还会强制要求三件事:
- 监控告警:Redis内存与慢日志、MQ积压量、应用RT和错误率、MySQL慢查询,全部上监控并设置分级告警。大促期间,告警电话必须能打到人;
- 开关预案:每个依赖Redis、MQ、风控的环节都要有开关。出问题时可以一键限流、一键降级、一键切到只读模式,哪怕损失一点体验,也不能让系统全崩;
- 灰度发布:新版本先放10%流量跑一段时间,确认无误再全量。优惠券系统出故障的影响面极大,灰度是最后一道保险。
6.3 上线后我真实的踩坑清单
即便是做了上面所有准备,我还是在真实运营中遇到过失信场景,挑几个典型的分享:
- 活动配置缓存被误删:运营在后台误操作刷新配置,导致Redis里的活动信息丢失,瞬间所有请求开始回源数据库查询,数据库连接池被打满。后来我在业务层加了“允许短暂读取旧配置”的二级缓存,才彻底避免这类同款问题。
- 用户维度限流误伤:有些正常用户在同一网络出口(如公司WiFi)共享IP,被IP维度限流误杀。解法是改用“用户ID + 活动ID”作为限流维度,IP只做辅助。
- 消息消费慢导致发券延迟:瞬时洪峰涌入MQ后,消费者数量不够,部分用户券记录延时几分钟。此时不是急着加消费者,而是先看消费慢是不是因为数据库唯一索引检查太慢,或者死信重试风暴。
这些都是压测阶段难以完全模拟、只有真实流量才能暴露的边界情况。我的经验是:不要试图一次性把所有情况都设计完美,而是给系统留出可观测、可干预、可回滚的空间,出了状况能快速止血。
最后再分享一点个人体会。搭建这种量级的优惠券系统,真正难的不是会写某个Lua脚本,也不是会用某个中间件,而是头脑里始终有一张完整的链路图:每个请求从哪进来、在哪一层被拦截、在哪一层被削峰、最终在哪个组件里留下事实记录。把这张图想清楚,10万QPS就不会是一个让人恐惧的数字;想不清楚,再贵的机器也救不了你。值得庆幸的是,一旦你把这条链路走通一遍,再遇到其他高并发业务场景,你会发现它们的解法其实大同小异。