秒杀系统几乎是所有头部互联网公司 Java 后端面试里的压轴题,也是我这些年面试候选人时最爱聊的话题之一。你简历上只要写了高并发、微服务这几个词,十有八九会被问“设计一个秒杀系统你会怎么做”,或者更直接“Redis 和数据库库存不一致怎么办”“MQ 消息积压了怎么处理”。这套“Spring Boot + Kafka + Redis”的组合,在电商秒杀场景里已经成了事实标配,背后的原理和应用细节却往往被一笔带过。
这篇文章我把这套体系拆开揉碎讲一遍,包括业务层面的设计思路、技术层面的原理剖析,还有我在模拟项目里一点点调出来的代码实现和踩坑记录。无论你是正要准备面试,还是已经在负责秒杀相关系统的开发,这份整理都能直接用上。
1. 秒杀系统的核心挑战与技术选型思路
1.1 电商秒杀到底难在哪里
先说业务本身。秒杀的定义就是:在极短时间内、有限库存下,大量用户同时抢购同一件商品。比如某手机品牌限量发售 200 台,开抢瞬间可能有 20 万人同时发起请求。这样量级的流量,直接打到单机 MySQL 上,数据库 10 秒内必挂。
我把秒杀系统的难点归纳成三个维度:
- 超高并发流量:瞬时 QPS 可能达到 10 万以上,系统需要在上万并发连接下保持可用。
- 超卖防控:100 个库存卖出 120 单,就是重大生产事故。超卖不仅涉及资损,还会引发舆论风险,属于绝对不能踩的红线。
- 数据一致性:库存扣减、订单生成、支付回调,这些环节不能因为高并发就乱了套。
传统做法是把所有请求都直接打到数据库上,用 MySQL 的行锁去控制库存,但数据库每秒能承受的事务量级有限,一般单库也就几千,根本无法承接十万级并发。这里面最核心的问题是——为什么要让所有流量都打到最脆弱的环节?架构设计的本质任务,就是让大部分流量在到达数据库之前就被拦截或消化掉。
1.2 为什么偏偏是这三个组件
先给出一套我自己用得很顺的分层模型:
- 接入层:负责限流、风控、用户身份校验。
- 缓存层:用 Redis 承担库存预减和热点数据读取。
- 异步缓冲层:用 Kafka 承接削峰后的下单请求。
- 存储层:MySQL 只处理真正需要落库的数据。
这套分层里,每个组件各司其职:
- Spring Boot提供了一套成熟的开发框架,把 HTTP 接口、依赖注入、自动配置都做好了,让我能集中精力写核心业务代码,而不是花时间搭骨架。
- Redis被放在数据库前面,是因为它的读写性能极高,同时它支持 Lua 脚本原子执行。库存预减操作在 Redis 里完成,可以扛住绝大多数并发流量。
- Kafka在这里扮演泄洪闸的角色。Redis 预减库存成功后,不直接写数据库,而是先把消息发到 Kafka,由消费者异步创建订单、扣减 DB 库存。峰值流量被削平了,数据库的写压力就变得可控。
这套架构有一个核心假设:最终一致性是被接受的。秒杀场景里用户关心的是“我抢没抢到”,只要在几秒内给出结果,不需要像金融转账那样强同步。
这套组件的另一个好处是故障隔离。Redis 挂了只影响秒杀入口,Kafka 堆积了订单也不会把数据库打崩。每个环节都有一定的缓冲余量,这在真实生产环境中非常重要。
2. 核心技术细节与原理剖析
2.1 Redis 预减库存:把压力挡在最前面
预减库存的核心思想是:先在一个高速的存储里把“谁能买”这件事定了,再让被选中的人去完成真正的订单创建。Redis 因为两个特点,成为这个场景的完美人选:一是基于内存,单线程模型下读写延迟极低;二是支持 Lua 脚本原子性执行,彻底避免了并发扣减时的竞争问题。
我一般用一段 Lua 脚本来做预减。脚本先把商品的剩余库存读出来,如果小于购买数量,直接返回失败;如果充足,就对库存做扣减,并返回成功。因为 Redis 是单线程执行脚本的,一段脚本在执行期间不会被其他命令打断,所以多个请求同时来时,它们是天然串行的,不会出现“超卖”的问题。
实际生产环境里,还需要给库存 key 设置合理的过期时间,防止 Redis 内存长期堆积无用数据。我一般设置为活动结束后 1 小时,这样既能覆盖退款、补偿等后续流程,又不至于长期占用内存。
注意:Redis 预减库存是“预扣”,数据库的扣减才是“最终凭证”。在秒杀成功后如果用户不支付,库存还需要回补。这里可以有两种策略——超时自动取消订单并回补 Redis 库存,或者在支付环节再去扣一次 DB 库存。我在项目里采用的是前者,逻辑更闭环。
2.2 Kafka 异步削峰:把瞬时洪峰变成平缓溪流
如果 Redis 预减完成后,直接把订单写入 MySQL,数据库依然会被瞬时打爆。因为 Redis QPS 能做到十万,MySQL 却只有几千,这中间必须有一个缓冲地带。Kafka 的定位就是这个缓冲。
Kafka 之所以在大数据量下表现优异,核心在于它的设计理念:
- 顺序写磁盘,而不是随机写,磁盘顺序写的速度远超随机写。
- 通过分区实现并行消费,一个 Topic 分成多个 Partition,消费者可以并行处理不同分区的数据,吞吐量随分区数扩容。
- 消息持久化,写入 Kafka 后消息不会丢失,消费者宕机后恢复还能继续消费,为异步流程提供了可靠性保障。
我把下单消息发送到 Kafka 后,接口层就可以直接返回“抢购成功,请等待支付”。用户的感知是流畅的,后端则按自己的节奏慢慢处理订单创建。
2.3 数据库最终一致性:避免超卖的最后防线
Redis 预减库存和 DB 库存扣减是两套数据,它们天然存在不一致的窗口。但只要流程设计正确,是可以做到最终一致的。
我的设计是:Redis 预减成功只能说明“用户获得了购买资格”,真正的库存扣减发生在 Kafka 消费者创建订单时。DB 层用一句带条件的 SQL 来扣减库存——UPDATE product SET stock = stock - 1 WHERE id = ? AND stock > 0。这句 SQL 利用数据库行锁保证同一商品在同一时刻只有一个事务能修改库存,从物理层面避免了超卖。
在消息异步处理过程中,如果消费者处理失败,可以重试。如果反复重试仍然失败,需要把消息转入死信 Topic,再通过定时任务补偿。这些都在代码层面做了兜底,保证 DBA 层面的数据最终不会多扣或少扣。
这里有一个容易犯的错误:认为只要 Redis 扣减正确,DB 扣不扣都无所谓。实际上,Redis 只是分布式缓存,数据可能因宕机丢失。DB 才是最终数据的权威来源。两个系统的数据必须对上账,依赖对账脚本定期扫描差异。
从“强一致性”到“最终一致性”的思维转变,是秒杀系统架构设计的核心,也是面试官最想从你嘴里听到的关键词之一。
3. 实操过程与代码实现
下面这套代码是一个完整的秒杀核心流程,我从一个模拟项目里抽取出来的,已经去掉业务细节,保留了最核心的主链路。
3.1 整体项目结构
seckill-demo/ ├── pom.xml ├── src/main/java/com/demo/seckill/ │ ├── controller/SeckillController.java │ ├── service/SeckillService.java │ ├── service/OrderService.java │ ├── mq/SeckillProducer.java │ ├── mq/OrderConsumer.java │ ├── config/RedisConfig.java │ └── mapper/ProductMapper.java └── src/main/resources/ ├── application.yml └── lua/stock.lua3.2 秒杀接口:前置校验与限流
第一个入口是 Controller。这里做的三件事是:校验用户登录态、准备 Redis key、调用预减库存逻辑。
@RestController @RequestMapping("/api/seckill") public class SeckillController { @Autowired private SeckillService seckillService; @PostMapping("/{skuId}") public SeckillResult doSeckill(@PathVariable Long skuId, @RequestParam Long userId) { // 前置校验:用户是否登录、是否在黑名单中 if (userId == null) { return SeckillResult.fail("请先登录"); } // 限流:每个用户每秒最多请求3次 boolean allowed = seckillService.rateLimit(userId); if (!allowed) { return SeckillResult.fail("请求过于频繁"); } return seckillService.startSeckill(skuId, userId); } }限流是一个极其重要的前置能力。我见过不少系统连最基本的按用户限流都没有做,一个用户用脚本几十毫秒发一次请求,库存瞬间被脚本党抢光。限流最轻量的实现是用 Redis 计数器,固定窗口 1 秒内限制 N 次。更复杂的滑动窗口、令牌桶算法适合放到网关层,这里 Redis 版足够。
3.3 Redis 预减库存的 Lua 脚本实现
这是整套系统的核心,我把 Lua 脚本单独放在 resources 目录。脚本接收三个参数:商品库存 key、购买数量、用户标识。脚本内部完成库存检查与扣减。
-- KEYS[1]: 库存 key -- ARGV[1]: 购买数量 -- ARGV[2]: 用户唯一标识 local stock = redis.call('GET', KEYS[1]) if not stock then return -1 end if tonumber(stock) < tonumber(ARGV[1]) then return 0 end redis.call('DECRBY', KEYS[1], ARGV[1]) -- 记录已抢购用户,用于幂等判断 redis.call('SADD', 'seckill_users_' .. KEYS[1], ARGV[2]) return 1Java 侧调用:
@Service public class SeckillService { @Autowired private StringRedisTemplate redisTemplate; @Autowired private SeckillProducer seckillProducer; private static final DefaultRedisScript<Long> SECKILL_SCRIPT; static { SECKILL_SCRIPT = new DefaultRedisScript<>(); SECKILL_SCRIPT.setLocation(new ClassPathResource("lua/stock.lua")); SECKILL_SCRIPT.setResultType(Long.class); } public SeckillResult startSeckill(Long skuId, Long userId) { String stockKey = "seckill:stock:" + skuId; String userKey = "seckill:user:" + skuId; // 幂等校验:同一用户不能重复抢 Boolean isMember = redisTemplate.opsForSet().isMember(userKey, userId.toString()); if (Boolean.TRUE.equals(isMember)) { return SeckillResult.fail("该商品已抢购,请勿重复操作"); } // 执行 Lua 预减库存 Long result = redisTemplate.execute(SECKILL_SCRIPT, Collections.singletonList(stockKey), "1", userId.toString()); if (result == null || result == -1) { return SeckillResult.fail("活动不存在或已结束"); } if (result == 0) { return SeckillResult.fail("商品已售罄"); } // 预扣成功,发送 MQ 消息,异步创建订单 seckillProducer.sendSeckillMessage(skuId, userId); return SeckillResult.success("抢购成功,请尽快支付"); } }这段代码的关键在于 Lua 脚本把“检查库存”和“扣减库存”合成了一个原子操作。如果不这样做,检查完发现库存还有,下一秒另一个请求把库存扣光了,当前请求还会继续执行——这就是典型的超卖 bug。
我建议把幂等校验放在 Lua 脚本内部,用 SADD 命令保证一个用户只能抢一次,避免 Redis 网络开销之外的多余事务。上面代码里我用 Set 做了第一层校验,Lua 内再做二次校验,双保险。
3.4 Kafka 生产者与消费者的落地细节
生产者负责把预减成功的数据丢给 Kafka。这里要注意选对 key,保证同一个商品的消息进入同一个分区,才能做到局部顺序消费。
@Component public class SeckillProducer { @Autowired private KafkaTemplate<String, String> kafkaTemplate; public void sendSeckillMessage(Long skuId, Long userId) { String message = skuId + ":" + userId; // 以 skuId 为 key,同一商品的订单进入同一分区,保持顺序 kafkaTemplate.send("seckill_orders", skuId.toString(), message); } }消费者一端,创建订单的流程包含几个关键步骤:接收消息、幂等校验、扣减 DB 库存、创建订单。
@Component public class OrderConsumer { @Autowired private OrderService orderService; @KafkaListener(topics = "seckill_orders", groupId = "seckill_order_group") public void onMessage(ConsumerRecord<String, String> record) { String[] parts = record.value().split(":"); Long skuId = Long.parseLong(parts[0]); Long userId = Long.parseLong(parts[1]); // 1. 前置幂等校验:同一个订单不能创建两次 if (orderService.existsOrder(skuId, userId)) { return; } // 2. 扣减 DB 库存(条件更新,防止超卖) int rows = orderService.deductStock(skuId); if (rows == 0) { // 更新失败,库存不足,发送告警并补偿 Redis 库存 orderService.compensateStock(skuId); return; } // 3. 创建订单 orderService.createOrder(skuId, userId); } }DB 扣减库存的 Mapper,用了条件更新来兜底超卖:
@Mapper public interface ProductMapper { @Update("UPDATE product SET stock = stock - 1 " + "WHERE id = #{skuId} AND stock > 0") int deductStock(@Param("skuId") Long skuId); }注意这里返回的rows如果为 0,说明数据库判断库存已经不足,降级方案是把 Redis 里的库存回补回去。这套回补逻辑我测试了很多次,核心难点在于:可能多个消费者同时失败,回补时又要保证 Redis 库存与 DB 库存的一致性,这里我额外加了分布式锁控制回补并发度。
3.5 消息手动提交与失败重试
在实际生产环境中,Kafka 消费者不能使用默认的自动提交 offset,否则会出现“消息被提交但业务处理失败”的场景。我使用手动确认模式,只有订单创建成功后才提交 offset。
@KafkaListener(topics = "seckill_orders", groupId = "seckill_order_group") public void onMessage(ConsumerRecord<String, String> record, Acknowledgment ack) { try { //... 业务逻辑 ack.acknowledge(); } catch (Exception e) { // 记录日志,不提交 offset,等待消息消费超时后自动重平衡 log.error("订单创建失败: skuId={}, userId={}", skuId, userId, e); } }这个细节很容易被忽略,但它直接影响数据的可靠性和最终一致性。
3.6 Spring Boot 整合配置
application.yml 里最关键的几项配置:
spring: redis: host: localhost port: 6379 kafka: bootstrap-servers: localhost:9092 producer: key-serializer: org.apache.kafka.common.serialization.StringSerializer value-serializer: org.apache.kafka.common.serialization.StringSerializer acks: all retries: 3 consumer: group-id: seckill_order_group enable-auto-commit: false auto-offset-reset: latest key-deserializer: org.apache.kafka.common.serialization.StringDeserializer value-deserializer: org.apache.kafka.common.serialization.StringDeserializeracks: all表示消息被所有副本接收才算成功,retries: 3提高了发送可靠性。消费者开启enable-auto-commit: false,并且配合手动 ack,才能真正做到“不丢消息”。
4. 常见问题与排查技巧实录
4.1 超卖问题排查
超卖是最严重的生产事故,我把它放在第一位说。模拟超卖时,可以使用 JMeter 或 Gatling 并发请求 1000 个用户抢 100 个库存。如果压测后发现订单数量超过了库存数量,排查方向按以下顺序进行:
- 检查 Redis Lua 脚本是否正确:执行扣减时是否先判断库存不足再返回失败。
- 检查 DB 扣库存 SQL 是否带条件:
WHERE stock > 0必不可少。 - 检查消费者是否重复消费:没有幂等设计时,同样的消息被 Kafka 集群重平衡后可能消费两次。
排查超卖的最佳方式是在数据库订单表增加唯一键约束,比如(sku_id, user_id)唯一索引,作为数据层面的最后防线。
4.2 缓存穿透、击穿与雪崩
这三个问题是 Redis 场景的经典配套问题,面试必考,生产必踩。
- 穿透:用户不断请求数据库中不存在的商品 ID,导致 Redis 查不到,每次都要打到 DB,DB 压力巨大。解决方案是布隆过滤器拦截非法 ID,或者将空结果缓存几秒。
- 击穿:某个热点 key 在过期瞬间,大量请求全部打到 DB。解决方案是热点 key 不设置过期时间,或者使用互斥锁,只允许一个请求去加载数据。
- 雪崩:大量 key 在同一时间过期,导致 DB 请求量瞬间飙高。解决方案是设置过期时间时加入随机值,避免集中淘汰。
我在秒杀场景里重点关注击穿问题。秒杀商品是天然的热点 key,数量有限且访问集中。处理方案很简单:秒杀商品的活动信息在活动期间永不过期,库存数据由 Lua 脚本和 DB 回写联动更新。
4.3 Kafka 消息积压与重复消费
消费者处理不过来了,Topic 里的 Lag 持续增长,这是最让人头疼的问题。排查和解决我总结成四步:
- 先看消费者的处理耗时:是否因为 DB 锁等待、外部接口超时导致消费变慢。
- 增加分区数和消费者实例数:Kafka 的分区数是并发消费的上限,分区不够加再多的消费者也没用。
- 批量消费优化:把单条处理改成批量处理,减少网络 IO 和 DB 连接的建立次数。
- 临时扩容:紧急情况可以新建一个临时 Topic,用多倍的消费者实例去消费原 Topic 里积压的消息,处理完成后再切回来。
重复消费的解决办法是幂等设计。最轻量的是用数据库唯一索引,插入重复订单时直接失败,然后 catch 后做“已存在”处理。我在订单表里加了(sku_id, user_id)唯一索引,重复消费时插入会报错,但逻辑上可以把它当成一次正常的重复提交忽略掉。
4.4 问题速查表
| 问题现象 | 可能原因 | 排查命令/思路 | 解决方案 |
|---|---|---|---|
| 订单数超过库存数 | Lua 脚本未原子执行 / DB 扣减无条件 | 检查 Lua 脚本和 UPDATE SQL | 预减脚本原子化 + DB 条件更新 |
| Redis 库存变成负数 | 回补库存逻辑错误 / 扣减前未判断 | MGET seckill:stock:*查看 | 脚本内先判断再扣减,回补加锁 |
| Kafka Lag 持续变大 | 消费者数量不足 / 处理逻辑慢 | kafka-consumer-groups.sh --describe | 增加分区与消费者,批量消费 |
| 用户重复抢购成功 | 幂等设计缺失 | 检查 Set 和唯一索引 | Redis Set 记录 + 数据库唯一索引 |
| 消息丢失 | 生产者 acks=0 / 消费者自动提交 | 查看日志中是否有发送失败 | acks=all + 手动 ack |
5. 面试官追问方向与应答要点
5.1 高频基础问题
把 Spring Boot + Kafka + Redis 这套方案讲完,面试官通常不会就此打住,而是顺着往下挖原理。我在多次面试交流里发现,问得最密集的有这几个:
Redis 是单线程的,为什么还这么快?关键回答点是:基于内存、核心数据结构高效、IO 多路复用、单线程避免上下文切换和锁竞争。注意不要回答“Redis 所有版本都是单线程”,从 6.0 开始网络模型也支持多线程了,但是命令执行还是单线程,这个概念要讲清楚。
Kafka 为什么吞吐量远高于传统 MQ?关键回答点是:顺序写磁盘、零拷贝、分区并行、批量发送。可以对比 RabbitMQ 基于 AMQP 协议要处理更多复杂语义,吞吐量天花板自然更低。
为什么不用 Redis 直接把订单存了?关键回答点是:Redis 是缓存,可靠性不够,数据重启可能丢失。订单是核心业务数据,必须有持久化保障。刚好回答这个问题能顺势引出 Kafka 的持久化机制。
有一个容易踩的坑:面试官问“Redis 和数据库一致性怎么保证”,你的回答里千万不要说“用 Redis 存数据,数据库只做备份”,这会让面试官觉得你缺少分布式系统的基本素养。正确思路是强调 Redis 预扣、DB 落库、对账补偿的最终一致性链路。
5.2 进阶场景推导
面试官接下来的问题往往是“如果流量再涨十倍怎么办”。核心思路是逐层加厚:
- 接入层:引入负载均衡器集群,水平扩容无状态服务节点。
- 缓存层:用 Redis 集群或 Redis 分片扩容,把热点 key 分散到多节点。
- MQ 层:增加 Topic 分区数,挂更多消费者实例。
- DB 层:引入分库分表,把订单表按用户维度分片,降低单库压力。
这整套扩容思路,本质上就是从“单点系统”进化成“分布式系统”的过程。每一层都无状态化,每一层都能独立扩展,这个架构演进思路是面试时最有区分度的部分。
5.3 设计题应答框架
如果面试官给一个全新的秒杀场景让你现场设计,不要上来就写代码,按这个顺序讲:
- 分析瓶颈:流量峰值、库存量、数据一致性要求。
- 分层设计:每一层负责什么。
- 核心机制:预减库存、异步削峰、幂等去重、超时回补。
- 数据一致性:最终一致性如何保证,兜底方案是什么。
- 容量规划:预估 QPS,计算 Redis、Kafka、MySQL 的量级。
实操下来,我能明显感觉到这套设计逻辑比堆砌技术名词更有说服力。不要一上来就喊 Redis 缓存、Kafka 消峰,先把每一步的输入输出和边界条件讲清楚,面试官才会觉得你真懂系统。
6. 从面试到实战的一点心里话
我面试过不少候选人,能把技术名词背得很溜的很多,但真正能答好秒杀系统的人,都有几个共同点:知道每个组件在系统里承担什么角色,知道为什么用这个组件而不是另一个,更知道数据不一致了怎么兜底。
秒杀系统不是技术的堆砌,而是一个对一致性、可靠性、高性能要求极高的综合工程。Redis 解决了性能问题,Kafka 解决了流量缓冲问题,Spring Boot 解决了工程化的问题,而真正让系统跑稳的,是你对每一步异常情况都做好了预案。把上面这套设计思路理解透、代码调过、问题踩过,再遇到这类问题,不管是面试还是实战,你都心里有底。