12. 下单链路同步调用 800ms,用户等得不耐烦(MQ 异步化)
【考察内容】异步化与解耦设计(同步/异步边界判断)
【题目】现在下单链路是同步串行:下单→扣库存→发短信→送积分,一次下单要 800ms,其中短信和积分要占 500ms,用户流失严重。你怎么改造这条链路?哪些步骤可以异步、怎么保证异步后不丢不错?
【参考答案】MQ 异步化的核心是把同步长链路拆成“快速受理 + 异步处理”:
- 容量估算(步进):下单同步 800ms,假设峰值 TPS=500,则应用并发占用 ≈ 500×0.8 = 400 线程;若异步化后受理接口只做校验+入队(RT=20ms),并发占用降到 500×0.02=10 线程,释放约 97% 线程资源。用户可感知 RT 从 800ms 降到 <50ms(返回“已受理”)。
- 拆分原则:适合异步的——积分、短信、通知、物流、部分库存/履约、对账;不适合——必须同步返回结果的支付扣款确认、用户立即要看的状态(可同步落库+异步后续)。
- 方案:入口事务内只写库——落订单受理记录,以及待发消息的行(本地消息表,与业务同一事务);提交后再由后台任务投递,事务里不做远程调用(第 62 题口径);消费者按 DB 能力限速处理;结果通过查询接口/推送告知用户。
- 失败与降级:MQ 挂 → 降级为同步处理并调低限流,或落本地表异步补发;消息丢失 → 本地消息表+对账补偿;重复消费 → 消费端幂等(唯一业务键);消息积压 → 先确认瓶颈在消费端而非下游(下游没恢复时扩消费者只会把它打得更狠,见第 83 题),再扩消费者+批量处理,超时自动关单。
- 代价:体验从“同步确定”变成“受理中”,必须设计查询与超时策略;最终一致依赖对账与补偿。本质是用一致性换吞吐与稳定性。
【原理溯源】
- 为什么短信/积分可以异步,扣库存不行? 判断标准是一致性强弱与业务容忍度。扣库存直接影响“能不能卖”,必须与订单强一致——异步扣库存会出现“用户下单成功但库存没扣=超卖”。短信/积分是“下单成功后的通知/权益”,晚几秒到达用户可接受,属于最终一致。异步化的边界 = 强一致操作与最终一致操作的分界。
- 为什么异步化能降低 RT? 同步串行时,RT = 所有步骤耗时之和(800ms)。异步后主流程只做必须同步的部分(约 300ms),短信/积分移到后台。用户等待时间从“全链路”缩短到“关键路径”。注意:系统总工作量没变,只是把非关键路径从用户等待时间里挪走了。
- 为什么异步化能削峰? 峰值时,短信服务可能跟不上。同步调用下,短信慢会把下单线程拖住,下单接口跟着慢。异步后,MQ 充当缓冲:下单接口快速入队返回,短信消费者按自己的节奏处理。把“接收速率”与“处理速率”解耦,峰值被缓冲在队列里。
- 为什么异步化能故障隔离? 同步调用下,短信服务挂了会导致下单失败——非核心依赖拖垮核心业务。异步后,短信服务挂了只是消息积压,下单不受影响;短信恢复后继续消费。把非核心依赖的可用性从核心链路中剥离。
- 为什么异步会引入“丢、重、乱”三个新问题? 同步调用的成败是即时可知的;异步经过 MQ,多了网络和存储环节:① 丢:生产者发了但 broker 没收到,或消费者还没处理就 ack 了;② 重:网络抖动导致重复投递;③ 乱:多分区/多消费者导致顺序打乱。因此必须:生产端 confirm、broker 持久化、消费端处理成功再 ack、消费端幂等、同订单消息进同分区。异步化的代价是把“同步的简单”换成“分布式的可靠”。
【选型判断树】
哪些步骤该异步?
判断标准:失败后能否补偿?用户能否接受延迟?
├─ 失败不可接受、必须立刻知道结果 → 同步
│ (扣库存、支付扣款、鉴权)
├─ 失败可补偿、用户可接受秒级延迟 → 异步
│ (发短信、送积分、发优惠券、更新统计、写日志)
└─ 失败可丢弃 → 异步 + 可丢
(埋点、非关键通知)
异步可靠性三件套:
生产端 confirm → 不丢生产
消费端幂等 → 不重复生效
死信+对账 → 失败有兜底【口述骨架】(5 分钟;框架 B,骨架共性见文首第二节,本题只写特定内容)
| 时间 | 任务 | 内容 |
|---|---|---|
| 0:00–0:30 | 定性 | “异步化边界判断题。核心是分清哪些必须同步、哪些可异步” |
| 0:30–1:30 | 边界判断 | 扣库存强一致必须同步;短信/积分最终一致可异步。给出判断标准 |
| 1:30–3:00 | 改造方案 | 主流程精简 + MQ 发事件 + 消费者异步处理;讲 RT、削峰、隔离三个收益 |
| 3:00–4:00 | 可靠性 | 引入的丢/重/乱问题;confirm、幂等、死信+对账(即本题的可靠性三件套,死信与对账算一件) |
| 4:00–5:00 | 收尾 | “异步化本质是把非关键路径从用户等待中挪走,代价是必须自己保证消息可靠” |
【关键数字】
| 参数 | 经验值 | 说明 |
|---|---|---|
| 改造前 RT | 800ms(含短信积分 500ms) | 用户流失临界约 1s |
| 改造后主流程完成 RT | <300ms | 只保留核心路径;受理接口 RT 见上文 <50ms,两者口径不同 |
| MQ 确认机制 | 生产 confirm + 消费手动 ack | 防丢消息 |
| 消费重试 | 3–5 次后进死信 | 防无限重试堵队列 |
| 幂等实现 | 唯一索引 / 状态机 / Redis SETNX | 防重复 |
| 消息延迟容忍 | 秒级 | 短信积分可接受 |
| 死信告警 | 必须有 | 人工介入信号 |
| 应用并发估算 | QPS × RT | 决定线程池与实例数 |
| 容量冗余 | 峰值的 1.5–2 倍 | 大促/突发留余量 |
| 限流阈值 | 压测容量的 70%–80% | 避免排队雪崩 |
【追问链】(三层)
L1|“能不能把扣库存也异步化?用户下单后后台慢慢扣。” → 不能轻易做。异步扣库存存在窗口:用户看到“下单成功”但库存还没扣,若此时库存不足,会出现“下单成功却无货”。除非业务接受这种可能性并有补偿(自动取消订单+通知),否则库存必须同步扣。这是强一致与最终一致的边界。
L2|“异步任务失败了怎么办?” → 分层处理:① 消费失败自动重试(限次数+退避);② 重试耗尽进死信队列,告警人工;③ 定时对账任务扫描“已下单但未发短信/未送积分”的记录,补发;④ 关键业务(如积分)要有业务状态机记录处理进度,可重入。
L3|“怎么保证消息不丢、不重复?” → 不丢:生产端开启 confirm(broker 确认后才算发送成功);broker 开持久化(多副本);消费端处理成功后再 ack,失败不 ack。不重复:MQ 只能保证 at-least-once,消费端必须幂等——用唯一业务键做唯一索引,或状态机判断“已处理则跳过”。一句话:不丢靠 ack 策略,不重靠业务幂等。
【评分标准】
| 档位 | 答案特征 |
|---|---|
| 60 分 | 能说出“把短信积分改成异步” |
| 80 分 | 能分清同步/异步边界并说明理由;知道 MQ 可靠性三件套 |
| 95 分 | 能解释异步化对 RT/削峰/隔离的三个收益;指出扣库存不能轻易异步;设计死信+对账兜底;说清 at-least-once 与幂等的关系 |
【关联题】
- 同簇: 第 80 题(消息不丢)、第 81 题(重复消费)、第 92/93 题(事务消息/本地消息表,业务与发消息的一致性)
- 对比参照: 第 79 题(MQ 价值与代价)、第 83 题(消息积压)
- 系统设计: 第 20 题(全链路一致性,本题是其中一环)
【自测】
- 判断标准是什么:一步操作该同步还是该异步? 参考答案: 看一致性强弱与失败能否补偿。强一致、失败不可补偿的必须同步;最终一致、可补偿的可异步。
- 异步化会引入哪三个新问题? 参考答案: 丢(消息丢失)、重(重复消费)、乱(顺序打乱)。
- 如何保证消息不丢? 参考答案: 生产 confirm + broker 持久化 + 消费端处理成功再 ack。
13. 某爆款商品的数据请求把 Redis 单节点打满(热 Key 治理)
【考察内容】Redis 性能治理实战
【题目】某商品突然上了热搜,它的数据在 Redis 里是同一个 key,所有请求都打到这一个节点上,该节点 CPU 打满、其他业务跟着遭殃。怎么快速缓解?长期怎么治理热 key?
【参考答案】热 Key 治理 = 发现、分散、就近、限流:
- 容量估算(步进):假设爆款商品详情读 QPS 峰值 10 万,集群 10 个 Redis 节点均匀分摊时每节点约 1 万 QPS(单节点能力约 10 万,看似安全)。但一个 key 只会落在一个分片上,所以按题干的「所有请求都打这一个 key」估:10 万 QPS × 1KB(1024B)≈ 1.024×10⁸ B/s ≈ 819Mbps(按 1MB=10⁶B 折约 102MB/s),已占千兆网卡 81%,再叠加单线程执行命令——该节点直接打满,集群总容量再大也救不了单分片。反过来按经验值「只有 50% 流量集中在一个热 key」算则只到 41%、CPU 半载,解释不了题干的「打满」,所以本题按 100% 集中估;若单值到 2KB 以上则 5 万 QPS 就打满。
- 发现:
redis-cli --hotkeys(底层是 OBJECT FREQ,只在 maxmemory-policy 为 LFU 时可用,且要全库扫描——生产慎用;Redis 8.6 起才有HOTKEYS系列命令,且它不是「取某个 key 的热度」——是HOTKEYS START/STOP/GET/RESET这套按 CPU 与网络字节占比采样后再取 Top K 的追踪命令;8.6 之前只能靠redis-cli --hotkeys,版本差异用COMMAND DOCS HOTKEYS看它的 since 字段自查)、monitor/代理层统计、客户端埋点、云 Redis 热 key 分析;监控单分片 CPU/QPS 倾斜。 - 分散:① 本地缓存(Caffeine)挡最热数据,TTL 秒级;② Key 多副本:
item:{id}:{random%N}读时随机副本,写时广播失效;③ 拆结构:大 hash 拆分,避免单 key 过大。 - 就近与保护:数据尽量只读静态化/推到 CDN 或网关缓存;对热 key 单独限流;业务可排队/排队页。
- 失败与降级:本地缓存兜底陈旧数据可接受的场景;Redis 单节点过载 → 读从库/只读副本;极端时网关直接返回缓存快照。监控命中率与热分片负载。
- 口径:热 Key 本质是流量分布不均,解法不是简单加机器,而是把读请求打散并把最热部分挪到更靠近用户、成本更低的层。
【原理溯源】
- 为什么热 Key 会打满单节点? Redis Cluster 按 key 的 hash 分片,一个 key 只会落在一个分片上。该 key 的所有请求都由这个节点处理,其他节点帮不上忙。若该 key QPS 达到 10 万,这个节点就要扛 10 万 QPS(含网络 IO 和单线程 CPU),而集群其他节点可能很闲。热 Key 的本质是访问分布极度倾斜,单点成为瓶颈——这不是集群扩容能解决的。
- 为什么本地缓存能立刻止血? 热点读请求集中在少数数据上。在应用进程内用 Caffeine 缓存这几份数据(TTL 1–5 秒),大部分读请求在应用内就命中返回,根本不走 Redis。10 台应用 × 每台挡掉 90% 读 = Redis 压力降到原来的 1/10 以下。这是成本最低、生效最快的止血手段。代价是多实例各自缓存,数据变更后有秒级不一致(热 key 通常可接受)。
- 为什么“多副本 key”能打散? 把
product:123复制成product:123:0、product:123:1…product:123:N,请求随机选一个副本读。不同副本的 hash 值不同,落到不同分片节点,单节点压力被均摊。这是“用空间换单点负载”的经典手法。写时要更新所有副本(或用短 TTL 让其自然过期后回源)。 - 为什么写热点(库存)要“分片存储+异步合并”? 写热点无法用本地缓存(要保证一致性),也无法简单多副本(写要原子)。做法:把库存 1000 拆成
stock:1~10各存 100,请求随机扣减一个分片;扣到 0 则试下一个;后台任务定期把各分片汇总到 DB。把“一个 key 的写热点”变成“N 个 key 的分散写”,每个分片压力是原来的 1/N。代价是逻辑变复杂,需要处理分片间不均、汇总延迟。 - 热 Key 和大 Key 的区别是什么? 热 Key:单 key 访问频率过高,打的是节点 CPU/网络带宽。大 Key:单 key 存储体积过大(如百万元素的 Hash),打的是单命令耗时和带宽,一次 HGETALL 就能卡住。两者可能叠加,但治理手段不同:热 Key 靠本地缓存+打散,大 Key 靠拆分结构。
【选型判断树】
热 Key 治理选型:
读热点(最常见)
├─ 应急 → 应用本地缓存(Caffeine,TTL 秒级)
├─ 加固 → key 多副本随机读,打散到多分片
└─ 长期 → 热点探测 + 自动推送到本地缓存
写热点(如库存)
├─ 低并发 → DB 条件更新即可(原子且正确,见第 5 题);仅当扣减逻辑复杂或需跨表校验才上分布式锁
├─ 高并发 → 分片库存(stock:1~N)+ 随机扣减 + 异步汇总
└─ 兜底 → DB 条件更新 + 对账
预防
└─ 压测找热点 + 监控单 key QPS + 限流保护 Redis
口诀:读热用本地缓存+多副本,写热用分片打散,预防靠压测+监控。【口述骨架】(5 分钟;框架 B,骨架共性见文首第二节,本题只写特定内容)
| 时间 | 任务 | 内容 |
|---|---|---|
| 0:00–0:30 | 定性 | “热 Key 治理题。我会先讲根因,再分应急止血和长期根治” |
| 0:30–1:00 | 根因 | 一个 key 只落一个分片,访问倾斜导致单节点瓶颈;区别于大 Key |
| 1:00–2:30 | 应急止血 | 本地缓存挡读(最快);key 多副本打散;限流保护 |
| 2:30–4:00 | 长期根治 | 读热点:多级缓存+热点探测;写热点:分片库存+异步汇总 |
| 4:00–5:00 | 收尾 | “热 Key 本质是访问分布倾斜,集群扩容无效,必须在应用层打散或缓存” |
【关键数字】
| 参数 | 经验值 | 说明 |
|---|---|---|
| 本地缓存 TTL(最热的少数 key) | 1–10 秒 | 热点数据可容忍秒级不一致 |
| 本地缓存容量 | 限制条目数(如 1000)+ 过期 | 防止内存撑爆 |
| key 副本数 | 5–20 | 按热点程度,打散到多分片 |
| 库存分片数 | 10–100 | 超热点场景 |
| 单 key QPS 告警 | 按该 key 所在分片的容量分档:≈5 万警戒、≈8 万严重(推导见表下「单 key 告警分档」) | 集中比例只用于从总 QPS 反推单 key QPS,不参与定阈值 |
| Redis 单节点能力 | 约 10 万 QPS | 简单命令 |
| 热点探测窗口 | 滑动 10s–1min | 及时发现新兴热点 |
| 应用并发估算 | QPS × RT | 决定线程池与实例数 |
| 容量冗余 | 峰值的 1.5–2 倍 | 大促/突发留余量 |
| 限流阈值 | 压测容量的 70%–80% | 避免排队雪崩 |
- 单 key 告警怎么分档: 告警线看的是该 key 所在分片的负载,不是集中比例本身——一个 key 只落一个分片,分片能力约 10 万 QPS(简单命令),所以按分片容量分档:约 5 万 QPS/key(1KB 单值折 ≈400Mbps、千兆网卡的 40%)警戒、约 8 万严重。集中比例只用于从「总 QPS」反推「单 key QPS」(100% 集中→单 key=总 QPS;50% 集中→折一半),它不参与定阈值
【追问链】(三层)
L1|“热 Key 和大 Key 有什么区别?治理手段一样吗?” → 不一样。热 Key 是访问频率过高,打的是节点 CPU/带宽;大 Key 是单 value 过大,打的是单命令耗时。热 Key 治理靠本地缓存+打散;大 Key 治理靠拆分数据结构(Hash 分页、ZSet 分片)。两者可能同时出现,要分别处理。
L2|“本地缓存多实例不一致怎么办?” → 用短 TTL 容忍秒级不一致(热点数据通常可接受)。若需要更快失效:① 更新时发广播(Redis Pub/Sub)通知各实例删除本地缓存;② 版本号比对,版本不一致则回源;③ 订阅 binlog 异步失效。注意广播可能丢消息,短 TTL 仍是兜底。
L3|“写热点(扣库存)能用本地缓存吗?为什么?” → 不能。写要保证一致性,本地缓存是各实例独立的,无法做原子扣减。写热点正确做法:① 分片库存打散写压力;② 或 Redis 预扣 + DB 兜底;③ 或分布式锁(吞吐低,仅超热点复杂逻辑)。本地缓存只适合读。
【评分标准】
| 档位 | 答案特征 |
|---|---|
| 60 分 | 能说出“加本地缓存” |
| 80 分 | 区分热 Key 与大 Key;知道本地缓存+多副本打散;了解写热点分片 |
| 95 分 | 能解释“一个 key 只落一个分片所以集群无效”;设计热点探测机制;能处理写热点的分片+汇总方案 |
【关联题】
- 同簇: 第 30 题(大 Key 治理)、第 17 题(多级缓存)、第 47 题(缓存预热)
- 对比参照: 第 32 题(Redis 单线程,热 Key 为何致命)、第 162 题(计数系统的热点打散)
- 系统设计: 第 134 题(优惠券,热点商品)
【自测】
- 为什么热 Key 扩 Redis 集群节点没用? 参考答案: 一个 key 只落在一个分片,所有请求仍打到该节点;扩容不改变单 key 的分片位置。
- 本地缓存为什么能止血热 Key? 参考答案: 热点数据在应用进程内缓存,大部分读不走 Redis,多实例各自拦截,Redis 压力大幅下降。
- 写热点(库存)如何打散? 参考答案: 拆成多个分片 key,请求随机扣减,后台汇总;或 Redis 预扣+DB 条件更新兜底。
14. 下单接口单机 TPS 只有 200,大促要 2000(吞吐优化)
【考察内容】性能优化的系统性方法
【题目】压测发现下单接口单机 TPS 只有 200,而大促目标要求单机 2000+。你从应用层、数据库、中间件、架构几个层面,分别能做什么把吞吐提上去?
【参考答案】吞吐优化目标:单机 TPS 200 → 2000,放大 10 倍,要找“耗时花在哪”:
- 容量估算(步进):TPS=200 时,单请求系统耗时约 5ms(1/200s 线程占用)。若单请求在 DB 花 20ms、缓存/远程再花 30ms,同步串联可达 50–100ms,则单机受 IO 等待限制,TPS 很难上 2000。目标公式(Little 定律:并发 = TPS × RT):要 2000 TPS,单请求占用系统的耗时必须 ≤ 线程数 ÷ 2000 秒——10 个线程即 ≤5ms,单线程则要 ≤0.5ms(10 只是演示量纲、不是本题硬约束:线程数本身可以加,能加到 100 就只需 ≤50ms,不必把 RT 压到 5ms;真正卡住的是下游连接池与 DB 并行度(见第 64 题),所以「加线程」的前提是下游扛得住,否则只是把排队从应用挪到 DB);常见路径是把重 IO 挪走或并行化,而非盲目加线程。
- 优化手段(题干要「应用层/数据库/中间件/架构」四层分别给;下面 ①–⑥ 只覆盖前三层,架构层见句末补充):① 缓存热点,减少 DB 往返;② SQL 优化/索引,把 DB RT 从 20ms 压到 2–3ms;③ 串行 RPC 改并行/合并批量;④ 同步改异步(可延迟步骤);⑤ 减少序列化、日志同步写、锁竞争;⑥ 垃圾回收与对象分配优化。架构层:读写分离与分库分表(把单库压力拆开)、多级缓存(本地+Redis,把读挡在应用外)、热点接口拆独立服务并单独扩容(隔离故障域)、无状态化水平扩容+MQ 削峰——只答前三层会被判「单机 200→2000 十倍差距靠调参补不平」。(按题干四层归位:①④=中间件层——Redis 缓存、MQ 异步;②=数据库层——索引与 SQL 改写;③⑤⑥=应用层——并行化、序列化与日志、锁与 GC;「架构层」不是第四个手段堆,而是上面这些的组合与取舍)
- 不要做的事:线程池无脑调大(上下文切换、连接池连带打满);只加机器不改架构(DB 还是瓶颈)。
- 失败与降级:优化过程中灰度验证,监控错误率与 RT 分布;回滚预案;优化后限流阈值同步上调前要压测确认。
- 验证:对比优化前后压测曲线;分层看应用/缓存/DB 各层耗时占比。本质是降低单请求资源占用时间,同样硬件承载更高并发。
【原理溯源】
为什么必须“先测量再优化”? 不测量就优化是瞎猜。TPS 上不去可能是 CPU 满、可能是锁等待、可能是下游慢、可能是连接池耗尽——原因不同,手段完全不同。盲目加缓存可能浪费,盲目加线程可能更糟(切换开销)。压测+监控定位到瓶颈资源,才能对症下药。这是性能优化的第一原则。
为什么瓶颈在“等待”时 CPU 会很低? TPS 上不去但 CPU 低,说明线程在等(IO/锁/下游),不在算。这时加 CPU 或加线程(若等待是共享资源限制则无效)都没用,要去掉等待源:优化下游、减少锁竞争、增加连接。CPU 满是计算瓶颈,CPU 空是等待瓶颈——这两种的优化方向截然相反。
为什么串行改并行能提吞吐? 若一次下单要串行调用库存(50ms)、风控(30ms)、积分查询(20ms),总耗时 100ms,单线程 TPS=10。改成三个调用并行,总耗时 ≈ max(50,30,20)=50ms,TPS 翻倍。并行不减少总工作量,但缩短关键路径。前提:这些调用之间没有依赖,且下游能承受更高并发。
为什么异步化能提吞吐? 非核心逻辑(发短信、写日志)从同步路径挪到 MQ,主流程更快结束,线程/连接占用时间缩短,同样资源能服务更多请求。本质是减少每个请求占用共享资源的时间。与并行的区别:并行是“同时做多件事”,异步是“这件事不做/晚点做”。
为什么缓存能提吞吐? 缓存把 DB 查询(1–10ms)变成内存读取(0.1ms),单请求耗时下降 10–100 倍,吞吐反比上升。更关键的是:DB 是共享瓶颈(连接池、磁盘 IO),缓存挡掉读流量后,DB 有余力处理写。所以读多写少场景,缓存是最有效的吞吐手段。
【选型判断树】
TPS 上不去,先看哪个资源先饱和:
├─ CPU 满 → 计算密集
│ ├─ 优化算法/序列化(JSON→Protobuf)
│ ├─ 减少重复计算(缓存计算结果)
│ └─ 水平扩容
├─ CPU 低但 TPS 低 → 等待瓶颈
│ ├─ 锁竞争 → 减少临界区/分段锁/无锁
│ ├─ 下游慢 → 并行化/异步化/熔断
│ └─ 连接池满 → 调大池/优化慢查询/扩 DB
├─ DB 饱和
│ ├─ 读多 → 缓存 + 读写分离
│ ├─ 写多 → 批量 + 异步 + 分库分表
│ └─ SQL 慢 → 索引 + EXPLAIN 优化
└─ IO/网络满 → 压缩、减少传输、本地缓存
口诀:先看谁先满,满了才有方向;CPU 满算得慢,CPU 空等得久。【口述骨架】(5 分钟;框架 B,骨架共性见文首第二节,本题只写特定内容)
| 时间 | 任务 | 内容 |
|---|---|---|
| 0:00–0:30 | 定性 | “吞吐优化题。我会先定位瓶颈,再按层给手段” |
| 0:30–1:00 | 定位方法 | 压测看 CPU/IO/线程/DB 谁先饱和;强调“不测量不优化” |
| 1:00–3:30 | 分类优化 | DB 慢→索引/缓存;串行→并行/异步;锁→减临界区;计算→算法/序列化 |
| 3:30–4:30 | 架构层 | 多级缓存、接口合并、无状态扩容 |
| 4:30–5:00 | 收尾 | “吞吐优化本质是减少单请求占用关键资源的时间,或增加资源数量” |
【关键数字】
| 参数 | 经验值 | 说明 |
|---|---|---|
| 当前 TPS | 200 | 起点 |
| 目标 TPS | 2000+ | 10 倍提升 |
| 串行改并行收益 | 关键路径缩短到 max(各步) | 前提是无依赖 |
| 缓存收益 | 读 RT 从 5–10ms → 0.1ms | 命中率 >90% 时显著 |
| 异步化收益 | 非核心逻辑移出主路径 | RT 和吞吐同时改善 |
| 连接池 | 按 DB 侧有效并行度分摊到各实例(不是拿应用核数×倍数,见第 64 题) | 全局总池 ≤ max_connections×0.8 |
| 单机扩容线性度 | 无状态服务接近线性 | 有共享瓶颈时递减 |
| 应用并发估算 | QPS × RT | 决定线程池与实例数 |
| 容量冗余 | 峰值的 1.5–2 倍 | 大促/突发留余量 |
| 限流阈值 | 压测容量的 70%–80% | 避免排队雪崩 |
【追问链】(三层)
L1|“优化了 SQL、加了缓存,TPS 还是上不去,怎么办?” → 回到定位:现在是哪个资源先饱和?可能是应用 CPU(计算逻辑)、可能是线程池(下游等待)、可能是连接池、可能是锁。每优化一步要重新压测,看瓶颈是否转移。瓶颈是会移动的——解决了 DB,可能应用变成新瓶颈。这是迭代过程,不是一步到位。
L2|“压测 TPS 上不去但 CPU 很低,说明什么?” → 瓶颈在等待不在计算。可能原因:锁竞争、下游服务慢、IO 等待、连接池耗尽、GC 等待。用 jstack 看线程状态(BLOCKED/WAITING),用调用链看哪一跳慢。这时加机器或加线程可能无效(如果等待的是共享资源),要消除等待源。
L3|“并行化改造要注意什么?有什么坑?” → ① 确认无依赖,有依赖不能并行;② 下游要能承受更高并发(并行会放大对下游的压力);③ 线程池隔离,避免并行任务耗尽全局线程;④ 异常处理:一个并行分支失败怎么办(全部失败?部分成功?);⑤ 超时设置:并行的总超时要覆盖最慢分支;⑥ 测试:并发路径的 bug 更难复现。
【评分标准】
| 档位 | 答案特征 |
|---|---|
| 60 分 | 能罗列“加缓存、加索引、加机器” |
| 80 分 | 先定位瓶颈再优化;能按 CPU 满/等待/DB 分类给手段 |
| 95 分 | 能解释“CPU 低 TPS 低=等待瓶颈”;知道瓶颈会转移需迭代;能讨论并行化的注意事项;给出量化验证方法 |
【关联题】
- 同簇: 第 21 题(性能优化方法论)、第 9 题(变慢排查)、第 10 题(压测验证)
- 具体手段: 第 51 题(慢 SQL)、第 12 题(异步化)、第 17 题(多级缓存)、第 11 题(线程池)
- 架构层: 第 15 题(读写分离)、第 55 题(分库分表)
【自测】
- TPS 上不去,第一步应该做什么? 参考答案: 压测定位瓶颈资源(CPU/IO/锁/DB/下游),不测量不优化。
- CPU 低但 TPS 低,说明什么问题? 参考答案: 瓶颈在等待(锁/下游/IO/连接池),不在计算;优化方向是消除等待。
- 串行改并行能提吞吐的原理是什么?前提是什么? 参考答案: 缩短关键路径耗时;前提是各步骤无依赖且下游能承受更高并发。
15. 商品详情页读 QPS 10 万、写 QPS 100(读多写少架构)
【考察内容】读优化架构
【题目】商品详情页每天被大量浏览:读 QPS 10 万、写 QPS 只有 100(价格、库存偶尔变动)。要求详情页秒开、数据库不能被打挂。你怎么设计这套读写极不对称的系统?
【参考答案】读 10 万 / 写 100 的读多写少架构:缓存抗读、DB 保写、多级分担:
- 容量估算(步进):读写比约 1000:1。DB 单机读 1–2 万 QPS,10 万读直接打 DB 必挂;Redis 单机约 10 万 QPS,一台/一组 Redis+本地缓存即可承接。写 QPS=100,单库毫无压力。缓存命中率目标 ≥95% 时,回源约 10万×5%=5000 QPS,读写分离后主库几乎只扛写。
- 架构:① 本地缓存(Caffeine,热点详情)→ ② Redis 集群 → ③ MySQL 读写分离(读走从库);页面/接口可 CDN 或静态化。
- 一致性:写后删除缓存+过期兜底;详情类允许短暂不一致;价格/库存等强一致字段不以缓存为权威,或短 TTL+主动失效。
- 失败与降级:Redis 挂 → 本地缓存+从库限流读,或返回兜底模板;缓存击穿 → 热点互斥重建/逻辑过期;从库延迟 → 关键读可走主库或提示延迟。
- 扩展:多级缓存一致性(广播失效)、热点 key 打散、监控命中率(低于 90% 要查)。本质:用内存层级吃掉读洪峰,DB 专注少量写与权威数据。
【原理溯源】
为什么读多写少要优先“静态化”? 读 10 万 QPS、写 100 QPS,意味着请求里 99.9% 是读(10万 ÷ 10.01万),而每条数据两次写之间被反复读很多次。这类几乎不变的数据最适合静态化:渲染成 HTML/JSON 放 CDN,请求在边缘节点就返回,根本不回源。这是把“动态计算”变成“静态分发”,成本最低、速度最快。只有数据变更时才重新生成静态页。
为什么 CDN 能扛掉绝大部分读流量? CDN 节点分布在各地,用户就近访问。同一商品详情页被百万用户浏览,CDN 命中后源站只收到回源请求(通常 <1%)。10 万 QPS 的浏览,到源站可能只有几百 QPS。这是“用边缘节点的分布式缓存对抗集中读”的极致形态。
为什么还要多级缓存(本地+Redis)? CDN 适合完全静态的内容;但详情页可能有个性化元素(用户级别优惠、库存实时数),不能完全静态化。本地缓存(微秒级)挡热点,Redis(毫秒级)共享缓存,DB 兜底。多级缓存是用“一致性递减、速度递增”的层级结构,让 99%+ 的请求在最便宜的层被满足。
为什么写时要“先更新 DB 再删缓存”而不是“先删缓存再更新 DB”? 先删缓存:删完后、更新 DB 前,有并发读把旧值读回缓存,旧数据被写回且长期存在(直到下次过期)。先更新 DB 再删缓存:并发读可能短暂读到旧缓存,但下次读会回源拿到新值。虽仍有极小概率不一致,但概率远低于前者,且可用延迟双删/ binlog 订阅进一步降低。这是 Cache Aside 模式成为主流的原因。
为什么缓存挂了必须 DB 限流而不是直接放行? 正常时按第 1 条的 ≥95% 命中率,10 万读里约 9.5 万被挡掉、DB 扛约 5000(只有再叠本地缓存把命中推到 99%,才降到几百)。缓存全挂后 10 万读全部打到 DB,DB 瞬间被打死,写也跟着挂。所以缓存故障时必须立即启用 DB 限流(把进入 DB 的读压到其容量以下),并返回兜底数据(可能不完整)或排队。宁可部分降级,不能 DB 挂掉。
【选型判断树】
读多写少架构选型:
数据是否可完全静态?
├─ 是(无个性化)→ 静态化 + CDN(最优)
└─ 否(有个性化/实时字段)
├─ 个性化字段独立加载(静态部分 CDN,动态部分接口)
└─ 多级缓存:本地 → Redis → DB
写路径
└─ 先更新 DB → 再删缓存(Cache Aside)
兜底:延迟双删 / 订阅 binlog
缓存故障
└─ DB 限流 + 降级返回
读写分离
└─ 主写从读,注意主从延迟(第 46/59 题)【口述骨架】(5 分钟;框架 A,骨架共性见文首第二节,本题只写特定内容)
| 时间 | 任务 | 内容 |
|---|---|---|
| 0:00–0:30 | 定性 | “读多写少架构题。核心思路:能静态就静态、能缓存就缓存、读写分离” |
| 0:30–1:00 | 定界 | 读 10 万、写 100,比例 1000:1,数据变化极少 |
| 1:00–3:00 | 分层设计 | 静态化+CDN → 多级缓存 → 读写分离;写路径 Cache Aside |
| 3:00–4:00 | 一致性与故障 | 先更 DB 再删缓存的原因;延迟双删/binlog 兜底;缓存挂了 DB 限流 |
| 4:00–5:00 | 收尾 | “读多写少的本质是用各种缓存层把读流量挡在 DB 外面,写路径保持简单正确” |
【关键数字】
| 参数 | 经验值 | 说明 |
|---|---|---|
| 读写比 | 1000:1 | 极不对称 |
| CDN 命中率目标 | >95% | 低于 90% 要查回源原因 |
| 多级缓存命中率 | >95%(Redis+本地) | 低于 80% 设计有问题 |
| 本地缓存 TTL(读多写少的热点) | 30s–5min | 变更低频才敢放长,别套到会变的字段上 |
| Redis TTL | 10min–24h | 视数据变更频率 |
| 主从延迟 | 通常 <1s,峰值可能秒级 | 读写分离要注意 |
| 缓存挂后 DB 限流 | 取 DB 读容量的 10%–20%(如 1–2 万 → 2000 QPS) | 保命:先保证 DB 不死,再谈命中率 |
| 静态页重新生成 | 写后秒级~分钟级 | 视业务容忍 |
| 应用并发估算 | QPS × RT | 决定线程池与实例数 |
| 容量冗余 | 峰值的 1.5–2 倍 | 大促/突发留余量 |
| 限流阈值 | 压测容量的 70%–80% | 避免排队雪崩 |
【追问链】(三层)
L1|“Cache Aside 为什么是‘先更 DB 再删缓存’,不是‘先删缓存’?” → 先删缓存有更大的不一致窗口:删后更新 DB 前,并发读把旧值回填缓存,旧数据长期存在。先更 DB 再删:即使并发读短暂读到旧缓存,下一次读会回源新值。虽非完美(仍有极小概率不一致),但概率低一个数量级,是工程上的最优实践。
L2|“主从延迟导致用户刚改完就查到旧数据,怎么办?” → 分场景:① 读己之写——用户自己的读走主库;② 关键读走主库,非关键走从库;③ 等待复制位点(读从库时检查复制进度是否已包含本次写);④ 降低延迟用半同步复制。详情页展示类读通常可接受秒级延迟。
L3|“缓存和 DB 不一致了,怎么发现和修复?” → 发现:① 定时对账任务抽样比对缓存与 DB;② 监控缓存更新失败率;③ 业务侧兜底短 TTL。修复:① 手动/自动删除缓存 key 强制回源;② 订阅 binlog 异步刷新;③ 延迟双删。预防:写路径失败要有重试与告警,不能静默。
【评分标准】
| 档位 | 答案特征 |
|---|---|
| 60 分 | 能说出“加 Redis 缓存” |
| 80 分 | 知道静态化+CDN、多级缓存、读写分离;了解 Cache Aside |
| 95 分 | 能解释先更 DB 再删缓存的原因;设计缓存挂了的 DB 限流降级;处理主从延迟的读己之写;有对账意识 |
【关联题】
- 同簇: 第 17 题(多级缓存设计)、第 27 题(缓存一致性)、第 28 题(延迟双删)、第 29 题(缓存策略选型)
- 读写分离细节: 第 46 题(主从延迟)、第 59 题(读写分离延迟)
- 对比参照: 第 16 题(写多读少,完全相反的架构)
- 系统设计: 第 123 题(短链,也是读多写少)
【自测】
- 读 10 万写 100,最优的第一层手段是什么? 参考答案: 静态部分走静态化+CDN,个性化字段(用户级优惠、实时库存)单独出接口。数据主体几乎不变,绝大部分静态读在边缘返回;但不能整页静态化(见第 2 条),答时要先划这条边界。
- Cache Aside 的写路径是什么?为什么这个顺序? 参考答案: 先更新 DB 再删缓存。先删缓存会有更大窗口把旧值回填,不一致概率更高。
- 缓存全挂了怎么办? 参考答案: 立即 DB 限流把读压到 DB 容量以下,返回兜底数据,防止 DB 被打挂导致写也挂。
16. 埋点日志每秒写入 10 万条、几乎不读(写多读少架构)
【考察内容】写优化与存储选型
【题目】你们要做一套埋点日志系统:用户行为日志每秒产生 10 万条,几乎只写不读(只有事后分析才查)。直接写 MySQL 肯定不行,你怎么设计存储与写入链路?
【参考答案】写 10 万条/秒、几乎不读:追加写、异步批、少索引、后置算:
- 容量估算(步进):10 万条/s,假设每条 200B → 约 20MB/s 峰值写入;一天约 86 亿条量级,必须提前做容量与生命周期。MySQL 行级插入远达不到 10 万/s(通常几千 TPS 量级),单靠 DB 同步写必挂。
- 架构:① 应用 → MQ/Kafka(高吞吐日志通道)快速 ACK;② 批量消费写入:按时间/条数攒批(如 500 条或 200ms)写入;③ 存储选型:顺序写友好的系统——Kafka 本身可短期存、ClickHouse/ES/HBase/对象存储归档;④ 强索引延后建,或只建时间+少量过滤字段。
- 写入优化:按天/小时分表分区,冷热分离;压缩;避开随机小 IO;可异步双写校验。
- 失败与降级:MQ 积压 → 扩消费者,积压可接受(日志类允许延迟);下游存储挂 → 堆积在 MQ,恢复后追赶;丢失可接受的埋点可降采样(抽样 10%–100% 可调);必须不丢的走同步+本地缓冲+重试。
- 口径:写多读少的核心是把随机同步写变成顺序批量写,并把“查询分析”与“采集写入”解耦。
【原理溯源】
为什么 MySQL 不适合海量写? MySQL InnoDB 是 B+ 树结构,写入时要维护索引(随机 IO)、可能触发页分裂、要写 undo/redo、要维护事务。每条写入的成本远高于“追加到文件末尾”。单机写入通常几千 TPS,面对 10 万/s 的日志写入差 1–2 个数量级。而且日志几乎不读,B+ 树为快速点查优化的结构完全浪费了。
为什么“顺序写+追加”是写密集场景的正解? 磁盘顺序写速度可达几百 MB/s,随机写只有几 MB/s(差 100 倍)——这组数是机械盘 HDD 口径;NVMe SSD 随机写能到数百 MB/s~GB/s,顺序/随机差距缩到几倍~十几倍,但「顺序追加仍优于随机写」的结论不变,引用这组数要先说盘型。Kafka 把消息追加到分区日志文件末尾(顺序写),充分利用磁盘顺序带宽。LSM 树(HBase/RocksDB)也是类似思想:先写内存,批量刷盘成有序文件,把随机写变成顺序写。写密集场景选存储,先看“是否支持顺序追加”。
为什么要客户端本地缓冲+批量上报? 每秒 10 万条,若每条一个 HTTP 请求,网络 RTT 和连接开销会成为新瓶颈。客户端先把日志写本地文件/内存缓冲,攒够一批(如 100 条或 1 秒)再上报,把“十万次小请求”压成“千次批量请求”(10 万条/s ÷ 100 条/批 = 1000 次/s,请求数降两个数量级)。代价是客户端崩溃可能丢最后一批(日志通常可接受)。
为什么用 Kafka 而不是直接写存储? Kafka 是为高吞吐日志场景设计的:顺序写、多副本、水平扩展。它作为“写入缓冲层”接收所有日志,下游消费者按自己的节奏处理入仓。把“写入速率”与“处理/存储速率”解耦,峰值不会打垮下游。若直接写 ClickHouse,高峰可能把 CK 打挂。
为什么日志系统可以“几乎不读”却还要设计查询? “几乎不读”指在线业务不读,但事后分析(用户行为分析、故障排查、合规审计)必须能查。查询模式是“按时间范围+条件过滤的批量扫描”,与在线交易的点查完全不同。所以选列式存储(ClickHouse)——列式压缩比高、适合聚合扫描,而不是为点查优化的行存。
【选型判断树】
写多读少架构选型:
先问:写入速率与数据特征
├─ 海量写、几乎不读、允许丢一点 → Kafka + 列式数仓
│ (埋点、日志、监控指标)
├─ 海量写、要按主键查 → LSM 存储(HBase/RocksDB)
│ (设备状态、时序数据)
└─ 写多但也要在线查 → Kafka 缓冲 + 可查询存储(ES/CK)
存储选择:
├─ 聚合分析为主 → ClickHouse / Hive / Presto
├─ 全文检索为主 → Elasticsearch
├─ 时序数据 → InfluxDB / TDengine
└─ 冷数据 → 对象存储(OSS/S3),成本极低
口诀:写多先看是否顺序追加;日志用 Kafka+列存;点查用 LSM;冷数据归档。【口述骨架】(5 分钟;框架 B,骨架共性见文首第二节,本题只写特定内容)
| 时间 | 任务 | 内容 |
|---|---|---|
| 0:00–0:30 | 定性 | “写多读少架构题。我会按写入链路、存储选型、可靠性来讲” |
| 0:30–1:00 | 定界 | 10 万条/s,几乎只写;查询是事后分析,非在线点查 |
| 1:00–3:00 | 链路设计 | 客户端缓冲批量 → Kafka 顺序写 → 流式消费入数仓 → 冷热分层 |
| 3:00–4:00 | 为什么不用 MySQL | B+树随机写慢;日志几乎不读,点查优化浪费;顺序写+追加才对 |
| 4:00–5:00 | 收尾 | “写多读少的本质是选为写优化的存储(顺序追加),用 MQ 解耦写入与处理速率” |
【关键数字】
| 参数 | 经验值 | 说明 |
|---|---|---|
| 日志写入速率 | 10 万条/s | 本题规模 |
| Kafka 单机写入 | 百万级 TPS(小消息) | 顺序写的优势 |
| MySQL 单机写入 | 数千 TPS | 差 1–2 个数量级 |
| 客户端批量 | 100 条或 1s 一次 | 平衡延迟与吞吐 |
| Kafka 保留策略 | 按天(如 3–7 天) | 防磁盘打满 |
| 冷数据归档 | T+7 或 T+30 迁 OSS | 成本降一个数量级 |
| ClickHouse 写入 | 批量写入,单行写性能差 | 必须攒批 |
| 压缩比 | 列存压缩 10:1 常见 | 节省存储成本 |
| 应用并发估算 | QPS × RT | 决定线程池与实例数 |
| 容量冗余 | 峰值的 1.5–2 倍 | 大促/突发留余量 |
| 限流阈值 | 压测容量的 70%–80% | 避免排队雪崩 |
【追问链】(三层)
L1|“日志也要强一致事务吗?” → 不需要,这是过度设计。埋点日志允许丢失少量、允许乱序、允许秒级延迟。强一致(事务、同步写)会把吞吐拉到 MySQL 级别,完全没必要。按数据价值选一致性:资金>订单>行为日志>调试日志。
L2|“Kafka 数据量膨胀怎么控制?” → ① 设置保留时间(retention.ms)或大小(retention.bytes),到期自动删除;② 按 topic 分离不同日志,分别设保留;③ 压缩(lz4/zstd)减少存储;④ 冷数据转存对象存储;⑤ 监控磁盘水位,提前扩容。不设保留策略是生产事故的常见原因。
L3|“ES 写入慢怎么办?” → ① 批量写入(bulk),别单条;② 降低副本数(分析场景可临时 0 副本);③ 按天建索引,删除旧索引比删文档高效;④ 调大 refresh_interval(默认 1s,可改 30s 或手动 refresh);⑤ 关闭不需要的字段索引;⑥ 写入量太大时前面再加 Kafka 缓冲。
【评分标准】
| 档位 | 答案特征 |
|---|---|
| 60 分 | 能说出“用 Kafka,不要用 MySQL” |
| 80 分 | 能讲清 MySQL 为何不适合海量写;设计客户端缓冲+Kafka+数仓链路;知道列式存储 |
| 95 分 | 能解释顺序写 vs 随机写的性能差异;设计冷热分层与保留策略;讨论 ES/CK 写入优化;知道日志不需要强一致 |
【关联题】
- 同簇: 第 86 题(Kafka 高吞吐原理)、第 79 题(MQ 价值)、第 84 题(MQ 选型)
- 对比参照: 第 15 题(读多写少,完全相反)
- 存储延伸: 第 65 题(数据归档)、第 146 题(BI 看板,读侧)
【自测】
- 为什么 MySQL 不适合 10 万条/s 的日志写入? 参考答案: B+树随机写+事务开销,单机几千 TPS;日志几乎不读,点查优化浪费;应选顺序追加存储。
- 为什么客户端要本地缓冲批量上报? 参考答案: 减少网络请求次数,把小请求变批量,网络效率提升 10–100 倍。
- 日志系统为什么不需要强一致事务? 参考答案: 数据价值低,允许少量丢失、乱序、延迟;强一致会严重牺牲吞吐。
17. 详情页要秒开,单层缓存还不够(多级缓存设计)
【考察内容】缓存分层架构
【题目】商品详情页要求 P99 30ms 内返回。只加一层 Redis 缓存后,网络开销仍然偏大、QPS 上不去。你打算引入本地缓存(进程内)与 Redis 配合,怎么设计多级缓存的读取与更新?各自缓存什么数据?
【参考答案】多级缓存目标:详情页秒开(题干口径 P99 ≤30ms;本地缓存未命中回源 Redis 时控制在 ≤20ms 内为可接受回退线)——为什么不是 50ms:题干 P99 目标是 30ms,而 L1 只挡 30%–50%,若回退线取 50ms,P50–P99 就落在回源集合上,P99 必然击穿 30ms;只有把未命中占比压到 <1%(让 P99 不再落在回源路径)时,回退线才可以放宽到 50ms。
- 容量估算(步进):假设详情页峰值读 5 万 QPS,单实例 50 台则每台约 1000 QPS。若全部打 Redis:集群仍可扛,但 RT 与带宽仍有压力;若命中本地缓存(进程内)RT 可到 <1ms,Redis 约 0.1–1ms(同机房,含网络与序列化),回源 DB 约 1–10ms(走索引)。命中率目标(口径:「占全部读请求的比例」,与下面【关键数字】里「热点数据 >90%」不是同一个分母):本地 30%–50% + Redis 40%–60% + 总命中 ≥95%,回源 ≈2500 QPS。
- 分层设计:L1 本地缓存(Caffeine,短 TTL 1–5s,热点详情)→ L2 Redis(TTL 分钟~小时,全量热点)→ L3 DB。静态片段/整个页可再加 CDN/网关缓存。
- 一致性:本地缓存变更不敏感的展示数据用短 TTL;写路径更新 DB 后发缓存失效广播(Redis Pub/Sub 或消息)清理各实例本地缓存;价格/库存等准实时字段单独短 TTL 或旁路读。
- 失败与降级:本地缓存放大陈旧风险 → TTL 收短+主动失效;Redis 挂 → 本地缓存兜底+DB 限流;重建风暴 → 互斥重建/逻辑过期;全链路失败时返回静态模板或“稍后刷新”。
- 监控:各级命中率、RT、重建次数、陈旧投诉率。本质:用空间换时间,把读压力从远端挪到离 CPU 更近的地方,同时用失效协议控制陈旧。
【原理溯源】
为什么单层 Redis 不够? Redis 访问仍有网络 RTT(同机房 0.1–1ms)和序列化开销。P99 30ms 的目标下,1ms 的 Redis 访问占比不大,但高并发时 Redis 单节点可能成为带宽/CPU 瓶颈——所有实例都来打同一个 Redis。本地缓存把热点数据放在进程内存,访问是纳秒–微秒级(见本题【关键数字】的「L1 本地访问」一行),且压力被分散到每个应用实例,把 Redis 单点热点的压力大幅摊掉;但热点并没有因此消失——Redis 仍要扛非热点读与写,还多出一份多实例间的一致性成本。
为什么多级缓存是“一致性递减、速度递增”的层级? L1 本地:最快(微秒)、最不一致(多实例各自为政)、容量最小(受实例内存限制)。L2 Redis:较快(毫秒)、较一致(共享)、容量中等。L3 DB:最慢(毫秒+)、最一致、容量大。让 90%+ 的热点请求在 L1 命中,剩下的去 L2,极少数回源 DB——用速度换一致性的分层妥协。
为什么本地缓存的多实例一致性是大坑? 每个应用实例有独立的本地缓存副本。写操作更新 DB 后,如何让 10 个实例的本地缓存都失效?① 短 TTL:到期自然过期,容忍秒级不一致(最简单);② 失效广播:更新时 Pub/Sub 通知所有实例删除(实时但可能丢消息);③ 版本号:读时比较版本,不一致则回源。没有完美方案,都是在一致性与复杂度间取舍。
什么数据适合放本地缓存? 适合:读多写少、可容忍短暂不一致、数据量小(能放进实例内存)。如:商品基础信息、配置项、字典表、热点用户信息。不适合:更新频繁(库存、余额——本地缓存会脏)、数据量大(撑爆内存)、强一致要求(资金)。
为什么回源时要“回填两级”而不是只填 L1? 只填 L1:其他实例下次访问还要回源 DB,且 L1 过期后又要回源。回填 L1+L2:L2 是共享的,其他实例可以先从 L2 拿,减少 DB 压力。L2 充当“实例间的共享层”,降低整体回源率。
【选型判断树】
要不要加本地缓存?
数据特征:
├─ 热点、读多写少、可容忍秒级不一致 → 加 L1(推荐)
├─ 更新频繁或强一致 → 不加 L1,只用 Redis
└─ 数据量大 → 只缓存热点子集到 L1,全量在 L2
L1 失效策略:
├─ 简单场景 → 短 TTL(1–30s)
├─ 要较快失效 → Redis Pub/Sub 广播
└─ 要精确 → 版本号比对
容量控制:
└─ L1 限制最大条目数+权重,防撑爆堆内存
口诀:热且可脏放本地,更新频繁别放,失效广播或短 TTL,容量一定要限。【口述骨架】(5 分钟;框架 B,骨架共性见文首第二节,本题只写特定内容)
| 时间 | 任务 | 内容 |
|---|---|---|
| 0:00–0:30 | 定性 | “多级缓存设计题。我会讲为什么分层、怎么读写、一致性怎么处理” |
| 0:30–1:00 | 为什么分层 | Redis 有网络 RTT 且可能单点热点;本地微秒级分散压力 |
| 1:00–2:30 | 读写设计 | 读:L1→L2→DB,回填两级;写:先 DB 后删缓存,L1 失效 |
| 2:30–3:30 | 一致性 | 多实例 L1 不一致是核心坑;短 TTL / 广播 / 版本号三种方案 |
| 3:30–4:30 | 适用边界 | 什么数据放 L1,什么不放;容量限制 |
| 4:30–5:00 | 收尾 | “多级缓存是用一致性换速度的分层妥协,核心难点在 L1 多实例失效” |
【关键数字】
| 参数 | 经验值 | 说明 |
|---|---|---|
| L1 本地访问 | 微秒级(纳秒–微秒) | 无网络 |
| L2 Redis 访问 | 0.1–1ms | 含网络+序列化 |
| L3 DB 访问 | 1–10ms | 走索引 |
| L1 TTL | 1s–5min | 热点可短,配置可长 |
| L1 容量限制 | 条目数+权重上限 | 防 OOM |
| L1 命中率目标 | 热点数据 >90% | 口径是「热点集合内部」命中率,不是全站请求占比(全站口径见正文第 1 条的 30%–50%) |
| 整体命中率 | >95% | 回源率控制 |
| P99 目标(题干口径) | 30ms | 比通用经验值 100–300ms 严得多,必须靠 L1+L2 双层挡量才可能达到 |
| 应用并发估算 | QPS × RT | 决定线程池与实例数 |
| 容量冗余 | 峰值的 1.5–2 倍 | 大促/突发留余量 |
| 限流阈值 | 压测容量的 70%–80% | 避免排队雪崩 |
【追问链】(三层)
L1|“本地缓存用什么组件?Caffeine 和 Guava Cache 有什么区别?” → 推荐 Caffeine。相比 Guava Cache:① 更高性能(W-TinyLFU 淘汰算法,命中率更高);② 真异步加载与刷新(buildAsync/AsyncCache,刷新不占读线程;Guava 也有 refreshAfterWrite,但其重载在调用线程同步执行);③ 更好的统计与监控。Guava Cache 已停止推荐,新项目用 Caffeine。
L2|“本地缓存更新时,怎么通知其他实例失效?” → 三种:① 短 TTL 自然过期(最简单,容忍秒级不一致);② Redis Pub/Sub 广播失效消息(较快,但 Pub/Sub 可能丢消息,需短 TTL 兜底);③ 版本号/时间戳比对(读时校验,精确但增加一次 Redis 访问)。生产常用 ①+② 组合。
L3|“本地缓存会不会把堆内存撑爆?” → 会,必须限制。① 设置最大条目数(maximumSize)和权重(maximumWeight);② 设置过期时间;③ 只缓存热点子集,不是全量数据;④ 监控缓存占用内存,设告警;⑤ 大对象不要放本地(序列化后仍大)。Caffeine 的 size-based eviction 必须配,不能只设 TTL。
【评分标准】
| 档位 | 答案特征 |
|---|---|
| 60 分 | 能说出“本地缓存+Redis” |
| 80 分 | 知道读写路径、回填两级、先更 DB 再删缓存;了解多实例失效问题 |
| 95 分 | 能解释分层的一致性/速度权衡;给出三种失效方案;讨论容量限制与适用边界;知道 Caffeine 优于 Guava |
【关联题】
- 同簇: 第 15 题(读多写少总架构)、第 27 题(缓存一致性)、第 38 题(更新顺序)、第 119 题(多级缓存一致性)
- 组件延伸: 第 45 题(缓存放进程内还是 Redis)
- 故障: 第 26 题(缓存雪崩,多级缓存的过期策略)
【自测】
- 为什么单层 Redis 不够,要加本地缓存? 参考答案: Redis 有网络 RTT 且高并发下可能单点热点;本地缓存微秒级,且压力分散到每个实例。
- 本地缓存最大的坑是什么? 参考答案: 多实例各自独立,更新时要通知所有实例失效;方案有短 TTL、广播、版本号。
- 什么数据不该放本地缓存? 参考答案: 更新频繁(库存/余额)、强一致要求(资金)、数据量大撑爆内存的。
18. 流量高峰要自动加机器、低谷要省成本(弹性伸缩)
【考察内容】云原生与容量工程
【题目】你们的服务白天高峰 CPU 90%、夜里低谷只有 10%,固定机器数要么浪费要么不够用。如何实现“高峰自动加机器、低谷自动减机器”?扩容的依据、步骤、风险分别是什么?
【参考答案】弹性伸缩要“指标驱动 + 冷却可控 + 成本平衡”:
- 容量估算(步进):假设白天峰值 2 万 QPS、夜间 2000,单实例健康容量 2000 QPS(利用率目标 60%–70%)→ 峰值理论实例 ≈ 2万/2000×1.4 ≈ 14–15 台,低谷 ≈ 2–3 台。伸缩目标是在 SLA 内尽量贴近曲线,节省约 50%–70% 闲置成本。
- 指标与触发:不只看 CPU,还要看 RT、线程池使用率、队列长度、自定义业务指标(如 QPS);CPU 60%–70% 或 RT>阈值持续 2–3 分钟触发扩容;缩容要更保守(更长观察期、更低阈值),防止抖动。
- 冷却与预热:冷却要分两侧——扩容冷却 0–1 分钟(要能快速跟上),缩容冷却 3–5 分钟(防抖动),与本题【关键数字】同口径;实例启动含拉镜像、建连接、缓存预热,预热完成才接流;大促可定时预测扩容(不完全靠反应式)。
- 失败与降级:云 API 失败 → 告警人工介入,保留最小实例数下限;扩出来的实例不健康 → 自动摘除;缩容误杀 → 快速回滚;依赖(DB/Redis)容量不够时扩应用反而加速打挂——下游容量是伸缩上限。
- 成本:低谷缩容、竞价实例/闲置计划;无状态服务才能横向伸缩;本地缓存实例变多可能降低命中率,需权衡。
【原理溯源】
- 为什么无状态化是弹性伸缩的前提? 有状态服务(本地 Session、本地文件、本地队列)扩容后,新实例没有旧状态,请求路由到新实例会出错;缩容时旧实例的状态丢失。无状态后任意实例可替换,加机器就是纯增加处理能力,减机器就是纯减少成本——这是弹性伸缩能成立的基础。
- 为什么扩容有“冷启动”问题? 新容器启动需要:拉取镜像(可能几十秒到几分钟)、进程启动、JVM 预热(JIT 编译、连接池建立、缓存加载)。在预热完成前,新实例处理慢,甚至可能超时。所以:① 预置“温备”实例(提前启动好但不接流量);② 镜像预拉取到节点;③ 使用较小的基础镜像加速拉取。扩容不是点击即生效,必须算上冷启动时间。
- 为什么缩容必须“优雅下线”? 直接杀进程,正在处理中的请求会中断。优雅下线:① 先从负载均衡摘除该实例(不再接新请求);② 等待存量请求处理完(graceful period,如 30s);③ 再销毁进程。K8s 的 preStop hook + terminationGracePeriodSeconds 就是干这个的。保证缩容不中断用户请求。
- 为什么要冷却时间(cooldown)? 指标有波动:CPU 瞬时到 90% 可能是 GC 或突发,马上扩了,1 分钟后又降下来,缩容——抖动扩容浪费资源且增加不稳定。冷却时间:扩容后 N 分钟内不再触发缩容,缩容后 M 分钟内不再触发扩容,给系统稳定期。另外用“持续超过阈值一段时间”而不是“瞬时超过”作为条件。
- 为什么数据库不能随意弹性伸缩? 应用无状态可随意加减;数据库有状态,扩容意味着分片(数据迁移、路由改造)、主从切换,不能秒级完成。所以数据库通常按峰值容量规划,不参与秒级弹性;或者用云数据库的只读副本扩展读,写扩展仍需分库分表提前设计。
【选型判断树】
弹性伸缩怎么落地?
前提检查:
├─ 应用是否无状态?否 → 先无状态化
└─ 是否容器化/镜像化?否 → 先容器化
指标选择:
├─ CPU 为主(通用)
├─ QPS/自定义指标(业务感知更准)
└─ 多指标:CPU+RT+队列长度
平台选择:
├─ K8s → HPA(Pod 级)
├─ 云主机 → 弹性伸缩组(AS)
└─ Serverless → 自动(但冷启动更明显)
关键配置:
├─ 扩容阈值:CPU 60–70% 持续 2–3 分钟
├─ 缩容阈值:CPU 30% 以下持续 5–10 分钟
├─ 冷却时间:扩 0–1min,缩 3–5min
└─ 最大/最小实例数:防无限扩/缩到 0
有状态组件:
└─ DB 不参与秒级弹性,按峰值规划【口述骨架】(5 分钟;框架 B,骨架共性见文首第二节,本题只写特定内容)
| 时间 | 任务 | 内容 |
|---|---|---|
| 0:00–0:30 | 定性 | “弹性伸缩题。我会讲前提、指标、流程、风险” |
| 0:30–1:00 | 前提 | 无状态化 + 容器化;数据库等有状态组件不参与秒级弹性 |
| 1:00–3:00 | 实现 | HPA/AS;指标选择;扩容流程(启动→注册→接流量);缩容优雅下线 |
| 3:00–4:00 | 风险 | 冷启动、抖动扩容、缩容中断、DB 瓶颈 |
| 4:00–5:00 | 收尾 | “弹性伸缩的本质是让计算资源随负载变化,前提是无状态,难点在冷启动与抖动控制” |
【关键数字】
| 参数 | 经验值 | 说明 |
|---|---|---|
| 扩容触发 | CPU >60% 或 RT >阈值,持续 2–3min | 提前扩;观察窗口与正文第 2 条同值 |
| 缩容触发 | CPU <30% 持续 5–10min | 谨慎缩 |
| 冷启动时间 | 容器 30s–2min;JVM 预热另加 1–3min | 预置温备可掩盖 |
| 扩容冷却 | 0–1 分钟 | 允许快速扩 |
| 缩容冷却 | 3–5 分钟 | 防抖动 |
| 优雅下线等待 | 15–60s | 等存量请求完成 |
| 最小实例数 | ≥2(高可用) | 不能缩到 1 |
| 镜像拉取 | 10s–几分钟 | 取决于大小与网络 |
| 应用并发估算 | QPS × RT | 决定线程池与实例数 |
| 容量冗余 | 峰值的 1.5–2 倍 | 大促/突发留余量 |
| 限流阈值 | 压测容量的 70%–80% | 避免排队雪崩 |
【追问链】(三层)
L1|“有状态服务能做弹性伸缩吗?” → 很难直接做。有状态服务扩容需要数据迁移/会话同步,不是秒级操作。变通方案:① 把状态外置(Session→Redis)后变无状态再弹性;② 有状态服务按固定容量规划,不参与秒级弹性;③ 数据库用只读副本扩读,写用分库分表提前规划。
L2|“扩容了但流量还是扛不住,为什么?” → 检查:① 冷启动未完成,新实例还没真正处理能力;② 扩的是应用,瓶颈在 DB/Redis(共享资源没扩);③ 注册中心/负载均衡还没把流量切过去;④ 扩容数量不够(阈值设太松);⑤ 限流把新流量挡住了。扩容只解决应用层 CPU,不解决共享资源瓶颈。
L3|“怎么防止抖动扩容?” → ① 冷却时间:扩容后一段时间不缩容,缩容后一段时间不扩容;② 用“持续超过阈值”而不是瞬时值;③ 多指标综合(CPU+RT+队列),避免单一指标误判;④ 设置最大/最小实例数边界;⑤ 缩容用更保守的阈值(缩容比扩容更谨慎,因为缩错了直接影响用户)。
【评分标准】
| 档位 | 答案特征 |
|---|---|
| 60 分 | 能说出“用 K8s HPA” |
| 80 分 | 知道无状态化前提、指标选择、优雅下线 |
| 95 分 | 能解释冷启动问题与预置温备;设计冷却时间防抖动;指出 DB 不参与秒级弹性;能处理“扩了还扛不住”的排查 |
【关联题】
- 同簇: 第 7 题(高可用,冗余)、第 1 题(水平扩容)、第 2 题(突发流量应急)
- 前提: 第 111 题(分布式 Session,无状态化)
- 瓶颈转移: 第 55 题(分库分表,DB 扩展)
- 国企 / 金融版: 第 222 题(同城双活,资源规划)
【自测】
- 为什么无状态化是弹性伸缩的前提? 参考答案: 无状态后任意实例可替换,加减机器纯增减处理能力;有状态则需要数据迁移/会话同步,无法秒级弹性。
- 缩容时直接杀进程有什么问题? 参考答案: 正在处理的请求会中断。必须优雅下线:先摘流量,等存量完成,再销毁。
- 什么是抖动扩容?怎么防? 参考答案: 指标波动导致频繁扩了又缩。用冷却时间、持续阈值、多指标、缩容更谨慎来防。
19. 秒杀开始前 1 秒请求已打满,脚本绕过前端直接抢(防提前/防脚本)
【考察内容】防刷与业务防提前
【题目】秒杀 10 点开始,9:59:59 服务器就已经被打满(有人提前请求);同时有脚本绕过前端按钮直接调后端接口抢购,真人用户根本点不进去。这两个问题怎么从接口层面解决?
【参考答案】防提前/防脚本:时间同步 + 资格凭证 + 服务端裁决:
- 容量估算(步进):假设 10 万资格用户,开抢前 1 秒恶意请求可能达 10 万+ QPS。真实库存 1 万,预期成功请求仅 1 万次。若开放接口无凭证,脚本可打满连接池,真实用户进不来。目标:开抢前 90%+ 请求应在“资格校验”层被快速拒绝,只放行有效凭证流量(「发多少资格」与「放行多少 QPS」是两个数,别混:本条前提是有 10 万资格用户,若按【关键数字】「token ≤ 库存或略多」只发 1 万个 token,则另外 9 万资格用户会被自己的资格校验挡掉;若给 10 万资格用户各发一个,则放行阈值要 ≈10 万 QPS 量级,此时真正收口在库存预扣层(1 万)而不是这一层。答法:资格校验层只负责「无凭证一律拒」,把有凭证的量全放到预扣层,由预扣层 + 库存决定成功数——10 万+ 请求若放行 2–5 万,只挡掉五到八成,达不到上面 90%+ 的拒绝目标)。
- 防护设计:① 统一时间:以服务端时间倒计时,前端仅展示;接口开抢前一律拒绝(服务端判断,不信客户端);② 动态 URL/签名:活动前才下发随机短时路径;③ 预取 Token/资格码:需登录+风控通过才能领,开抢时校验 Token 未用;④ 一码一用、绑定用户/设备。
- 脚本对抗:验证码/滑块在领 Token 阶段消耗脚本;频次限流+异常设备特征;对无 Token 请求不进核心逻辑,直接 403。
- 失败与降级:时间服务异常 → 以配置中心/本机单调时间兜底;风控过严误杀 → 申诉通道与白名单;Token 服务挂 → 降级为登录态限流+临时关闭活动入口,避免裸奔。
- 口径:把“能否参与”提前到开抢前完成核验,开抢瞬间只做极轻的 Token 与库存判定。
【原理溯源】
为什么前端倒计时防不了脚本? 前端 JS 控制的是“真人看到的按钮”,脚本根本不经过浏览器渲染,直接发 HTTP 请求。前端校验对自动化攻击完全无效——这是安全的基本原则:永远不信任客户端。所有关键校验(时间、资格、库存)必须在服务端做。
为什么要服务端时间校验? 即使有人提前发请求,服务端检查
now < startTime则拒绝。看似简单,但要注意:① 时间必须是服务端时间,不能用客户端传的时间;② 集群多机器时钟要同步(NTP),否则 A 机认为未开始、B 机认为开始了;③ 时间窗口边界要精确(用毫秒时间戳比较)。为什么 URL 动态化有效? 若秒杀接口 URL 是固定的(如
/api/seckill/buy?id=123),脚本可以提前写好直接打。动态 URL:活动开始前才下发带签名的随机路径(如/api/seckill/xxx?sign=abc&ts=...),脚本在活动前不知道真实地址。配合签名校验(防篡改)和有效期(防囤积),把“知道地址”变成门槛。本质是隐藏并保护业务入口。为什么要资格预审/token? 动态 URL 仍可能泄露(用户分享、抓包)。资格 token 把链路拆成两步:① 先获取资格 token(有频控、与用户绑定、SETNX 防重复领取);② 秒杀时凭 token 下单。脚本即使知道秒杀 URL,没有合法 token 也调不通。把“拼速度”变成“拼资格”,从根本上削弱脚本优势。
为什么 SETNX 防 token 重复使用? token 应该一人一次。用
SET token_key 1 NX EX ttl(不能写成SETNX token_key 1 EX ttl——SETNX只接受 key 与 value 两个参数,带不了过期时间):第一个请求返回 OK 设置成功,后续请求因 NX 不成立返回 nil,直接拒绝。原子性由 Redis 单线程保证,避免“先查后用”的竞态。
【选型判断树】
防提前+防脚本,分层设防:
前端(防小白)
└─ 按钮置灰 + 倒计时(可被绕过)
接口(防直接调用)
├─ 服务端时间窗口校验(必须)
├─ URL 动态化 + 签名(隐藏入口)
└─ 资格 token 预发(SETNX 防重用)
风控(防专业脚本)
├─ 用户/设备/IP 频控
├─ 验证码(滑块/图形)
└─ 设备指纹 + 行为分析
兜底
└─ 网关限流,超阈值排队/拒绝
口诀:前端只防小白,服务端时间必须校验,URL 动态+token 预发是关键,风控兜底。【口述骨架】(5 分钟;框架 B,骨架共性见文首第二节,本题只写特定内容)
| 时间 | 任务 | 内容 |
|---|---|---|
| 0:00–0:30 | 定性 | “防提前+防脚本题。核心原则:永远不信任客户端,校验必须在服务端” |
| 0:30–1:00 | 两个问题 | 提前请求:服务端时间校验;绕过前端:URL 动态化+token |
| 1:00–3:00 | 分层方案 | 前端(弱)→ 接口(时间/URL/token)→ 风控(频控/验证码)→ 限流兜底 |
| 3:00–4:00 | 实现细节 | token 用 SET k v NX EX ttl 防重复(SETNX 带不了过期时间);时钟同步;签名有效期 |
| 4:00–5:00 | 收尾 | “安全的本质是让攻击成本高于收益;前端校验只是体验,服务端校验才是安全” |
【关键数字】
| 参数 | 经验值 | 说明 |
|---|---|---|
| token 有效期 | 30s–5min | 防转发囤积 |
| token 与用户绑定 | 必须 | 防分享 |
| 时钟同步 | NTP,偏差 <100ms | 集群时间一致 |
| URL 签名有效期 | 与活动窗口一致或更短 | 防提前使用 |
| 频控阈值 | 每用户 1–5 次/秒 | 按业务 |
| 时间校验精度 | 毫秒级时间戳 | 避免秒级边界问题 |
| 资格 token 数量 | 按「资格人数」发放(题干 10 万资格就发 10 万个),「≤库存」只适用于资格数≈库存的场景 | 否则另外 9 万资格用户会被自己的资格校验挡掉;真正收口在库存预扣层(1 万),不在发放层 |
| 应用并发估算 | QPS × RT | 决定线程池与实例数 |
| 容量冗余 | 峰值的 1.5–2 倍 | 大促/突发留余量 |
| 限流阈值 | 压测容量的 70%–80% | 避免排队雪崩 |
【追问链】(三层)
L1|“只靠前端倒计时行不行?” → 不行。脚本直接发 HTTP,不经过前端。前端倒计时只能防普通用户手滑提前点,对自动化攻击无效。所有关键校验(时间、资格、库存)必须服务端做。
L2|“token 泄露了怎么办?用户分享给脚本。” → 多重绑定:① token 与用户 ID 绑定,换了用户无效;② 与设备指纹绑定(可选);③ 短有效期;④ SETNX 保证一人一次;⑤ 使用频控。即使泄露,也只能用一次且绑定原用户,脚本无法批量使用。
L3|“服务端时间校验,集群机器时间不一致怎么办?” → ① 所有机器必须 NTP 对时,监控时钟偏差;② 时间判断统一用中心时间服务(或 Redis TIME 命令)而不是本机时间;③ 缓冲只能放在「开始后」一侧:开始后极小宽容窗口内到达的请求照常受理,开抢前一律拒绝(把宽容期放在开抢前,等于把本题题干投诉的提前抢合法化);④ 活动开始/结束用配置中心统一下发,不要各机器独立判断。
【评分标准】
| 档位 | 答案特征 |
|---|---|
| 60 分 | 能说出“加验证码、加时间校验” |
| 80 分 | 知道前端校验可绕过;了解 URL 动态化和 token 预发 |
| 95 分 | 能解释“永远不信任客户端”原则;设计 token 的 SETNX 防重用与多维绑定;处理集群时钟一致问题 |
【关联题】
- 同簇: 第 4 题(防刷风控)、第 6 题(秒杀系统全貌)、第 3 题(限流)
- 安全原则: 第 192 题(重放攻击,时间戳+签名)、第 197 题(越权,服务端校验)
- 系统设计: 第 157 题(预约资格系统)
【自测】
- 为什么前端倒计时防不了脚本? 参考答案: 脚本直接发 HTTP 不经过前端;安全原则是永远不信任客户端,关键校验必须服务端做。
- URL 动态化是怎么防脚本的? 参考答案: 活动前不发真实接口地址,脚本不知道打哪里;配合签名和有效期防止泄露后滥用。
- token 为什么用 SETNX? 参考答案: 原子性保证一人一次,避免“先查后用”的并发竞态导致 token 被多次使用。
20. 从点击下单到支付回调,全链路数据要“不丢、不错、不乱”(下单全链路一致性)
【考察内容】下单全链路一致性设计(数据不丢、不错、不乱)
【题目】用户从点击“下单”到支付成功,中间经过订单落库、库存扣减、支付回调、状态流转多个环节。任何一个环节出问题,就会出现“扣了钱没订单”“扣了库存没订单”“订单状态永远处理中”。你如何保证全链路数据不丢、不错、不乱?
【参考答案】下单全链路一致性要求不丢、不错、不乱:
- 容量与链路估算(步进):假设下单峰值 5000 TPS,链路:校验→扣库存→创建订单→计价优惠→支付单→履约。同步串联 RT 可能 >500ms,且任一步失败都要补偿。采用“本地消息表/TCC/可靠消息”组合,按步骤可否回滚选择策略;资金相关步骤强一致,履约类最终一致。
- 不丢:业务库操作与消息发送同事务(本地消息表)或使用事务消息;下游消费成功要 ACK;超时与未知状态要有对账任务扫描补齐。
- 不错:幂等(订单号/请求 Token 唯一键);状态机合法迁移(只能创建→支付→发货,不能乱跳);金额与优惠计算以服务端为准,防篡改;分布式事务失败要回滚库存/优惠券占用。
- 不乱:顺序要求高的状态变更用同一分片键串行或单线程消费;支付回调乱序时以状态机幂等合并;重复回调只生效一次。
- 失败与降级:支付渠道超时 → 查询订单状态再决定,禁止盲目重扣;库存回滚失败 → 补偿任务+对账;MQ 故障 → 同步降级并限流;人工差错处理台处理长尾不一致。监控:补偿次数、对账差异、状态机违规告警。
【原理溯源】
为什么“扣了钱没订单”会发生? 典型场景:支付回调先到,但订单创建消息还在 MQ 里没消费;或订单服务写库失败但支付已成功。根因是跨系统的操作无法用本地事务包住。解法:① 支付回调与订单状态更新要幂等且可重试;② 定时对账任务比对“支付流水”与“订单状态”,发现有支付无订单则补单或退款;③ 超时主动查单(不只依赖回调)。
为什么订单状态机很重要? 若没有状态机,任何代码都可能把“待支付”直接改成“已发货”,或把“已取消”改成“已支付”。状态机定义合法流转(待支付→已支付→已发货→已完成;待支付→已取消),非法跳转直接拒绝。这从机制上防止“状态错乱”,而不是靠开发者自觉。
为什么 MQ 要“同订单同分区”保证顺序? 订单的“创建→支付→发货”是有序事件。若乱序消费,可能“发货消息”先于“支付消息”处理,导致未支付就发货。同一订单的所有消息路由到同一分区,分区内天然有序,消费者按序处理。代价是热点订单可能分区倾斜,但对正确性是必要的。
为什么支付回调必须幂等? 支付平台可能重试回调(网络超时、处理慢都会重试)。若不幂等,重复回调会导致重复发货、重复加积分。幂等手段:用支付流水号做唯一键,已处理则直接返回成功。幂等是应对“至少一次投递”的必备防御。
为什么需要本地消息表/事务消息? “订单落库成功”和“发消息通知库存/积分”是两个动作,无法在一个本地事务里完成。先写库后发消息:写库成功但发消息前宕机,消息丢了。本地消息表:把“订单数据”和“待发消息”放进同一个本地事务,后台任务扫描未发送的消息投递。事务消息(RocketMQ)是类似思想的中间件实现。消除“业务成功但消息丢失”的窗口。
【选型判断树】
全链路一致性分层保障:
接入层 → 限流 + 幂等键(防重复提交)
业务层 → 原子扣减 + 状态机(防超卖、防乱流转)
异步层 → confirm + 幂等 + 同分区(不丢、不重、有序)
数据层 → 分布式 ID + 备份(不重、不丢)
兜底层
├─ 本地消息表/事务消息(业务与消息同事务)
├─ 定时对账(订单 vs 支付 vs 库存)
└─ 支付超时主动查单(不只依赖回调)【口述骨架】(5 分钟;框架 A,骨架共性见文首第二节,本题只写特定内容)
| 时间 | 任务 | 内容 |
|---|---|---|
| 0:00–0:30 | 定性 | “全链路一致性综合题。我会按接入/业务/异步/数据/兜底五层来讲” |
| 0:30–1:00 | 定界 | 链路:下单→扣库存→支付→回调→状态流转;风险:不丢、不错、不乱 |
| 1:00–3:30 | 五层展开 | 每层讲“风险+对策”:幂等、原子扣减、状态机、MQ 可靠、对账 |
| 3:30–4:30 | 典型故障 | “扣了钱没订单”怎么补;“状态永远处理中”怎么查单 |
| 4:30–5:00 | 收尾 | “一致性没有银弹,核心是幂等+状态机+对账兜底,接受最终一致” |
【关键数字】
| 参数 | 经验值 | 说明 |
|---|---|---|
| 支付超时 | 15–30 分钟 | 超时关单+回补库存 |
| 查单补偿 | 超时后主动查,不只等回调 | 回调可能丢 |
| 对账频率 | 分钟级~T+1 | 资金类建议准实时 |
| 幂等键 | 支付流水号/订单号+版本 | 唯一索引 |
| 状态机 | 禁止非法跳转 | 机制强制 |
| MQ acks | all(至少) | 防丢 |
| 消费重试 | 3–5 次后死信 | 防堵 |
| 应用并发估算 | QPS × RT | 决定线程池与实例数 |
| 容量冗余 | 峰值的 1.5–2 倍 | 大促/突发留余量 |
| 限流阈值 | 压测容量的 70%–80% | 避免排队雪崩 |
【追问链】(三层)
L1|“Redis 预扣了但 MQ 消息丢了,库存不一致怎么办?” → DB 是权威。流程上:Redis 预扣成功 → 创建订单(DB)→ 发消息。若消息丢,订单已落库,对账任务发现“有订单未扣 DB 库存”则补扣。另外:本地消息表保证业务与消息同事务;Redis 预扣要有过期回补。
L2|“支付回调一直没来,订单永远‘处理中’怎么办?” → 不能只依赖回调。① 设置支付超时(如 15 分钟),超时未回调则主动调支付平台查单接口;② 查单确认已支付则补推进订单状态;③ 确认未支付则关单+回补库存;④ 定时任务兜底扫描长时间处理中的订单。回调是通知,查单是核实,两者都要。
L3|“怎么设计订单状态机?” → 明确定义状态集合与合法流转:待支付→已支付→已发货→已完成;待支付→已取消;已支付→退款中→已退款。非法流转(如已取消→已支付)在代码层拒绝并告警。状态变更要用条件更新(UPDATE ... WHERE status=?)保证并发下只有一次成功。状态机是防错乱的机制保障,不是文档约定。
【评分标准】
| 档位 | 答案特征 |
|---|---|
| 60 分 | 能说出“加事务、加锁、加对账” |
| 80 分 | 能分层展开(接入/业务/异步/数据/兜底);知道幂等、状态机、MQ 可靠 |
| 95 分 | 能设计“扣钱没订单”的补偿;解释支付超时主动查单;说明本地消息表的作用;状态机用条件更新实现 |
【关联题】
- 同簇: 第 5 题(防超卖)、第 12 题(MQ 异步化)、第 99 题(分布式事务选型)
- 消息可靠: 第 80 题(不丢)、第 81 题(不重)、第 82 题(有序)、第 92/93 题(事务消息/本地消息表)
- 幂等: 第 110 题(接口幂等)、第 117 题(重复下单)
- 国企 / 金融版: 第 219 题(日终对账)、第 218 题(跨行转账一致性)
【自测】
- “扣了钱没订单”怎么发现和修复? 参考答案: 定时对账比对支付流水与订单状态;超时主动查单;发现有支付无订单则补单或退款。
- 为什么支付回调必须幂等? 参考答案: 支付平台会重试回调,不幂等会导致重复发货/重复加积分;用流水号唯一键去重。
- 订单状态机的作用是什么? 参考答案: 定义合法流转,非法跳转拒绝;用条件更新保证并发安全;防止状态错乱。
21. 大促前压测 TPS 上不去、RT 抖动,老板要优化方案(性能优化方法论)
【考察内容】性能优化方法论(先测量后优化、单变量、数据验证)
【题目】大促前压测,核心下单接口 TPS 卡在 3000 上不去,RT 忽高忽低。老板要一份系统性的优化方案。你从“目标定义→瓶颈定位→方案排序→实施验证→上线回归”按什么标准步骤推进,才能保证每步优化可量化、不回退?
【参考答案】性能优化方法论:定位瓶颈 → 分层优化 → 量化验证:
- 容量背景(步进):假设目标大促峰值 2 万 TPS,压测只到 3000 且 RT 抖动(与题干同一口径)。先算是否“线程被 RT 吃掉”:并发线程 200 时,理论 TPS≈200/RT;若 RT 从 20ms 抖到 200ms,TPS 从 1 万掉到 1000。抖动往往比均值更致命。
- 定位顺序:看监控分层——入口 QPS、应用 RT/GC/线程池、缓存命中、DB 慢 SQL/锁、网络与连接池等待;用压测阶梯找拐点,用链路追踪定位最慢一跳;区分计算密集 vs IO 密集 vs 锁竞争。
- 优化抓手(题干要「方案排序」,先给排序标准再列手段,否则只是罗列):排序标准=投入产出比(预期提升 ÷ 改动成本+风险),先用监控确认限制吞吐的那一层,低垂果实(索引/缓存命中率/批量/超时与池参数)先做,架构级(异步/分片/多活)后置。手段清单:SQL/索引与缓存命中率;串行改并行/批量;同步改异步;锁粒度与无锁结构;序列化与日志;JVM 参数与 GC;热点与大对象;连接池配置。
- 失败与降级:优化期保留回滚;灰度对照;限流保护线上;若短期不能优化完,先靠扩容+限流+降级保大促。
- 验证:每项优化有 before/after 压测数据与分层指标;建立容量模型(QPS-机器-RT)供下次预算。本质:用证据链替代经验主义,先解决限制吞吐的那一层。
【原理溯源】
为什么必须“先测量再优化”? 不测量就优化是瞎猜。TPS 卡在 3000 可能是 CPU 满、锁等待、下游慢、连接池耗尽——原因不同,手段完全不同。盲目加缓存可能无效,盲目加线程可能更糟(切换开销)。压测+监控定位到瓶颈资源,才能对症下药。这是性能优化的第一原则,也是最容易被跳过的一步。
为什么 RT“忽高忽低”比“稳定偏高”更难查? 稳定偏高通常是固定瓶颈(慢 SQL、下游 RT),找到就能修。RT 抖动往往来自间歇性争用:锁竞争(无冲突时快、有冲突时慢)、GC 停顿(周期性)、连接池排队(请求量波动时等待时间变化)、下游偶发慢。抖动说明瓶颈不是“一直存在”,而是“有时触发”,需要看 P99/P999 分位数和时间相关性,不能只看平均。
为什么方案要按“投入产出比”排序? 优化资源有限(人力、时间),必须先做低成本高收益的:加索引可能 1 小时搞定提 30%;分库分表可能 2 个月提 3 倍。大促前时间窗口有限,先摘低垂果实,再攻架构级改造。排序还能降低风险——小改动容易验证和回滚。
为什么“一次只改一个变量”? 同时改索引和线程池参数,压测结果变好了,你不知道是哪个起作用;结果变差了,不知道该回滚哪个。单变量控制是科学实验的基本方法,让每步优化的效果可归因。实践中可以用 A/B 压测组对比,但至少保证同一时间只有一个变量在变。
为什么要“沉淀基线”? 没有基线,下次压测你不知道“3000 TPS 是好是坏”。基线记录了:当前硬件配置下的 QPS/RT/错误率/资源利用率。每次大促前压测对比基线,性能回退可以立即发现(如某次发布引入慢 SQL,TPS 从 3000 降到 2500)。基线是性能治理的锚点。
【选型判断树】
性能优化五步法:
1. 定目标 → 指标(QPS/RT/P99/错误率)+ 目标值
2. 找瓶颈 → 压测 + 监控 + 工具(谁先饱和)
├─ CPU 满 → 计算密集 → 优化算法/序列化/扩容
├─ CPU 空但 TPS 低 → 等待瓶颈 → 锁/下游/IO/连接池
├─ DB 饱和 → SQL/索引/缓存/读写分离/分库分表
└─ GC 停顿 → 堆调优/对象分配/垃圾收集器
3. 排方案 → 按投入产出比(低成本高收益优先)
4. 逐步实施 → 一次一个变量,每步重压对比
5. 回归沉淀 → 全链路回归 + 基线记录 + 告警
口诀:先测量再优化,先低垂再架构,单变量可归因,有基线知回退。【口述骨架】(5 分钟;框架 A,骨架共性见文首第二节,本题只写特定内容)
| 时间 | 任务 | 内容 |
|---|---|---|
| 0:00–0:30 | 定性 | “性能优化方法论题。核心原则:先测量后优化、单变量、数据验证” |
| 0:30–1:00 | 定界 | TPS 卡 3000、RT 抖动;先搞清目标是多少(大促峰值 × 冗余系数) |
| 1:00–3:00 | 五步展开 | 定目标→找瓶颈(工具+方法)→排方案(投入产出比)→逐步实施→回归沉淀。每步说清“为什么” |
| 3:00–4:00 | 关键细节 | RT 抖动怎么查(分位数+时间相关性);单变量控制的原因;基线怎么建 |
| 4:00–5:00 | 收尾 | “性能优化不是玄学,是可测量、可归因、可验证的工程过程” |
【关键数字】
| 参数 | 经验值 | 说明 |
|---|---|---|
| 目标冗余 | 峰值的 1.5–2 倍 | 应对模型偏差 |
| 拐点定义 | RT 开始陡升的 QPS | 生产运行在拐点 70% 以下 |
| 优化对比基准 | 同环境、同数据、同模型 | 否则不可比 |
| P99 vs 平均 | 必须同时看 | 平均会掩盖尾部 |
| 每步验证 | 改一项→重压→记录 | 单变量控制 |
| 基线更新 | 每次大促前/重大发布后 | 持续维护 |
| 瓶颈转移 | 解决一个后要重新定位 | 迭代过程 |
| 应用并发估算 | QPS × RT | 决定线程池与实例数 |
| 容量冗余 | 峰值的 1.5–2 倍 | 大促/突发留余量 |
| 限流阈值 | 压测容量的 70%–80% | 避免排队雪崩 |
【追问链】(三层)
L1|“你优化了 SQL、加了缓存,TPS 还是上不去,怎么办?” → 回到测量:现在是哪个资源先饱和?瓶颈会转移——解决了 DB,可能应用 CPU 或线程池变成新瓶颈。每优化一步重新压测,看瓶颈是否转移。这是迭代过程,不是一步到位。关键是不要在旧瓶颈上继续投入。
L2|“压测 TPS 上不去但 CPU 很低,说明什么?” → 瓶颈在等待不在计算。可能原因:锁竞争(jstack 看 BLOCKED)、下游服务慢(调用链看哪一跳)、IO 等待、连接池耗尽、GC 等待。这时加机器或加线程可能无效(如果等待的是共享资源),要消除等待源。CPU 满是计算瓶颈,CPU 空是等待瓶颈——优化方向截然相反。
L3|“老板问'你怎么证明这次优化真的有效',怎么答?” → 同条件压测对比:① 优化前后的 QPS/RT 曲线(同一压测模型、同一环境);② 关键指标对比表(TPS、P99、错误率、CPU/内存);③ 如果可能,A/B 组对照。不能只说“感觉快了”,要有数据。同时说明:优化目标是否达成、瓶颈是否真正消除、有没有引入新问题。
【评分标准】
| 档位 | 答案特征 |
|---|---|
| 60 分 | 能说出“看监控、加缓存、加索引” |
| 80 分 | 有五步框架(目标→定位→方案→验证→回归);知道先测量再优化;会用工具定位瓶颈 |
| 95 分 | 能解释 RT 抖动的排查方法(分位数+时间相关性);按投入产出比排序方案;坚持单变量控制;强调基线沉淀;能回答“怎么证明有效” |
【关联题】
- 同簇: 第 14 题(吞吐优化具体手段)、第 9 题(接口变慢排查)、第 10 题(全链路压测)
- 工具延伸: 第 51 题(慢 SQL)、第 52 题(索引失效)、第 169 题(线程池打满)
- 架构级: 第 12 题(异步化)、第 17 题(多级缓存)、第 55 题(分库分表)
- 对比参照: 第 14 题是“具体怎么优化”,本题是“优化过程怎么组织”
【自测】
- 性能优化的第一步是什么?为什么? 参考答案: 测量定位瓶颈。不测量就优化是瞎猜;不同瓶颈需要完全不同的手段。
- RT“忽高忽低”和“稳定偏高”,排查思路有什么不同? 参考答案: 稳定偏高通常是固定瓶颈(慢SQL/下游);抖动来自间歇性争用(锁/GC/连接池排队),要查分位数和时间相关性。
- 为什么要“一次只改一个变量”? 参考答案: 让每步优化的效果可归因;同时改多项,结果变好/变差都不知道是哪项起作用。
22. 大促高峰资源紧张,要让用户“能用、不崩、不丢单”(降级兜底设计)
【考察内容】容量保障与体验设计的平衡
【题目】大促高峰期,系统资源已经逼近极限,继续硬扛所有功能必然全崩。你怎么通过降级、限流、兜底等设计,让用户在最坏情况下依然“能完成下单、页面不崩、订单不丢”?哪些功能可以牺牲、哪些必须保?
【参考答案】降级兜底设计:分级、可开关、保核心、可恢复:
- 容量估算(步进):大促峰值假设 5 万 QPS,降级后目标:核心链路(下单/支付/登录)容量优先保障,非核心(推荐/评论/积分明细/营销位)关闭后预计可释放 30%–50% 线程与连接——这句话只在「核心链路自身压测容量 ≥ 题干峰值」时够用:若核心链路压测只有 2 万 QPS 而峰值 5 万,把非核心全关也补不上缺口,必须同时扩容或把入口限流压到 2 万;所以先给核心链路的压测容量这个数,再说降级能保多少,否则「释放 30%–50%」是被追问打回的空口数字。限流阈值按核心链路压测容量 80% 设定;资源紧张时非核心 QPS 目标直接打到 0(兜底静态/默认值)。
- 降级层次:① 功能降级:关推荐、换静态页、简化查询字段;② 读降级:缓存陈旧数据/默认值;③ 写降级:可延迟操作入 MQ/异步任务;④ 依赖降级:第三方短信/风控超时走简化策略;⑤ 终极:整体排队/只读模式。
- 开关与预案:降级开关集中配置、秒级生效、按功能粒度;预案分级(L1 关非核心 → L2 限制读写 → L3 只读排队);明确触发条件(CPU/RT/错误率)。
- 失败与恢复:降级配置错误 → 快速回滚;过度降级影响业务 → 监控核心转化率;恢复时逐步放量观察,防止瞬间回流量打挂。
- 口径:降级不是故障后的手忙脚乱,而是平时设计好的资源再分配方案;目标是用户“能用、不崩、关键操作不丢”。
【原理溯源】
为什么要“分级”而不是一刀切全降级? 资源有限时,如果所有功能一起降,核心链路(下单、支付)也被牺牲,业务直接损失。正确做法是识别核心与非核心:推荐、积分、通知可以降(用户几乎无感),下单、支付必须保(直接关系交易)。降级的本质是资源再分配——把非核心占用的线程、连接、CPU 腾出来给核心。
为什么排队比直接拒绝好? 直接 502 用户体验极差,且不知道发生了什么。排队给用户明确反馈:“前方拥挤,预计等待 X 秒”,用户可以等待或离开。系统侧:排队把峰值流量缓冲下来,按处理能力匀速放行,避免瞬间打垮。关键是排队必须有超时——不能让用户“排死队”。
为什么“能完成下单”比“页面完美”更重要? 大促的核心目标是成交。页面装饰性元素(推荐位、广告、积分弹窗)可以降级为静态内容或直接隐藏;但下单、支付、订单查询必须保。用户来大促是为了买东西,不是看推荐。降级的优先级应该跟着业务价值走,不是跟着技术难度走。
为什么“不丢单”需要兜底+补偿? 资源紧张时,写操作可能失败(超时、连接池耗尽)。如果直接返回失败,用户可能放弃。正确做法:① 写操作先落 MQ(快速返回“已受理”),异步处理;② 失败进入重试队列;③ 定时对账任务扫描“受理了但未完成”的记录,补处理。用户看到的是“成功”,系统在后台保证最终成功——这就是最终一致在降级场景的应用。
为什么降级开关必须“预置好”? 大促高峰时你不可能现场写降级代码。所有降级开关(配置中心控制)必须在大促前预置好,支持秒级打开/关闭。同样,降级策略要演练——不演练的降级开关可能本身有 bug。降级是应急预案,不是临时发挥。
【选型判断树】
降级怎么设计?按链路分级:
核心链路(必须保)
├─ 登录、浏览、下单、支付、订单查询
├─ 资源优先保障,限流阈值设高
└─ 降级手段:排队 + 简化流程(跳过风控异步校验)
非核心链路(可以降)
├─ 推荐、积分、通知、营销弹窗、个性化
├─ 资源紧张时主动降级
└─ 降级手段:返回兜底数据/关闭功能/延迟处理
降级层次:
├─ 接口级 → 返回默认值/缓存快照
├─ 页面级 → 静态化页面(去掉动态元素)
├─ 依赖级 → 弱依赖失败不阻断主流程
└─ 功能级 → 直接关闭非核心功能
排队机制:
├─ 超出容量 → 排队(MQ 缓冲)
├─ 排队超时 → 明确失败 + 引导重试
└─ 排队状态 → 用户可查询(进度条)
兜底保障:
├─ 写操作失败 → 重试队列
├─ 关键数据 → 先落 MQ 再异步
└─ 对账任务 → 扫描"受理未完成"补处理
口诀:核心保、非核心降;排队别拒绝;先受理后处理;对账兜底不丢单。【口述骨架】(5 分钟;框架 B,骨架共性见文首第二节,本题只写特定内容)
| 时间 | 任务 | 内容 |
|---|---|---|
| 0:00–0:30 | 定性 | “降级兜底设计题。核心目标:能下单、不崩、不丢单” |
| 0:30–1:00 | 定界 | 资源逼近极限,必须取舍;先明确哪些是核心、哪些可牺牲 |
| 1:00–3:00 | 四块展开 | 分级保障(核心/非核心)→ 降级预案(接口/页面/依赖/功能)→ 排队机制(超时处理)→ 兜底补偿(MQ+对账) |
| 3:00–4:00 | 关键细节 | 降级开关必须预置;排队超时怎么设;“不丢单”的最终一致怎么保证 |
| 4:00–5:00 | 收尾 | “降级的本质是在容量不足时做资源再分配,优先保障核心交易链路,用异步+对账保证不丢单” |
【关键数字】
| 参数 | 经验值 | 说明 |
|---|---|---|
| 核心链路 | 登录、浏览、下单、支付 | 必须保 |
| 非核心链路 | 推荐、积分、通知、营销 | 可降级 |
| 排队超时 | 30s–2min | 超过给明确失败+引导 |
| 排队提示 | 预计等待时间 | 透明化比转圈好 |
| 降级开关生效 | 秒级(配置中心) | 必须预置 |
| 写操作重试 | 3–5 次后死信 | 防无限重试 |
| 对账频率 | 分钟级~T+1 | 资金类建议准实时 |
| 降级演练 | 大促前必须 | 验证开关有效 |
| 应用并发估算 | QPS × RT | 决定线程池与实例数 |
| 容量冗余 | 峰值的 1.5–2 倍 | 大促/突发留余量 |
| 限流阈值 | 压测容量的 70%–80% | 避免排队雪崩 |
【追问链】(三层)
L1|“降级和限流有什么区别?” → 限流是控制进入系统的流量(挡掉超额请求),降级是简化系统处理逻辑(返回兜底数据/关闭功能)。通常组合使用:事前预案阶段先落地降级开关、再调限流阈值;事中止血仍以限流为先(与第 2 题「限流→降级→扩容→削峰」同一口径,别答成两套顺序)。一句话:限流管“进多少”,降级管“做多少”。
L2|“用户排队了,但处理失败了怎么办?” → 分层处理:① 排队时先返回“已受理”(用户看到成功);② 异步处理失败自动重试(限次数+退避);③ 重试耗尽进死信队列,告警人工;④ 定时对账扫描“受理了但未完成”的记录,补处理或通知用户。关键是用户侧的“成功”与系统侧的“最终成功”要对齐。
L3|“降级开关打开了,但用户投诉功能少了,产品有意见怎么办?” → 用数据说话:① 降级前后对比:不降级会全崩(0% 可用),降级后核心功能 100% 可用、非核心暂时关闭;② 降级时长:通常只持续峰值的几分钟到几十分钟;③ 用户体验数据:降级期间的下单成功率 vs 如果全崩的成功率(0)。降级是权衡不是失职,关键是提前和产品对齐“哪些可以降、降多久”。
【评分标准】
| 档位 | 答案特征 |
|---|---|
| 60 分 | 能说出“限流、降级、加机器” |
| 80 分 | 能分级(核心/非核心);知道排队比拒绝好;了解降级层次(接口/页面/依赖/功能) |
| 95 分 | 能解释“资源再分配”的本质;设计排队超时处理;保证“不丢单”的最终一致;强调降级开关预置与演练;能处理产品投诉 |
【关联题】
- 同簇: 第 2 题(流量突增应急,降级是其中一步)、第 8 题(雪崩防护,降级是手段之一)、第 12 题(MQ 异步化,兜底的实现方式)
- 系统设计: 第 20 题(全链路一致性,兜底补偿的完整方案)、第 6 题(秒杀系统,降级预案)
- 对比参照: 第 2 题是“事中应急”,本题是“事前设计”
- 国企 / 金融版: 第 217 题(金融级服务降级)
【自测】
- 降级和限流分别是什么?关系是什么? 参考答案: 限流控制进入系统的流量,降级简化系统处理逻辑。事前做预案时先落地降级开关、再定限流阈值;事中止血仍以限流为先——先切断正反馈,再降级释放资源。
- 为什么排队比直接 502 好? 参考答案: 排队给用户明确反馈,系统侧缓冲峰值流量按能力放行;但排队必须有超时,不能让用户“排死队”。
- 怎么保证“不丢单”? 参考答案: 写操作先落 MQ(用户看到成功),异步处理失败重试,对账任务扫描“受理未完成”补处理——最终一致+补偿。