ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

秒杀系统高并发架构:Redis预扣库存与Kafka削峰实战

秒杀系统高并发架构:Redis预扣库存与Kafka削峰实战 1. 秒杀流量为什么能把常规接口打挂先看懂流量模型1.1 秒杀流量的三个特征我面过好几家大厂面试官问秒杀时我最先讲的不是组件而是流量模型。因为如果你不理解秒杀流量长什么样后面的所有设计都是空中楼阁。秒杀流量有三个特征每个特征都直指一个技术选型。第一是瞬时性。正常电商接口的 QPS 曲线是平缓的但秒杀开抢那一刻流量会在几秒内冲到平时的几十倍甚至上百倍。我做过的一个项目平时订单接口 QPS 也就两三千秒杀开场那 30 秒直接冲到 8 万。这种突发流量靠扩容是来不及的因为业务高峰通常只持续一分钟以内等你把机器拉起来活动已经结束了。第二是热点集中。普通接口的请求会分散在上万个商品和用户上但秒杀不一样——一万个请求里有九千个打在同一个 SKU 上。这意味着缓存系统里有一个 key 被疯狂访问数据库里有一行数据被疯狂争抢。热点集中带来的问题比单纯的高并发更难处理因为它无法通过横向扩容分散压力。第三是高写并发。秒杀的核心操作是扣库存这是写操作不是读操作。很多人一上来就谈 CDN、页面静态化、Redis 缓存读接口这些只能解决读的问题扣库存的写压力如果处理不好照样会压垮数据库。1.2 纯数据库方案为什么会超卖先说一个很多初级开发都会踩的坑直接用数据库扣库存。-- 错误示范先查再减 SELECT stock FROM product WHERE id ?; -- 业务判断 stock 0 UPDATE product SET stock stock - 1 WHERE id ?;这段代码在高并发下必超卖。两个请求同时查到 stock 1都判断大于 0都执行了减一最后库存变成 -1但有两单成功了。有人会说那我用一条原子 SQL 不就行了UPDATE product SET stock stock - 1 WHERE id ? AND stock 0;这条 SQL 在单行数据上确实是原子的不会超卖。但问题是高并发下这条 SQL 的性能扛不住。行锁竞争会让大量请求排队等待我实测一个 4C8G 的 MySQL 实例这种带行锁的更新 SQL极限吞吐也就一两千 TPS。秒杀 8 万 QPS 打过来意味着每个请求在后面排队几十秒最终全部超时用户看到的就是无限转圈。这里有一个关键结论数据库扣库存的问题不是会不会超卖而是扛不扛得住。你不用原子 SQL 会超卖用了原子 SQL 会挂本质矛盾在于瞬时流量超过了数据库的处理上限。1.3 为什么是 Spring Boot Redis Kafka 这套组合理解了流量模型选型就顺理成章了。Redis 解决的是高写并发扛得住的问题。Redis 单线程模型天然没有锁竞争单实例就能支撑 10 万级别的 QPS。库存这种热点数据放在 Redis 里配合 Lua 脚本做原子扣减既快又不会超卖。Kafka 解决的是流量削峰的问题。它就像一个蓄水池秒杀瞬间把十万个请求全部接住写入消息队列后端消费者按照自己的处理能力慢慢消费。数据库接收到的永远是一个平滑的、可控的流量而不是一个暴力冲击波。Spring Boot 解决的是快速可靠落地的问题。它对 Redis 和 Kafka 都有成熟的 starter 集成配置简单生态完善大厂内部也大量使用拿出来聊不会显得业余。这套组合的核心思想其实就一句话把瞬时高并发转化成持久可控的流量。你不能让数据库直面秒杀高峰而是要让流量先经过 Redis 的快速筛选再通过 Kafka 平滑地灌给数据库。2. 分层防御架构怎么搭每一层到底挡掉多少流量2.1 入口层限流先挡住八成无效流量秒杀流量里面真正有效的请求其实只占很小一部分。大量的请求是重复点击、非登录用户、脚本刷单。所以架构的第一层就是尽可能地把无效流量挡在外面。我在项目中按这样的顺序做了入口拦截Nginx 层限流按 IP 做令牌桶限流单个 IP 每秒最多放行 5 个请求。这一层能挡住绝大部分脚本刷单的流量。Nginx 的limit_req_zone配置很简单但效果非常明显。CDN 和静态化秒杀的商品详情页全部静态化到 CDN用户点击立即抢购之前的浏览行为根本不打到后端服务。活动的倒计时页面也走 CDN避免刷新页面产生的大量请求。验证码或滑块开抢前要求先完成滑块验证拿到一个临时 token。这个 token 有效期一分钟下单时必须携带。这样可以把机器脚本的成本抬高也能显著降低无效请求。应用层用户维度限流同一个 userId 对同一个 skuId 的请求用 Redis 的setnx做标记5 秒内只放行第一个。接口层的重复点击直接用这种方式挡住。这一层做完之后8 万 QPS 进来的流量大概只有 1 万左右能真正走到业务逻辑。剩下的大部分请求在入口就直接返回系统繁忙或者请稍后重试。2.2 Redis 层预扣库存与请求拦截过了入口层流量进入 Spring Boot 服务。这时候做的第一件事是 Redis 预扣库存。秒杀开始前我会通过一个定时任务把当天的活动库存从数据库加载到 Rediskey 设计为seckill:stock:{skuId}value 是剩余库存数。用户点击抢购时先执行一个 Lua 脚本脚本内部完成两件事判断库存是否大于 0如果大于 0 就减一同时给这个用户打一个已参与的标记。这个步骤是整个秒杀系统里最关键的一道闸门。Redis 预扣库存通过之后才说明这个用户抢到了资格。预扣不通过的直接返回已售罄。这里有一个容易被忽略的设计点Redis 预扣的是一种资格不等于订单已经创建成功。真正的订单落库在后面的 Kafka 消费环节完成两者之间存在着时间差。前端给用户的提示是正在排队处理中而不是直接告诉他下单成功。2.3 Kafka 层异步下单与削峰填谷Redis 预扣库存成功后服务把一条下单消息发送到 Kafka 的order-createtopic。消息内容至少要包含 userId、skuId、价格快照、活动批次号。发送成功后就返回给前端抢购成功正在处理。Kafka 在中间起到了两个作用。第一是削峰Redis 扣减成功的请求无论有多少全部先写入 Kafka写入 broker 这个动作本身很快producer 端几乎不会成为瓶颈。第二是解耦后端消费者按照自己的节奏拉取消息、创建订单数据库的负载完全可控。我给这个 topic 设置了 12 个分区消费者组的并发度也设置为 12每个消费者线程负责一个分区。这样既保证了消息可以并行消费又不会因为单分区消费太慢导致积压。Kafka 单个分区内的消息是有序的但跨分区不保证全局顺序——好在下单这个场景本身不要求全局顺序后面我会细说。2.4 数据库层最终落库与对账兜底数据库是整个链条的最后一环。Kafka 消费者从队列里拉取消息后在事务里完成两件关键操作插入订单记录、扣减数据库真实库存。这里我强烈建议订单表设计一个唯一索引比如(user_id, sku_id, activity_id)。这个唯一索引不是为了业务查询而是为了兜底幂等。Kafka 的 at-least-once 语义下消息可能被重复消费唯一索引能保证同一用户在同一活动里最多只有一条有效订单。消费者落库成功后手动提交 Kafka offset。如果落库失败根据失败原因决定是重试还是跳过错失网络抖动、DB 死锁这种瞬时错误可以重试业务校验失败比如用户不在白名单则直接跳过不要阻塞队列。另外我还会启动一个定时对账任务每五分钟扫描一次 Redis 中已预扣但数据库里没有对应订单的记录。如果发现 Redis 预占了库存但 DB 没有订单说明消息在 Kafka 消费环节丢了或者一直失败这时候要做回补库存把 Redis 中的库存加回去同时记录一条补偿日志。这个兜底机制非常重要它是整个系统最终一致性的保障。可以这样理解整个流量分配的路径8 万并发进来Nginx 层挡掉一大半应用层限流再挡掉一部分真正到达 Redis 预扣的可能是 1 万左右预扣成功的可能只有五千最终进入 Kafka 落库的也就是这五千。数据库承受的永远是一个可预期的、平滑的负载。3. Redis 预扣库存的正确姿势Lua 脚本、分布式锁与缓存一致性3.1 Lua 脚本保证扣减原子性Redis 预扣库存时最典型的一个错误用法是先get库存然后判断大于 0再dec。这套读-判断-写的组合在并发下一定会出问题因为三次操作之间不是原子的多个请求可能同时读到同一个库存值。正确做法是使用 Lua 脚本。Redis 在执行 Lua 脚本时是单线程的整个脚本作为一个原子操作执行中间不会被其他命令插入。扣库存这种需要读-判-写原子完成的场景Lua 是首选。-- KEYS[1]: 库存 key例如 seckill:stock:1001 -- KEYS[2]: 用户参与标记 key 的前缀 -- ARGV[1]: skuId -- ARGV[2]: userId local stockKey KEYS[1] local userKey KEYS[2] .. : .. ARGV[1] .. : .. ARGV[2] -- 判断用户是否已参与过 if redis.call(exists, userKey) 1 then return -1 -- 已参与不能重复抢 end -- 判断库存是否存在 if redis.call(exists, stockKey) 0 then return -2 -- 库存未初始化 end local stock tonumber(redis.call(get, stockKey)) if stock 0 then return -3 -- 已售罄 end redis.call(decr, stockKey) redis.call(set, userKey, 1, EX, 86400) return 1 -- 扣减成功Spring Boot 里的调用方式DefaultRedisScriptLong redisScript new DefaultRedisScript(); redisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource(seckill-stock.lua))); redisScript.setResultType(Long.class); Long result redisTemplate.execute( redisScript, Arrays.asList(seckill:stock: skuId, seckill:user), skuId, userId );这段脚本返回 -1、-2、-3 分别对应不同的失败原因业务层根据返回值决定如何给用户提示。这里要提醒一点Lua 脚本里的 key 和参数不要用字符串拼接要全部通过ARGV传入这样 Redis 的 key 路由才能正确执行也避免脚本注入问题。3.2 分布式锁到底用在哪先说清楚别一上来就 setnx我在面试中被问过一个问题你的预扣库存为什么不用分布式锁这个问题很多候选人答不好因为很多人默认 Redis 扣库存必须加锁。这里要说清楚一个核心概念Lua 脚本本身就是原子操作扣库存这件事完全不需要分布式锁。锁是用来保护跨多个 Redis 命令或跨多个操作的非原子流程的而不是用来解决单次原子操作的并发问题。那分布式锁在秒杀系统里用在哪我总结了三类场景第一防止同用户重复提交。虽然 Lua 脚本里已经用setnx做了用户维度标记但如果你不想把标记逻辑放进扣库存脚本里也可以用分布式锁包住校验用户资格 扣库存 发送消息这段逻辑。不过既然 Lua 已经原子解决了我一般不额外加锁。第二订单创建后需要更新多个缓存。比如用户下单成功后需要同步更新用户订单列表缓存、商品热度缓存、活动统计缓存。如果多个线程并发做这些更新可能出现数据不一致。这时候对缓存重建这个操作加分布式锁是合理的。第三对账任务的并发控制。如果线上有多台机器同时跑对账任务需要保证同一时刻只有一个任务在执行避免重复回补库存。这种场景用分布式锁非常合适。如果确实需要用分布式锁我推荐 Redisson 而不是手写setnx。手写setnx加锁最容易踩的坑是忘记设置过期时间或者设置了过期时间但业务执行超过过期时间锁提前释放导致并发进入。Redisson 的lock()默认带看门狗机制会每 10 秒自动续期直到业务代码执行完主动unlock()。这让锁的过期时间和业务执行时间自动匹配不会出现锁提前失效的问题。RLock lock redissonClient.getLock(seckill:lock: skuId); boolean acquired lock.tryLock(2, 10, TimeUnit.SECONDS); if (!acquired) { return 太火爆请重试; } try { // 需要加锁保护的业务逻辑 } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } }3.3 缓存与数据库一致性延迟双删在秒杀场景的用法秒杀系统的库存数据既在 Redis 里也在数据库里。Redis 预扣和 DB 扣减是异步的两者天然存在暂时的不一致这个靠最终一致性兜底。但还有一种一致性问题也需要处理DB 库存更新后如何让 Redis 中的库存值尽快同步。最常见的坑是这样的数据库里商品库存因为退款或者运营人工调整发生了变化需要同步更新 Redis。如果你先更新数据库再更新 Redis在更新数据库和更新 Redis 之间的窗口期会有请求读到 Redis 里的旧值。如果你先删 Redis再更新数据库在数据库更新完成前高并发请求会回源数据库把旧值重新写进 Redis。我用的方案是延迟双删先删除 Redis 中的库存 key更新数据库中的库存等待几百毫秒具体时间取决于缓存回源的平均耗时一般 300-500ms再次删除 Redis 中的库存 key。延迟双删的原理是就算第一次删除后有读请求把旧值写回了 Redis第二次删除也会把这个旧值清掉。这期间 Redis 中可能短暂存在旧值但最终会被清空下一次请求会重新从数据库加载新值。在秒杀场景里还要注意一个点活动结束后一定要主动删除 Redis 中的库存 key或者给库存 key 设置合理的 TTL。不然下一次活动开始旧库存数据还残留在 Redis 里新活动加载库存时可能出现数据错乱。我习惯在秒杀开始前由定时任务主动刷新库存 key而不是依赖 TTL 到期自动失效。4. Kafka 消费侧的真实坑消息积压、重复消费和顺序性4.1 重复消费为什么必现以及如何幂等Kafka 的消费语义是 at-least-once也就是说消息至少被处理一次但可能被处理多次。这在秒杀场景不是理论问题而是几乎必现的问题。最常见的场景消费者拉取了一批消息处理到第 300 条时程序突然挂掉此时这批消息的 offset 还没有提交。消费者组重新平衡或者进程重启后会再次拉取这一批消息从第 0 条开始处理前面 300 条就会重复执行。如果消费逻辑是插入订单 扣减库存重复执行就会产生重复订单、重复扣库存。解决办法就是幂等// 伪代码幂等消费的核心思路 public void consume(OrderCreateMessage message) { // 1. 先检查订单是否已存在利用订单表的唯一索引 try { orderMapper.insertWithUniqueIndex(buildOrder(message)); // 2. 只有insert成功才执行库存扣减 productMapper.decreaseStock(message.getSkuId(), 1); // 3. 全部成功后提交offset ack(); } catch (DuplicateKeyException e) { // 订单已存在说明这条消息已经被处理过直接跳过并提交offset ack(); } }这里要注意的是判断是否重复处理不能只靠查表因为查表和插入之间也有并发窗口。最可靠的做法是让数据库的唯一索引来做最终仲裁插入的时候如果违反唯一索引就说明之前已经处理过了。所以我在设计订单表时唯一索引不是可选项而是必选项。另外一个常见的做法是使用本地消息表在同一个数据库事务里先插入业务数据再插入一条消息消费记录messageId 做唯一键。如果这条消息之前消费过插入消费记录时会失败从而跳过重复处理。这个方案适合对一致性要求极高的场景代价是每个消费操作都多一次数据库写入。4.2 消息积压排查思路和恢复操作秒杀结束后最怕看到的就是 Kafka 消息积压报警。消费者处理不过来数据库订单迟迟不落库用户在前端一直看到处理中这就是事故。积压的排查顺序我总结为先看消费者再看消息分布最后看下游依赖第一步看消费者是不是还活着。如果消费者全部下线了消息当然会积压。最常见的原因是消费线程抛异常后没有 catch导致消费者退出消费循环。第二步看消费速度。找到积压的 topic看每个分区的 lag。如果所有分区都积压说明整体处理能力不足需要扩容消费者。如果只有个别分区积压说明消息 key 路由不均匀某个分区的消息量特别大。第三步看下游依赖。消费者消费时要写库、调其他服务如果数据库连接池满了或者下游服务超时消费速度就会骤降。恢复操作要看积压的严重程度。如果积压量不大几千条以内通常调整一下消费者参数就能追上。如果积压了几十万条我一般这样处理临时扩容消费者。这是最快的止血方式。但注意消费者数量不能超过分区数否则多余消费者会空闲。如果分区不够新建一个临时 topic设置更多的分区然后写一个转发程序把积压的消息转发到新 topic用更多消费者并行处理。关闭消费端的慢操作。如果消息里需要调用下游 HTTP 接口先改成异步或者直接跳过优先保证消费速度事后通过补偿任务处理漏掉的数据。调整拉取参数。适当增大max.poll.records让每次拉取处理更多消息减少频繁拉取的网络开销。但要注意max.poll.interval.ms的限制如果单批处理时间超过这个阈值消费者会被认为已经死亡触发 rebalance。所以增大批量要配合检查单条消息的处理耗时。还有一个运维层面的经验秒杀前就要预估峰值消息量然后提前给消费者留出余量。比如预估秒杀 30 秒内产生 5 万条消息消费者单秒处理 500 条需要 100 秒才能消化完。这个时间是可以接受的但如果有用户等着看结果就要把消费者处理能力提到单秒 1000 条以上或者提前增加分区和消费者数量。4.3 顺序性下单消息要不要保序很多面试官会问Kafka 能保证消息顺序吗你的秒杀下单要不要保证顺序先说 Kafka 的保序语义同一个分区内的消息是有序的不同分区之间不保证全局有序。所以要想让消息有序只能让需要保序的消息都进同一个分区。秒杀下单这个场景实际上不需要全局有序。用户 A 的订单和用户 B 的订单之间没有依赖关系谁先落库都一样。真正可能有顺序要求的是单用户维度同一个用户下单和取消下单两条消息不能让取消先被执行。但秒杀场景里一单交易通常只发一条消息不太存在这种问题。如果确实需要按用户保序做法很简单将消息的 key 设置为 userIdKafka 会按 key 的哈希值路由到固定分区同一个用户的消息永远进同一个分区分区内严格有序。代价是单个用户的消费吞吐受限因为同一个分区只能被一个消费者线程处理。我在实际项目里没有做用户级保序但保留了 key 设置。理由有两个一是 key 的设置可以让同一用户的消息尽可能落在同一分区遇到用户异常投诉时方便按分区追溯日志二是一旦未来需要扩展取消/售后这类消息不用改路由逻辑。5. 面试官高频追问清单超卖、缓存、降级与丢消息5.1 库存扣减失败怎么办——补偿链路要完整面试官问这个问题其实是在考察你的失败处理能力和最终一致性意识。你要让他看到你不仅设计了正常流程还想清楚了失败流程。我的回答分三层第一层Redis 预扣失败的直接返回。Lua 脚本返回 -1、-2、-3对应重复参与库存未初始化已售罄这些是业务失败直接返回给前端即可不需要任何补偿。第二层Kafka 消费失败的常规重试。落库失败先区分原因如果是数据库死锁、网络抖动这类瞬时错误可以重试我配置了最多 3 次重试每次间隔递增。重试用 Kafka 的retry topic实现失败的消息先转发到order-create-retry队列由专门的消费者延迟处理。第三层重试仍失败的消息进死信队列同时触发回补流程。回补流程要做的不是简单地把 Redis 库存加回去而是先检查数据库里到底有没有这条订单。如果 DB 里其实有但 offset 提交失败了就提交 offset 即可不能回补。只有确认 DB 里确实没有订单才能把 Redis 库存加回去。这个判断逻辑必须严谨否则会出现DB 有订单但 Redis 库存也加回去了的超卖事故。5.2 缓存穿透、击穿、雪崩分别怎么处理这三个概念是面试必考但要结合秒杀场景讲才不显得背答案。穿透是查询一个不存在的 key每次都打到数据库。秒杀场景下如果用户抢一个不存在的 skuId或者活动已被下架但前端还显示可抢就会产生穿透。我的处理方式是在活动开始前把本次参与秒杀的商品 ID 列表加载到布隆过滤器请求进来先过布隆过滤器如果判定不存在直接返回商品不存在。另外对查询结果为空的数据也做短时间缓存比如缓存一个空值 30 秒避免恶意请求反复穿透。击穿是热点 key 过期的瞬间大量请求同时回源。秒杀场景里seckill:stock:{skuId}就是典型的热点 key活动期间大量请求同时访问它。如果活动创建的瞬间没有预热就会击穿。我的处理方式是活动开始前用定时任务主动把库存加载到 Redis并且活动期间不设置过期时间活动结束后由定时任务主动删除。这样就绕开了过期瞬间击穿的问题。如果确实需要设置过期时间就采用逻辑过期方案value 里带上真实过期时间读到过期后加互斥锁重建缓存其他请求短暂等待后读到新值。雪崩是大量 key 同时过期数据库被瞬间打爆。秒杀场景里如果给每个 sku 的库存都设置了相同的 TTL就可能雪崩。处理方式很简单过期时间加随机数分散比如 TTL 在 5 到 10 分钟之间随机。另外做多级缓存兜底Redis 之外再加一层本地缓存 Caffeine热点 key 在本地也存一份即使 Redis 短暂不可用读请求也能从本地拿到数据。5.3 Redis 挂了怎么办——宁可失败也不能超卖这个问题的答案和普通业务系统完全不一样。普通系统 Redis 挂了可以降级到数据库顶多慢一点。但秒杀系统里 Redis 承担了库存预扣如果降级到数据库直接扣库存数据库瞬间就会被流量打爆。我的回答是秒杀期间 Redis 不可用直接走熔断。具体做法是维护一个 Redis 健康检查的开关机制监控发现 Redis 不可用时秒杀接口直接返回活动太火爆请稍后重试不再放任何流量到下游。因为秒杀的本质是稀缺资源的分配一旦基础设施不可用宁可让用户抢不到也不能让系统数据出错或者被压垮。活动结束后 Redis 恢复系统会自动打开秒杀开关继续处理积压的消息。这个降级策略的核心思想是在强一致场景下要么正确要么失败绝不允许用错误的结果去换取可用性。5.4 Kafka 会丢消息吗如何保证不丢丢消息可以从三个环节分别讨论。生产端producer 发送消息默认是异步的如果网络异常或者 broker 端写入失败消息就丢了。解决方法是在 producer 配置里设置acksall要求分区所有副本都写入成功才算发送成功。同时开启重试机制配置retries3。如果重试仍失败要把失败的消息记录到本地日志或消息表通过定时任务补偿。broker 端单副本的 Kafka 在 broker 重启时可能丢数据。生产环境至少设置 3 个副本配合min.insync.replicas2确保至少两个副本写入成功才返回 ack。消费端最常见的丢消息原因是自动提交 offset。消费者拉到消息后还没处理完offset 就自动提交了。如果这时进程挂掉消息就找不回来了——因为 Kafka 认为你已经消费过了。所以消费端必须关闭自动提交改为手动提交并且先落库后提交 offset。如果消息处理成功但提交 offset 失败最多造成重复消费不会造成消息丢失。5.5 最终一致性怎么保障——对账机制是底牌Redis 预扣和 DB 落库是异步的系统必然存在一个短暂的不一致窗口。面试官问这个问题是想知道你怎么处理这种分布式场景下的数据一致性。我的方案是三个环节闭环正向流程Redis 预扣成功 - 发 Kafka 消息 - 消费者落库。正常情况下最终 DB 订单数和 Redis 预扣数是相等的。反向流程订单创建失败或超时未支付 - 发送退款/回补消息 - Redis 库存加回去。兜底流程定时对账任务每 5 分钟扫描一次 Redis 预扣记录找出已预扣但 DB 无订单的数据查明原因后回补库存同时记录补偿日志。对账任务的 SQL 逻辑要写得谨慎。我是把 Redis 中预扣成功的用户 ID 集合和数据库订单表的 user_id 集合做差集差集就是可能异常的数据。但秒杀订单数据量很大直接做差集会拖垮数据库。所以我按活动批次和 skuId 分批扫描每批处理几万个用户分批对账避免一次任务把数据库打满。6. 压测数据与参数调优这些配置我实测过6.1 压测环境怎么搭纸上谈兵没有说服力面试时能报出一组压测数据会加不少分。我压测用的环境是服务端 4C8G 单节点部署 Spring BootRedis 6 单节点Kafka 3 节点集群MySQL 8 单实例。压测客户端用两台 4C8G 机器跑 JMeter分布式压测模式。压测的核心指标有三个接口 QPS、P99 延迟、数据库写入 QPS。QPS 衡量系统的吞吐能力P99 衡量用户体验数据库写入 QPS 衡量消费端的处理效率。压测时先不加 Kafka 消费端单独压 Redis 预扣接口看服务端能扛多少再单独压消费端看落库能力最后串起来压整体链路。6.2 关键参数和配置清单我整理了一份压测后沉淀下来的参数配置放在这里供参考组件参数配置值说明Redis 连接池lettuce max-total200连接数不是越大越好200 已经满足单机扣库存需求Redis 连接池lettuce max-idle50控制空闲连接避免资源浪费Kafka Produceracksall保证消息不丢Kafka Producerlinger.ms5延迟 5ms 凑批量吞吐和延迟的折中Kafka Producerbatch.size1638416KB 批量减少网络IO次数Kafka Consumerenable.auto.commitfalse手动提交 offset防止丢消息Kafka Consumermax.poll.records500单批拉取 500 条批量写入消费者并发分区数1212 个消费者线程和分区数匹配数据库连接池HikariCP maximum-pool-size4040 个连接足够支撑 600 TPS 落库数据库连接池HikariCP minimum-idle10控制空闲连接有一个参数细节容易被忽略Kafka Consumer 的max.poll.interval.ms默认 300 秒如果你的单批消息处理时间超过了这个值消费者会被判定为失活触发 rebalance。我压测时把max.poll.records从 100 调整到 500 之后单批处理时间从 2 秒涨到 8 秒如果处理逻辑里有慢 SQL很容易踩这个坑。建议监控单批处理耗时和max.poll.interval.ms做对比。6.3 压测数据参考压测结果如下表这是单节点服务端的实测数据场景并发线程数接口 QPSP99 延迟备注纯 Redis 预扣接口无 Kafka10007800180ms主要消耗在 Spring Boot 业务代码和 Redis 网络往返纯 Redis 预扣接口无 Kafka50008200320ms陷入Tomcat 线程池成为瓶颈完整链路含 Kafka 发送和消费落库30003100220ms数据库落库 TPS 约 600是链路瓶颈完整链路消费端扩容到 12 消费者30003100210ms落库 TPS 提升到 780仍有上行空间从数据里能看出几个结论。第一Redis 预扣接口的瓶颈不在 Redis而在应用服务本身的线程模型和网络 IO单节点 8 万 QPS 是达不到的8000 左右比较现实。第二完整链路的瓶颈在数据库落库单库单表的 TPS 就在 600-800 之间这就是为什么削峰是关键——如果 8000 的预扣请求全部同时打到数据库数据库直接崩溃但通过 Kafka 削峰后每分钟积压的几千条消息会被消费端按 600-800 TPS 平滑落库完全在可控范围内。第三加消费者扩容确实能提升落库速度但会推高数据库压力需要平衡。调优的顺序也很重要。我习惯先看连接池配置Redis 和数据库再看线程池参数然后看 GC 日志最后才调整 Kafka 的批量参数。很多人一上来就调 Kafka 的 batch.size结果发现瓶颈在数据库连接池白忙一场。7. 面试复盘为什么这么讲面试官才会点头7.1 讲项目的核心逻辑先说为什么再说怎么做我发现很多候选人在面试讲秒杀项目时喜欢一上来就背架构Nginx 限流、Redis 预扣、Kafka 异步、数据库落库。面试官听完只觉得你在背八股因为他问任何一个环节你都说不出取舍。正确的叙述方式应该反过来先讲清楚你面对的问题是什么每一步为什么要这么选。比如我会说这个项目上线前我们评估过纯数据库方案在 500 并发时数据库 CPU 就到 80% 了所以必须引入 Redis 做预扣。但引入 Redis 之后产生了两个新问题一是 Redis 和 DB 的库存一致性怎么保证二是 Redis 挂了怎么办。针对第一个问题我设计了延迟双删和定时对账针对第二个问题我做了降级熔断。这样的叙述方式面试官能明显感觉到你是真的经历过这些决策而不是看了几篇博客在背答案。取舍才是经验的体现方案本身不是。7.2 一个可以直接套用的回答话术如果你要现场回答讲一下你的秒杀项目可以参考这个结构第一步一句话概括业务指标我在上一家公司负责的核心秒杀系统单场活动峰值 QPS 约 8 万参与用户几十万商品有限量系统要求不能超卖、不能崩溃。第二步讲整体架构的分层思路我按流量入口到数据落库分成了四层。入口层用 Nginx 限流和验证码挡掉无效流量应用层用 Redis 预扣库存 Lua 脚本保证扣减原子性10 万 QPS 在这个层被削到几千Kafka 做异步削峰把下单消息平滑地灌给消费端数据库层通过唯一索引和手动提交 offset 保证最终一致同时有定时对账兜底。第三步主动抛出两个深度问题这个方案里有两个我重点处理过的难点一个是 Redis 和数据库的库存一致性我用了延迟双删加定时对账另一个是 Kafka 重复消费我通过订单表唯一索引实现了消费幂等。这两个问题如果您感兴趣我可以展开讲。主动抛出难点让面试官顺着你熟悉的方向问其实是面试中的一种掌控节奏的技巧。7.3 一点个人体会秒杀系统做了几轮之后我最大的体会是这套架构的每一个组件单独拿出来都不复杂难的是它们之间的边界和失败路径。Redis 负责快、Kafka 负责稳、数据库负责准任何一环出问题都要有对应的兜底方案。面试官真正在考察的不是你会不会用这些工具而是你有没有在真实流量面前做过决策。我见过太多人把架构图画得漂漂亮亮一追问Redis 挂了怎么办就卡壳。反过来只要你能把每一层为什么存在、挂了怎么办、数据怎么兜底讲清楚这个项目在面试里就成功了一大半。希望这份实录里的思路和参数能让你在准备秒杀类面试题或者真正落地这类系统时少走一点我走过的弯路。
返回列表