秒杀几乎是Java后端面试里绕不开的“高并发试金石”。Spring Boot、Kafka、Redis 这三件套是电商秒杀方案里最常被问到的组合。面试官只要问出“让你设计一个秒杀系统你怎么做”大概率就是想从流量削峰、库存扣减、数据一致性这三个维度看你有没有真实项目经验。落到技术栈上Spring Boot 负责搭起整体工程Redis 在前端扛住读请求和分布式锁Kafka 在中间做异步削峰这套组合已经是电商场景的标准答案。但标准答案和线上能稳定跑通的方案之间隔着大量细节坑比如 Redis 分布式锁续期、Kafka 消息顺序性、缓存与数据库一致性每一条我都踩过。这篇实录没有教科书式的废话适合正准备跳槽的 Java 开发也适合已经把 CRUD 写得很熟、想往高并发方向突破的工程师。我会从整体方案设计讲起再落到 Spring Boot 工程里的核心代码最后把面试官最常追问的高频问题和线上排查经验一起给你。你不需要一次全背下来但建议先理解每层设计要解决什么问题后面看代码和面试答案会轻松很多。1. 电商秒杀场景的整体方案设计1.1 秒杀为什么难流量模型与核心矛盾先看流量模型。普通业务接口的 QPS 可能就几百秒杀开场那几秒流量往往达到平时的几十倍甚至上百倍而且用户像潮水一样涌进来。数据库连接池一般也就 50~100 个连接一个慢 SQL 就能把连接池打满更别说秒杀瞬间的写入压力。所以你会发现所有秒杀方案本质上都在解决同一个矛盾有限的数据库处理能力 vs 瞬间爆发的海量请求。既然处理不过来思路就变成“让请求在到达数据库之前尽量被拦截、排队或丢弃”。后端常见手段有四级浏览器/CDN 限流、Nginx 层限流、Redis 层拦截、Kafka 层削峰。前两级是挡无效流量后两级才是保护订单系统。秒杀结果本来就只有少数人能抢到所以系统设计的第一原则不是“每个请求都成功”而是“保证成功请求不丢、库存不超卖、数据最终一致”。这里要注意一个思维转换很多新手一上来就想着“怎么优化数据库”其实秒杀最忌讳让数据库直接面对峰值流量。数据库在秒杀里扮演的角色应当是最终流水存储而不是实时请求处理。把数据库往后放前面用 Redis 和 Kafka 顶住才是正确的架构方向。1.2 三层削峰方案前端限流、Redis 缓存与 Kafka 异步削峰从用户点击“立即抢购”到最后收到订单结果我习惯把秒杀链路分成三段来设计。第一段是请求接入层。用户请求先进 Nginx通过 OpenResty 的 lua-resty-limit-traffic 做令牌桶限流每秒只放行比如 1 万请求超过直接返回“限流中”。这一层能防住一部分脚本刷单。再往下后端接口在 Spring Boot 层面用 Guava RateLimiter 或 Resilience4j 做单机限流防止单节点被冲垮。很多人忽略这一层但线上秒杀如果没有接入层限流后面 Redis 压力再小也会被无效请求打满。第二段是Redis 预扣库存。秒杀接口的第一步不是写数据库而是先查 Redis 里的库存再通过 Lua 脚本完成“检查库存 校验是否已购买 扣减库存”。Lua 脚本可以保证原子性避免超卖。库存数据在秒杀开始前从数据库预热到 Redis秒杀结束后再异步同步回数据库。Redis 能抗的 QPS 在十万级别用来做瞬间拦截再合适不过。第三段是Kafka 异步下单。Redis 预扣成功后只说明用户“抢到了资格”真正的订单创建、扣减数据库库存、生成支付记录等动作放到 Kafka 消息里由订单服务异步消费。这样数据库收到的写入频率就被拉平了从峰值每秒几万变成每秒几百上千。消费端做一定的批量处理还能进一步提高吞吐。这套三段式方案的好处是每一层都在做“减负”。流量到数据库之前已经过了一层层的漏斗真正落到数据库的写操作数量级大幅下降。面试时我们讲方案不需要把每层代码都背出来但一定要讲清楚每一层拦截了什么流量、为什么要放在这一层。2. Spring Boot 工程落地接口分层与并发控制2.1 秒杀接口的核心流程预扣库存、校验重复、异步下单用 Spring Boot 写一个秒杀接口代码上其实不复杂复杂的是并发控制。我习惯把接口拆成三步请求校验、Redis 预扣、发送 Kafka 消息。第一步请求校验包括参数合法性、用户是否登录、活动是否已开始/结束。这些校验要在 Redis 预扣之前做干净否则一个非法参数就可能浪费一次库存扣减。第二步调 Redis Lua 脚本预扣库存这一步同时完成“库存 0 判断”和“扣减”两个动作确保原子。第三步如果扣减成功就封装一个 OrderMessage 发给 Kafka由下游服务创建订单。有一个非常容易被忽视的点发送 Kafka 消息放在 Redis 扣减成功之后但如果在发送消息前服务宕机了用户明明抢到了资格订单却永远不生成。所以工程上不能简单发了就完事。我这边用的方案是预扣成功后先把一条“待创建订单”记录写入本地数据库或者写一个订单流水表状态是“待处理”然后发消息消费端处理成功后回写状态。如果发消息失败或消费失败由一个定时任务扫描待处理订单重新投递消息。这个“本地消息表 定时对账”的思路下面讲数据一致性时会详细说。2.2 Redis 分布式锁的正确写法与续期问题秒杀里最容易被面试官追问的就是 Redis 分布式锁。很多网上的教程还在教 setnx expire但真正生产环境这样写至少有两个坑一是 setnx 后服务崩了没有设置过期时间锁永远不释放二是锁过期时间太短业务还没执行完锁就自动释放了另一个线程进来造成并发覆盖。正确的写法是用SET key value NX EX timeout这一条命令完成加锁和设置过期时间value 必须是唯一标识比如 UUID 或业务订单号。释放锁的时候不能直接 del而要先用 Lua 比较 value 是否是自己是自己的才 del避免误删别人的锁。原因很简单如果线程 A 执行时间过长锁过期了线程 B 拿到锁开始执行A 执行完直接 del就会把 B 的锁删掉。再说续期。高版本 Redis 客户端推荐用 Redisson 的 watchdog 机制默认锁的租期是 30 秒每 10 秒自动续期。如果业务执行完了就释放锁不用关心续期如果服务宕机了锁也会因为租期到期自动释放不会死锁。这比自己写 Timer 续期安全得多。我见过不少为了省依赖自己实现续期的最后要么少续期导致锁提前释放要么忘了释放导致死锁。能用 Redisson 就用 Redisson面试时可以提一嘴看门狗机制会很加分。2.3 数据一致性缓存与数据库的最终一致性秒杀里 Redis 库存是“前置库存”数据库库存是“最终库存”。理论上两个值最终要一致但不可能实时一致所以必须接受“最终一致性”。怎么保证我的做法是扣减 Redis 时记录一条库存流水流水表里带本次扣减的唯一订单号Kafka 消费者收到消息后在数据库本地事务里同时完成“扣减数据库库存 更新库存流水状态 创建订单”如果数据库扣减成功但 Kafka 消费失败流水表里会有一直处于“待处理”的数据由定时任务扫描并重发如果数据库库存不足或消费端业务失败需要回补 Redis 库存。回补操作同样先写流水再通过消息或直接调用来恢复 Redis。这套“流水 对账”机制是一个通用保底方案面试里一定要讲清楚因为它同时回答了“消息丢失怎么办”“消费失败怎么办”“Redis 和数据库不一致怎么办”三个问题。至于用不用分布式事务我的观点是秒杀这种高并发场景尽量规避强分布式事务因为 XA 事务会锁资源、拖垮性能。用本地消息表和最终一致性就好。3. Kafka 消息中间件在秒杀中的实战配置3.1 为什么用 Kafka削峰填谷与流量整形秒杀场景选消息队列Kafka 是首选。相比 RabbitMQ、RocketMQKafka 的吞吐更高、分区模型更适合并行消费而且在大促场景下有成熟的“削峰填谷”能力。所谓削峰填谷就是生产者把高峰期的大量消息快速写入 Kafka消费者按自己的节奏慢慢消费。从时间维度看请求的“峰”被削掉了数据库处理的“谷”被填平了整体负载平稳。Kafka 写入延迟极低生产端吞吐可达每秒几十万条这点对秒杀特别重要。消费者虽然默认单线程但可以通过增加分区数和消费者实例数并行消费。每个分区只能被同一个消费组内的一个消费者实例消费分区数是并行消费的上限。所以大促前我会把秒杀订单 topic 的分区数提前设置成 16 或 32而不是用默认的 1。后续想扩容分区会比较麻烦需要提前规划。3.2 生产端与消费端的几个关键参数陷阱先说生产端。Kafka 单条消息默认最大 1MBtopic 的 max.message.bytes 默认也是 1MB。如果订单消息里塞了完整的商品快照、优惠券详情甚至营销日志很容易踩到“Record is too large”的坑。我的经验是消息体里只放订单号、用户 ID、商品 ID、数量、秒杀活动 ID 等必要字段其他详情由消费端去查缓存或数据库。实在要传大对象需要在 broker 端调大 message.max.bytes但带来的网络和存储开销也会变大非必要不建议。消费端有几个容易踩的配置enable.auto.commit 默认 true处理逻辑复杂时容易消息还没处理完就自动提交了 offset服务重启后会丢消息。建议设为 false手动提交并等业务处理完成后提交。max.poll.interval.ms 默认 300 秒如果单条消息处理耗时超过这个时间消费者会被踢出消费组触发 rebalance。秒杀订单处理里要避免在消费线程里做长任务比如远程调用、慢 SQL尽量做成异步化或批量化。消费线程数不要超过分区数。用多线程消费时如果同一订单的重试消息落到不同分区顺序就无法保证。要保序只能牺牲并行度一个分区一个线程或者按订单号哈希到固定分区。3.3 Kafka 消息积压与消费延迟的排查实录线上秒杀最常见的故障就是“消息积压”。现象是 Redis 已扣库存但用户迟迟出不来订单结果。排查路径大概是第一步看 Kafka 消费组 lag。用命令行工具kafka-consumer-groups.sh --describe --group order_group看每个分区的 lag 数。lag 持续增长说明消费能力跟不上生产速度或消费者卡住了。第二步看消费者日志有没有频繁 rebalance。rebalance 会导致消费暂停。常见原因是 max.poll.interval.ms 设置太短、处理耗时超时或消费者实例频繁宕机。rebalance 期间分区的 offset 可能要重新分配也会放大延迟。第三步看下游数据库连接池和慢 SQL。很多时候 Kafka 不背锅而是消费线程在等数据库连接。秒杀订单服务要把数据库连接池从默认的 10 个调大压测时观察活跃连接数和等待线程数。我遇到过“消息积压 10 万”的报警最后排查是消费端一个查询商品详情的 SQL 没走索引单条处理从 5ms 变成 300ms直接拖垮了消费速度。优化后 lag 几分钟就清掉了。排查这种事靠经验也靠工具。可视化工具可以装 Offset Explorer原 Kafka Tool看 cluster、topic、consumer group 的 lag 非常直观不用每次都敲命令。4. 面试高频问题汇总与避坑指南4.1 Redis 缓存穿透、击穿、雪崩的区别与应对这是 Java 大厂面试的基础题但放在秒杀场景里问就成了进阶题。三者的区别简单说穿透查一个根本不存在的数据比如伪造的商品 ID缓存和数据库都没有导致每次请求都打数据库。击穿热点 key 在缓存过期的一瞬间大量请求同时打到数据库。雪崩大量 key 在同一时间过期或者 Redis 宕机导致请求全部落到数据库。秒杀场景里击穿是重点。秒杀商品的库存 key 是绝对热点一旦过期瞬间所有请求都会去数据库查库数据库必挂。应对方案有几种热点 key 永不过期或者设置逻辑过期时间由后台任务异步更新加互斥锁缓存未命中时只让一个线程去查数据库其他线程等待后回源还有多级缓存本地 Caffeine 或 Redis 副本兜底。穿透的应对是布隆过滤器或缓存空值并设置短过期时间。雪崩则需要加随机过期时间、做 Redis 高可用、限流降级。这些都要作为方案的一部分讲给面试官不要只背概念要结合秒杀链路的哪一环会出现每种问题。4.2 Kafka 重复消费与 Redis 幂等性设计消息队列不丢消息很难做到“恰好一次”所以消费端必须做幂等。秒杀订单场景的重复消费主要有两种来源一是生产者发送时服务重试导致同一条消息发了两遍二是消费端处理成功后还没来得及提交 offset 就宕机重启后又消费一遍。幂等设计我常用三种方案数据库唯一键约束。订单流水表用“用户ID 秒杀活动ID 商品ID”建唯一索引重复插入时数据库报 duplicate key捕获后直接返回成功。Redis 幂等标记。每个用户抢购前先把用户ID写入 Redis SETNX设置过期时间如果已经存在则拒绝。这是业务层的重复请求拦截。本地去重表 状态机。消费消息后先查流水表状态已经“处理中”或“已完成”就不重复处理。这三种可以叠加使用。面试时提到“消费端必须幂等”然后说出具体怎么设计比单纯喊口号强很多。4.3 分布式事务与最终一致性本地消息表 vs 事务消息秒杀链路里“扣减库存、创建订单、增加积分、推送通知”分布在多个服务里数据库没办法用本地事务一把梭这就涉及分布式事务。我从来不用强一致方案而是用最终一致性。两种主流实现一是本地消息表。在业务数据库中维护一张消息表业务操作和消息写入在同一个本地事务里提交。然后有一个定时任务把消息表中状态为“待发送”的记录发给 MQ成功后再更新状态。这样做的好处是简单可靠坏处是消息表和业务表耦合且对数据库有一定额外压力。二是事务消息。RocketMQ 支持事务消息Kafka 生态里对应 Exactly Once 和 Kafka Streams 更复杂通常不直接用。如果我们强制用 Kafka 做分布式事务一般还是“本地消息表 Kafka”的组合。所以面试时你可以说Kafka 生态下我更倾向本地消息表如果架构里用了 RocketMQ可以选用事务消息配合回查。所谓“回查”就是事务消息发送到 MQ 后如果事务未提交MQ 会反向调用生产者的接口查一下本地事务状态。这个机制保证了“本地事务成功但消息没发出去”也不会丢。能把这些区别讲明白面试官就知道你是真写过。4.4 面试现场如何回答“秒杀系统的设计”很多候选人对方案很熟但回答时没有框架东讲一句西讲一句。我建议按下面这个顺序组织答案先说核心矛盾瞬时流量 vs 数据库有限处理能力。再说总体架构接入层限流 - Redis 预扣库存 - Kafka 异步下单 - 数据库最终落库。重点讲三个关键点如何防超卖Redis Lua 原子扣减、如何防重复幂等设计、如何保证最终一致本地消息表/对账。最后补充高可用Redis 哨兵/Cluster、Kafka 多副本、数据库主从、降级策略。这个顺序符合“问题 - 方案 - 细节 - 保障”的逻辑面试官就算追问细节你也能稳稳接住。千万不要一上来就背 Redis 命令让人觉得你只是看过八股文。5. 实操复盘一个可复现的秒杀 Demo 核心代码5.1 环境准备与基础配置本地跑一个最小原型推荐用 Docker 一次性把 Redis 和 Kafka 拉起来。如果你用的是 IntelliJ IDEA 社区版也可以正常创建 Spring Boot 项目社区版只是少了 Spring Initializr 的向导按钮去 start.spring.io 手动生成压缩包导入 IDEA 即可。个人开发足够用。先用 Docker 起服务写一个 docker-compose.ymlversion: 3 services: redis: image: redis:7-alpine ports: - 6379:6379 zookeeper: image: bitnami/zookeeper:3.8 ports: - 2181:2181 environment: - ALLOW_ANONYMOUS_LOGINyes kafka: image: bitnami/kafka:3.4 ports: - 9092:9092 environment: - KAFKA_BROKER_ID1 - KAFKA_CFG_ZOOKEEPER_CONNECTzookeeper:2181 - KAFKA_CFG_LISTENERSPLAINTEXT://:9092 - KAFKA_CFG_ADVERTISED_LISTENERSPLAINTEXT://localhost:9092 - ALLOW_PLAINTEXT_LISTENERyes depends_on: - zookeeper本地单节点够用。如果面试或工作中要搭集群至少三台 broker配置 KAFKA_CFG_BROKER_ID 分别为 1、2、3并修改 advertised.listeners 使用各自内网 IP。集群的价值在副本默认 replication.factor 至少 2否则一台 broker 挂了分区没有副本生产环境会出大事。Spring Boot 项目里引入依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdorg.springframework.kafka/groupId artifactIdspring-kafka/artifactId /dependency配置 application.ymlspring: 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 key-deserializer: org.apache.kafka.common.serialization.StringDeserializer value-deserializer: org.apache.kafka.common.serialization.StringDeserializer auto-offset-reset: latest listener: ack-mode: manual_immediate这里有几个参数我解释一下。acksall 表示生产者要等所有 ISR 副本都写入成功才返回保证消息不丢代价是吞吐略降但秒杀场景下可靠优先。enable-auto-commitfalse ack-modemanual_immediate 表示消费者手动提交 offset拿到消息处理完后立即提交避免处理中出问题导致 offset 丢失。auto-offset-resetlatest 表示消费者启动后从最新 offset 开始消费秒杀场景通常不接受回放老消息所以用 latest如果你要做补偿扫描再单独用 earliest。5.2 秒杀接口核心代码与解释先定义一个 Redis Lua 脚本做原子性的库存预扣和防重复。RedisTemplate 序列化这里有个大坑很多人存进去是字符串取出来却报类型错误。原因是用默认的 JdkSerializationRedisSerializer 把对象序列化成了二进制。我在配置里统一把 key 和 value 改成 StringRedisSerializer需要存对象时再用 JSON 序列化这样可读性也更好。Redis 扣库存脚本如下注意 KEYS[1] 是库存 keyKEYS[2] 是用户去重 keyARGV[1] 是用户 IDARGV[2] 是扣减数量ARGV[3] 是去重 key 的过期时间local stock redis.call(get, KEYS[1]) if not stock or tonumber(stock) tonumber(ARGV[2]) then return 0 end local bought redis.call(sismember, KEYS[2], ARGV[1]) if bought 1 then return 2 end redis.call(decrby, KEYS[1], ARGV[2]) redis.call(sadd, KEYS[2], ARGV[1]) redis.call(expire, KEYS[2], ARGV[3]) return 1解释一下返回 0 表示库存不足返回 2 表示重复秒杀返回 1 表示扣减成功。用户去重集合单独设置过期时间避免 key 一直占内存。这个脚本把“查库存、查重复、减库存、记录用户”放在一个 Lua 里执行Redis 单线程保证原子性不会出现两个请求同时读到库存 1 然后都扣减成功的情况。接着是秒杀接口的 Service 方法核心代码Service public class SeckillService { Autowired private StringRedisTemplate redisTemplate; Autowired private KafkaTemplateString, String kafkaTemplate; private static final String STOCK_KEY seckill:stock:1001; private static final String BUY_KEY seckill:buy:1001; // DefaultRedisScript 初始化省略 public long seckill(Long userId, Long goodsId, Integer count) { // 前置校验省略 ListString keys Arrays.asList(STOCK_KEY, BUY_KEY); Object result redisTemplate.execute( SECKILL_SCRIPT, keys, userId.toString(), count.toString(), 86400 ); long code Long.parseLong(result.toString()); if (code 1) { // 构建订单消息发送到 Kafka OrderMessage message new OrderMessage(); message.setUserId(userId); message.setGoodsId(goodsId); message.setCount(count); kafkaTemplate.send(seckill-order-topic, userId.toString(), JSON.toJSONString(message)); } return code; } }发送消息时把用户 ID 作为 key目的是让同一个用户的订单消息始终进入同一个分区这样消费端可以按照用户维度保证顺序。Kafka 的分区器会用 key 的哈希值选分区这一点对后续消费顺序很重要。消费端代码Component public class SeckillOrderConsumer { KafkaListener(topics seckill-order-topic, groupId seckill-order-group) public void onMessage(ConsumerRecordString, String record, Acknowledgment ack) { try { OrderMessage message JSON.parseObject(record.value(), OrderMessage.class); // 1. 校验本地流水表是否已存在 // 2. 本地事务创建订单、扣减数据库库存、更新流水状态 // 3. 假如流程成功则提交 offset ack.acknowledge(); } catch (Exception e) { // 记录错误由对账任务补偿不要在这里无限重试 log.error(consume order message failed, e); } } }到这里一个最小链路就通了用户调用秒杀接口 - Redis Lua 扣库存 - Kafka 发消息 - 消费者落库。你可以用 Postman 或写个 Jmeter 脚本模拟并发请求观察 Redis 库存不会变负数、数据库订单数不超过库存数。5.3 压测与监控用实际数据验证方案Demo 写完后要压测。我用 JMeter 开 1000 线程循环 10 次观察三个指标Redis 的 QPS、Kafka 的消费 lag、数据库的活跃连接数。压测前先预热否则第一次请求会大量落到数据库。压测中重点看 Redis 的info commandstats里 lua 脚本的平均耗时如果在 1ms 以下说明原子扣减还有余量。监控上Spring Boot 可以接 Spring Boot Admin 或者 Micrometer Prometheus。如果你只是本地验证最简单的就是直接看 Kafka 消费组的 lag。命令如下kafka-consumer-groups.sh --bootstrap-server localhost:9092 --describe --group seckill-order-grouplag 为 0 说明消费速度跟得上。如果 lag 持续增大优先查消费者日志里的 rebalance 记录以及数据库慢查询日志。这个顺序我压测时用过几十次不会错。6. 踩坑实录与个人心得6.1 线上坑 TOP5把我在真实秒杀项目里踩过的、也是面试官最喜欢问的坑整理成一张表方便你自查。坑现象根因解法Redis 库存为负超卖判断和扣减不是原子的Lua 脚本合并锁被误删并发覆盖释放时没有校验持有者释放前用 Lua 比较 value消息丢失用户抢到无订单消费者 offset 自动提交或生产者未确认acksall 手动提交消费顺序错乱同一用户订单乱序多线程消费且没有分区策略用用户ID做 key固定分区消息体积超限发送失败消息体塞入大字段只传必要字段或调大 max.message.bytes第一行那个坑我印象最深。早期用 Redis 的get和decrby两条命令来判断库存高并发下 A 请求读到库存 1B 请求也读到库存 1两个都执行 decrby库存变成 -1。这就是典型的“非原子操作导致超卖”。后来换成 Lua 脚本再也没出现过。面试现场你如果能主动讲出这个演化过程会让面试官有共鸣。第二行的锁误删也很经典。值用 UUID 后释放锁前必须用 Lua 判断 value 再删除。但还有一个更隐蔽的坑如果业务只加锁不加续期代码执行超过锁过期时间即使加了 UUID 也可能被误删。所以要么用 Redisson要么业务方法里尽量缩短锁内耗时。其实这些坑背后有个共同规律分布式场景下的每个判断和操作都要优先考虑是不是原子的、会不会因为网络或宕机产生中间状态。这个思维方式比背具体命令更重要。6.2 给新人的建议从 Demo 到项目实战如果你现在还在学习阶段我的建议是把 Demo 跑通之后再自己动手改造成一个完整的小项目增加商品活动表、把库存预热做成定时任务、给订单消费加上本地消息表和定时对账然后用压测验证超卖和消息积压问题。这样再去看大厂面经就不会觉得“这只存在于面试官嘴里”了。有人说秒杀系统是面试造火箭、工作拧螺丝但我觉得秒杀场景是极少数能在一套系统里同时训练高并发、一致性和高可用的实战场景。你把这些思路吃透再去面对日常的接口性能优化、缓存治理和消息队列问题思维层次会完全不一样。最后说一个我压箱底的小技巧秒杀活动开始前提前把商品库存和活动信息预热到 Redis并做一次小规模压测别等线上流量真的涌进来了才排查问题。还有所有秒杀相关操作都要加接口幂等和用户级别限流不然脚本一刷你的活动就没意义了。这些听起来都是小事但线上的大事故往往就败在这些小细节上。