1. 百万级QPS抢券系统的核心挑战
抢券系统本质上是一个特殊的秒杀场景,但与普通秒杀相比存在三个显著差异点:首先是券的库存通常比实物商品更轻量化,这意味着系统可以承受更高的并发压力;其次是券的发放往往伴随着复杂的业务规则(如每人限领、叠加规则等);最后是券的使用存在时间窗口,这要求系统在发放时就要考虑后续核销的链路一致性。
在百万QPS的压力下,系统会面临几个典型瓶颈:数据库连接池耗尽、缓存雪崩、库存超卖以及分布式锁争用。我曾参与设计的一个电商平台抢券系统,在未优化前MySQL集群的CPU利用率在峰值时直接冲到98%,导致整个交易链路瘫痪。后来通过以下架构改造,最终稳定支撑了120万QPS的流量洪峰。
2. 分层抗压架构设计
2.1 接入层优化
接入层是流量洪峰的第一道防线。我们采用Nginx+OpenResty的方案,通过Lua脚本实现以下关键功能:
- 流量染色:给不同渠道的请求打上标签,便于后续限流策略的差异化执行。例如APP端请求优先级高于H5端:
location /coupon { access_by_lua ' if ngx.var.http_user_agent ~= nil then if string.find(ngx.var.http_user_agent, "APP") then ngx.req.set_header("X-Source-Type", "HIGH_PRIORITY") end end '; }- 动态限流:基于令牌桶算法实现多维度限流。这里有个关键技巧是采用Redis的CL.THROTTLE命令而非自己实现算法:
redis-cli --eval throttle.lua $ip $burst_rate $rate , $expire_time踩坑提醒:Nginx的limit_req模块在极端高并发下会导致worker进程阻塞,我们实测改用Lua+Redis方案后,单节点限流性能提升17倍。
2.2 服务层设计
服务层采用"漏斗型"架构,逐层过滤无效请求:
- 请求预处理:
- 参数校验(券ID有效性、用户资质等)
- 内存布隆过滤器拦截无效请求(误判率设为0.1%时,可减少98%的Redis查询)
- 本地缓存热点数据(如券基本信息)
- 分布式锁优化: 传统Redis锁在百万QPS下会产生严重争用。我们的解决方案是:
// 分段锁+本地锁结合 public boolean tryLock(String couponId, Long userId) { int segment = userId.hashCode() % 32; // 32个分段 synchronized(segmentLocks[segment]) { String lockKey = "lock:" + couponId + ":" + segment; return redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS); } }- 库存扣减方案: 采用Redis+Lua保证原子性,关键是要处理库存回滚:
-- 扣减库存脚本 local stock = tonumber(redis.call('GET', KEYS[1])) if stock <= 0 then return 0 end redis.call('DECR', KEYS[1]) redis.call('HSET', KEYS[2], ARGV[1], ARGV[2]) -- 记录领取明细 return 12.3 数据层设计
- 缓存策略:
- 采用多级缓存架构:本地缓存(Caffeine) -> 分布式缓存(Redis Cluster) -> 持久层(MySQL)
- 热点Key检测:通过Redis的MONITOR命令分析热点Key,对检测到的热点进行本地缓存预热
- 数据库优化:
- 垂直分库:用户库、券库、订单库分离
- 水平分表:按券ID哈希分16个表
- 异步落库:通过Binlog+MQ实现最终一致性
3. 库存一致性保障方案
3.1 预扣库存模式
采用"预占-确认-释放"的三阶段模式:
- 先在Redis中预扣减
- 创建预占记录到MQ
- 消费者异步更新数据库
// 库存服务核心逻辑 public Result grabCoupon(Long couponId, Long userId) { // 1. Redis预扣减 Long remain = redisTemplate.execute(STOCK_DEDUCTION_SCRIPT, Arrays.asList("stock:" + couponId, "record:" + couponId), userId.toString(), System.currentTimeMillis()); if (remain < 0) { return Result.fail("库存不足"); } // 2. 发送预占MQ mqProducer.send(new PreOccupyMessage(couponId, userId)); // 3. 返回预占成功 return Result.success(preOccupyId); }3.2 异常处理机制
我们设计了库存对账系统,每小时执行以下流程:
- 比对Redis库存与DB库存差异
- 扫描预占超时(>30分钟)的记录
- 自动触发库存回补
经验之谈:对账时一定要加分布式锁,我们曾因没加锁导致重复回补,一夜之间多发了200万张券。
4. 性能压测实战数据
4.1 压测环境配置
- 服务器:8C16G * 20台(K8s集群)
- 中间件:Redis Cluster(16分片)、RocketMQ(8节点)
- 数据库:MySQL 8.0(1主3从)
4.2 优化前后对比
| 指标 | 优化前 | 优化后 |
|---|---|---|
| 最大QPS | 12万 | 152万 |
| 平均延迟 | 820ms | 38ms |
| DB CPU峰值 | 98% | 23% |
| 超卖发生率 | 0.3% | 0 |
4.3 关键参数调优
- Redis连接池:
spring.redis.lettuce.pool: max-active: 500 # 原默认8 max-idle: 100 min-idle: 20- Tomcat参数:
server.tomcat.max-threads=800 server.tomcat.accept-count=1000- JVM调优:
-XX:+UseG1GC -Xms4g -Xmx4g -XX:MaxGCPauseMillis=2005. 容灾与降级方案
5.1 多级熔断策略
- 硬件层:在SLB配置每秒请求数阈值
- 服务层:Sentinel配置异常比例熔断
- 功能层:开关控制非核心功能降级
// 降级开关示例 @GetMapping("/coupon/list") public Result listCoupons() { if (degradeSwitch.get()) { return Result.success(cacheService.getBasicCouponInfo()); } return couponService.queryDetailList(); }5.2 热点隔离方案
- 物理隔离:独立部署抢券服务集群
- 数据隔离:单独Redis集群处理库存
- 链路隔离:独立线程池处理核心流程
我们通过Arthas监控发现,在流量峰值时非核心的日志打印占用了15%的CPU资源,后来改为异步日志后性能提升显著。
6. 监控体系搭建
6.1 核心监控指标
- 系统层:
- 服务器Load值
- GC次数与耗时
- 网络带宽使用率
- 应用层:
- 接口TP99
- 线程池活跃度
- 缓存命中率
- 业务层:
- 库存偏差率
- 抢券成功率
- 用户重复领取率
6.2 全链路追踪
通过SkyWalking实现:
@Trace(operationName = "coupon/grab") public void grabCoupon() { ActiveSpan.tag("user_id", getUserId()); // 业务逻辑... }在实战中发现,将Trace采样率设置为100%会导致性能下降40%,最终调整为动态采样:
- 日常:1%
- 大促:10%
- 异常时:100%
这套系统经过618大促实战检验,在130万QPS压力下保持稳定,期间Redis集群峰值QPS达到210万,平均延迟控制在50ms以内。最关键的经验是:高并发系统不是设计出来的,而是压测出来的。我们前后进行了37次全链路压测,每次都能发现新的性能瓶颈。