今天我们来聊一个让无数工程师“又爱又怕”的话题——秒杀原理。很多人以为,不就是把数据库加个索引、加点缓存、多开几个服务器?但现实远非如此。
某次电商大促中,我们曾遭遇单日订单峰值突破500万/分钟的极端场景。初期团队直接把所有请求打到数据库,结果慢查询积压、连接池耗尽、CPU飙升至100%,最终系统雪崩。紧急切换流量后,我们才意识到:真正的问题从来不在流量本身,而在于系统是否具备“分身术”。
所谓“分身术”,就是将请求分流、缓存预热、异步解耦、状态隔离……每一环都牵一发而动全身。下面我们就从秒杀原理的底层逻辑出发,逐层拆解高并发场景下的核心技术要点。
某品牌手机首发,10万件库存,15秒售罄。初期方案:数据库直接扣减库存。结果前3秒就出现大量“超卖”与“库存负值”,用户反馈“已付款未出库”。紧急重构后:采用Redis预减库存 + Lua脚本原子扣减 + 消息队列异步落库,最终系统平稳支撑,订单准确率达100%。
秒杀核心原理:流量削峰 + 异步解耦 + 状态隔离
用户请求是“无序洪流”,但系统只能“有序处理”。因此,我们必须在入口层做流量整形:
- 前端按钮防误触:倒计时结束后才启用提交按钮
- 服务端限流:令牌桶或漏桶算法控制QPS(如Guava RateLimiter)
- CDN静态资源缓存:避免静态资源请求压垮应用服务器
- 异步排队机制:将下单请求推入消息队列,后台逐步消费
关键点在于:让用户“感知快”,哪怕后台实际处理慢一点。比如前端显示“排队中(第234位)”,比直接卡死要友好100倍。
秒杀请求不是“同步等待结果”,而是“提交即成功”,后续通过异步任务完成订单生成、库存扣减、通知推送等操作。
典型方案:RabbitMQ/Kafka 消息队列 + 延迟队列 + 补偿机制。
️⃣ 用户点击“秒杀”按钮
前端拦截重复提交,服务端校验用户资格(是否登录、是否黑名单),将请求封装为订单事件消息入队。
️⃣ 消费端异步处理订单
从队列拉取消息 → 校验库存(Redis预减)→ 生成订单 → 更新订单状态为“待支付” → 返回“排队成功”提示。
️⃣ 超时未支付自动释放库存
使用延迟队列(如RabbitMQ TTL+DLX),30分钟后自动触发库存回滚任务。
️⃣ 异常补偿兜底
定时任务扫描“超时未支付订单”,强制释放库存并记录异常日志,防止死锁。
秒杀系统中,库存是最核心的状态资源。若直接操作数据库库存字段,每次更新都要走事务+锁,必然成为瓶颈。
正确做法:库存预热 + Redis原子操作 + 消息队列异步落库。
- 活动前10分钟,将库存从DB同步至Redis,作为“可用库存池”
- 用户下单时,先在Redis中扣减库存(Lua脚本保证原子性)
- 扣减成功后,生成订单消息入队,由后台服务异步写DB
- 若最终DB落库失败(如订单重复),通过补偿任务回滚Redis库存
某次活动未做Redis与DB一致性校验,导致1000件库存卖出1032单。事后补救:对超卖订单执行“人工审核+补偿优惠券”,并上线“库存快照一致性校验”模块,每次大促前自动比对Redis与DB库存差值。
Redis深度优化:不只是“快”,更要“稳”
? Redis数据结构:从“简单计数”到“原子状态机”
秒杀场景下,库存字段不能只存一个数字(如SET stock:1001 5000),必须结合多维信息防止错扣:
Hash结构:存储商品库存详情
ZSet结构:记录用户抢购队列(用于防刷)
扣库存时,使用Lua脚本确保:
- 检查版本号(防止并发覆盖)
- 原子性:reserved+1 <= total
- 更新version,避免ABA问题
? 持久化策略:RDB vs AOF,如何取舍?
秒杀期间Redis内存压力极大,持久化策略直接影响恢复速度与数据一致性。
| 策略 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| RDB快照 | 恢复快、文件小 | 可能丢失最后一次快照后数据 | 库存数据可容忍少量丢失的场景 |
| AOF日志 | 数据安全性高(每秒同步) | 文件大、恢复慢 | 订单关键数据需强一致 |
| 混合持久化 | RDB + AOF增量,兼顾速度与安全 | 配置复杂 | 推荐用于秒杀系统 |
实战建议:主库用混合持久化,从库用RDB,既保证恢复效率,又避免AOF文件过大拖慢启动。
? 分片与集群:如何横向扩展?
单机Redis内存有限(如16GB),若库存商品超10万件,必须分片。
方案一:客户端分片(ShardingJDBC)
- 按商品ID哈希分片:如
user:8848:1001→ Redis-0;user:8848:1002→ Redis-1 - 优点:无代理层,延迟低
- 缺点:扩容困难,需重新哈希
方案二:Redis Cluster(推荐)
- 自动分片 + 主从复制 + 故障转移
- 节点间Gossip协议同步元数据
- 客户端直连任意节点,自动跳转
- 节点集群:3主3从(避免脑裂)
- 开启
cluster-require-full-coverage no:单分片故障不影响整体服务 - 设置
maxmemory-policy allkeys-lru:淘汰冷数据 - 关闭
slowlog-log-slower-than 1000:记录>1ms的慢命令
库存扣减:从“数据库自杀式冲锋”到“原子级保障”
若直接用SQL扣库存:
UPDATE goods SET stock = stock - 1 WHERE id = 1001 AND stock > 0
问题:
- 高并发下行锁竞争激烈,大量线程阻塞
- 索引失效风险:若
stock > 0条件未走索引,全表扫描 - 事务回滚开销大,拖垮连接池
正确姿势:Redis预减 + DB补偿校验
- Redis扣减失败 → 直接返回“已售罄”
- 扣减成功 → 发送异步消息,后台服务再校验DB库存并落单
- 若DB库存不足(如Redis与DB不一致),回滚Redis并通知用户
常见方案:UPDATE goods SET stock = stock - 1, version = version + 1 WHERE id = 1001 AND version = ?
但秒杀场景下,版本号更新极快,导致大量重试失败。更优解是:位图(Bitmap)+ 时间戳分片
假设商品1001总库存1000,可拆为10个时间片(每100秒一个池),每个池独立库存(如100件)。
优点:避免单点竞争;缺点:需动态切换池,逻辑稍复杂。
用户下单后未支付,超时未释放库存,最终导致超卖。
解决方案:Redis库存预留 + DB二次校验 + 补偿任务兜底
- Redis扣减时,库存进入“预留池”(如
reserved字段) - 订单支付成功后,再将预留库存转为“已售”
- 超时订单触发补偿任务,将预留库存回滚至“可用池”
最终一致性保障:定时任务扫描“30分钟未支付订单”,批量回滚预留库存。
并发控制:从“线程爆炸”到“精准调度”
部分开发者习惯用Executors.newCachedThreadPool(),但在秒杀场景下极易导致:
- 线程数无限增长(每请求创建1线程)
- 上下文切换开销巨大
- OOM风险
正确做法:固定大小线程池 + 队列排队 + 拒绝策略降级
关键点:队列必须有界!否则堆积请求最终导致内存溢出。
悲观锁(SELECT ... FOR UPDATE)在秒杀中极易引发死锁:
- 事务A锁行1→等行2
- 事务B锁行2→等行1
- 形成循环等待 → 死锁
乐观锁(版本号+CAS)更安全,但需注意:
- 重试次数限制(避免无限循环)
- 重试间隔指数退避(如10ms→20ms→40ms)
- 最终失败时降级为人工审核
当业务需跨服务协调(如订单服务+库存服务+支付服务),需分布式锁:
| 方案 | 优点 | 风险 |
|---|---|---|
| Redisson(单Redis) | 自动续期(WatchDog)、可重入 | 主从切换时可能丢失锁 |
| RedLock(多Redis实例) | 高可用,单点故障不影响 | 网络分区时可能失效(CAP权衡) |
秒杀场景建议:单Redis + Redisson + 合理过期时间(如10秒) + 补偿任务
用户体验:后台扛压,前台丝滑
用户等待超过3秒无反馈,会产生强烈挫败感。因此必须:
- 提交后立即显示“排队中(第234位)”
- 每10秒自动刷新状态(避免手动刷新)
- 倒计时结束前禁用按钮,防止重复提交
- 超时未支付,弹窗提示“库存紧张,建议提前加购”
- 使用骨架屏(Skeleton Screen)代替加载动画
- 订单状态通过WebSocket实时推送(比轮询更省资源)
- 网络较差时,自动降级为“仅展示结果,不实时更新”
订单创建后,需异步执行:
- 发送支付通知
- 扣减用户优惠券
- 更新商品销量
- 写入用户行为日志
若用同步调用,用户需等待所有操作完成 → 体验极差。
推荐方案:Kafka延迟队列 + 死信队列补偿
- 订单创建 → 发送消息到延迟队列(延迟30分钟)
- 若30分钟内支付成功 → 消费消息并删除
- 若超时未支付 → 消费消息 → 执行回滚库存、释放优惠券
- 若回滚失败 → 投递到死信队列 → 人工介入
结语:真正的性能优化,是让系统默默扛住流量
秒杀系统没有“银弹”,它是一场系统工程的综合考验。从架构设计到代码实现,从缓存优化到用户体验,每一环都需精心打磨。记住:用户不会关心你用了多少Redis、多少线程池,他们只在乎“点一下,能不能抢到”。而我们的使命,就是让这份“确定性”,在高并发的风暴中依然坚如磐石。
—— 秒杀原理-秒杀核心原理,值得每一位高并发工程师深度掌握