秒杀系统架构拆解:Redis预减库存与Kafka异步削峰实战 秒杀系统几乎是所有头部互联网公司 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 DefaultRedisScriptLong 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 KafkaTemplateString, 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(ConsumerRecordString, 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(ConsumerRecordString, 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 查不到每次都要打到 DBDB 压力巨大。解决方案是布隆过滤器拦截非法 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 记录 数据库唯一索引消息丢失生产者 acks0 / 消费者自动提交查看日志中是否有发送失败acksall 手动 ack5. 面试官追问方向与应答要点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 解决了工程化的问题而真正让系统跑稳的是你对每一步异常情况都做好了预案。把上面这套设计思路理解透、代码调过、问题踩过再遇到这类问题不管是面试还是实战你都心里有底。