第一部分:高并发系统架构(第 1~15 题)
本批 10 题均属「第一阶段:基础架构能力」,但要求给出架构级回答:明确数据规模 → 性能指标 → 组件职责 → 瓶颈与权衡 → 故障兜底。
第 1 题:电商秒杀——50 万 QPS 抢购与库存防超卖 高并发
- 1. 整体架构如何设计?2. 如何防止库存超卖?3. Redis 与 DB 库存如何一致?4. Redis 宕机怎么办?5. 如何削峰让 2 万 TPS 的库承接 50 万 QPS?6. 如何避免请求直接打库?7. 如何保证订单/库存/积分最终一致?8. QPS 从 50 万涨到 100 万,哪先崩、怎么扩?
第一步:分析问题。秒杀本质不是「高并发写」,而是读多写少 + 极小有效请求比例。1 万库存面对 500 万用户,99.8% 请求注定失败。第一性原理:尽早、廉价地拒绝无效请求,把 50 万 QPS 收敛到 1 万次成功写入。
第二步:核心挑战。超卖=0 件(资损红线);DB 承压 ≤1.5 万 QPS;单 Key 热点;重复下单;最终一致延迟 ≤5s;脚本拦截率 >99%。
第三步:整体架构。「四层拦截 + 异步下单 + 数据库终判」:①客户端答题/随机延迟削 30%;②CDN+WAF+网关限流到 5 万;③Redis Lua 原子预扣(库存/一人一单/售罄一次完成)放行约 1.2 万;④RocketMQ 串行消费匀速落库。核心:Redis 决定谁能买,DB 决定买成不成,Lua 保证不超卖。
第四步:技术选型。Redis Cluster(8 分片)+ 库存分段 + Lua;RocketMQ(事务/顺序/重试/死信);MySQL 条件扣减为最终裁判;Gateway+Redis 用户维度限流;雪花算法订单号。
第五步:一致性。预扣 → MQ → DB 条件扣减 → 对账回补。允许少卖、绝不允许超卖。
第六步:高可用。Redis 主从自动转移;全挂降级 DB 乐观锁直扣(限 2000 QPS);秒杀集群与交易主链路物理隔离(独立连接池/线程池)。
第七步:性能优化。库存分段打散热 Key;缓存预热;Caffeine 本地售罄标记(零 Redis 调用);singleflight 合并查询。
潜在瓶颈:网关热点、Redis 单分片热 Key(分段解决)、MQ 消费能力不足积压、DB 行锁竞争(分段+合并扣减)。
为什么用 Redis 而非直写库?1 万库存只有 1 万次有效写入,但入口 500 万请求;直落库会让 499 万无效请求打满连接池与行锁,连累有效请求。Redis 十万级 QPS + Lua 原子性天然做「阀门」。
为什么用 Lua 而非分布式锁?分布式锁需「加锁→读→扣→解锁」四次往返,还要处理超时/误释放/GC 致锁失效;Lua 在 Redis 内一次性原子执行,一次 RTT 完成判断+扣减,无锁无死锁,性能高一数量级。
为什么不能直接写库防超卖?SELECT stock → UPDATE 存在读写竞态,并发必超卖。必须 UPDATE ... SET stock=stock-1 WHERE sku_id=? AND stock>0,靠受影响行数判定成败。
Redis 与 MySQL 一致性?预扣+终判+对账三段式:Redis 逻辑库存(快)、DB 物理库存(权威);DB 扣失败/消息丢失 → 回补 Redis 库存(宁可少卖不超卖);30s 对账比对差异并纠偏。
Redis 宕机?①分片故障:Cluster 秒级主从切换+客户端重试;②集群整体不可用:开关降级纯 DB 模式(限 2000 QPS,条件扣减直扣);③DB 也不可用:快速失败返回「太火爆」,优于挂起拖垮全站。
选型权衡:Redis 预扣+MQ vs DB 乐观锁直扣 vs 分布式锁+DB——前者吞吐高/削峰/链路长;后者链路短/强一致但万级 QPS 即瓶颈;分布式锁性能差有死锁风险,不推荐。
① Lua 原子预扣:
-- KEYS[1] 库存段 key KEYS[2] 已购用户 SET ARGV[1] 用户ID ARGV[2] 扣减数量 local stock = tonumber(redis.call('GET', KEYS[1]) or '0') if stock < tonumber(ARGV[2]) then return -1 end -- 该段不足 if redis.call('SISMEMBER', KEYS[2], ARGV[1]) == 1 then return -2 end -- 重复购买 redis.call('DECRBY', KEYS[1], ARGV[2]) redis.call('SADD', KEYS[2], ARGV[1]) return tonumber(redis.call('GET', KEYS[1]))
② 秒杀服务(分层):
@Service @RequiredArgsConstructor public class SeckillServiceImpl implements SeckillService { private static final int SEG = 100, SEG_COUNT = 100; private final StringRedisTemplate redis; private final RocketMQTemplate mq; private final IdGenerator idGen; private final SeckillConfigCache configCache; private final RedisScript<Long> script = RedisScript.of(SeckillLua.SCRIPT, Long.class); public SeckillResult doSeckill(SeckillCmd cmd) { if (configCache.isSoldOut(cmd.activityId(), cmd.skuId())) return SeckillResult.reject("已售罄"); String rateKey = "sec:rate:%d:%d".formatted(cmd.activityId(), cmd.userId()); if (Boolean.FALSE.equals(redis.opsForValue() .setIfAbsent(rateKey, "1", Duration.ofSeconds(5)))) return SeckillResult.reject("操作过于频繁"); long orderId = idGen.nextId(); Long remain = null; for (int i = 0; i < 3; i++) { int seg = ThreadLocalRandom.current().nextInt(SEG_COUNT); remain = redis.execute(script, List.of(stockKey(cmd, seg), boughtKey(cmd)), String.valueOf(cmd.userId()), "1"); if (remain != null && remain >= 0) break; if (remain != null && remain == -2L) return SeckillResult.reject("每人限购 1 件"); } if (remain == null || remain < 0) { configCache.markSoldOut(cmd.activityId(), cmd.skuId()); return SeckillResult.reject("已售罄"); } SeckillOrderMsg msg = new SeckillOrderMsg(orderId, cmd.userId(), cmd.skuId(), cmd.activityId(), System.currentTimeMillis()); mq.asyncSend("SECKILL_ORDER_TOPIC", MessageBuilder.withPayload(msg) .setHeader(RocketMQHeaders.KEYS, String.valueOf(orderId)).build(), new SendCallback() { public void onSuccess(SendResult r) {} public void onException(Throwable e) { compensateStock(cmd, segOf(orderId)); } }); return SeckillResult.queuing(orderId); } }
③ 消费端幂等落库 + DB 条件扣减:
@RocketMQMessageListener(topic = "SECKILL_ORDER_TOPIC", consumerGroup = "seckill-order-cg", consumeMode = ConsumeMode.ORDERLY, maxReconsumeTimes = 3) public class SeckillOrderConsumer implements RocketMQListener<SeckillOrderMsg> { @Transactional(rollbackFor = Exception.class) public void onMessage(SeckillOrderMsg msg) { int ins = orderMapper.insertIgnore(SeckillOrder.builder() .orderId(msg.orderId()).userId(msg.userId()).skuId(msg.skuId()) .activityId(msg.activityId()).status(OrderStatus.CREATED).build()); if (ins == 0) return; // 重复消息,幂等丢弃 int up = skuStockMapper.deductStock(msg.skuId(), 1); // WHERE stock>=1 if (up == 0) { compensateRedis(msg); throw new IllegalStateException("no stock"); } orderMapper.insertOutbox(OutboxEvent.of("SECKILL_ORDER_CREATED", msg.orderId())); } } // Mapper: 条件扣减,天然防超卖 @Update("UPDATE sku_stock SET stock=stock-#{num},version=version+1 " + "WHERE sku_id=#{skuId} AND stock>=#{num}") int deductStock(@Param("skuId") Long skuId, @Param("num") int num);
追问 1:Redis Cluster 部分节点宕机,正在秒杀的请求怎么办?Cluster 每分片主从结构,故障秒级提升从节点。库存分段让落在该分片的段暂不可用、轮询下一段自动绕过;若超容忍范围则降级开关切 DB 直扣。
追问 2:消息重复消费怎么办?消费端幂等:唯一索引 (activity_id,user_id) + 高并发先查 Redis 幂等表(SET NX 24h);非幂等操作用状态机条件更新或流水表去重。
追问 3:如何保证消息不丢失?生产端同步发送+失败重试/事务消息;Broker 同步刷盘+主从同步复制(RocketMQ SYNC_MASTER,Kafka acks=all+min.insync.replicas=2);消费端处理完再提交 offset,失败进重试→死信。
追问 4:为什么要库存分段?单 Key 全落同一分片,50 万 QPS 打爆单分片(约 10 万上限)而其他 7 分片闲置。分段后 hash 打散到不同 Key/分片,集群吞吐线性提升 N 倍;代价是轮询多段与聚合对账。
追问 5:QPS 从 50 万涨到 100 万哪先崩?①网关(水平扩容+边缘限流);②Redis 热分片(增分段+扩容,或下沉本地内存令牌);③MQ 消费能力(扩到 5000 TPS,查 DB 行锁);④DB 连接池与主从延迟。100 万 QPS 必须把限流前置到 CDN/边缘,否则回源打爆机房带宽。
追问 6:少卖和超卖允许哪个、如何量化?允许少卖、绝不允许超卖。超卖=0 为硬指标(30s 对账);少卖率=少卖件数/总库存,目标 <3%,靠死信监控与自动回补控制。
- ❌ 用 synchronized/ReentrantLock 锁库存——单机锁跨 200 副本无效,必超卖。正确:Redis Lua 或 DB 条件更新。
- ❌ SELECT→UPDATE 判断库存——读写竞态必超卖。正确:UPDATE ... WHERE stock>0 看影响行数。
- ❌ 扣 Redis 后同步写库并用 @Transactional 包整个秒杀方法——事务含 Redis/MQ 不原子,且不必要拉长连接占用。正确:预扣后立即返回,异步落库。
- ❌ 库存只放 Redis 不落库——宕机丢失无法对账。正确:DB 权威 + 对账。
- ❌ 「加机器就能扛」——忽略热点 Key 单点上限。正确:先打散热点再扩容。
第 2 题:商品详情页——千万级 SKU 热点数据多级缓存 缓存
- 1. 为什么一次详情要查 12 个服务,怎么聚合?2. 多级缓存如何设计?3. 热点商品怎么处理?4. 库存每 100ms 变,缓存怎么不脏读?5. 缓存与 DB 一致性方案?6. 某个下游(如评价服务)慢,详情页要不要跟着慢?7. 缓存击穿怎么办?
第一步:分析。详情页是读多写少、聚合型、容忍短暂不一致的典型。核心矛盾:12 个异构数据源的高扇出 vs 200ms RT,以及库存类强时效数据 vs 缓存。
第二步:挑战。扇出爆炸(串行 12 次→RT 翻倍)、热点倾斜(少量 SKU 占 40%)、强时效库存、下游故障传染、缓存击穿。
第三步:架构。①动静分离:标题/图文/规格等静态部分 CDN 化(可缓存 5min);②多级缓存:浏览器→CDN→Nginx 本地缓存→应用本地 Caffeine→Redis 集群→DB;③并行聚合:CompletableFuture 并行调 12 个服务,取最快 N-1 + 兜底;④热点探测:Redis 访问计数+本地 ZooKeeper 广播,热点 SKU 推本地缓存并加多副本;⑤库存强时效:库存走独立短 TTL(如 200ms)+ 变更主动失效。
第四步:选型。Caffeine(进程内,亚毫秒,扛本地热点);Redis(集群共享,穿透兜底);Canal 订阅 Binlog 主动失效(替代轮询);OpenFeign + CompletableFuture(并行聚合)。
第五步:一致性。价格/库存:Binlog→Kafka→本地缓存失效 + Redis DEL,保证变更秒级生效;其余详情:TTL 5min + 写时主动失效。
第六步:高可用。下游慢/挂:舱壁隔离 + 超时降级(评价/推荐不可用时用空/默认,不阻塞主路径);熔断兜底返回旧缓存。
第七步:优化。热点本地缓存;合并相同 SKU 的并发回源(singleflight);详情页静态化预渲染;图片走对象存储+CDN。
为什么多级缓存?越靠近用户越快越便宜:本地缓存亚毫秒且零网络、Redis 共享但需网络、DB 最权威最慢。全走 Redis 仍可能被热 Key 打爆,本地缓存可扛住 90% 热点读。
库存 100ms 变怎么防脏读?库存单独短 TTL(如 200ms)或用版本号/时间戳校验;写时 Canal 订阅 Binlog 主动 DEL/更新,避免轮询。注意:详情页展示的「剩余库存」允许 ±几百的短暂偏差(用户感知无碍),下单时才走实时 Redis 校验。
一致性方案权衡:Cache-Aside(读穿写失效)实现简单、最终一致,适本题;延迟双删解决「写后立刻读」脏窗口;Binlog 订阅(Canal)解决「多端缓存失效遗漏」,最彻底但引入复杂度。
下游慢传染:用舱壁隔离(每个下游独立线程池)+ 超时(如 80ms)+ 降级(返回默认/空),保证评价服务挂了详情页仍 200ms 返回。这正是「核心路径不依赖非核心服务」。
缓存击穿:热点 Key 失效瞬间大量请求穿透。用 singleflight(只放一个回源,其余共享结果)+ 热点 Key 永不过期(逻辑过期,后台刷新)+ 互斥锁重建。
// 并行聚合 12 个域,任一超时/异常走降级,主路径不阻塞 public ItemDetailVO getDetail(Long skuId) { CompletableFuture<BaseVO> f1 = supplyAsync(() -> baseSvc.get(skuId), basePool); CompletableFuture<PriceVO> f2 = supplyAsync(() -> priceSvc.get(skuId), pricePool); CompletableFuture<StockVO> f3 = supplyAsync(() -> stockSvc.realTime(skuId), stockPool); CompletableFuture<ReviewVO> f4 = supplyAsync(() -> reviewSvc.top(skuId), reviewPool) .exceptionally(ex -> ReviewVO.EMPTY); // 评价降级 CompletableFuture<List<ItemDetailVO>> all = CompletableFuture.allOf(f1, f2, f3, f4).thenApply(v -> List.of(f1.join(), f2.join(), f3.join(), f4.join())); try { return assemble(all.get(200, TimeUnit.MILLISECONDS)); // 整体超时 200ms } catch (TimeoutException e) { return assembleFromCache(skuId); // 降级:返回旧缓存快照 } } // singleflight:同一 skuId 并发只回源一次 public String singleflightLoad(Long skuId, Function<Long,String> loader) { CompletableFuture<String> f = inFlight.computeIfAbsent(skuId, k -> CompletableFuture.supplyAsync(() -> loader.apply(k)) .whenComplete((r, e) -> inFlight.remove(skuId))); return f.join(); }
追问 1:本地缓存和 Redis 数据不一致怎么处理?接受秒级不一致;强时效字段(库存)短 TTL + 写时主动失效;用 Canal 统一失效,避免「写完 Redis 忘了清本地」。
追问 2:热点 SKU 怎么探测?Redis 原子计数 + 滑动窗口,超阈值上报配置中心/Nacos,推送到各节点本地缓存;或机器学习预测(大促名单预热)。
追问 3:缓存雪崩?Key 统一过期时间 + 随机抖动(如 5min±30s);热点 Key 逻辑过期;Redis 高可用集群 + 本地缓存兜底。
追问 4:详情页 RT 还是 200ms 怎么再降?静态预渲染(活动前生成静态页)+ CDN 化;图片/视频独立域名走对象存储;压缩(gzip/Brotli);首屏只返回核心字段,其余懒加载。
追问 5:12 个服务调用,其中一个一直超时导致频繁降级,怎么根治?定位慢服务根因(慢 SQL/锁/GC);对它限流+熔断+独立池;非核心域异步化(评价/推荐走 MQ 后补偿),核心域(库存/价格)强依赖则升级 SLA 与容量。
- ❌ 串行 for 循环调 12 个服务——RT 叠加 1s+。正确:CompletableFuture 并行。
- ❌ 所有数据统一缓存 30min——库存脏读。正确:分级 TTL + 主动失效。
- ❌ 下游挂了详情页也挂——未做舱壁降级。正确:隔离+超时+默认。
- ❌ 用 Redis 当唯一缓存被热 Key 打爆——缺本地缓存。正确:多级。
第 3 题:大促限流——从网关到资源的多级流量防护体系 限流
- 1. 限流分哪几层?每层限什么?2. 令牌桶 vs 漏桶怎么选?3. Sentinel 和 Gateway 限流区别?4. 集群限流怎么做?5. 单用户维度限流怎么实现且不误杀?6. 限流后请求怎么处理(丢弃/排队/降级)?7. 限流阈值怎么定?
第一步:分析。限流目标是保护系统不被超过处理能力的流量打垮,本质是「在成本边界内最大化有效吞吐」。80 万入口、8 万承接,需层层收敛 90%。
第二步:挑战。入口无效流量 60%、单实例限流无法控全局、用户刷单、阈值难定、误杀正常用户。
第三步:多级限流架构。①接入层(Nginx/SLB):连接数+并发限制,挡 DDoS;②网关层:全局 QPS 令牌桶 + 路由级限流(核心 8 万、非核心可丢)+ 用户维度配额;③应用层:Sentinel 资源级(热点参数/接口)限流 + 线程池隔离;④资源层:DB/Redis 连接池上限 + 信号量。
第四步:选型。Gateway 令牌桶(入口粗粒度);Sentinel(热点参数、熔断降级、集群流控);Redis + Lua(用户维度精确计数);Nacos 动态下发阈值。
第五步:一致性/正确性。集群限流用中心计数器(Redis INCR 窗口计数)或 Sentinel 集群流控(Token Server)。
第六步:高可用。限流组件自身须高可用:Sentinel 控制台/Token Server 多副本;Redis 计数用本地兜底(Redis 挂则退化为单机令牌桶)。
第七步:优化。阈值基于压测 + 历史容量模型动态调;非核心接口限流直接返回默认(降级),核心接口返回「排队中」并走 MQ 排队。
令牌桶 vs 漏桶:令牌桶允许突发(桶内攒的令牌一次性放行),适合有短时尖峰的 Web 请求;漏桶强制恒定速率,平滑输出,适合保护后端稳态处理能力。网关入口常用令牌桶(允许合理突发),保护 DB 的资源层可用漏桶/信号量(恒定)。
Gateway vs Sentinel:Gateway 限流在路由/IP/Header 维度,粗粒度、入口第一道;Sentinel 在方法/资源/热点参数维度,细粒度、可熔断降级、支持集群流控。两者互补:Gateway 挡量,Sentinel 控质。
集群限流:单机令牌桶无法控全局(N 台×单机阈值≠集群阈值)。方案:①Sentinel 集群流控(Token Server 统一发牌);②Redis 滑动窗口计数(INCR+过期)做全局配额。后者简单但需关注 Redis RT,故本地加一档单机兜底。
用户维度限流不误杀:用 Redis 滑动窗口(如 1s 内同一 userId ≤ 5 次),SET NX+TTL 或 ZSET 去重;区分「合法突发」与「脚本刷单」:结合设备指纹、行为序列(如 1ms 间隔连续点击)识别脚本,命中直接进黑名单而非简单 429。
限流后处理:核心交易→返回「排队中」+MQ 排队(削峰);非核心(搜索/推荐)→直接返回默认/降级内容(快速失败,不占资源);前端配合重试退避,避免雪崩式重放。
// 用户维度滑动窗口限流(Redis + Lua,原子校验+计数) private static final String SCRIPT = """ -- KEYS[1]=user:limit:{id} ARGV[1]=now(ms) ARGV[2]=window(ms) ARGV[3]=max redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, ARGV[1]-ARGV[2]); local cnt = redis.call('ZCARD', KEYS[1]); if cnt >= tonumber(ARGV[3]) then return 0 end redis.call('ZADD', KEYS[1], ARGV[1], ARGV[1]); redis.call('PEXPIRE', KEYS[1], ARGV[2]); return 1"""; // Sentinel 资源限流 + 热点参数(skuId 维度) @SentinelResource(value = "createOrder", blockHandler = "orderBlocked", fallback = "orderFallback") public OrderVO create(OrderCmd cmd) { return orderSvc.create(cmd); } public OrderVO orderBlocked(OrderCmd cmd, BlockException e) { return OrderVO.queuing(); // 排队提示,前端转 MQ 异步 }
追问 1:限流阈值怎么定才不误杀?压测得到单实例拐点 QPS(RT 陡升点)×实例数×0.7 安全系数;再按历史流量分位数(P99.9)+业务容忍度微调;大促灰度观察逐步放开。误杀用「白名单+行为识别」降低。
追问 2:Sentinel Token Server 挂了怎么办?退化为单机限流(独立阈值),虽然失去全局精确性但保证不崩溃;Token Server 多副本 + 本地兜底计数。
追问 3:漏桶实现里队列满了怎么办?直接拒绝(503/排队满),或溢出到 MQ 异步处理。依业务而定:交易类拒绝保一致,日志类可异步。
追问 4:如何防止限流被绕过(伪造 userId)?网关层按真实连接/IP + 设备指纹综合判定,不只信 Header 的 userId;对匿名流量按 IP+UA 限流。
追问 5:热点参数限流和普通限流区别?普通限流控「整个资源 QPS」;热点参数限流(如某些爆款 skuId)对高频率参数值单独限制,避免个别热点拖垮全局配额。
- ❌ 只在应用层加 @SentinelResource——入口 80 万已打到网关,应用早被连接打满。正确:网关先挡。
- ❌ 用 synchronized 计数限流——单 JVM 无效。正确:Redis/集群流控。
- ❌ 限流阈值拍脑袋写死——大促必然误杀或击穿。正确:压测+容量模型+动态。
- ❌ 限流后无限重试——重放雪崩。正确:前端退避/排队提示。
第 4 题:优惠券/红包高并发领取——金额拆分、库存扣减与防刷 高并发
- 1. 总额 1 亿、1000 万个红包怎么保证不超发?2. 金额随机怎么生成才公平且预知总数?3. 库存扣减与秒杀有何不同?4. 重复领取怎么防?5. 防刷怎么做?6. 红包预拆好还是实时拆?
第一步:分析。红包 = 有限资源(个数)+ 有限总额(金额)+ 随机分配 + 防重 + 防刷。比秒杀多一个「金额拆分」维度,且「不超发总额」是另一条硬约束。
第二步:挑战。总额与个数双约束、随机金额可解释、并发预扣、防重防刷、公平。
第三步:架构。①预拆分:活动前用「二倍均值法/线段切割」离线生成 1000 万个红包金额,写入 Redis 队列(List/Stream),总额固定;②领取:Redis LPOP/XREAD 原子取一个金额 + SET NX 记录 userId 防重;③异步落库:消息写红包领取记录 + 账户加款(事务消息保证);④防刷:用户维度限流 + 设备指纹 + 行为风控。
第四步:选型。Redis List/Stream(预拆分池,原子取);SET NX(防重);RocketMQ 事务消息(领取+加款一致);规则引擎(风控)。
第五步:一致性。预拆分定总额,领取只减池;加款失败则回补池;对账:已领金额之和 + 池剩余 = 1 亿。
第六步:高可用。Redis 集群多副本;领取失败重试;加款走账户核心服务(幂等)。
第七步:优化。预拆分避免实时随机导致总额不可控;分片池(按用户 hash)打散;本地缓存防重 bloom。
为什么预拆分而非实时随机?实时随机(如每次 rand 1~剩余)无法保证总额恰好 1 亿且末尾易极端(最后一个拿走全部剩余)。预拆分用「二倍均值法」:每次在 [0.01, 2×剩余均值] 取,保证均值稳定、总额精确、分布合理,且领取是 O(1) 原子出队,性能极高。
与秒杀区别:秒杀抢的是「同一件商品」(库存共享,需 Lua 复合判断);红包抢的是「池里独立一份」(金额各异,LPOP 即扣,天然不超发、无竞争)。所以红包更简单——不需要 Lua 复合,但需要总额预知与防重。
防超发总额:池里只有 1000 万个、总额 1 亿,取出一个就少一个,不可能超;加款失败回补池,保证「已领+剩余=总额」。
防重复:SET NX(userId→红包id,24h TTL),LPOP 与 SET NX 需原子——用 Lua 包成一条脚本,避免「取出但防重失败」导致丢红包。
防刷:设备指纹(同一设备多账号)、行为序列(毫秒级连点)、IP 聚集、新注册账号限制;命中进黑名单 + 风控拦截,而非仅限流。
// 预拆分:二倍均值法,保证总额精确、分布合理 public List<Long> split(long total, int count) { // 单位:分 List<Long> res = new ArrayList<>(count); long remain = total, left = count; for (int i = 0; i < count - 1; i++, left--) { long max = remain * 2 / left; // 二倍均值上限 long amt = 1 + ThreadLocalRandom.current().nextLong(max - 1); res.add(amt); remain -= amt; } res.add(remain); return res; } // 原子领取:LPOP 取金额 + SET NX 防重,一条 Lua private static final String CLAIM = """ if redis.call('SISMEMBER', KEYS[2], ARGV[1]) == 1 then return -1 end local v = redis.call('LPOP', KEYS[1]); if not v then return -2 end redis.call('SADD', KEYS[2], ARGV[1]); return v"""; // 账户加款(幂等):事务消息保证领取记录与加款一致 @RocketMQTransactionListener public class RedPacketListener implements RocketMQLocalTransactionListener { public RocketMQLocalTransactionState executeLocal(Message m, Object a) { return accountSvc.add((RedPacketMsg) a) ? COMMIT : ROLLBACK; } public RocketMQLocalTransactionState checkLocal(Message m) { return accountSvc.exists(((RedPacketMsg) a).id()) ? COMMIT : UNKNOWN; } }
追问 1:预拆分 1000 万个写入 Redis 很慢/占内存怎么办?分批 pipeline 写入(每批 1 万);用 Stream 或 List,内存约 1000 万×~50B ≈ 500MB,可接受;或按 uid hash 分 64 个池,避免单 Key。
追问 2:用户领了但 24h 没核销,钱怎么回?红包本身已加款到账户,「未核销」是消费侧,不影响发放;若是「领取即锁定、核销才结算」,则超时定时任务回补池。
追问 3:金额分布想偏小/偏大怎么调?二倍均值法偏均匀;要右偏(少数大)用「线段切割+随机权重」或对数分布;要更刺激可调上限倍数。
追问 4:防刷误杀真实用户?风控用多因子评分而非硬规则;灰度 + 人工复核;误杀走申诉补发,平衡安全与体验。
追问 5:红包池 Redis 全挂,已领数据还在吗?领取即异步落库(账户+记录),Redis 只是「待领池」。池丢失可重建(总额-已领=剩余重新拆分),领取记录不丢。
- ❌ 实时随机金额不设上限——总额失控/末尾极端。正确:预拆分。
- ❌ LPOP 与 SET NX 分开调用——可能丢红包(取出防重失败)。正确:Lua 合并。
- ❌ 只靠限流防刷——黄牛换 IP/设备。正确:设备指纹+行为风控。
- ❌ 领取与加款非事务——可能领了不加款或加了重复。正确:事务消息/幂等。
第 5 题:热点库存分片——Redis 热 Key 与库存分段扣减 热Key
- 1. 热 Key 怎么发现?2. 单 Key 为什么扛不住?3. 库存分段怎么设计?4. 分段后总库存怎么聚合与对账?5. 如何避免「段内为 0 但总库存还有」误判售罄?6. 本地缓存/本地内存令牌能否彻底解决?
第一步:分析。热 Key 根因是同一 Key 的全部流量落同一分片单线程,集群并行度被锁死在单分片。解决思路:把单 Key 拆成多个 Key,使流量 hash 到不同分片/实例。
第二步:挑战。单分片吞吐上限、聚合准确性、误售罄、本地缓存一致性。
第三步:分段架构。①库存 10 万分成 N 段(如 100 段×1000);②每段独立 Key 落在不同槽位;③扣减时轮询/一致性 Hash 选段执行 Lua;④总库存 = 各段和,售罄 = 所有段为 0;⑤热点进一步下沉:本地内存令牌桶(Caffeine 预分配段额度),Redis 只做最终兜底。
第四步:选型。Redis Cluster(槽位分散);一致性 Hash(请求均匀分布);本地 Caffeine(扛本地热点);监控(Redis 热点 Key 探测 / Proxy 统计)。
第五步:一致性。分段扣减用 Lua 保证段内原子;总库存聚合读多段之和(或维护一个汇总 Key 异步更新);对账:汇总 Key + 各段和 == DB 库存。
第六步:高可用。某分片挂→轮询下一段绕过;本地令牌与 Redis 差额定时回补。
第七步:优化。热 Key 探测自动触发本地缓存;客户端分片缓存路由;减少大价值 Key。
为什么单 Key 扛不住?Redis 单分片单线程处理命令,约 10 万 QPS 上限;35 万流量全落该分片 → 命令排队、RT 飙升、CPU 100%,其余分片闲置。这是分布式系统中的「倾斜」反模式。
分段设计:key 由 sec:stock:{sku}:{seg} 组成,seg 参与 CRC16 槽位计算,天然分散到不同分片。扣减时随机或 Hash 选段,命中空段再换段(最多重试 K 次),把 35 万 QPS 摊到 100 个 Key×多分片。
避免误售罄:不能只看某一段为 0 就判售罄。需「遍历所有段都 ≤0 才售罄」,或用汇总 Key + 各段增量。生产中常用本地标记+异步聚合:某段为 0 时本地记该段空,所有段空才全局售罄。
本地内存令牌能否彻底解决?能扛绝大部分读(亚毫秒、零网络),但不是真相源——多实例本地额度需协调,否则总超发。工程实践:本地令牌做「粗筛/加速」,Redis 做「精确裁判」,DB 做「最终兜底」,三层递减。
权衡:分段增加聚合与对账复杂度、轮询重试增加少量 RT;但换来集群吞吐线性提升,是热 Key 治理的必选项。
// 一致性 Hash 选段,避免请求倾斜到固定分片 public int pickSegment(long skuId, long userId, int segCount) { return Hashing.consistentHash( Hashing.murmur3_128().hashLong(skuId * 31 + userId), segCount); } // 段内 Lua 扣减(同第1题),外层轮询重试 public SeckillResult claim(long skuId, long userId) { for (int i = 0; i < 4; i++) { int seg = pickSegment(skuId, userId, SEG); Long r = redis.execute(script, List.of(segKey(skuId, seg), boughtKey(skuId)), userId + "", "1"); if (r != null && r >= 0) return queuing(); if (r != null && r == -2L) return reject("限购"); // 重复 // r==-1 该段空,换段重试 } markSoldOut(skuId); return reject("已售罄"); } // 本地令牌桶:预分配段额度,扛本地热点(每实例 ~1000/段) LoadingCache<Long, AtomicLong> localTokens = Caffeine.newBuilder() .build(skuId -> new AtomicLong(preloadFromRedis(skuId)));
追问 1:热 Key 怎么自动化发现?Proxy/客户端统计 Key 访问频次(滑动窗口),超阈值上报配置中心;Redis 7 的 HOTKEY 命令;或采样 + 大促名单预热。
追问 2:分段数怎么定?使单段 QPS ≤ 单分片上限/安全系数。35 万 / 10 万 ×1.5 ≈ 至少 6 段,工程取 50~200 段平衡冲突与聚合成本。
追问 3:某分片挂,落其上的段怎么办?轮询下一段绕过;汇总时该段视为不可用(保守判有库存),等恢复后回补。必要时降级 DB 直扣该段。
追问 4:大 Key 和热 Key 区别?热 Key 是访问频率高(CPU/网络瓶颈),大 Key 是体积大(删除/序列化阻塞)。治理不同:热 Key 分片,大 Key 拆分(Hash/分桶/压缩)。
追问 5:本地令牌和 Redis 不一致,超卖了?本地只做粗筛,最终判断在 Redis Lua + DB 条件扣减,三层递减保证不超卖;本地多发只会导致「Redis 扣失败时回补本地」的少卖。
- ❌ 加 Redis 节点解决热 Key——单 Key 仍落单分片,无效。正确:分片/本地。
- ❌ 只看一段为 0 判售罄——误售罄损失 GMV。正确:全段聚合。
- ❌ 只用本地内存——多实例超发。正确:本地粗筛+Redis 裁判+DB 兜底。
第 6 题:高并发下单——异步化削峰填谷与超时关单 异步
- 1. 为什么下单要异步化?2. 同步校验那么多系统怎么不全同步?3. 异步下单后用户怎么拿结果?4. 超时关单怎么做才可靠不漏?5. 关单释放库存与超卖的关系?6. 消息堆积导致下单延迟过长怎么办?
第一步:分析。下单是写密集 + 多系统校验 + 需快速响应。入口 25 万 TPS 远超库 3 万,必须削峰;但「下单成功」对用户体验即时,故采用预校验同步 + 落库异步。
第二步:挑战。校验扇出、写峰值、关单可靠性与时效、库存占用释放、消息延迟。
第三步:架构。①同步轻校验:库存预扣(Redis)、价格/风控(可降级)在网关后同步做,毫秒级;②异步落库:生成订单号即返回「提交中」,订单消息入 MQ,消费者匀速 3 万 TPS 落库;③结果查询:前端轮询/长连接/WebSocket 推订单状态;④关单:RocketMQ 延迟消息(30min)或定时任务扫描「待支付」表,触发关单 + 库存回补。
第四步:选型。RocketMQ(延迟消息/顺序/事务);Redis(库存预扣/订单状态缓存);时间轮/延迟队列(关单);Canal(订单 Binlog 同步到查询库)。
第五步:一致性。预扣库存与订单创建最终一致;关单成功回补库存;对账:待支付占用 + 已支付 = 总扣减。
第六步:高可用。MQ 堆积时消费者扩副本 + 提高并发;关单延迟消息失败重试 + 补偿扫描兜底(双保险);订单状态多副本。
第七步:优化。库存预热到 Redis;下单链路剥离非核心(积分/推荐异步);订单读写分离 + 分库分表;关单用延迟消息避免全表扫。
为什么异步?25 万 TPS 入口,3 万 TPS 库,若同步落库,连接池秒满、RT 飙升、连锁雪崩。异步把「用户感知」与「持久化」解耦:用户立刻拿到订单号(心理完成),落库由 MQ 匀速消化。
同步校验取舍:库存(强一致,必须同步预扣)、价格(可缓存 1s)、风控(可降级跳过)、用户/地址(缓存)。核心校验同步、非核心异步,平衡 RT 与正确性。
关单可靠性:只用延迟消息有丢失风险(Broker 宕机),故延迟消息 + 定时补偿扫描双保险:定时任务每分钟扫「待支付且创建>30min」订单关单,保证不漏;延迟消息做实时性。
与超卖关系:关单回补的是 Redis 预扣库存,使库存可继续卖;DB 物理库存在支付成功时才扣,故关单不影响 DB,只影响「逻辑库存」。
削峰 vs 延迟权衡:异步引入下单到可见的延迟(本题目标 <3s)。若 MQ 堆积,订单可见变慢,需监控积压并扩大消费并发;极端时前端提示「订单处理中」。
// 关单:延迟消息 + 补偿扫描双保险 @RocketMQMessageListener(topic = "ORDER_DELAY_TOPIC", consumerGroup = "close-cg") public class CloseOrderConsumer implements RocketMQListener<MessageExt> { public void onMessage(MessageExt m) { Order order = parse(m); if (order.status() == Status.UNPAID) { orderMapper.closeOrder(order.id()); // 状态机条件更新 stockRedis.release(order.skuId(), order.num()); } } } // 补偿:每分钟扫超时未支付,防止延迟消息丢失 @Scheduled(cron = "0 */1 * * * *") public void compensate() { List<Order> list = orderMapper.listUnpaidOlderThan(30, TimeUnit.MINUTES); list.forEach(o -> { orderMapper.closeOrder(o.id()); stockRedis.release(o.skuId(), o.num()); }); }
追问 1:用户下单后马上查不到订单怎么办?返回「提交中」并轮询/推送;查询走订单状态缓存(Redis)+ 查询库(Canal 同步),落库后秒级可见。
追问 2:关单和支付同时到达(并发)?状态机条件更新 UPDATE ... SET status=PAID WHERE id=? AND status=UNPAID,看影响行数;关单与支付谁先谁赢,另一方失败回滚(支付失败退款/关单回补)。
追问 3:延迟消息精度不准(30min±几分钟)?允许偏差;补偿扫描以「创建时间」为准,保证最终关单;精度非强需求。
追问 4:订单库主从延迟导致查不到刚下的单?写后读走主库(强制路由)或读本地缓存;读从库加短暂等待/重试。
追问 5:堆积 1000 万订单消息怎么快速消化?临时扩消费者副本 + 提高消费并发 + 批量落库;必要时跳过低峰重试,保证主流程;监控积压与 P99。
- ❌ 同步落库扛 25 万 TPS——连接池爆。正确:异步削峰。
- ❌ 只靠延迟消息关单——丢失风险。正确:双保险补偿。
- ❌ 关单不回补库存——库存长期占用/少卖。正确:回补。
- ❌ 全同步校验 6 系统——RT 爆炸。正确:核心同步非核心异步。
第 7 题:大促洪峰——全链路压测、容量规划与降级预案 稳定性
- 1. 全链路压测怎么做才真实?2. 容量怎么规划?3. 降级/熔断/限流的触发条件?4. 预案怎么演练?5. 活动当天出问题怎么止血?6. 压测数据怎么造不污染线上?
第一步:分析。大促稳定性 = 容量充足 + 防御到位 + 预案可执行 + 可观测。事故往往不是单点,而是级联失效(一个慢依赖拖垮全链路)。
第二步:挑战。影子流量隔离、容量模型、级联雪崩、预案可执行性、实时监控。
第三步:架构。①全链路压测:流量打标(shadow tag)+ 影子库/影子 topic,生产环境真实压测不影响真实数据;②容量规划:基于历史 QPS×增长系数×峰值因子,算各组件实例数与阈值(网关/应用/Redis/DB/带宽);③防御三层:限流(入口)、熔断(依赖)、降级(非核心);④预案:预案平台一键执行(开关/切流/降级),演练常态化;⑤监控:Prometheus+Grafana+SkyWalking,大促作战室大盘。
第四步:选型。全链路压测(自研/开源如流量回放);Sentinel(熔断降级);Nacos(开关);Prometheus/Grafana/SkyWalking(可观测)。
第五步:一致性/正确性。压测流量隔离(影子标),结果不落真实库;预案开关幂等可回滚。
第六步:高可用。多 AZ 部署;预案多级(限流→降级→切流→拒绝);混沌工程验证容错。
第七步:优化。容量留 30% 余量;非核心服务可整段降级释放资源给核心;自动弹性(HPA)。
全链路压测真实性:核心在影子流量——请求打标(如 header x-shadow: true),中间件识别后路由到影子库/影子 topic/影子缓存,与生产同环境同数据规模,但结果隔离。避免「只在测试环境压」导致容量失真。
容量规划模型:目标实例数 = 峰值QPS × 安全系数(1.3) / 单实例拐点QPS;各组件分别算(网关连接、应用线程、Redis 分片、DB 连接、机房带宽)。瓶颈在最低的水桶板。
三道防线触发:限流——入口超阈值;熔断——依赖错误率/RT 超 SLA(如错误率>50% 或 RT>1s 持续 N 秒);降级——依赖不可用或容量紧张时关闭非核心(推荐/评论/积分)。
预案可执行性:写进预案平台(开关/脚本),定期演练(红蓝对抗),避免「纸上预案」。活动当天按监控指标触发,一键止损。
级联失效根因:历史事故是「无隔离线程池 + 无超时 + 无熔断」→ 一个慢依赖占满线程 → 全链路阻塞。解法:舱壁隔离 + 超时 + 熔断 + 降级四件套。
// 依赖调用隔离 + 超时 + 熔断(Resilience4j 示例) Supplier<ReviewVO> call = () -> reviewClient.top(skuId); ReviewVO r = Resilience4j.wrap(call) .withBulkhead(Bulkhead.of("review", config)) // 舱壁:独立信号量 .withTimeLimiter(3, TimeUnit.SECONDS) // 超时 .withCircuitBreaker(CircuitBreaker.of("review", cbConfig)) // 熔断 .fallback(ex -> ReviewVO.EMPTY) // 降级 .get();
追问 1:压测会污染线上数据/影响真实用户?影子标路由隔离 + 压测账号白名单 + 生产数据脱敏;压测在低谷期并对真实流量限流保护。
追问 2:容量算出来要加 200 台机器,成本太高?用弹性(HPA/K8s)+ 临时扩容(大促前升配、后降配)+ 非核心降级释放资源,避免常驻;成本与可用性权衡。
追问 3:熔断后依赖恢复了,怎么自动半开?熔断器半开状态放少量探查流量,成功则关闭、失败继续打开,避免人工介入。
追问 4:监控指标上百个,看哪些?黄金四指标:流量(QPS)、错误率、延迟(P99/P999)、饱和度(CPU/连接池/队列)。红警即触发预案。
追问 5:预案执行后业务有损(如关了推荐),怎么决策?预案分级:核心保可用性(可少赚不可崩),非核心可牺牲;决策由作战室按 SLA 与损失评估,自动化阈值 + 人工确认结合。
- ❌ 只在测试环境压测——容量失真。正确:全链路影子压测。
- ❌ 预案写在文档里没演练——出事不会用。正确:平台化+演练。
- ❌ 所有依赖共用线程池——级联雪崩。正确:舱壁隔离。
- ❌ 只加机器不降级——成本爆炸且仍有单点。正确:降级+弹性。
第 8 题:实时计数与排行榜——UV 统计与 TopN 榜单 计数
- 1. 播放量(可重复)和 UV(去重)用什么结构?2. HyperLogLog 精度与代价?3. 在线人数怎么算(连接 vs 计数)?4. TopN 榜单用什么(ZSet vs 外部排序)?5. 实时大屏高并发读怎么扛?6. 数据量太大存储成本怎么控?
第一步:分析。计数场景分两类:可重复计数(播放量,INCR 即可)与去重计数(UV,需 Set/HLL);榜单是有序实时排名(ZSet)。关键是精度 vs 成本 vs 延迟的权衡。
第二步:挑战。UV 去重存储、在线人数准确性、榜单实时性、大屏高读、成本。
第三步:架构。①播放量:Redis INCR(分片 key 按视频+小时);②UV:HyperLogLog(误差 0.81%,内存固定 12KB/key)做近似,精确场景用 Bitmap(日活 < 用户数且稀疏时省);③在线人数:WebSocket 连接数 + Redis 计数(进房 INCR/离房 DECR,心跳保活);④TopN:ZSet ZINCRBY+ZREVRANGE;⑤大屏:ZSet/聚合结果缓存 + 定时刷新(1s)+ CDN。
第四步:选型。Redis(INCR/HLL/ZSet);Bitmap(精确 UV 小范围);Flink(实时流聚合,分钟级→秒级);ClickHouse(离线榜单/回放)。
第五步:一致性。计数最终一致可接受;UV 近似可接受;大屏展示允许 ±1s 延迟。
第六步:高可用。Redis 集群;HLL/ZSet 持久化;Flink 检查点容错。
第七步:优化。key 按时间分片避免大 Key;HLL 合并(每日 PFCOUNT 汇总);榜单缓存 1s 降低读压;冷数据归档 ClickHouse。
HLL 精度与代价:标准 HLL 误差 ~0.81%,内存恒为 ~12KB,无论 UV 是 1 万还是 10 亿。适合「大致知道量级」的 UV(DAU/在线)。若要 100% 精确,用 Bitmap(1 用户 1 bit,1 亿用户 ~12MB/天,稀疏时反而不省),或 Set(精确但内存爆炸,不可用)。
在线人数:连接数 ≠ 真实在线(断线未感知)。用「进房 INCR + 心跳续期 + 离房/超时 DECR」维护计数,配合 WebSocket 连接做校验;超时(如 30s 无心跳)清理。
TopN 榜单:ZSet 的 ZINCRBY 实时更新分数、ZREVRANGE 取 Top100 是 O(log N),远优于每次全量排序。注意大 Key(一个热门榜单几百万成员),可分段+合并或只保留 TopK 窗口。
大屏高读:榜单/计数结果写入缓存并 1s 刷新,读走缓存 + CDN,避免每次查 Redis 聚合;多用户看同一大屏,缓存命中率极高。
成本:HLL 用空间换精度;Bitmap 压缩;冷数据按天归档到 ClickHouse 做历史榜,Redis 只留热数据。
// UV 去重(HLL 近似) redis.opsForHyperLogLog().add("uv:video:" + videoId + ":" + date, userId); long uv = redis.opsForHyperLogLog().size("uv:video:" + videoId + ":" + date); // 精确 UV 小范围(Bitmap,按 offset=userId) redis.opsForValue().setBit("uvbit:" + date, userId, true); // TopN 榜单 redis.opsForZSet().incrementScore("rank:hour:" + hour, videoId, 1); Set<ZSetOperations.TypedTuple<String>> top = redis.opsForZSet().reverseRangeWithScores("rank:hour:" + hour, 0, 99); // 在线人数:进房 +1 / 心跳续期 / 离房 -1 redis.opsForValue().increment("online:live:" + roomId);
追问 1:HLL 0.81% 误差在大促 GMV 统计不可接受?UV 类指标容忍误差;金额/订单等用精确计数(INCR/DB)。按精度需求选结构。
追问 2:ZSet 榜单成员几百万,大 Key 怎么办?只维护 TopK 窗口(超过阈值裁剪),或按类目/时段分片再合并;读时限制返回条数。
追问 3:UV 跨天/跨月怎么算?每日 HLL,月活 = 每日 PFCOUNT 合并(HLL 支持 PFUNION);精确用 Bitmap 按位 OR 聚合(存储大,需评估)。
追问 4:Flink 和直接写 Redis 怎么选?单条 INCR 直接写;需窗口/去重/关联/多维度聚合用 Flink,减轻应用逻辑与 Redis 压力。
追问 5:在线人数和连接数差很多?连接数含僵尸连接;以心跳计数 + 超时清理为准,定期与连接数对账校准。
- ❌ UV 用 Set 存 userId——内存爆炸。正确:HLL/Bitmap。
- ❌ 每次查榜单都全量排序——RT 高。正确:ZSet 增量维护。
- ❌ 在线人数只看连接数——含僵尸。正确:心跳计数。
- ❌ 大屏每次查实时聚合——读压爆。正确:缓存 1s。
第 9 题:库存中心——强一致防超卖、对账与回补 一致性
- 1. 库存中心为什么要独立?2. 扣减语义怎么统一(预扣/实扣/回补)?3. 强一致 vs 最终一致怎么选?4. 对账怎么做?5. 少卖/超卖分别怎么处理?6. 多机房库存怎么同步?
第一步:分析。库存是多业务共享的核心资源,分散在各方会导致语义不一致、超卖、对账难。独立库存中心提供统一 API + 统一账本。
第二步:挑战。统一语义、多场景扣减、一致性、对账、回补、多机房。
第三步:架构。①库存中心:对外提供 tryDeduct(预扣)、confirm(实扣)、cancel(回补);②存储:DB 为权威账本(流水表+库存表),Redis 为加速层(缓存当前库存);③扣减:DB 条件更新(行锁+谓词)为最终裁判,Redis 预扣为加速;④流水:每笔扣减写流水(业务单号+类型+前后值),可审计;⑤对账:定时比对 Redis 与 DB、流水与库存,差异告警+自动/人工回补。
第四步:选型。MySQL(账本+流水,分库分表);Redis(缓存);CDC(Canal 同步流水到数仓);对账任务(定时)。
第五步:一致性。强一致:DB 条件扣减(单笔不可超卖);最终一致:Redis 与 DB 异步对齐 + 对账纠偏。允许少卖(可回补),禁止超卖。
第六步:高可用。库存中心多副本;DB 主从+半同步;Redis 集群;扣减失败有补偿。
第七步:优化。热点 SKU 分段(见第5题);流水归档;预扣超时自动 cancel(防长期占用)。
为什么独立?库存是跨业务的核心状态,分散维护必然出现「A 扣了 B 不知」导致超卖。中心化提供统一语义、统一账本、统一对账,是防超卖的架构基石。
扣减语义(TCC 思想):tryDeduct 预占(Redis+DB 冻结)、confirm 实扣(业务成功提交)、cancel 回补(业务失败/超时释放)。三段式让「占用」与「消耗」分离,杜绝占而不用/用而不占。
强一致 vs 最终一致:单笔扣减必须强一致(DB 条件更新保证不超卖);Redis 与 DB 同步、跨机房同步允许最终一致(秒级),靠对账兜底。绝不能让「最终一致」出现在单笔扣减的裁判环节。
对账:恒等式:当前库存 = 初始库存 - Σ实扣 + Σ回补。核对:①Redis 缓存 vs DB 库存;②流水汇总 vs 库存表;③每日全量对账 + 实时差异告警。差异来源:消息丢失、扣减失败未回补、并发边界。
少卖 vs 超卖处理:少卖(Redis 预扣成功但 DB 失败)→ 回补 Redis,重新可卖;超卖(绝不允许)→ 发生即告警 + 冻结 + 人工赔付决策。架构上通过「DB 条件扣减为唯一裁判」从根上避免。
// 库存中心:DB 条件扣减(强一致裁判)+ 流水 @Transactional(rollbackFor = Exception.class) public DeductResult confirm(Long skuId, int num, String bizNo) { int n = stockMapper.deduct(skuId, num); // WHERE stock >= num if (n == 0) return DeductResult.fail("no stock"); flowMapper.insert(new StockFlow(skuId, bizNo, "CONFIRM", -num)); redis.opsForValue().decrement(stockKey(skuId), num); // 异步对齐缓存 return DeductResult.ok(); } // 预扣超时自动回补(定时任务) @Scheduled(fixedDelay = 30_000) public void expirePreDeduct() { List<PreDeduct> list = preMapper.listExpired(5, TimeUnit.MINUTES); list.forEach(p -> { stockMapper.release(p.skuId(), p.num()); flowMapper.insert(cancel(p)); }); }
追问 1:Redis 和 DB 偶尔对不上几十件,怎么定位?查流水表定位具体 bizNo 的 confirm/cancel 是否丢失;消息丢失则重放流水;定位后自动回补。
追问 2:预扣长期不 confirm 也不 cancel,库存被占死?预扣带超时(如 5min),定时任务扫描过期预扣自动 cancel 回补;业务完成时同步 confirm 清预扣。
追问 3:多机房库存怎么同步不超卖?单元化:库存按 SKU 路由到主单元,跨单元调用主单元扣减(强一致);或各单元持有库存副本 + 全局配额(最终一致+对账)。
追问 4:流水表太大(每日亿级)怎么存?按日/月分表 + 冷热分离,热数据 Redis/近期表,冷数据归档 ClickHouse/数仓做对账与审计。
追问 5:库存中心挂了,上游怎么下单?降级:本地缓存最近库存 + 限流直扣主库(牺牲部分体验保核心);或拒绝非核心场景,核心场景走 DB 直扣。
- ❌ 库存散落各业务系统——超卖/对账难。正确:中心化。
- ❌ 只 Redis 扣减不落账本——无法审计/丢失。正确:DB 裁判+流水。
- ❌ 无对账——差异累积。正确:恒等式对账+回补。
- ❌ 预扣无超时——库存长期占用。正确:超时 cancel。
第 10 题:性能目标治理——P99、容量模型与成本优化 治理
- 1. P99 和平均 RT 有什么区别,为什么看 P99?2. 容量模型怎么建?3. 「加机器 vs 优化代码」怎么决策?4. 成本怎么优化(不无脑堆)?5. 可用性 99.99% 意味着什么、怎么达成?6. 性能目标怎么落地到研发流程?
第一步:分析。性能治理 = 目标可量化 + 容量可预测 + 成本可控 + 可用性可保障。平均 RT 掩盖长尾,P99/P999 才反映「最差用户体验」与系统风险。
第二步:挑战。长尾延迟、容量预测、成本与性能权衡、可用性量化、目标落地。
第三步:体系。①指标:QPS/RT(P50/P99/P999)/错误率/饱和度,设 SLO;②容量模型:压测得单实例拐点 → 算实例数 + 余量;③决策矩阵:扩展性瓶颈(加机器)vs 算法/锁/IO 瓶颈(优化代码);④成本:弹性扩容、冷热分离、缓存命中率、资源超卖(K8s);⑤可用性:多副本/多 AZ/自动故障转移+降级;⑥流程:性能门禁(CI 压测)、大促前评审、复盘。
第四步:选型。Prometheus/Grafana(指标);JMeter/全链路压测(拐点);K8s HPA(弹性);混沌工程(可用性验证)。
第五步:一致性。目标一致:SLO 对齐业务(如支付 P99 200ms);成本与可用性冲突时按业务优先级取舍。
第六步:高可用。SLO 驱动冗余;故障演练;自动止损。
第七步:优化。先定位瓶颈(火焰图/链路追踪)再动手;优化 > 扩容(长期成本);扩容用于真水平扩展。
为什么看 P99 而非平均?平均 RT 100ms 可能掩盖「1% 请求 2s」——这些长尾正是雪崩前兆与最差体验。P99/P999 反映尾部,是容量与稳定性的真信号。优化目标应「压平长尾」(连接池、锁竞争、GC、慢依赖)。
容量模型:实例数 = ceil(峰值QPS × 安全系数 / 单实例拐点QPS),拐点 = RT 开始陡升的 QPS(通常取 P99=200ms 处)。各组件分别计算取最大,加 30% 余量应对突发。
加机器 vs 优化代码(决策矩阵):①扩容解决:纯 CPU/连接/带宽瓶颈,且可水平扩展(无状态服务、只读);②优化解决:N+1 查询、慢 SQL、锁竞争、同步阻塞、大对象 GC、不必要的远程调用。原则:优化优先于扩容(扩容是线性成本且掩盖问题,优化降长期成本)。
成本优化:弹性(HPA/定时扩容,大促后缩容);冷热分离(热数据 Redis/SSD,冷数据对象存储/归档);提升缓存命中率(减少回源);K8s 资源超卖与调度优化;多云/竞价实例跑离线。目标:核心域保性能,非核心域压成本。
99.99% 可用性:年停机 ≈ 52 分钟。需多副本(无单点)、多 AZ、自动故障转移、降级预案、快速发布回滚。每个 9 都是架构投入:单副本 99% → 双副本 99.9% → 多 AZ+自动 99.99%。
// CI 性能门禁:压测结果不满足 SLO 拒绝发布(伪代码) public void perfGate(LoadTestResult r, SLO slo) { if (r.p99() > slo.p99Ms() || r.errorRate() > slo.errorRate() || r.throughput() < slo.minQps()) { fail("性能门禁未通过:P99=%sms 阈值=%sms", r.p99(), slo.p99Ms()); } } // 瓶颈定位:分布式链路埋点(Micrometer + SkyWalking) @Timed(value = "order.create", percentiles = {0.5, 0.99, 0.999}) // P50/P99/P999 public OrderVO create(OrderCmd cmd) { ... }
追问 1:P99 突然飙到 2s 但平均正常,怎么查?链路追踪定位慢调用(哪段 RT 高);看该实例 GC/CPU/连接池;多为慢依赖/锁/Full GC,火焰图定位热点方法。
追问 2:容量模型算出来要 500 台,但预算只够 300 台?优化代码降单实例拐点(提升单机能力);非核心降级释放;异步化削峰降低峰值所需实例;弹性只在高峰用。
追问 3:99.999% 现实吗?年停机 5 分钟,需多 Region 多活 + 自动切换 + 严格变更管控,成本极高。按业务价值选 SLO,非越高越好。
追问 4:性能门禁误伤正常迭代?门禁基于基线(环比),仅当显著劣化才拦截;允许豁免+评审,避免僵化。
追问 5:成本优化动了缓存命中率反而升了延迟?冷热分离需保证热数据留缓存;冷数据查询走异步/预取;以 SLO 为约束做成本优化,不突破 P99。
- ❌ 只看平均 RT——掩盖长尾雪崩。正确:看 P99/P999。
- ❌ 性能差就加机器——掩盖问题+成本爆炸。正确:先定位优化。
- ❌ 可用性拍脑袋 99.99%——无架构支撑。正确:多副本+多AZ+演练。
- ❌ 成本无脑堆缓存——可能升延迟。正确:以 SLO 约束优化。
第 11 题:缓存预热与缓存回源风暴治理 缓存
- 1. 预热怎么做才不把 DB 打挂?2. 回源风暴(缓存集中过期)怎么防?3. 预热数据很多、单次拉取慢怎么办?4. 缓存和 DB 数据不一致怎么处理?5. 预热失败如何降级?
第一步:分析。预热本质是「把读压力从开门瞬间平移到大促前」,并把回源从「并发」变「可控串行/分批」。
第二步:挑战。批量回源打 DB;Key 集中过期;预热耗时;一致性;失败兜底。
第三步:架构。①分批 + 限速预热:定时任务按 SKU 分批(每批 1 万)多线程拉 DB 写入 Redis,限速 QPS ≤ 3000;②错峰过期:TTL 基础值 + 随机抖动(如 30min±5min),避免集体失效;③回源合并:singleflight/分布式锁,同一 Key 只回源一次;④多级缓存:本地 Caffeine 扛本地重复读。
第四步:选型。Redis(集群)+ Caffeine(本地)+ XXL-JOB(分批预热)+ singleflight(合并)。
第五步:一致性。预热数据与 DB 源一致即可;更新走「先更 DB 再删缓存(Cache-Aside)+ 延迟双删」。
第六步:高可用。预热任务可重试、可断点续跑;DB 限速保护;部分 Key 失败不影响其他。
第七步:优化。预热数据压缩(如 protobuf);按访问热度分级预热(只预热 Top 1% 爆款,长尾自然回源)。
为什么错峰过期?统一 TTL 会在同一时刻集体失效,引发回源风暴。为什么 singleflight?同一 Key 并发回源只放一个,其余复用,DB 压力降 N 倍。为什么本地缓存?扛实例内重复读,进一步降 Redis 压力。替代方案:「提前刷新」——TTL 到期前异步续期(看门狗式),但实现更复杂;错峰 TTL 更通用。
// 回源合并:Guava/自定义 singleflight,同一 key 仅回源一次 public <T> T loadWithMerge(String key, Function<String, T> loader) { return merges.computeIfAbsent(key, k -> { T v = loader.apply(k); return v; // 加载完成即移除(用 ConcurrentHashMap + 弱引用或主动清) }); } // 分批预热(XXL-JOB 分片):每实例预热一段 SKU,限速 public void preheat(List<Long> skuIds) { skuIds.stream().limit(10000).forEach(sku -> { Item it = itemMapper.selectById(sku); // 走只读从库 redis.set(skuKey(sku), JSON.toJSONString(it), Duration.ofMinutes(30 + ThreadLocalRandom.current().nextInt(10))); // 错峰 TTL RateLimiter.get(3000).acquire(); // 全局限速 3000/s }); }
追问 1:预热期间 Redis 内存暴涨怎么办?分级预热(只预热 Top 爆款);预热数据用压缩;预估内存=Key数×均值,提前扩容分片。
追问 2:预热用了从库,主从延迟导致脏数据?预热用「准实时」配置类数据可接受;价格/库存用主库或活动开始前停更,保证开门准确。
追问 3:singleflight 内存泄漏?computeIfAbsent 的 value 需及时清除,或用带过期 map,避免 Key 无限堆积。
追问 4:开门后仍有未命中的长尾 Key 回源?长尾回源走 singleflight + 限速,DB 承压 ≤ 5000;长尾占比极低,命中率仍 ≥ 99.5%。
追问 5:缓存和配置中心双写冲突?配置以配置中心为准,缓存仅做镜像;变更走配置中心推送 + 缓存失效。
- ❌ 统一 TTL 半夜过期——开门集体回源。正确:错峰抖动。
- ❌ 开门前一把梭全量拉 DB——打挂。正确:分批限速。
- ❌ 不合并回源——N 倍压力。正确:singleflight。
第 12 题:高并发下单排队与异步化架构 削峰
- 1. 同步下单扛不住,怎么异步化?2. 排队怎么实现、用户怎么感知进度?3. 异步后如何保证不超卖、不重复?4. 排队超时/消息积压怎么办?5. 用户一直「排队中」体验怎么兜底?
第一步:分析。下单是「重操作」,必须异步削峰:把 40 万 QPS 的「请求」与 8000 TPS 的「处理」解耦。
第二步:挑战。同步直写崩库;用户需即时反馈;超卖/重复;积压;超时。
第三步:架构。①网关层预校验(登录/限购/风控)后发 MQ;②Redis Lua 原子占座(扣库存+占座+防重),成功才进队;③RocketMQ 顺序消费(按场次分区)匀速落库生成订单;④WebSocket/轮询 推送排队进度;⑤定时回滚占座未支付。
第四步:选型。RocketMQ(削峰/顺序/重试/死信)+ Redis(占座)+ WebSocket(进度)。
第五步:一致性。占座成功=可下单;DB 条件扣减为最终裁判;支付超时占座回滚(库存回补)。
第六步:高可用。MQ 堆积时消费扩容;死信队列兜底失败单;占座回滚补偿。
第七步:优化。本地缓存售罄标记;消费并行度按分区;热点场次独立队列隔离。
为什么异步?请求流量与处理能力解耦,40 万 QPS 先收下,按 DB 能力匀速消费。为什么占座用 Lua?原子保证「扣库存+占座+防重」一步完成,不超卖。为什么要顺序消费?同场次顺序处理避免并发导致状态错乱;按场次分区保证吞吐。替代:同步直写简单但必崩;纯 Redis 扣减不持久化会丢。
// 占座 Lua:库存-1、占座集合加入、重复返回-2 private static final String OCCUPY = """ if redis.call('SISMEMBER', KEYS[2], ARGV[1]) == 1 then return -2 end local s = tonumber(redis.call('GET', KEYS[1])) if not s or s <= 0 then return -1 end redis.call('DECR', KEYS[1]); redis.call('SADD', KEYS[2], ARGV[1]); return 1"""; // 消费:条件扣减生成订单(最终裁判) @RocketMQMessageListener(topic = "ticket_order", consumeThreadMax = 32) public void on(OrderMsg m) { int n = orderMapper.createIfSeat(m.userId(), m.showId()); // 唯一索引防重+库存条件 if (n == 1) push(m.userId(), "已抢到"); }
追问 1:占座成功但 MQ 消息丢了?占座即「锁定库存」,即使订单未生成,库存仍被占(不超卖);补单任务扫描「占座未下单」重新投递。
追问 2:用户付了但订单没生成?支付回调按订单号幂等;占座与订单同事务(本地消息表/事务消息)保证最终都有。
追问 3:积压到 100 万?临时扩容消费者 + 增加分区;优先级队列(已占座优先);非核心场次降级。
追问 4:排队进度怎么准?Redis 计数「已处理/总数」,估算等待;WebSocket 推百分比,允许抖动。
追问 5:占座一直不支付?定时任务扫描占座超时(如 15min)回滚库存+释放座位,回补可售。
- ❌ 同步直写——40 万 QPS 必崩。正确:异步削峰。
- ❌ 占座用 select+update 非原子——超卖。正确:Lua。
- ❌ 不回滚占座——库存被永久占用。正确:超时补偿。
第 13 题:大促系统隔离与降级策略 稳定性
- 1. 怎么做到一个域挂了不影响其他域?2. 线程池/连接池隔离怎么设计?3. 降级开关怎么设计、怎么快速生效?4. 哪些该降级、哪些绝不能降级?5. 熔断和降级区别?
第一步:分析。稳定性 = 隔离(不扩散)+ 降级(保核心)+ 熔断(快速失败)。
第二步:挑战。故障扩散;资源争抢;降级误伤核心;开关生效慢。
第三步:架构。①物理隔离:秒杀域独立集群/独立 K8s 命名空间/独立 DB 连接池;②线程池隔离:每个下游用独立线程池(舱壁模式),避免 A 慢拖 B;③熔断:Resilience4j/Sentinel 错误率超阈值断开,走 fallback;④降级开关:配置中心实时推送(如「推荐位→静态兜底」「评论→不展示」)。
第四步:选型。Sentinel(流控/熔断/降级)+ Resilience4j(舱壁)+ Nacos(开关)+ 独立资源池。
第五步:一致性。降级不破坏核心数据一致(订单/支付不降级);非核心可最终补偿。
第六步:高可用。隔离防扩散;熔断防雪崩;降级保核心 SLA。
第七步:优化。按业务优先级划分「保命/重要/可丢」三级,大促前演练降级。
为什么物理隔离?共享资源池一损俱损。独立池把故障边界锁在域内。为什么舱壁模式?下游 A 慢只耗尽 A 的池,B 不受影响。熔断 vs 降级:熔断是「检测到下游不行就快速失败」,降级是「主动关掉非核心功能」。开关设计:必须配置中心实时推送 + 灰度生效,避免重启。
// 舱壁:每个下游独立线程池 ThreadPoolBulkhead seckillBulk = Bulkhead.of("seckill", BulkheadConfig.custom().maxConcurrentCalls(200).maxWaitDuration(Duration.ofMillis(50)).build()); Supplier<String> guarded = Bulkhead.decorateSupplier(seckillBulk, () -> seckillClient.buy()); // 熔断 + 降级 CircuitBreaker cb = CircuitBreaker.of("rec", CircuitBreakerConfig.ofDefaults()); Supplier<List> recs = CircuitBreaker.decorateSupplier(cb, () -> recommendClient.list(uid)); // 熔断后 fallback 返回空/静态
追问 1:独立集群成本太高?核心域独立、非核心共享但隔离线程池;大促弹性扩容独立节点,平时回收。
追问 2:降级误关了核心功能?分级清单 + 双人评审 + 灰度生效;核心(下单/支付)禁止降级。
追问 3:熔断器一直开着?半开探测 + 成功率恢复自动闭合;配最小请求数防误判。
追问 4:开关推送到上千实例有延迟?配置中心长轮询/推送,秒级;关键开关配本地默认值兜底。
追问 5:隔离后资源利用率低?用 K8s 弹性 + HPA,按域流量动态调配,平时合并、大促拆分。
- ❌ 全站共用一个线程池——一损俱损。正确:舱壁隔离。
- ❌ 降级靠改代码重启——生效慢。正确:配置中心实时开关。
- ❌ 核心链路也降级——资损。正确:核心保命。
第 14 题:秒杀场景的风控与防刷体系 安全
- 1. 怎么区分人和机器?2. 风控放哪里、RT 怎么压住?3. 规则引擎和模型怎么配合?4. 误杀真人怎么办?5. 设备指纹怎么防伪造?
第一步:分析。风控是「多层打分 + 实时决策」,不是单点规则。
第二步:挑战。识别机器、低延迟、误杀、对抗升级。
第三步:架构。①网关层:验证码/滑块(高价值动作触发)、IP 频控;②设备指纹:采集 UA/Canvas/行为生成设备 ID,Redis 存风险分;③实时规则:用户维度频控(同一 uid/IP 短时多次);④模型:特征入实时计算(Flink),异常评分;⑤决策:分数→放行/挑战/拦截;⑥灰度+申诉兜底。
第四步:选型。Redis(频控/指纹)+ Flink(实时特征)+ 规则引擎(自研/Drools)+ 验证码服务。
第五步:一致性。风控只做决策不写业务数据;决策结果异步入离线库用于迭代模型。
第六步:高可用。风控自身故障「fail-open 放行但有监控」或 fail-close(高价值动作 fail-close);多级降级。
第七步:优化。本地缓存设备分;批量特征计算;RT 预算 20ms 内(只查缓存+轻规则)。
为什么放网关?越早拦截越省资源。但重模型放网关会拖 RT,所以用「网关轻规则 + 异步重模型」。规则 vs 模型:规则快可解释(拦截明显作弊),模型抓隐蔽对抗(需特征工程)。fail-open 风险:放行会被刷,故高价值动作 fail-close、低价值 fail-open。
// 用户维度频控(Redis 滑动窗口,RT 极低) public RiskLevel judge(long uid, String ip) { long cnt = redis.zcount(slideKey(uid), now - 60_000, now); if (cnt > 30) return BLOCK; // 1分钟>30次 拦截 if (cnt > 10) return CHALLENGE; // 触发验证码 return PASS; } // 设备指纹:行为+环境生成稳定 ID(对抗伪造靠多因子交叉) String devId = Hashing.sha256().hashString(ua + canvas + touch + ipSeg).toString();
追问 1:黄牛用真机群控怎么防?行为序列分析(人机操作节奏差异)+ 设备群聚特征(同批次设备)+ 设备风险分累积。
追问 2:误杀正常用户?灰度和申诉通道;风控分设「挑战」中间态而非直接拦;规则阈值留余量。
追问 3:风控 RT 超标?只走缓存+轻规则(<20ms),重模型异步打分,结果回写下次请求用。
追问 4:规则引擎性能?规则编译为 DSL/预编译;高频规则进 Redis;冷规则异步。
追问 5:验证码被 OCR 破解?滑块/轨迹行为验证 + 动态难度;结合设备风险分决定是否出验证码。
- ❌ 重模型放同步链路——RT 爆。正确:异步打分+缓存。
- ❌ 一刀切拦截——误杀真人。正确:分级挑战。
- ❌ 只靠 IP——代理池绕过。正确:设备指纹+行为。
第 15 题:库存预热与防刷结合的流量整形 综合
- 1. 四个目标怎么串成一条流水线?2. 预热时机与容量怎么定?3. 各层限流阈值怎么算?4. 全链路如何观测?5. 一键大促切换怎么做?
第一步:分析。把单点方案编排为「预热→拦截→占座→异步→防护」流水线。
第二步:挑战。四目标耦合;阈值联动;可观测;一键切换。
第三步:架构。①开门前 30min:分批预热库存/商品到 Redis(错峰 TTL);②边界:CDN/WAF 拦 40% 异常;③网关:用户/接口限流 + 风控分级;④Redis Lua 占座(原子);⑤MQ 削峰异步落库;⑥全链路:SkyWalking + Prometheus + 大促大盘。
第四步:选型。Redis(预热/占座)+ RocketMQ(削峰)+ Sentinel(限流)+ SkyWalking(追踪)+ Nacos(一键开关)。
第五步:一致性。占座=可下单;DB 条件扣减裁判;对账回补。
第六步:高可用。各层独立降级;Redis/DB 故障降级路径;多副本。
第七步:优化。阈值由压测容量模型推导;本地售罄标记零 Redis 调用。
为什么编排而非堆组件?各层阈值基于下游容量联动:DB 2 万 TPS → MQ 消费 2 万 → 占座放行 ~1.2 万 → 网关限流 5 万 → WAF 拦异常。一键切换:Nacos 发布「大促模式」配置,自动开启限流/预热/降级策略组。
// 一键大促:Nacos 监听配置切换策略组 @NacosConfigListener(dataId = "seckill-mode") public void onMode(String mode) { boolean big = "PROMO".equals(mode); sentinelRules.reload(big ? PROMO_RULES : NORMAL_RULES); preheatJob.setEnabled(big); switchGroup.setEnabled(big); } // 阈值联动:下游容量推导(示例) // DB_TPS(2w) → 消费(2w) → 占座放行(1.2w) → 网关(5w) → WAF(异常拦截)
追问 1:预热没完成开门了?门口增加「库存未就绪」快速失败 + 分批预热持续到开门后;长尾自然回源。
追问 2:阈值算错导致大面积拒单?压测标定 + 灰度 + 监控告警;阈值可热更(Nacos)。
追问 3:大盘看什么?各层 QPS/拒绝率、Redis RT、MQ 堆积、DB TPS、超卖件数=0。
追问 4:一键切换失败?开关幂等 + 本地缓存默认(普通模式);演练回滚。
追问 5:多活动并发抢资源?按活动隔离队列/分片;全局令牌总控防止资源超卖。
- ❌ 各层阈值独立拍脑袋——链路不匹配。正确:容量联动。
- ❌ 大促靠手动改配置——易错慢。正确:一键策略组。
- ❌ 无大促大盘——黑盒。正确:全链路可观测。