Skip to content

Day20 项目实战梳理 + 面试高频八股复盘:架构讲述、简历量化、线上故障排查 ​

📖 基础知识讲解【基础原理 + 高级深挖知识点】 ​

基础原理 ​

1. Day20 的定位:从「会写」到「会讲」 ​

前 19 天解决的是「知识点会不会」,Day20 解决的是「能不能在 40 分钟里把它说成一个可信的项目」。这两件事的难度完全不对等——大量候选人的八股答得比面试官还顺,但一问「这个在你项目里怎么用的」就瞬间失语,面试评价里写的是「基础不错,但工程落地经验存疑」。

text
面试官的真实评估模型(不是在考你背书)

        ┌─────────────────────────────────────────┐
        │ ① 这个人说的项目,是真的做过吗?         │  ← 靠「追问细节」验证
        │ ② 他做的那部分,技术含量够不够我这个岗?  │  ← 靠「为什么这么选」验证
        │ ③ 出了问题,他能不能自己兜住?           │  ← 靠「线上故障」验证
        │ ④ 招进来我要花多久带?                   │  ← 靠「表达结构」验证
        └─────────────────────────────────────────┘

★ 八股只影响 ②,而 ①③④ 全靠项目叙述能力
  这就是「八股背满分却挂在项目环节」的根本原因
候选人类型典型表现面试官的判断
只会八股概念准确,一问场景就说「这个我们用框架封装了」没真做过,或只是改 CRUD
只会业务能讲清流程,但说不出「为什么用 MQ 不用定时任务」技术深度不够,招进来做不了方案
会讲项目技术点 ↔ 业务痛点 ↔ 方案对比 ↔ 量化结果,四者能互相印证可以给 offer,甚至可以聊更高职级

面试加分表述:「我介绍项目习惯先给一句话定位,再给我负责的模块,最后给一个量化结果。比如『这是一个日均 80 万单的电商交易系统,我负责下单链路和库存一致性,把大促下单接口的 P99 从 1.2 秒压到 180 毫秒』。这样面试官能立刻知道该往哪个方向问,也省掉双方互相试探的 10 分钟。」

⚠️ 不要开场就背技术栈。「我们用了 Spring Cloud Alibaba、Nacos、Sentinel、RocketMQ、Seata、Redis、MySQL……」这句话的信息量在面试官眼里接近于零——这些是岗位 JD 的标配,不是你的差异点。技术栈要在回答「为什么这么设计」时自然带出来,而不是当成开场的罗列。


2. 项目业务架构全景:一次下单请求的完整路径 ​

这张图是整个 Day20 的骨架,必须能在白板上从左到右画出来并逐层讲清职责。

text
  用户端(App / 小程序 / H5)
        │  HTTPS
        ▼
 ┌──────────────────────────────────────────────────────────┐
 │  ① 接入层:Nginx / SLB                                    │
 │     · SSL 卸载、静态资源、四层负载、黑名单                 │
 └───────────────────────┬──────────────────────────────────┘
                         ▼
 ┌──────────────────────────────────────────────────────────┐
 │  ② 网关层:Spring Cloud Gateway                           │
 │     · 路由转发(lb://order-service)                      │
 │     · 统一鉴权(JWT 校验 + 用户上下文下传)                │
 │     · Sentinel 限流(接口维度 + 用户维度 + IP 维度)       │
 │     · 日志埋点、traceId 生成                              │
 └───────────────────────┬──────────────────────────────────┘
                         ▼
 ┌──────────────────────────────────────────────────────────┐
 │  ③ 业务层(Nacos 注册发现 + 配置中心)                     │
 │                                                           │
 │   order-service ──Feign──▶ storage-service               │
 │        │                        │                         │
 │        └──────Feign──────▶ account-service               │
 │        │                                                  │
 │        ├─ Redis:商品详情缓存、幂等 token、分布式锁        │
 │        ├─ MySQL:订单主表(分库分表 + 读写分离)           │
 │        ├─ RocketMQ:短信、物流、积分、数据同步(异步解耦)  │
 │        ├─ Seata AT:跨服务事务一致性                       │
 │        └─ Sentinel:远程调用熔断降级 + 线程隔离            │
 └───────────────────────┬──────────────────────────────────┘
                         ▼
 ┌──────────────────────────────────────────────────────────┐
 │  ④ 存储层                                                 │
 │     MySQL 主从 + 分库分表  ·  Redis Cluster  ·  对象存储   │
 └──────────────────────────────────────────────────────────┘

 ┌──────────────────────────────────────────────────────────┐
 │  ⑤ 可观测层(贯穿所有层)                                  │
 │     SkyWalking 链路追踪 · Prometheus+Grafana 指标          │
 │     ELK 日志 · Spring Boot Admin 实例健康                 │
 └──────────────────────────────────────────────────────────┘
层组件它必须存在的理由(面试要答这个,不是答功能)
接入层Nginx / SLB网关是 Java 进程,抗不住 SSL 握手和超大并发连接,需要前面有一层四层负载
网关层Gateway鉴权和限流必须在业务之前做,否则无效流量会一路打到数据库
注册配置Nacos实例会扩缩容、会宕机,地址不能写死;配置要能热改且不重启
服务调用OpenFeign把 HTTP 调用写成接口,统一超时、重试、拦截器(traceId 透传)
流量治理Sentinel下游变慢时必须快速失败,否则线程池被占满引发雪崩
异步解耦RocketMQ短信/物流/积分不该让用户等;大促时需要削峰
分布式事务Seata下单要同时改订单、库存、账户,三个库不能半边成功
缓存Redis商品详情读多写少;库存预扣要在内存里拦掉大部分无效流量
存储MySQL订单是强持久化数据,必须落盘且可对账

⚠️ 这张图最容易被追问的三个点:① 为什么库存要「Redis 预扣 + MQ 扣真实库存」两步,不能只扣一个地方?② Seata 和 RocketMQ 都能做一致性,为什么同时用两套?③ 网关限了流,为什么服务内部还要再限一次?这三个问题后面的「高级深挖」和面试题部分会逐个展开——答不出来说明这套架构不是你设计的。


3. 下单主链路的时序拆解:把「流程」说成「方案」 ​

讲项目时只说「用户下单,扣库存,发消息」是不够的,面试官要听的是每一步的失败会怎样、你怎么兜的。

text
下单接口的 9 步(同步部分必须在 200ms 内走完)

 ① 幂等校验      Redis SETNX(idempotent:{token})
                 ★ 失败 → 直接返回上次结果,不重复下单
 ② 参数与风控     用户状态、收货地址、黑名单
 ③ 商品与价格     Redis 读商品详情(本地缓存 → Redis → DB 三级)
                 ★ 价格必须服务端重算,绝不信前端传的金额
 ④ 库存预扣       Redis Lua 原子扣减(库存不足立即返回)
                 ★ 这一步拦掉大促 90%+ 的无效流量
 ⑤ 开启全局事务   @GlobalTransactional(Seata AT)
 ⑥ 落订单         INSERT 订单主表 + 明细(本地事务)
 ⑦ 扣真实库存     Feign → storage-service(分支事务)
 ⑧ 扣账户余额     Feign → account-service(分支事务)
 ⑨ 提交并发消息   事务提交后发 RocketMQ(短信/物流/积分/数仓)
                 ★ 必须「事务提交后」发,否则消息比数据先到

 ────────── 以下为异步,失败可重试,不阻塞用户 ──────────
 ⑩ 消费端:发短信、推物流、加积分、同步 ES/数仓
 ⑪ 兜底:Redis 预扣与 DB 库存定时对账;超时未支付订单自动关单回补库存
步骤失败怎么办对用户的表现
① 幂等返回首次结果重复点击只生成一单
④ 预扣直接返回「已售罄」快速失败,不打数据库
⑦⑧ 分支事务Seata 回滚全部分支 + 回补 Redis 预扣下单失败,库存不丢
⑨ 发消息本地消息表 / 事务消息兜底重投用户已下单成功,短信稍晚到
⑪ 对账发现差异告警 + 自动修正无感知

⚠️ 第 ⑦⑧ 步回滚时「回补 Redis 预扣」是最容易漏的一环。Seata 只管数据库,不管 Redis。如果全局事务回滚了但 Redis 里的预扣没加回去,库存就会「凭空少掉」——大促跑几个小时之后,Redis 显示售罄而数据库还有几百件货。正确做法是在全局事务的回滚钩子里显式回补,并且回补操作本身要幂等。


4. 库存服务:为什么是「Redis 预扣 + DB 扣真实库存」两级 ​

这是整套架构里最高频被追问的一个设计,必须能讲清「少任何一级会怎样」。

方案能不能抗住大促会不会超卖问题
只扣 MySQL❌ 不行不会(行锁兜底)热点行上所有请求串行排队,单行写入 TPS 只有几百,连接池瞬间打满
只扣 Redis✅ 能会Redis 不是账本,宕机/主从切换丢数据后库存就错了,且无法对账
Redis 预扣 + DB 扣减✅ 能不会两处数据要对账(可接受,且有定时任务兜底)
text
两级库存的职责分工(关键是「谁是真相,谁是闸门」)

   ┌──────────────── Redis(闸门:拦流量)────────────────┐
   │  stock:{skuId} = 1000                                │
   │  Lua 原子:if stock >= n then decrby else return -1  │
   │  · 作用:在内存里把「卖不到的人」直接挡掉             │
   │  · 不是账本:允许丢,丢了从 DB 重建                   │
   └──────────────────────┬───────────────────────────────┘
                          │ 预扣成功的少量请求才往下走
                          ▼
   ┌──────────────── MySQL(账本:定真相)────────────────┐
   │  UPDATE storage SET used = used + n                  │
   │  WHERE sku_id = ? AND total - used >= n              │
   │  · 作用:最终一致的真实库存 + 行锁做最后兜底          │
   │  · 影响行数 = 0 → 说明已无库存,回滚全局事务          │
   └──────────────────────────────────────────────────────┘

   ┌──────────────── 定时对账(修差异)──────────────────┐
   │  每 5 分钟:比对 Redis 剩余 与 DB (total-used)        │
   │  差异超阈值 → 告警 + 以 DB 为准重建 Redis            │
   └──────────────────────────────────────────────────────┘

面试加分表述:「我会把这两级定义成不同性质的东西:Redis 是闸门,职责是『把注定失败的请求在内存里拦掉』,它允许不准、允许丢;MySQL 是账本,职责是『给出唯一真相并用行锁做最后一道防超卖』。所以 UPDATE ... WHERE total - used >= n 这个条件不是冗余,它是最终防线——即便 Redis 因为主从切换把库存算多了,数据库这一层也绝不会卖超。」

⚠️ 预扣成功但订单最终没支付,库存必须能回来。我们的做法是下单时写入一条延迟消息(RocketMQ 延迟级别 / 定时任务扫表),15 分钟后检查订单状态:仍是「待支付」则关单,同时回补 Redis 预扣和 DB 的 used。少了这一步,大促结束后库存会「虚假售罄」——明明没卖掉却显示没货。


5. 数据库层:分库分表、读写分离与慢 SQL 治理 ​

手段解决什么触发阈值(什么时候才该上)代价
索引优化 / 慢 SQL 治理单表查询慢永远第一步做几乎无
读写分离读 QPS 高、主库 CPU 高读写比 > 5:1主从延迟导致读到旧数据
分表单表数据量大(> 1000 万 / 2000 万)单表行数或单表文件过大跨表查询、分页变复杂
分库单库连接数 / 磁盘 IO 到顶单库 QPS 接近实例上限跨库事务、跨库 JOIN 全部没了
text
订单表的分片设计(必须能回答「为什么按这个字段分」)

分片键:user_id(不是 order_id!)
  理由:订单最高频的查询是「我的订单列表」,按 user_id 分片
        能让这类查询**落到单个分片**,不需要全分片扫描聚合

order_id 的生成:雪花算法,并把 user_id 的分片位「编进 ID」
  → 这样用 order_id 查单也能直接算出分片,不用建映射表
  ★ 这是分库分表里最经典的「基因法(genes)」技巧

分 16 库 × 16 表 = 256 片
  路由:db_idx = hash(user_id) / 16 % 16 ,  tb_idx = hash(user_id) % 16
  ★ 分片数取 2 的幂,扩容时可以「成倍扩容 + 双写迁移」

遗留问题及对策:
  · 运营要按「商品、时间、状态」多维查询 → 同步到 ES 查询
  · 要做统计报表                        → 同步到数仓(MQ / Canal 订阅 binlog)
  · 跨库事务                            → 本就不用,订单落单只动自己库

⚠️ 分片键选错是不可回退的架构错误。如果按 order_id 分片,「查我的订单列表」就要扫描全部 256 个分片再聚合排序,分页越往后越慢,大促时直接把数据库打满。判断标准只有一条:选那个出现在 90% 查询 WHERE 条件里的字段。而「少数不带分片键的查询」应该用 ES 或映射表解决,不能反过来迁就它去改分片键。

面试加分表述:「我会主动讲我们当时没有上分库分表(如果确实没上)。订单表 800 万行、加好索引后 P99 在 30 毫秒,上分库分表只会带来跨库查询和运维复杂度。我的判断是:分库分表是最后手段,不是技术亮点。真正该先做的是索引、慢 SQL、冷热分离(历史订单归档)。」这比硬说「我们分了 1024 张表」要可信得多——后者一追问「为什么是 1024」就会露底。


6. 可观测体系:出问题时你靠什么定位 ​

text
三件套的分工(面试常考它们各自解决什么,别混为一谈)

 Metrics(指标)  Prometheus + Grafana
   · 回答「有没有问题、问题多大」
   · 时序聚合数据:QPS、RT、错误率、GC 次数、线程池活跃数
   · ★ 用于告警(数据量小、可长期存储)

 Tracing(链路)  SkyWalking / Zipkin
   · 回答「问题在哪一跳」
   · 单请求的完整调用树 + 每一跳耗时
   · ★ 用于定位(采样存储,通常 1%~10%)

 Logging(日志)  ELK / Loki
   · 回答「为什么会这样」
   · 带 traceId 的明细上下文、异常堆栈
   · ★ 用于归因(数据量最大,保留 7~30 天)

排查顺序永远是: Metrics 发现 → Tracing 定位 → Logging 归因
反过来(先翻日志)会浪费掉故障处理的黄金 5 分钟
工具在项目里具体看什么
Prometheus + Grafana下单接口 QPS/P99、Sentinel Block 数、MQ 堆积量、JVM 老年代使用率、连接池 Pending
SkyWalking下单链路拓扑图、慢调用 Top、某个 traceId 的完整调用树
ELK按 traceId 串起跨服务日志;按异常类型聚合统计
Spring Boot Admin实例健康、在线改日志级别(排障时极其有用)、查看运行时配置

面试加分表述:「我们的 traceId 是在网关生成并通过 Feign 拦截器 + MQ 消息头全链路透传的,日志格式里固定带 [%X{traceId}]。排障时拿到用户的一个失败请求,用 traceId 就能在 ELK 里一次捞出横跨 5 个服务的全部日志。这个能力的价值在于——把『跨团队互相甩锅』变成『看一眼就知道是谁的问题』。」

⚠️ 只接了监控不配告警等于没有监控。监控大盘是「出事后给你看的」,告警才是「出事前/刚出事时叫醒你的」。我们踩过的坑是:Grafana 大盘做得很漂亮,但 MQ 堆积没配告警,结果积压了 40 万条消息、延迟 2 小时才被用户投诉发现。


7. 项目亮点的四大块:怎么说才像真的 ​

「亮点」不是技术栈清单,而是「我遇到了 X 问题 → 我对比了 A/B/C → 我选了 B 因为 Y → 结果从 M 变成 N」。下面四块是电商后端最通用的四个亮点方向,每一块都给出可直接口述的版本。

① 高并发优化

做了什么解决的具体问题可量化的结果
商品详情三级缓存(Caffeine → Redis → DB)大促商品详情 QPS 2 万,直接打 DB 会挂缓存命中率 99.2%,DB 读 QPS 从 2 万降到 160
缓存穿透/击穿/雪崩三件套防护恶意刷不存在的 ID;热点 key 过期瞬间击穿布隆过滤器拦截无效查询 98%,无击穿故障
Sentinel 接口 + 热点参数限流单个商品被脚本刷恶意流量在网关层被挡掉,正常用户不受影响
RocketMQ 削峰瞬时 5 万 QPS 写请求数据库写入平滑在 3000 TPS,无连接池打满
慢 SQL 治理(索引 + 去掉 SELECT * + 分页改游标)订单列表深分页 LIMIT 100000, 20 耗时 4 秒改成「基于上次 ID 的游标分页」后稳定在 20ms

② 分布式一致性

场景方案为什么不用别的
下单扣库存扣余额(同步、要求强一致感知)Seata AT用 MQ 最终一致的话,用户会看到「下单成功但余额没扣」的中间态
下单后发短信/积分/物流(异步、允许延迟)RocketMQ 事务消息用 Seata 会把短信网关的 RT 算进全局事务,锁持有时间过长
所有 MQ 消费端幂等表 + 唯一索引MQ 只保证「至少一次」,重复投递是常态不是异常
Redis 与 DB 库存定时对账 + 以 DB 为准修正跨存储介质做不了事务,只能对账兜底

③ 高可用

text
每一层都必须能「挂掉一个节点不影响整体」

  Nacos       3 节点集群 + MySQL 存储        → 挂 1 台仍可读写
  Redis       Cluster 3 主 3 从              → 主挂自动故障转移
  RocketMQ    多 Master 多 Slave + 同步双写  → Broker 挂不丢消息
  MySQL       一主两从 + MHA/云托管高可用     → 主挂自动切换
  业务服务     每个服务 ≥ 2 实例 + 健康检查    → 故障实例自动摘除
  Seata TC    集群 + DB 存储模式              → TC 挂不丢全局事务状态

④ 性能优化(JVM + 异步化)

优化项具体动作结果
GC 调优从 ParNew+CMS 换成 G1,-XX:MaxGCPauseMillis=200,堆 8GFull GC 从每天 3~5 次降到 0,STW P99 从 1.2s 降到 90ms
GC 可观测开 GC 日志 + Prometheus 采集老年代使用率内存泄漏能在上线后 2 小时内发现
接口异步化下单接口里「发消息、写日志、算优惠券」改成 CompletableFuture 并行 + 线程池下单 P99 从 1.2s 降到 180ms
连接池调优HikariCP maximumPoolSize 按 核数×2 + 磁盘数 重算,Feign 换 OkHttp 连接池连接等待(Pending)归零

⚠️ 量化数字必须自洽。面试官会用「反算」来验真:你说 QPS 5 万、P99 180ms、部署 4 台 4C8G——他心算一下就知道不可能。给数字前自己先过一遍:QPS × RT ≈ 并发数,并发数要和线程数、实例数对得上。说不准的宁可说「大促峰值在万级 QPS 量级,具体数字记不清了」,也不要编一个经不起反算的数。


8. 简历里的项目怎么写:三段式 + 量化 ​

text
❌ 常见写法(面试官看完不知道你做了什么)
  项目描述:本项目是一个电商平台,包含用户、商品、订单、支付等模块。
  技术栈:SpringBoot + SpringCloud + MySQL + Redis + RocketMQ + Seata
  我的职责:负责订单模块的开发和维护。

✅ 三段式写法
  【一句话定位 + 规模】
   电商交易中台,支撑日均 80 万订单、大促峰值 1.2 万 QPS 的下单链路。

  【我负责什么 + 关键设计】
   独立负责下单主链路与库存一致性方案:
   · 设计「Redis Lua 预扣 + MQ 异步落库 + DB 行锁兜底」的三级库存模型,
     大促零超卖;
   · 用 Seata AT 保证订单/库存/账户三库一致,MQ 事务消息解耦短信与积分,
     消费端统一幂等框架;
   · 搭建下单链路可观测体系(SkyWalking + Prometheus 告警)。

  【量化结果】
   下单接口 P99 从 1.2s 降至 180ms,大促期间零超卖、零资损,
   数据库写入 TPS 平滑在 3000 以内。
写简历的三条硬规则原因
只写你能被追问 20 分钟的内容简历上的每一行都是「出题范围」,写了答不上来是减分项,不是中性
动词要具体:设计 / 重构 / 定位并修复 > 参与 / 负责 / 协助「参与」等于没做,「负责」太泛,面试官会问「具体哪部分」
每段必须有一个数字没有数字的描述无法与其他候选人区分

面试加分表述:「如果某个模块确实不是我做的,我会直接说『这部分是组里另一位同学做的,我了解方案但没写过代码,我可以讲我理解的设计思路』。这比硬装更安全——面试官追问三层就能分辨真假,而被识破一次之后,你前面说的所有真话也都不可信了。」


9. STAR 法则:把「最难的问题」讲成一个完整故事 ​

面试官问「讲讲你遇到的最难的问题」,考的不是难度,是你的问题拆解能力和决策过程。

text
S(Situation 场景)  10 秒:说清背景和约束,不展开技术
T(Task 任务)       10 秒:说清你要达成什么、难在哪
A(Action 行动)     90 秒:★ 主体。分点讲,每点带「为什么这么选」
R(Result 结果)     20 秒:量化结果 + 一句复盘(学到了什么)

完整示例:大促超卖与数据库压力

阶段口述内容
S「去年双十一大促,我们有个爆款 SKU 库存 5000 件,开抢瞬间涌入约 5 万 QPS 的下单请求。这条链路之前只扛过日常的几百 QPS。」
T「我要解决两个问题:一是绝对不能超卖(超卖要赔付,是资损);二是数据库不能被打挂(下单库挂了整站交易停摆)。而且留给我的时间只有两周。」
A「我分了五层来做:
① 库存预热到 Redis,用 Lua 脚本做原子预扣,库存为 0 直接返回售罄——这一步把 90% 以上的无效请求挡在内存里,核心理由是无效流量不该消耗数据库连接;
② 网关层 Sentinel 限流,按接口 + 用户 ID 双维度,防脚本刷单;
③ RocketMQ 削峰,预扣成功后发消息异步落单,把瞬时 5 万 QPS 摊平成数据库能接住的 3000 TPS;
④ 数据库行锁兜底,UPDATE ... WHERE total - used >= n 靠影响行数判断,即使前面全部失效也不会超卖;
⑤ 监控与预案,对 MQ 堆积量、Redis 与 DB 库存差值、下单失败率配了告警,并准备了『关闭非核心功能』的降级开关。」
R「大促当天平稳运行,零超卖、零资损,下单接口 P99 稳定在 200ms 以内,数据库 CPU 峰值 60%。复盘时我最大的收获是:高并发的本质是层层过滤,每一层只处理它该处理的那部分流量,而最后一定要留一道不依赖任何中间件的兜底。」

⚠️ A 部分最容易犯的错是「只说做了什么,不说为什么」。「我用了 Redis 预扣」是陈述,「我用 Redis 预扣是因为无效流量不该消耗数据库连接,而且这一层允许不精确——真正的准确性由数据库行锁保证」才是方案。面试官给分的是后者。


10. 线上故障排查总纲:先止损,再定位,最后复盘 ​

text
故障处理的铁律:止损优先于定位

  0~2 分钟   确认影响面:哪个接口、多少用户、是否资损
             ★ 同时拉群通知,不要闷头查
  2~10 分钟  止损动作(不要求找到根因)
             · 刚发布过?→ 立刻回滚(最高频有效的操作)
             · 某依赖挂了?→ 开降级开关,熔断该依赖
             · 流量异常?→ 加限流 / 封禁来源
             · 实例异常?→ 摘流量 + 扩容
  10 分钟后  根因定位(Metrics → Tracing → Logging)
  故障之后   复盘:根因、止损耗时、改进项、告警是否生效

★ 面试高分回答一定包含「先回滚/降级止损,再查根因」
  只说「我去看日志」会被判定为没有线上经验
现象最可能的几个方向(按概率排序)
接口变慢慢 SQL / 下游超时 / Full GC / 锁等待 / 连接池耗尽 / MQ 堆积导致下游数据滞后
接口报错刚发布的代码 bug / 下游不可用 / 限流熔断触发 / 配置改错
CPU 飙高死循环 / 频繁 GC / 正则回溯 / 加解密与序列化 / 流量真的涨了
内存溢出集合类泄漏 / 大对象查询(SELECT * 全表)/ 线程数失控 / 本地缓存无上限
MQ 堆积消费端异常重试 / 消费线程不足 / 下游依赖慢 / 生产端突增

面试加分表述:「我们组定的规则是『五分钟没定位就止损』。排障过程中最大的误区是技术人员的本能——想先搞明白为什么。但线上故障的首要目标是让用户可用,根因可以等到服务恢复后慢慢查。我经历过一次事故,同事坚持要先找到根因,结果故障时长从可能的 5 分钟拖到了 40 分钟。」


11. 排查 SOP 之一:接口超时 / 变慢 ​

text
① 看监控(30 秒内判断范围)
   · 是单个接口慢,还是整个服务慢?   → 整个服务慢 → 怀疑 GC / CPU / 连接池
   · 是单个实例慢,还是全部实例慢?   → 单实例 → 直接摘流量,先止损
   · 慢的起点和什么事件对齐?         → 发布 / 配置变更 / 流量上涨 / 大促活动

② 看链路(SkyWalking 找到慢的那一跳)
   · 耗时集中在某个 Feign 调用  → 是下游的问题,联系下游 + 开降级
   · 耗时集中在 SQL            → 慢 SQL,看执行计划
   · 耗时在自己方法体内         → 看是不是死循环 / 大循环内查库(N+1)
   · ★ 总耗时 远大于 各段之和   → 说明时间花在「排队」上:
                                 线程池队列、连接池等待、GC 停顿

③ 分方向验证
   慢 SQL   → EXPLAIN 看是否走索引;是否深分页;是否锁等待
   GC       → jstat -gcutil <pid> 1000,看 FGC 次数是否在涨
   连接池   → Hikari 的 pending 指标;Feign 的连接池 leased/pending
   锁等待   → MySQL: SELECT * FROM performance_schema.data_locks
              Java: jstack 看 BLOCKED 线程
bash
# 快速判断是不是 GC 问题(最常被忽略的原因)
jstat -gcutil <pid> 1000 10
#  S0   S1   E    O     M    CCS   YGC  YGCT   FGC  FGCT  GCT
#  ★ 关注 O(老年代使用率)是否长期 > 90%,FGC 次数是否持续增长
#  ★ 老年代涨不下去 = 内存泄漏 or 堆太小,两者的处理方式完全不同

# 慢 SQL 从哪来(MySQL 侧)
SELECT * FROM mysql.slow_log ORDER BY query_time DESC LIMIT 20;
-- 或看 performance_schema.events_statements_summary_by_digest(按模板聚合,更实用)
SQL 慢的典型原因怎么确认怎么改
没走索引EXPLAIN 的 type=ALL加索引;注意最左前缀和索引失效场景(函数、隐式转换、%xx)
深分页SQL 里有 LIMIT 100000, 20改游标分页:WHERE id > ? ORDER BY id LIMIT 20
回表太多EXPLAIN 无 Using index 且扫描行多建覆盖索引,把 SELECT 的列放进索引
锁等待SHOW ENGINE INNODB STATUS 有 lock wait缩短事务、调整加锁顺序、热点行改用队列串行化

⚠️ 隐式类型转换导致索引失效是线上最阴的一种慢 SQL。user_id 是 varchar 而代码传了数字(WHERE user_id = 123),MySQL 会把列做类型转换从而放弃索引走全表扫描。它的隐蔽之处在于:测试环境数据量小,怎么测都很快,上线后数据量一大就直接拖垮数据库。


12. 排查 SOP 之二:CPU 飙高、OOM、线程阻塞 ​

① CPU 飙到 100%:三条命令定位到代码行

bash
# 第 1 步:找到最耗 CPU 的进程
top
# 第 2 步:找到该进程内最耗 CPU 的线程(-H 显示线程,-p 指定进程)
top -Hp <pid>
# 第 3 步:线程 ID 转十六进制(jstack 里的 nid 是十六进制)
printf "%x\n" <tid>
# 第 4 步:抓线程栈,搜刚才那个十六进制 nid
jstack <pid> > /tmp/jstack.log
grep -A 30 "nid=0x<hex>" /tmp/jstack.log
# ★ 栈顶的业务方法就是元凶
栈顶特征结论
业务方法 + 循环相关死循环 / 大循环(最常见:while 条件写错、无界重试)
GC task thread / 大量 GC 线程频繁 GC,要转去查内存,不是查 CPU
java.util.regex.Pattern$...正则回溯爆炸(灾难性回溯),检查正则表达式
序列化 / 加解密相关大对象反复序列化,或加密算法选得过重
各线程栈都很正常流量真的涨了,该扩容而不是查代码

② OOM:先拿到现场,再分析

bash
# ★ 启动参数必须提前加好,等 OOM 发生时再加就晚了
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/data/dump/
-Xlog:gc*:file=/data/gc/gc.log:time,uptime:filecount=10,filesize=50M

# 运行中主动 dump(会 STW,生产慎用,建议先摘流量)
jmap -dump:live,format=b,file=/tmp/heap.hprof <pid>

# 看对象分布(比 dump 轻量,可快速定位是谁占内存)
jmap -histo:live <pid> | head -30
OOM 类型报错关键字典型根因
堆溢出Java heap space集合类泄漏、SELECT * 查出百万行、本地缓存无上限
GC 开销超限GC overhead limit exceeded堆几乎满了,GC 一直跑却回收不掉 → 几乎必然是泄漏
元空间溢出Metaspace动态生成类过多(CGLIB、反射、热部署、脚本引擎)
无法创建线程unable to create new native thread线程数失控(每次请求 new 一个线程池是经典写法错误)
直接内存Direct buffer memoryNetty / NIO 的 DirectByteBuffer 未释放

⚠️ unable to create new native thread 不是堆不够,调大 -Xmx 反而会让它更快发生。线程栈用的是堆外内存,堆越大留给线程栈的空间越少。这个错的根因几乎总是「线程池被当成局部变量反复创建」或「用了 Executors.newCachedThreadPool() 无上限创建线程」。

③ 线程阻塞 / 死锁

bash
jstack <pid> | grep -A 5 "BLOCKED"        # 看谁被阻塞
jstack <pid> | grep "Found one Java-level deadlock" -A 30   # JVM 自动检测死锁
# 线程状态统计(一眼看出线程池是否打满)
jstack <pid> | grep "java.lang.Thread.State" | sort | uniq -c

面试加分表述:「我习惯在排障时先跑一行 jstack <pid> | grep 'java.lang.Thread.State' | sort | uniq -c,它能在 1 秒内告诉我『线程都在干什么』。如果几百个线程都 WAITING 在同一个连接池上,那问题就不在业务代码,而在下游或连接池配置——这能省掉翻半小时日志的时间。」


13. 排查 SOP 之三:MQ 消息大量堆积 ​

text
堆积的本质只有一个不等式:   生产速度 > 消费速度

① 先判断是哪一侧的问题
   生产端 TPS 是否异常暴涨?  → 是 → 上游有重复发送 / 死循环 / 大促
   消费端 TPS 是否掉到很低?  → 是 → 消费侧问题(绝大多数情况)

② 消费侧按这个顺序查
   a. 消费端有没有在刷异常?   → 有 → 消息反复重试,吞吐被重试吃掉
      ★ 典型:一条「毒消息」永远失败,阻塞整个队列(顺序消费时最致命)
   b. 单条消息处理耗时多少?   → 变长 → 下游依赖(DB/HTTP)慢了
   c. 消费线程数 / 实例数够吗? → 不够 → 扩容
   d. 是不是顺序消息?         → 是 → 单分区串行,扩实例也没用

③ 止损动作(按代价从小到大)
   · 扩消费者实例(★ 注意:实例数 > 分区数 时多余实例拿不到消息,必须同时扩分区)
   · 加大单实例消费线程数 / 批量拉取条数
   · 毒消息直接打到死信队列(DLQ),先保证主流程通畅,事后单独处理
   · 极端情况:新建一个临时 Topic 分区更多,用「转发程序」把堆积消息搬过去并行消费
根因处理事后改进
毒消息无限重试设最大重试次数,超限进 DLQ消费逻辑区分「可重试异常」与「不可重试异常」,后者直接进 DLQ
下游变慢对下游调用加熔断,失败快速进重试队列消费端也要有超时与熔断,不能无脑等
消费线程不足调大 consumeThreadMax / concurrency把消费吞吐纳入容量评估
分区数不够扩分区(注意顺序消息的分区扩容会破坏既有顺序)设计时按峰值 TPS 预留分区
消费端有阻塞调用排查同步 HTTP / 锁 / 大事务消费逻辑要「短、幂等、无长事务」

⚠️ 扩容消费者实例不是万能解。如果实例数已经等于分区数,再加实例是纯浪费——多出来的实例拿不到任何分区,rebalance 之后处于空转。而如果是顺序消息,同一分区只能单线程串行消费,扩容完全无效,这时唯一的解法是优化单条消息的处理耗时,或者重新设计分区键让并行度变高。

面试加分表述:「我会补一句堆积的告警不能只看绝对值。我们配的是『堆积量 / 最近 1 分钟消费速率 = 预估消化时间』,超过 10 分钟才告警。因为大促时堆积 10 万条但 1 分钟能消化完,是正常的削峰;而平峰期堆积 2000 条却 20 分钟消化不掉,才是真故障。用绝对值告警会在大促时被告警风暴淹没,反而错过真问题。」


14. 前 19 天知识地图:每个八股点绑定一个项目场景 ​

这张表是 Day20 的核心交付物。八股背不住的根本原因是它没有挂载点——而一旦和项目里的某个具体场景绑定,它就从「要背的知识」变成「要讲的事」。

Day核心考点在本项目里的落地场景(口述时直接用这句)
Day1~3 Java 基础equals/hashCode、String 不可变、异常体系、泛型擦除订单 DTO 做 Set 去重时重写 equals/hashCode;日志拼接用 StringBuilder 避免循环内创建对象
Day4 集合HashMap 扩容与红黑树、ConcurrentHashMap 分段本地商品缓存用 ConcurrentHashMap + 定时刷新;订单状态机用 EnumMap
Day5~6 并发基础线程生命周期、volatile、CAS、AQS库存预扣的本地计数用 AtomicLong;配置热更新的标志位用 volatile
Day7 锁synchronized 升级、ReentrantLock、读写锁本地缓存重建用 ReentrantLock 防击穿(只让一个线程查库)
Day8 线程池七参数、拒绝策略、CompletableFuture下单接口里「发消息 + 算券 + 写流水」用自定义线程池并行编排,拒绝策略用 CallerRuns 保证不丢
Day9 JVM 内存运行时数据区、对象创建、引用类型本地缓存用 SoftReference?——不用,改用 Caffeine 定上限,软引用在大堆下回收不可控
Day10 GC分代、G1/CMS、调优参数换 G1 + MaxGCPauseMillis=200,Full GC 从每天 3~5 次降到 0
Day11 JVM 排障jstack / jmap / jstat / Arthas上面第 11~12 节的三套 SOP 就是这天内容的实战版
Day12 MySQL 基础索引、B+ 树、执行计划订单表 (user_id, create_time) 联合索引支撑「我的订单」分页
Day13 MySQL 进阶事务隔离、MVCC、锁、日志扣库存靠 UPDATE ... WHERE total-used>=n 的行锁 + 影响行数判断防超卖
Day14~15 SpringIOC/AOP、Bean 生命周期、事务传播幂等注解用 AOP 实现;@Transactional 的 REQUIRES_NEW 用于「主流程失败也要留流水」
Day16 Redis 基础数据结构、持久化、过期策略商品详情用 Hash;库存用 String + Lua;幂等 token 用 SET NX EX
Day17 Redis 进阶缓存三件套、分布式锁、集群三级缓存 + 布隆过滤器 + 逻辑过期;Redisson 看门狗做订单操作互斥
Day18 MQ可靠投递、幂等、顺序、事务消息、死信下单后的短信/积分走事务消息;消费端幂等表;毒消息进 DLQ
Day19 微服务Nacos / Sentinel / Feign / Gateway / Seata整套架构的治理层;Seata AT 保证三库一致

面试加分表述:「我准备面试的方法是反向挂载——不是按知识点复习,而是拿着自己项目的一张架构图,逐个组件问自己『这里为什么用它、它的原理是什么、它坏掉会怎样、有没有更好的替代』。这样准备出来的答案天然带项目背景,不会出现『概念说得很溜但举不出例子』的情况。」

⚠️ 不要试图把每个八股都强行塞进项目。有些点(比如泛型擦除、ZGC)在你的项目里确实没用到,硬编一个场景反而容易被追问穿。坦率地说「这个我是看资料学的,项目里没用上,不过我理解它适合的场景是……」是完全可以接受的回答。


15. 「为什么不用 X」清单:技术选型题的标准答法 ​

面试官问「为什么用 A」时,真正想听的是「你知道 B、C 的存在,并且知道它们为什么不合适」。只说 A 的优点等于没有做选型。

我们用了为什么不用它的替代品
NacosEureka 已停止维护(2.x 闭源);ZooKeeper 是 CP 的,选主期间整个注册中心不可用,这对注册中心是硬伤;Consul 生态在国内偏弱。而 Nacos 注册 + 配置二合一,少维护一套组件
SentinelHystrix 已停更;Sentinel 有独立控制台 + 规则持久化 + 热点参数限流 + 系统自适应,而且默认用信号量隔离,没有 Hystrix 线程池隔离的上下文丢失和线程开销问题
GatewayZuul 1.x是同步阻塞模型,每个请求占一个线程,网关这种高并发 IO 密集场景必须用异步非阻塞(WebFlux/Netty)
RocketMQRabbitMQ 单队列堆积能力弱、不支持大规模消息堆积;Kafka 更适合日志/数仓场景,事务消息和延迟消息的能力不如 RocketMQ,而我们强依赖这两个特性
Seata AT2PC/XA 性能太差(全程持有数据库锁);TCC 要写三个方法、改造成本高;本地消息表只能做最终一致,用户会看到中间态。AT 的无侵入 + 自动回滚在我们这个「低冲突、要求同步一致感知」的场景性价比最高
Redis Cluster主从 + Sentinel 只能解决高可用、不能解决容量和写入扩展;Codis 已基本停更
MySQL(而不是全上 NoSQL)订单是要对账、要事务、要复杂查询的核心资产数据,必须用关系型数据库;MongoDB/HBase 只在日志、轨迹这种场景用
text
选型题的通用四步答法(任何「为什么用 X」都能套)

 ① 先说业务约束     「我们的场景是写少读多 / 要求强一致 / 允许秒级延迟」
 ② 列候选方案       「当时对比了 A、B、C」
 ③ 说排除理由       「B 的问题是……,C 的问题是……」  ← ★ 得分点在这
 ④ 说代价与兜底     「选 A 的代价是……,我们用……兜住了」← ★ 加分点在这

★ 第 ④ 步几乎没人说,但它最能体现成熟度:
  任何技术选型都有代价,能说清代价说明你真的权衡过,而不是跟风

⚠️ 最忌讳回答「因为它性能好/功能强大」。这是营销文案不是技术判断。面试官会立刻追问「好多少」「在什么场景下好」,答不上来就暴露了选型不是你做的。宁可说「这个选型是我进组之前就定了的,我理解的理由是……,如果让我重新选我会考虑……」。


✨ 高级深挖知识点 ​

16. 下单幂等的三道防线:前端、网关、服务端 ​

幂等是面试里唯一必问的分布式话题,但大部分人只答「用 Redis setnx 加个 key」。真实系统是三道防线叠加的,少一道都会漏。

防线位置手段挡住的场景挡不住的场景
第一道前端按钮置灰 + loading 遮罩 + 防抖用户手快连点页面刷新重提交、抓包重放、网络重试
第二道网关/接入层同一 token 在短窗口内只放一次(Redis SET NX EX)重复提交、客户端自动重试、恶意重放token 过期后的重放、多实例时钟偏差边界
第三道服务端 + DB业务唯一键 + 唯一索引(如 uk_user_biz_no)所有漏网之鱼,最终兜底无(这是终极保证)
text
为什么第三道防线必须是数据库唯一索引?

 前两道都是「概率性」的:
   Redis 挂了  → 第二道失效
   key 过期了  → 第二道失效
   主从延迟     → 第二道可能失效

 只有唯一索引是「确定性」的:
   它由数据库的 B+ 树结构保证,不依赖任何外部中间件
   并发写入时,后插入的那条必然抛 DuplicateKeyException

 ★ 标准写法:catch 住 DuplicateKeyException 不要当失败,
   而是「查出已存在的那条记录,返回它的结果」→ 对调用方表现为成功
   这才是真正的幂等(同样的请求 → 同样的响应),
   而不是「第二次返回失败」(那叫去重,不叫幂等)

幂等 token 的完整时序(这是面试里能直接画出来的加分项):

text
  客户端                 网关/服务                 Redis
    │                       │                       │
    │ ① 进入下单页          │                       │
    │ ──── getToken ──────> │                       │
    │                       │ ── SET t:{uuid} 1 ──> │  EX 300
    │ <──── token=uuid ──── │                       │
    │                       │                       │
    │ ② 点击提交(带 token)  │                       │
    │ ──── submit ────────> │                       │
    │                       │ ── Lua: GET+DEL ────> │  原子
    │                       │ <── 1(抢到) / nil ─── │
    │                       │                       │
    │              抢到 → 执行下单                   │
    │              nil   → 直接返回「请勿重复提交」   │

⚠️ GET 和 DEL 必须用 Lua 合成一个原子操作。分两步写的话,两个并发请求可能都 GET 到 1,然后都通过校验——这是面试官最喜欢挖的那个坑。

面试加分表述:「幂等和去重不是一回事。去重是『第二次请求拒掉』,幂等是『第二次请求返回和第一次完全相同的结果』。我们的订单接口做的是幂等——重复提交会把第一次生成的订单号原样返回,这样客户端重试拿到的数据是一致的,不会出现『明明下单成功了但客户端收到失败』的状态撕裂。」


17. 防超卖的 5 种手段:为什么最终是「Redis Lua + DB 兜底」 ​

超卖的本质是**「查」和「扣」之间有时间窗**。所有方案都是在消灭这个窗口,区别只在代价。

方案实现并发能力问题适用场景
① 悲观锁SELECT ... FOR UPDATE很低(行锁串行)持锁跨网络 IO,RT 高;容易锁等待超时低频、强一致(如资金账户)
② 乐观锁UPDATE ... WHERE stock >= n AND version = v中高并发下失败率极高,大量重试放大压力冲突少的场景
③ 原子 UPDATEUPDATE SET stock = stock - n WHERE stock >= n中(靠行锁)热点行锁竞争,单行上限约 1000~3000 TPS中等并发、不想引 Redis
④ 分布式锁Redisson 锁住 skuId低~中锁粒度大、RT 翻倍、还要处理锁续期和误删流程复杂、必须串行
⑤ Redis Lua 预扣Lua 里判断 + 扣减,原子很高(单实例 10w+)Redis 和 DB 可能不一致,需要对账兜底秒杀/大促主方案
text
我们的最终形态 = ⑤ + ③ 叠加

  Redis Lua 预扣(闸门,挡住 99% 的无效流量)
        │  扣成功才放行
        ▼
  DB 原子 UPDATE(账本,WHERE stock >= n 作为最后一道保险)
        │
        ▼
  定时对账任务(兜住 Redis 宕机/数据漂移)

★ 关键认知:Redis 不是「替代」DB 校验,而是「前置」DB 校验。
  DB 那句 WHERE stock >= n 永远不能省 —— 它是超卖的最终防线。
  省掉它的话,一旦 Redis 被重置/预热数据写错,直接超卖。

⚠️ 库存回滚最容易漏的一步:下单失败回滚时,Redis 预扣也要回补。很多人只回滚了 DB 事务(Seata 自动做的),忘了 Redis 是手动扣的、不在事务里。结果就是「DB 库存还有,但 Redis 显示卖光了」,表现为商品明明有货却一直提示售罄。我们的做法是把 Redis 回补放在事务的 afterRollback 回调里,并且回补操作本身做幂等(用订单号做标记位,避免回补两次)。


18. 缓存与数据库一致性:四种方案的取舍与我们的选择 ​

这题的标准错误答案是「先更新数据库再更新缓存」。面试官要的是你知道每种方案在什么时刻会脏、脏多久、能不能接受。

方案流程不一致窗口问题
先更库 + 更缓存update DB → set cache并发写时可能永久脏两个线程更新顺序交错,后写的旧值覆盖新值
先删缓存 + 更库del cache → update DB删后到更新完成期间这期间读请求会把旧值重新加载进缓存 → 脏
先更库 + 删缓存(Cache Aside)update DB → del cache极短(删除前的瞬间)删除失败就长期脏;读写并发极端场景仍有理论窗口
延迟双删 / 订阅 binlog更库 + 删缓存 + 延迟再删一次 / Canal 监听 binlog 删缓存接近 0复杂度高、要维护额外组件
text
为什么「先更库 + 删缓存」是工业界默认答案?

  它不是「没有不一致」,而是「不一致的概率极低且窗口极短」:

  要出问题必须同时满足:
    ① 读请求刚好在「缓存失效」之后进来
    ② 并且读 DB 比写 DB 慢(实际上读通常更快)
    ③ 并且读的回写动作发生在写的删除动作之后

  三个条件同时满足的概率非常低 → 工程上可接受

★ 为什么是「删」而不是「更新」缓存?
  ① 删除是幂等的,更新不是(更新有顺序问题)
  ② 懒加载:删了之后如果没人读,就省掉一次无用的序列化和写入
  ③ 缓存值可能是多表聚合出来的,更新成本远高于删除

我们项目的实际分层策略(不是一招打天下,按数据特性分级):

数据类型一致性要求采用方案
商品详情、类目秒级可延迟Cache Aside + TTL 兜底(30 分钟)
价格、促销要求强一些Cache Aside + Canal 订阅 binlog 补偿删除
库存不走通用缓存Redis 是权威写入点,DB 是账本,靠对账收敛
用户余额/订单状态强一致不缓存,直读 DB(走从库会有延迟问题,关键读走主库)

⚠️ 库存是个特例,千万别套 Cache Aside。库存的 Redis 值不是「DB 的副本」,而是「扣减的权威入口」,流向是 Redis → DB(异步落库),和缓存的 DB → Redis 方向相反。很多候选人在这里把两套模型混着说,一追问就乱。

面试加分表述:「一致性方案不是全局统一选一个,而是按数据分级。我们把数据分成三类:允许秒级延迟的走 Cache Aside 加 TTL;要求准实时的额外挂一条 Canal 订阅 binlog 做补偿删除;强一致的干脆不缓存。缓存的本质是用一致性换性能,所以要先问清楚『这个数据脏 1 秒会发生什么业务后果』,再决定方案。」


19. 并发控制三选一:分布式锁、乐观锁、MQ 串行化 ​

面试官常问「这个场景你用什么控制并发」。关键是能说出判断依据,而不是直接报一个方案。

text
  决策路径

  这个操作是否必须立刻返回结果给用户?
    │
    ├── 否(可异步)────────> MQ 串行化
    │                        同一个 key 路由到同一分区/队列
    │                        天然串行,无锁、吞吐最高
    │
    └── 是(必须同步)
          │
          ├── 冲突概率低(如改个人资料)──> 乐观锁(version / CAS)
          │                                 失败重试代价小
          │
          └── 冲突概率高(如热门 SKU 扣减)
                │
                ├── 临界区短且能原子化 ──> Redis Lua(首选)
                │
                └── 临界区长、跨多个资源 ──> 分布式锁
                                            (Redisson + 看门狗续期)
维度分布式锁乐观锁MQ 串行化
吞吐低(串行 + 网络 RTT)高冲突时骤降最高
实时性同步返回同步返回异步,需要查询结果
失败表现等锁超时更新 0 行,需重试不失败,只是延迟
复杂度高(续期、误删、重入)低中(要处理顺序和堆积)
典型场景定时任务防重、复杂流程后台管理、低频写秒杀落单、积分发放

⚠️ 分布式锁三个必问的坑:① 锁误删——value 要存唯一标识(如 UUID + 线程 ID),释放时用 Lua 比对后再删,否则会删掉别人的锁;② 业务执行超过锁过期时间——Redisson 的看门狗每 10 秒续期到 30 秒,但加了 leaseTime 参数后看门狗就不生效了,这是最容易踩的点;③ 主从切换丢锁——主节点写入锁后未同步就宕机,新主上没有这把锁,两个客户端同时持锁。严格场景要用 RedLock,但 RedLock 本身也有争议,更稳的做法是业务层再加一道幂等兜底。


20. 削峰的三种实现:答案不止「加个 MQ」 ​

手段本质适用位置代价
MQ 异步化把「立即处理」改成「排队处理」下单落库、积分、通知用户看到的是「排队中」,要配状态查询接口
限流把超出容量的请求直接丢掉网关、接口入口部分用户直接失败,体验差但保住系统
答题/验证码/分批放行把峰值在时间轴上摊平活动前端需要产品配合
text
三者的关系:不是选一个,而是从外到内串成漏斗

  用户 100 万 QPS
      │
      ▼  ① 前端:按钮置灰 + 本地倒计时 + 静态资源全走 CDN
   10 万 QPS 到网关
      │
      ▼  ② 网关:Sentinel 限流(匀速排队 / 令牌桶)
    2 万 QPS 到服务
      │
      ▼  ③ 服务:Redis Lua 预扣(库存卖完直接快速失败)
   售罄后 99% 请求在这一层被挡掉,不触达 DB
      │
      ▼  ④ MQ:扣减成功的才投递,异步落库
  1000 TPS 平稳打到 DB(DB 的真实承载能力)

⚠️ 「卖完即止」是削峰里最便宜也最有效的一招。库存只有 1000 件,第 1001 个请求之后的所有流量都可以在 Redis 层直接返回「已售罄」,完全不走后面的链路。很多人设计秒杀时漏掉这一点,导致 10 万请求全都在抢那 1000 个库存位,白白消耗资源。更进一步可以在 Redis 里打一个「售罄标记位」,后续请求连 Lua 都不用执行,一次 GET 就返回。

面试加分表述:「削峰的目标不是『让所有请求都成功』,而是『让系统在峰值下不崩,并且让该成功的请求成功』。所以我设计的时候是从外往内一层层收窄的漏斗:前端拦掉无效点击,网关限流控制入口水位,Redis 层做售罄快速失败,MQ 做最后的流量整形。每一层都比下一层便宜,越早拦掉成本越低。」


21. 容量评估:「你们系统能承载多少 QPS」怎么算出来 ​

这题答「大概几千吧」就直接扣分。面试官要听到推导过程。

text
  自上而下估算(业务视角)

  ① 日活 DAU            = 100 万
  ② 人均下单访问次数     = 3 次/天  → 300 万次请求/天
  ③ 平均 QPS            = 300 万 / 86400 ≈ 35 QPS
  ④ 峰值系数(二八法则)  = 80% 流量集中在 20% 时间(约 4.8 小时)
     峰值 QPS = 300 万 × 0.8 / (4.8 × 3600) ≈ 139 QPS
  ⑤ 大促突发系数      = 平时的 10~50 倍 → 1400 ~ 7000 QPS

  ★ 所以容量规划的目标不是 139,而是 7000(按最坏情况留余量)
text
  自下而上验证(资源视角)

  单机能力  = 1000 / 单请求 RT(ms) × 有效线程数 × 利用率
  举例:RT = 50ms,Tomcat 线程 200,CPU 允许压到 60%
       单机 QPS ≈ (1000 / 50) × 200 × 0.6 = 2400
       实际压测往往低于理论值(有锁、有 GC、有下游瓶颈)→ 取 1500

  集群容量 = 单机压测值 × 节点数 × 0.7(留 30% 余量应对单点故障和抖动)
       8 台 × 1500 × 0.7 = 8400 QPS  → 能兜住 7000 的峰值 ✅

  ★ 为什么要留 30%?
    ① 容器漂移/发布滚动时会少几个节点
    ② 水位超过 70% 后 RT 开始非线性恶化(排队效应)
    ③ 留出扩容的反应时间(HPA 拉起新 Pod 需要 1~3 分钟)
常见资源的经验量级(答题时能报出来显得很专业)参考值
单台 MySQL(4C8G,简单主键查询)读 5000~8000 QPS,写 1000~2000 TPS
MySQL 单行热点更新1000~3000 TPS(行锁串行上限)
单实例 Redis(单线程,简单命令)8~10 万 QPS
单台 Nginx3~5 万 QPS(静态/反代)
RocketMQ 单 Broker10 万级 TPS(顺序写 + 零拷贝)
一次跨机房网络 RTT1~3 ms;同机房 0.1~0.5 ms

⚠️ 容量评估的落脚点必须是「瓶颈在哪」。算出来 8400 QPS 没用,要能说「这个链路的瓶颈是订单库的写入,上限 2000 TPS,所以我们做了分库分表把它拆成 16 份,并且用 MQ 把写入削平到 1500 TPS 以内」。只报数字不指出瓶颈,说明你没真做过容量规划。


22. 全链路压测:和「单接口压测」的本质区别 ​

维度单接口压测全链路压测
测什么单个接口的 RT / QPS 上限真实业务链路在峰值下的整体表现
环境测试环境生产环境(否则数据量、网络、缓存命中率都不真实)
能发现代码级性能问题容量短板、链路放大效应、中间件配置瓶颈
核心难点无流量染色 + 影子库表 + 数据隔离
text
  全链路压测的三个关键技术点

  ① 流量染色
     压测请求打上标记(如 Header: X-Stress=true)
     标记必须能**跨线程、跨 RPC、跨 MQ** 传递
     → 用 ThreadLocal + Feign 拦截器 + MQ 消息属性透传

  ② 影子库 / 影子表
     带压测标的请求,数据写到 order_shadow 而不是 order
     实现:ShardingSphere / 自定义 DataSource 路由,按标记切表
     Redis 则用 key 前缀隔离(stress:stock:1001)

  ③ 下游 Mock
     短信、支付、风控等外部依赖不能真的调
     压测标记命中时走 Mock 返回,但要**模拟真实 RT**(不能秒回,否则压不出来)

⚠️ 压测最容易被忽略的是「链路放大效应」。一次下单请求在用户看来是 1 次,但内部可能是:1 次网关 → 3 次 Feign → 8 次 Redis → 5 次 DB → 2 次 MQ。所以 1000 QPS 的下单会给 Redis 带来 8000 QPS 的压力。面试时能说出这个「扇出比」,说明你是真的做过容量换算,而不是背概念。

面试加分表述:「压测不是为了拿到一个漂亮的 QPS 数字,而是为了提前找到系统的短板和失效模式。我们每次大促前压测,关注三件事:第一,瓶颈点在哪、是不是和预期一致;第二,到达瓶颈后系统是『优雅降级』还是『雪崩』——这个比吞吐数字重要得多;第三,监控告警在真实压力下有没有按预期触发。」


23. 灰度发布与回滚:让「有问题」变成「影响很小」 ​

text
  我们的发布流水线

  ① 代码合并 → CI 构建镜像(打 tag:v1.2.3-{commitId})
  ② 预发环境(连生产 DB 的从库,真实数据只读验证)
  ③ 生产金丝雀:1 台(占集群 1/8 流量≈12%)
     └─ 观察 10 分钟:错误率、RT P99、GC 频率、业务指标(下单成功率)
  ④ 分批滚动:25% → 50% → 100%,每批间隔 5 分钟
  ⑤ 任一批次指标异常 → 自动暂停 + 告警 → 人工决定回滚还是继续

  ★ 回滚必须比发布更快:
    镜像回滚(切回上一个 tag)< 1 分钟
    配置回滚(Nacos 历史版本一键回退)< 10 秒
灰度维度怎么分流适用场景
按实例比例K8s 滚动更新 / 网关权重纯技术改动(性能优化、重构)
按用户 IDuserId % 100 < 5新功能验证,保证同一用户体验一致
按地域/渠道网关按 Header 路由区域性活动、渠道专属功能
按内部白名单配置中心维护 uid 列表新功能先给内部员工用

⚠️ 数据库变更不能跟着代码一起灰度。灰度期间新旧版本代码同时在线,所以 DDL 必须兼容双向:加字段要给默认值、不能加 NOT NULL 无默认值;删字段要分两次发布(先发代码停止使用该字段,确认稳定后再删字段);改字段类型要走「新增新字段 → 双写 → 迁移 → 切读 → 下线旧字段」五步。这是面试里能明显区分「发过生产」和「只在本地跑过」的问题。

面试加分表述:「我们对发布的要求不是『不出问题』,而是『出问题时影响面可控、恢复时间够短』。所以两个硬指标:灰度阶段最大影响用户比例不超过 5%,回滚动作必须在 1 分钟内完成。为了让回滚足够快,我们规定数据库变更和代码变更必须解耦——先发兼容双向的 DDL,再发代码,这样回滚代码时不用回滚数据。」


24. 限流降级预案与故障演练:大促前到底在准备什么 ​

text
  大促预案清单(真实项目里是一张表,面试时能背出结构就够了)

  ┌── 降级开关(全部挂在 Nacos,秒级生效)
  │    ① 关闭「猜你喜欢」推荐       → 省掉一次算法服务调用
  │    ② 关闭订单列表的实时物流查询  → 改为点击时再查
  │    ③ 评价/积分改为纯异步          → 失败只记日志不阻塞主流程
  │    ④ 关闭非核心埋点上报
  │
  ├── 限流阈值(压测结论 × 0.8)
  │    网关总入口 / 下单接口 / 查询接口 分别设不同阈值
  │
  ├── 扩容方案
  │    应用层:提前扩到平时 3 倍(K8s 手动扩,不依赖 HPA 的反应速度)
  │    DB:提前加从库;连接池上限同步调大
  │
  └── 值班与演练
       大促前一周做一次**故障演练**(混沌工程)
演练项注入方式验证什么
下游服务超时用 ChaosBlade 给某服务注入 3s 延迟熔断是否触发、降级返回是否正确
Redis 挂掉断开 Redis 连接是否打穿到 DB、有没有本地缓存兜底
MQ 不可用停掉 Broker生产端是否有本地落库补偿、是否阻塞主流程
单可用区故障摘掉一个机房的节点流量是否自动切走、容量是否够
CPU 打满注入 CPU 满载告警是否触发、HPA 是否扩容

⚠️ 「降级开关写了但从没打开过」等于没有。我们吃过这个亏:某个降级开关因为代码重构后逻辑走不到了,大促时打开完全没效果。所以后来加了硬规定——每个降级开关每季度必须在生产演练一次,验证它真的生效、并且打开后业务表现符合预期。这个细节在面试里一说,面试官基本能确认你是真的在生产上做过稳定性。

面试加分表述:「稳定性不是靠『写得好』保证的,是靠『提前演练过』保证的。预案的价值不在文档里,在于真的演练过、知道打开开关后业务长什么样、知道恢复要多久。没演练过的预案在故障时没人敢按。」


25. 告警分级与值班 SOP:怎么让告警「有用」 ​

告警最大的问题不是没有,而是太多以至于没人看(告警疲劳)。

级别触发条件示例通知方式响应时限
P0 致命下单成功率 < 95%、核心接口全量 5xx电话 + 短信 + 群 @全员5 分钟内响应
P1 严重接口错误率 > 1% 持续 3 分钟、P99 > 2s短信 + 群 @值班15 分钟
P2 一般单机 CPU > 80%、MQ 堆积预估消化 > 10 分钟群消息1 小时
P3 提示慢 SQL 新增、磁盘 > 70%日报汇总当天处理
text
  三个让告警变得「可用」的原则

  ① 告业务指标,而不只是技术指标
     ❌ 「订单服务 CPU 80%」   → 可能只是在跑定时任务,没人关心
     ✅ 「下单成功率跌到 96%」 → 一定有真实用户受损,必须看

  ② 告「持续性」而非「瞬时值」
     ❌ 单次 RT 超阈值就告        → 一次 GC 就能触发,天天响
     ✅ 连续 3 个采样周期超阈值    → 过滤抖动

  ③ 告警必须自带「下一步动作」
     ❌ 「MQ 堆积 50 万」
     ✅ 「MQ order_topic 堆积 50 万,预估消化 25 分钟,
         建议动作:① 查看消费者线程栈 ② 扩容消费者至 32 实例」
     → 值班人员不需要思考,照做就行,这才能缩短 MTTR

面试加分表述:「我们衡量稳定性用两个指标:MTTD(平均发现时间)和 MTTR(平均恢复时间)。MTTD 靠监控告警的覆盖度和准确度,MTTR 靠预案和演练。做告警治理的时候我做了两件事:把纯技术指标告警降级或合并,新增业务黄金指标告警;给每条 P0/P1 告警都挂上处置手册链接。治理之后告警量降了 70%,但真实故障的发现时间从平均 8 分钟降到 2 分钟内。」


26. 架构演进路径:讲「为什么变成现在这样」比讲「现在是什么」更有说服力 ​

面试官听了太多「我们是微服务架构」。真正能加分的是演进故事——因为什么痛点,所以做了什么改造,换来了什么。

text
  阶段一:单体(0 ~ 1 万日活)
    一个 war 包,一个库,所有功能在一起
    痛点:改一行代码要全量发布;一个慢 SQL 拖垮整站;团队并行开发冲突多

        │ 触发改造的信号:发布频率受阻 + 故障爆炸半径太大
        ▼
  阶段二:垂直拆分(1 万 ~ 10 万)
    按业务域拆成 用户/商品/订单/支付 四个服务,各自独立库
    引入 Nacos 做注册发现、Gateway 做统一入口
    痛点:跨服务调用链路变长、排查困难;分布式事务问题出现

        │ 触发信号:排障靠人肉翻日志 + 数据不一致投诉
        ▼
  阶段三:治理体系补齐(10 万 ~ 100 万)
    引入 链路追踪 + 统一日志 + Prometheus 监控
    引入 Sentinel 限流熔断、Seata 处理分布式事务
    痛点:单库写入到瓶颈(订单表 5000 万行,写入 TPS 顶到 2000)

        │ 触发信号:慢 SQL 暴增 + 大促写入超时
        ▼
  阶段四:存储与流量治理(当前)
    订单分库分表(16×16)、读写分离、多级缓存
    MQ 削峰异步化、热点库存 Redis Lua 预扣
    下一步规划:单元化部署 + 异地多活
每一次演进要能回答的三个问题示例(以引入 MQ 为例)
不改会死在哪大促下单 RT 从 200ms 涨到 3s,DB 连接池耗尽
改了之后换来什么主链路 RT 降到 180ms,DB 写入压力削平 60%
引入的新问题怎么兜最终一致(状态查询接口 + 轮询)、堆积告警、死信队列 + 人工补偿台

⚠️ 不要把演进讲成「我们一开始就设计成这样」。那反而暴露过度设计。架构的正确叙事是「被业务逼着一步步长出来的」。如果项目规模本来就不大,诚实地说「我们日活 5 万,所以没上分库分表,只做了读写分离,预估到 3000 万行再考虑拆」——这种有量级判断的回答,远好过硬吹一个用不上的复杂架构。

面试加分表述:「架构没有最优解,只有在特定约束下的合理解。我讲这个项目会按演进讲——每一步都是被具体的痛点逼出来的,并且每引入一个组件都带着新的代价,我们怎么把代价兜住。这样面试官能看到的不只是『我用过什么』,而是『我判断过什么』。」


27. 主动讲「项目的不足」:这是成熟度最强的信号 ​

几乎所有候选人都在美化项目,所以主动说不足的人会被记住。但要讲得有技巧:说的不足必须是「有判断的取舍」而不是「能力不够没做」。

可以大胆讲的不足(体现判断力)怎么说
没做异地多活「目前是同城双机房,异地多活的成本(数据同步、单元化改造)相对我们的业务体量还不划算,但我们算过一次机房级故障的影响时长是 30 分钟,这个 RTO 业务是接受的」
分布式事务用了 AT 而非 TCC「AT 有全局锁,高冲突场景性能不如 TCC。我们当前冲突率低所以可接受,如果后面热点冲突上升,优先改造的是库存这一个分支」
对账是 T+1 定时任务「不一致的发现延迟最长 5 分钟,业务上能接受。更优解是订阅 binlog 实时对账,这是下一步规划」
缓存一致性依赖 TTL 兜底「商品详情最长脏 30 分钟,大促期间我们会把 TTL 调到 1 分钟换一致性」
text
  讲不足的标准句式(三段)

  ① 承认现状     「这块我们目前的做法是 X」
  ② 说清代价与为什么可接受
                 「X 的问题是 Y,但在我们的场景下 Y 的影响是 Z,业务可接受」
  ③ 给出改进方向 「如果要彻底解决,应该做 W,这是我们排在下一阶段的事」

★ 千万别说成:「这块我们没做,确实是个问题」 ← 停在①就变成扣分项

⚠️ 不要主动暴露「致命级」的不足,比如「我们没有监控」「我们没做过压测」「出问题全靠用户反馈」。这不是取舍,这是基本功缺失。要讲的不足应该是「在合理架构之上的下一级优化」。

面试加分表述:「这个项目我觉得最大的不足是对账还是定时任务驱动的,发现不一致最长要 5 分钟。虽然业务能接受,但更好的做法是订阅 binlog 做实时双向校验。我做过方案评估,主要卡在要额外维护一套 Canal 集群和下游的消费逻辑,收益和成本在当前体量下还不够正,所以放在下一阶段。」


28. 被问到「没做过的模块」怎么答:三段式止损法 ​

项目是团队做的,你不可能每个模块都参与。面试官问到你没做的部分时,最差的两种回答是「这个不是我做的」(显得推责、没好奇心)和硬编造(一追问就崩)。

text
  三段式止损法

  ① 诚实划界(1 句,别铺垫太多)
     「支付对接这块主要是另一位同学负责的,我参与的是订单侧的回调处理」

  ② 展示你理解的部分(2~3 句,证明你关心整体)
     「不过我了解整体设计:支付成功后会回调我们的 notify 接口,
       我们做了签名校验 + 幂等(按 payNo 唯一索引),
       并且有定时任务主动查询支付单状态做补偿,防止回调丢失」

  ③ 给出你的判断(1~2 句,这是加分点)
     「如果让我做,我会特别注意回调的幂等和乱序——
       支付平台可能重复回调、也可能成功和失败的回调乱序到达,
       所以状态机必须只允许单向流转」

★ 这个结构的效果:
  面试官得到的印象不是「他没做过」,
  而是「他知道边界在哪,而且对没做过的部分也有技术判断」
场景❌ 错误回答✅ 正确回答
「分库分表是你做的吗」「不是我做的」(结束)「方案是架构组定的,我负责订单服务的改造落地和数据迁移校验,所以分片键选型的理由和双写迁移的流程我很清楚」
「JVM 参数谁调的」「运维调的」「参数是和运维一起定的,我负责提供 GC 日志分析结论。我们从 CMS 换到 G1,把 MaxGCPauseMillis 设成 200ms,Full GC 从每天 3 次降到 0」
「你们压测谁做的」「测试做的」「测试同学执行,我负责定压测场景和分析结果。我们压出来瓶颈在订单库写入,所以才做了 MQ 削峰」

⚠️ 绝对不要编造数字。面试官会连环追问「这个 200ms 是怎么定的」「为什么是 16 库不是 32 库」。编的数字扛不住两轮追问,而一旦被发现编造,整场面试的可信度全部清零——包括你真实做过的部分。


29. 面试最后的反问环节:这不是客套,是最后一个加分位 ​

面试官问「你有什么想问我的吗」时,答「没有了」会浪费一个明显的加分机会。好的反问能体现你在认真评估这份工作,而不是随便投投。

反问方向示例问法传递的信号
技术挑战「团队当前最大的技术挑战是什么?是在稳定性上还是在业务迭代速度上?」关心真实问题,想解决问题
技术演进「这个系统接下来半年有什么架构演进规划吗?」有长期视角
协作方式「团队的发布节奏是怎样的?有专门的稳定性值班机制吗?」有工程规范意识
期待「如果我加入,前三个月你最希望我把哪件事做成?」结果导向、想快速产出
技术文化「团队怎么处理技术债?有专门的时间窗口吗?」关心可持续性
text
  反问的三个禁区

  ❌ 薪资、福利、加班(留给 HR 面,技术面问会显得重点错位)
  ❌ 「我表现怎么样/能过吗」(把面试官架在难回答的位置)
  ❌ 网上能查到的基础信息(显得没做功课)

  ★ 最好的反问是「基于本场面试聊过的内容」延伸的:
    「刚才您提到你们在做单元化改造,这块现在卡在
      数据同步还是流量路由上?」
    → 证明你全程在认真听,而且听懂了

面试加分表述:「我一般会问两个问题:一是团队现在最大的技术痛点是什么——这决定我进来之后能不能真的帮上忙;二是希望我前三个月做成哪件事——这能让我判断自己的能力和岗位需求是否匹配。面试是双向选择,问清楚对双方都好。」


❓ 高频面试题【基础简答 + 连环追问链 + 精简口述答案 + 项目关联话术】 ​

题 1:设计一个分布式系统,需要考虑哪些问题? ​

精简口述答案(90 秒版)

我会按四个层次来考虑。

第一层是通信:服务之间怎么找到对方(注册发现)、怎么调用(RPC/HTTP)、调用失败怎么办(超时、重试、幂等)。这里最关键的认知是网络是不可靠的,任何跨进程调用都可能超时,而超时的含义是「结果未知」——不是失败。所以重试必须配幂等。

第二层是数据:数据怎么分片(分库分表的分片键选型)、怎么保证一致性(强一致 / 最终一致)、怎么做高可用(主从、多副本)。这一层的核心取舍就是 CAP——分区容忍 P 在分布式系统里是必选项,所以真实选择是在 C 和 A 之间:注册中心选 AP(Nacos)、配置中心可以 CP、资金账户要 C。

第三层是稳定性:限流、熔断、降级、隔离,防止一个服务的故障通过调用链扩散成雪崩。还要有容量规划和预案。

第四层是可观测:Metrics 发现问题、Tracing 定位问题、Logging 归因问题。分布式系统最大的难点其实不是设计,而是出问题后能不能快速知道问题在哪——所以可观测不是附属品,是设计的一部分。

连环追问链

#追问答题要点
1CAP 里的 P 为什么是必选的只要是跨网络的多节点系统,网络分区就一定会发生,不能选择「不发生」,只能选择发生时牺牲 C 还是 A
2那 CAP 和 BASE 什么关系BASE 是 AP 路线的具体落地:基本可用(Basically Available)、软状态(Soft State)、最终一致(Eventually Consistent);它不是推翻 CAP,而是在放弃强一致后给出可工程化的一致性模型
3超时了到底算成功还是失败算「未知」。可能请求没到、可能到了执行成功但响应丢了。所以必须靠「幂等 + 主动查询对端状态」来收敛,不能直接当失败回滚
4重试会带来什么新问题① 重复执行(要幂等)② 重试风暴放大下游压力(要配退避 + 熔断)③ 重试叠加导致整体 RT 远超预期(要算清超时预算)
5什么是超时预算链路总超时要大于各段之和,并且上游超时必须大于下游超时 + 重试次数 × 下游超时,否则上游先超时,下游还在跑,白白消耗资源
6分布式系统里最难的是什么个人认为是状态的一致性和故障的可观测。无状态服务扩容很容易,难的是有状态的数据层;而故障时最怕的是「不知道哪里坏了」

✅ 项目关联话术

「我们这套电商系统这四层都踩过。通信层最初没配超时,一次下游服务抖动直接把上游线程池打满,后来统一规定所有 Feign 调用必须显式配超时,并且按超时预算倒推每一跳的配额。数据层做了订单分库分表,分片键选 user_id,因为 C 端 95% 的查询都带用户维度。稳定性靠 Sentinel 做限流熔断 + Nacos 托管降级开关。可观测这块我主导补齐的,之前排查一次故障要人肉翻好几台机器的日志,接了链路追踪 + 统一日志之后,平均定位时间从 30 分钟降到 5 分钟以内。」


题 2:如何设计一个秒杀系统? ​

精简口述答案(90 秒版)

秒杀的核心矛盾是瞬时流量极高、但有效请求极少(10 万人抢 1000 件,99% 的请求注定失败)。所以整体思路就一句话:把无效流量尽早拦掉,让 DB 只承接真正需要落库的那部分。

我会分四层设计:

第一层,静态化与前端控制:商品详情页全部静态化推 CDN,秒杀按钮由服务端下发的时间戳控制,点击后立即置灰 + 本地倒计时防重复点击,并且秒杀 URL 做动态化(开始前不可预测),防止提前抓包刷接口。

第二层,网关限流:Sentinel 在入口按接口 + 用户维度限流,单用户限制每秒 1 次,整体阈值取压测值的 80%。超出的直接快速失败,返回「活动太火爆」。

第三层,Redis 预扣 + 售罄标记:库存提前预热进 Redis,用 Lua 脚本原子地「判断 + 扣减 + 记录已购用户」。扣减成功才放行,失败立刻返回售罄。库存扣到 0 时打一个售罄标记位,后续请求连 Lua 都不执行,一次 GET 就返回。

第四层,MQ 异步落单:预扣成功后不直接写库,而是投 MQ,由消费者按 DB 能承受的速率(我们是 1500 TPS)平稳落库。前端拿到的是「排队中」,再轮询订单状态接口。

最后必须有对账兜底:定时比对 Redis 预扣量和 DB 实际订单量,差异超阈值告警并自动修正。

连环追问链

#追问答题要点
1为什么库存要放 Redis,不直接用 DB 的原子 UPDATEDB 单行热点更新上限约 1000~3000 TPS(行锁串行),秒杀峰值远超这个量级;Redis 单实例能到 10 万 QPS,且 Lua 保证原子
2Redis 挂了怎么办① Redis 集群 + 主从保证高可用;② 降级策略:切回 DB 原子 UPDATE 扣减并同步收紧限流阈值(宁可少卖不能超卖);③ 恢复后用对账任务重建 Redis 库存
3用户看到「排队中」但最后没下单成功,怎么处理订单状态接口要能返回明确终态(成功/失败/处理中),失败时 Redis 预扣要回补;并且对「处理中」设置最长等待时间,超时自动转失败并回补库存
4怎么防黄牛和脚本动态 URL + 单用户限购(Lua 里用 Set 记录已购 uid)+ 风控规则(同 IP/设备频次)+ 答题延迟;必要时上验证码
5热点 SKU 把单个 Redis 分片打爆怎么办库存分桶:把 1000 库存拆成 10 个 key 各 100,请求随机路由到一个桶,分散到不同分片。代价是某桶卖空而其他桶有货时需要「跨桶重试」
6和普通下单的最大区别是什么普通下单追求「每个请求都正确处理」,秒杀追求「极速拒绝绝大多数请求」。所以秒杀可以接受「少卖」和「失败率高」,但绝对不能超卖和崩溃

✅ 项目关联话术

「我们大促的秒杀场次就是这套结构。峰值入口 QPS 大概 3 万,经过前端拦截和网关限流后到服务层约 8000,Redis 预扣这一层挡掉 97% 以上(因为很快就售罄打标),真正投到 MQ 的只有库存数量级的请求,最终落库稳定在 1500 TPS 以内,订单库完全没有压力。整场活动零超卖、下单接口 P99 控制在 120ms。最花时间的反而不是主流程,是失败回补——预扣成功但落库失败时必须把 Redis 库存加回去,我们把这个动作做成幂等的补偿任务,按订单号打标防止重复回补。」


题 3:讲一个你在项目中遇到的最难的问题,以及你是怎么解决的 ​

精简口述答案(90 秒版)

用 STAR 结构讲一个真实的技术攻坚。

S(背景):去年双十一前的压测,下单接口在 3000 QPS 时 P99 从 200ms 飙到 3 秒以上,订单库 CPU 打到 95%,连接池频繁等待超时。按业务预估峰值要支撑 5000 QPS,差距很大,而距离大促只剩两周。

T(任务):我负责定位瓶颈并把下单主链路的 P99 压到 300ms 以内,同时保证零超卖。

A(行动),分四步:

第一步定位。看链路追踪的 span 耗时分布,发现 80% 的时间消耗在「扣减库存」这一跳,且该跳内部几乎全是等待数据库行锁。再看慢 SQL 日志,确认是同一个热门 SKU 的 UPDATE stock 在排队——单行更新被行锁串行化,上限只有 2000 多 TPS。

第二步改扣减模型。把库存扣减从「直接改 DB」改成「Redis Lua 原子预扣 + MQ 异步落库」。Redis 做闸门挡住无效流量,DB 的 WHERE stock >= n 保留作为超卖的最终防线。

第三步处理一致性。Redis 和 DB 可能漂移,所以加了两道:落库失败时在事务回滚回调里幂等回补 Redis 预扣;再加一个 5 分钟级别的定时对账任务,比对差异、自动修正并告警。

第四步压测验证并做预案。重新全链路压测确认瓶颈转移到 MQ 消费侧,把消费者扩到 16 实例;同时把限流阈值按压测结论调好,配了三个降级开关。

R(结果):P99 从 3 秒降到 180ms,峰值承载能力从 3000 提到 1 万 QPS 以上,大促当天零超卖、零 P0 故障,订单库 CPU 峰值只有 45%。

连环追问链

#追问答题要点
1你是怎么确定瓶颈在库存扣减的,而不是别的地方靠链路追踪的 span 耗时分解(哪一跳最慢),再用慢 SQL 日志和 SHOW ENGINE INNODB STATUS 的锁等待信息交叉验证,不是靠猜
2为什么单行更新只能到 2000 TPSInnoDB 行锁让同一行的更新完全串行,单次更新含加锁、写 undo/redo、等待提交,耗时约 0.3~0.5ms,倒推就是 2000~3000 TPS 的天花板
3改成 Redis 预扣后,怎么保证不超卖Lua 保证「判断 + 扣减」原子;DB 层保留 WHERE stock >= n 做最终防线;再加定时对账兜住数据漂移。三道叠加
4对账发现不一致怎么处理,直接改数据吗分情况:Redis 多扣(DB 库存比 Redis 多)可以自动回补 Redis;反方向(可能已超卖)绝不自动改,直接告警人工介入,因为涉及真实订单
5这个方案的代价是什么① 用户看到的是「排队中」,要配状态查询;② 多了 Redis 和 MQ 两个依赖,可用性链路变长;③ 一致性从强一致降为最终一致,最长 5 分钟
6如果再给你一个月,你会怎么优化把对账从定时任务改成订阅 binlog 实时校验,把不一致发现时间从分钟级降到秒级;另外把库存做分桶,解决单个热点 key 的分片压力

✅ 项目关联话术

「这件事对我最大的收获不是技术本身,而是排查的顺序。一开始团队里有人直接说『加机器吧』,但扩容对行锁瓶颈完全无效——瓶颈在单行串行,加应用实例只会让锁等待更严重。所以我坚持先用链路追踪把耗时分解清楚,确认瓶颈点再动手。后来这件事成了我们团队的一条规范:任何性能问题都要先有数据定位,再谈方案。」


题 4:怎么保证下单接口的幂等? ​

精简口述答案(90 秒版)

先区分两个概念:去重是「第二次请求拒掉」,幂等是「第二次请求返回和第一次完全相同的结果」。下单接口要做的是幂等,因为客户端重试时必须能拿到同一个订单号,否则会出现「下单其实成功了但客户端显示失败」的状态撕裂。

我们是三道防线叠加:

第一道,前端:按钮置灰 + loading 遮罩 + 防抖。能挡住用户手快连点,但挡不住页面刷新重提交和抓包重放,所以只能算辅助。

第二道,幂等 token:进入下单页时先调接口领一个 token,服务端把它以 SET NX EX 300 写进 Redis。提交时带上 token,服务端用 Lua 脚本原子地 GET + DEL,抢到才放行。这里必须用 Lua——分两步写的话两个并发请求可能都 GET 到值然后都通过校验。

第三道,数据库唯一索引:订单表上建业务唯一键,比如 uk_user_bizno(user_id, biz_no)。这是唯一确定性的防线,因为它由 B+ 树结构保证,不依赖任何外部中间件。前两道都有失效可能——Redis 挂了、key 过期了、主从延迟了,但唯一索引永远有效。

关键的实现细节是:捕获到 DuplicateKeyException 时不能当失败返回,而要把已存在的那条订单查出来、返回它的结果,这样才真正做到幂等。

连环追问链

#追问答题要点
1为什么不能只用 Redis 做幂等Redis 是概率性保证:宕机、key 提前过期、主从切换丢数据都会让它失效。数据库唯一索引是确定性保证,必须作为最终兜底
2为什么 GET + DEL 必须用 Lua两步非原子时,并发请求可能都读到 token 存在然后都通过。Lua 在 Redis 单线程里整段执行,天然原子
3biz_no 从哪来由客户端在进入下单流程时生成(UUID/雪花 ID)并全程携带,重试时必须复用同一个。如果客户端每次重试都换新的,所有服务端幂等手段都失效——这点必须在接口协议里明确约定
4token 过期了怎么办返回明确的「页面已过期,请刷新重试」业务码,让客户端重新领 token。不要静默放行,也不要报系统错误
5MQ 消费侧怎么保证幂等同理:消息里带业务唯一键,消费时先按唯一键查/插,依赖唯一索引拦重复。RocketMQ 至少一次投递,重复消费是常态不是异常
6唯一索引会不会成为性能瓶颈唯一索引插入需要做唯一性校验(可能读磁盘页),比普通索引略慢,且在分库分表下唯一索引只在单分片内有效——所以业务唯一键必须包含分片键,否则跨分片无法保证唯一

✅ 项目关联话术

「我们线上真实出过一次重复下单,根因很有意思:客户端做了自动重试,但每次重试都重新生成了 biz_no,服务端三道防线一道都没拦住——因为在服务端看来这是三个不同的请求。后来我们在接口文档里把『重试必须复用同一个 biz_no』写成强制约定,并且在服务端加了监控:同一用户 5 秒内不同 biz_no 的下单请求超过 2 次就告警。这件事让我明白,幂等不是服务端一方的事,是客户端和服务端的协议。」


题 5:怎么防止超卖? ​

精简口述答案(90 秒版)

超卖的本质是**「查库存」和「扣库存」之间存在时间窗**,两个请求都查到还有 1 件,然后都扣成功。所有解法都是在消灭这个窗口。

从代价由低到高有五种:

① 悲观锁 SELECT ... FOR UPDATE:能解决但并发极低,而且持锁期间如果有网络 IO,锁持有时间不可控,容易锁等待超时。

② 乐观锁 UPDATE ... WHERE version = v:高并发下冲突失败率极高,大量重试反而放大压力。

③ 原子 UPDATE UPDATE SET stock = stock - n WHERE stock >= n:把判断和扣减合成一条 SQL,靠行锁保证原子。这是最简洁正确的 DB 方案,但受行锁串行限制,上限 1000~3000 TPS。

④ 分布式锁:锁粒度大、RT 翻倍,还要处理续期和误删,一般只在流程复杂必须串行时用。

⑤ Redis Lua 预扣:在 Lua 里原子地判断 + 扣减,单实例能到 10 万 QPS,是秒杀场景的主方案。

我们的最终形态是 ⑤ + ③ 叠加:Redis Lua 做闸门挡住绝大多数无效流量,DB 的 WHERE stock >= n 保留作为超卖的最终防线,再加定时对账兜住 Redis 和 DB 的数据漂移。

这里有个关键认知:Redis 不是替代 DB 校验,而是前置 DB 校验。DB 那句 WHERE stock >= n 永远不能省——一旦 Redis 被重置或者库存预热数据写错了,没有它就直接超卖。

连环追问链

#追问答题要点
1为什么原子 UPDATE 就不会超卖WHERE stock >= n 的判断和扣减在同一条 SQL 内,由 InnoDB 行锁保证串行执行,不存在「查」和「扣」之间的窗口。返回影响行数 0 就是库存不足
2那为什么还要 Redis行锁让同一行更新串行,单行上限只有 2000 TPS 左右,扛不住秒杀峰值。Redis 挡在前面,让只有少量真正有效的请求才打到 DB
3库存回滚最容易漏什么漏掉回补 Redis 预扣。Seata 只会自动回滚 DB 事务,Redis 是手动扣的、不在事务里。漏了就表现为「DB 有货但页面显示售罄」。要挂在事务回滚回调里,并且回补本身做幂等
4多件商品一起下单怎么扣一个 Lua 脚本里按固定顺序(如 skuId 升序)依次扣减,任一件不足则全部回滚并返回失败。固定顺序是为了避免多请求交叉持有导致的死锁式互等
5超卖已经发生了怎么补救先止损(立刻下架该 SKU 或把 Redis 库存置 0),再按下单时间排序,对超出部分的订单做取消 + 退款 + 补偿(优惠券),同时复盘根因
6预扣库存的超时释放怎么做下单后库存处于「预占」状态,用延迟消息(RocketMQ 延迟级别)或 Redis ZSet 扫描,到期未支付则释放库存。释放动作要和「支付成功」做互斥,避免支付成功的订单被误释放

✅ 项目关联话术

「我们的库存模型是『Redis 是闸门,DB 是账本』。Redis 负责高并发下的准入判断,DB 负责真实库存和可对账。最难的不是扣,是各种异常路径下的回补:下单超时、落库失败、用户取消、支付超时,每条路径都要回补 Redis,而且回补必须幂等。我们踩过的坑是回补了两次导致库存虚高,后来统一用订单号做标记位,回补前先 SET NX,已回补过的直接跳过。」


题 6:缓存和数据库怎么保证一致性? ​

精简口述答案(90 秒版)

先给结论:工业界默认方案是「先更新数据库,再删除缓存」(Cache Aside),它不是没有不一致,而是不一致概率极低且窗口极短。

为什么其他方案不行:

  • 先更库 + 更新缓存:并发写时两个线程的更新顺序可能交错,后写的旧值覆盖新值,导致永久脏。
  • 先删缓存 + 更库:删完到更新完成这段时间,读请求会把旧值重新加载进缓存,同样脏。

为什么是「删」而不是「更新」缓存,三个理由:① 删除是幂等的,更新有顺序问题;② 懒加载更省,删了之后没人读就不用加载;③ 缓存值可能是多表聚合的,更新成本远高于删除。

Cache Aside 仍有理论窗口(读请求刚好在缓存失效后进来,且读 DB 比写 DB 慢,且回写发生在删除之后),但三个条件同时满足概率极低,工程上可接受。真要再收紧,可以加延迟双删或者订阅 binlog(Canal)做补偿删除。

更重要的是:一致性方案不该全局统一,要按数据分级。我们分三类:允许秒级延迟的(商品详情)走 Cache Aside + TTL 兜底;要求准实时的(价格、促销)额外挂 Canal 订阅 binlog 补偿删除;强一致的(余额、订单状态)干脆不缓存,直读主库。

连环追问链

#追问答题要点
1删缓存失败了怎么办① 本地重试(有限次 + 退避);② 重试仍失败则投入 MQ 异步重试;③ 最终靠 TTL 兜底;④ 更稳的是订阅 binlog,由独立链路保证删除一定执行
2延迟双删的「延迟」设多久要大于「一次读请求从读 DB 到回写缓存」的耗时,通常几百毫秒到 1 秒。但这个值只能靠经验和压测估,这也是延迟双删不够优雅的原因
3为什么库存不能套 Cache Aside库存的 Redis 值不是 DB 的副本,而是扣减的权威入口,数据流向是 Redis → DB(异步落库),和缓存的 DB → Redis 方向相反。两套模型不能混
4缓存穿透、击穿、雪崩怎么区分和解决穿透=查不存在的数据(空值缓存 + 布隆过滤器);击穿=单个热点 key 过期瞬间大量请求打到 DB(互斥重建 + 逻辑过期永不物理过期);雪崩=大量 key 同时过期或 Redis 整体挂(TTL 加随机抖动 + 多级缓存 + 熔断限流)
5主从延迟会不会导致缓存写入旧值会。如果删缓存后的读请求走从库,可能读到旧值并回写缓存。解决办法:① 关键数据读主库;② 延迟双删覆盖主从延迟时间;③ 订阅 binlog 时机天然在主库写入之后
6什么时候应该干脆不用缓存写多读少、数据强一致要求高、或者单次查询本来就很快(走主键索引几毫秒)的场景。缓存是用一致性换性能,不值得换的时候就别加

✅ 项目关联话术

「我们做缓存改造时定了一条原则:先问清楚『这个数据脏 1 秒会发生什么业务后果』,再决定方案。商品描述脏 30 分钟没人投诉,价格脏 10 秒就是客诉甚至资损,所以价格这块我们单独接了 Canal。另外我们线上出过一次缓存击穿——一个爆款 SKU 的 key 到期瞬间,几千个请求同时打到 DB,DB 连接池直接满了。后来改成逻辑过期 + 异步重建:key 永不物理过期,value 里存逻辑过期时间,读到过期时只放一个线程去重建、其他线程先返回旧值。这之后同类问题没再出现过。」


题 7:线上接口突然变慢了,你怎么排查? ​

精简口述答案(90 秒版)

我会先止损、再定位,这个顺序不能反。

0~2 分钟确认影响面:是全量还是部分用户?是单接口还是全站?是单机还是集群?有没有伴随发布或配置变更?如果刚发过版,第一反应是回滚,不要在生产上边查边想。

2~10 分钟止损:能回滚就回滚,不能回滚就降级(关掉非核心依赖)、限流(保住核心链路)、摘掉异常节点。

10 分钟后定位,分四步:

第一步,看监控面板,确认是「整体慢」还是「个别慢」:QPS 有没有突增?RT 是 P99 涨还是平均值涨?错误率有没有同步上升?CPU、内存、GC、连接池、线程池的水位怎么样?

第二步,看链路追踪,把一次慢请求的 span 耗时拆开,直接定位是哪一跳慢。这一步能省掉 80% 的瞎猜。

第三步,按方向验证:

  • 慢在 DB → 看慢 SQL 日志、SHOW PROCESSLIST 看有没有锁等待、看执行计划是否走错索引
  • 慢在下游服务 → 看下游的监控,判断是下游真慢还是网络问题
  • 慢在 Redis → 看 slowlog、看有没有大 key/热 key、看是不是用了 KEYS 这种阻塞命令
  • 慢在自身 JVM → jstat -gcutil 看 GC 频率和耗时,Full GC 频繁就 dump 堆看对象分布
  • 慢在线程池 → 看队列长度和活跃线程数,判断是不是线程耗尽在排队

第四步,归因并留证据:保留现场(堆 dump、线程 dump、日志),事后复盘出改进项和监控告警补充。

连环追问链

#追问答题要点
1为什么要先看「是不是刚发版」线上故障里绝大多数是变更引起的(代码发布、配置修改、数据变更)。先问变更能最快缩小范围,而且回滚是最快的止损手段
2平均 RT 没涨但 P99 涨了,说明什么说明只有部分请求慢,常见原因:GC 停顿、慢 SQL 只影响特定参数(如大数据量用户)、某个节点异常、缓存未命中的那部分走了 DB、锁竞争只在热点数据上
3慢 SQL 的常见原因有哪些① 没走索引/索引失效(函数、隐式类型转换、前导通配)② 数据量增长导致原本够用的索引选择性变差 ③ 深分页 LIMIT 100000,10 ④ 大事务和锁等待 ⑤ 统计信息过期导致优化器选错索引
4什么是隐式类型转换导致索引失效字段是 varchar 但传了数字(WHERE phone = 138... 不加引号),MySQL 会把列转成数字再比较,等于对列做了函数运算,索引直接失效。反过来字段是 int 传字符串则不影响
5怎么判断是「下游慢」还是「网络慢」对比上游记录的调用耗时和下游自己记录的处理耗时。两者差值大 = 网络或排队问题;两者都大 = 下游真慢。这也是为什么链路追踪要在客户端和服务端都埋点
6排查完之后要做什么① 补监控告警(这次为什么没提前发现)② 补预案(下次能不能更快止损)③ 改代码/配置消除根因 ④ 写复盘文档。只修 bug 不补监控,同类问题一定会再来

✅ 项目关联话术

「我们真实遇到过一次:订单列表接口 P99 从 200ms 涨到 5 秒,但平均 RT 只涨了一点。靠链路追踪发现慢的全是『订单数超过 2000 条的老用户』——根因是深分页 LIMIT 20000, 20,MySQL 要扫 20020 行再丢掉前面 2 万行。我们改成游标分页(WHERE id < lastId ORDER BY id DESC LIMIT 20),P99 直接回到 150ms。这件事让我养成了一个习惯:看性能指标一定看分位值,平均值会骗人。」


题 8:线上 CPU 飙到 100%,怎么定位? ​

精简口述答案(90 秒版)

四步,命令我都记得:

第一步,top 确认是哪个进程。如果是 Java 进程,记下 PID。顺便看 load average 和 CPU 的 us/sy/wa 分布——us 高是用户代码,sy 高可能是线程切换过多,wa 高是 IO 等待。

第二步,top -Hp [PID] 查看该进程内各线程的 CPU 占用,找出最高的那几个线程 ID(十进制)。

第三步,printf "%x\n" [线程ID] 把线程 ID 转成十六进制,因为 jstack 输出里的 nid 是十六进制。

第四步,jstack [PID] | grep -A 40 [十六进制nid] 拿到这个线程的完整栈,直接看到是哪段代码在烧 CPU。

常见的根因有四类:

① 死循环 / 自旋:比如 while(true) 没有 sleep、HashMap 在并发下成环(JDK 7 的经典问题)。 ② 频繁 Full GC:这时 CPU 高的线程是 GC task thread,要配合 jstat -gcutil [PID] 1000 看 GC 频率,根因其实是内存问题不是 CPU 问题。 ③ 正则回溯 / 大对象序列化 / 加解密:计算密集型代码被高频调用。 ④ 线程数过多:上下文切换开销吃掉 CPU,表现为 sy 偏高,用 vmstat 1 看 cs 列确认。

连环追问链

#追问答题要点
1为什么线程 ID 要转十六进制jstack 输出的 nid 字段是十六进制表示的本地线程 ID,而 top -Hp 显示的是十进制,必须转换才能对上
2如果最高 CPU 的是 GC 线程呢说明本质是内存问题:对象创建过快或存在泄漏导致 GC 疯跑。要看 jstat -gcutil 确认是 Young GC 频繁还是 Full GC 频繁,再 jmap -histo:live 看谁占内存最多
3生产环境能直接执行 jmap dump 吗要谨慎:dump 会触发 STW,几个 G 的堆会卡住几秒到几十秒。做法是先把这台机器从负载均衡摘掉再 dump,或者直接用启动参数 -XX:+HeapDumpOnOutOfMemoryError 让它自动留现场
4没有 jstack 权限/容器里没装怎么办① 用 Arthas(thread -n 3 一条命令直接给出最忙的三个线程栈);② 容器里可以 kill -3 [PID] 把线程栈打到标准输出;③ 云厂商的 APM 一般有线程剖析功能
5CPU 高但业务没变慢,要不要处理要看。可能是定时任务或批处理在跑(正常),也可能是水位已经接近上限、只是还没触发排队。原则是水位超过 70% 就要开始处理,不要等到业务受损
6怎么预防这类问题① 代码评审时重点看循环、正则、递归;② 线程池必须显式指定大小和拒绝策略,禁用 Executors.newCachedThreadPool;③ 监控上加 CPU、GC、线程数三类告警

✅ 项目关联话术

「我处理过一次 CPU 100%,按这四步十分钟就定位了:最忙的线程栈停在一个正则匹配上。根因是一个用来校验商品标题的正则写得有问题,存在灾难性回溯,遇到特定长标题时复杂度指数级爆炸。修复方式是改写正则消除嵌套量词,并且给校验加了长度上限。这次之后我们在代码规范里加了一条:所有用户输入参与的正则必须做长度限制和回溯测试。」


题 9:线上 OOM 了,你怎么排查? ​

精简口述答案(90 秒版)

OOM 排查的前提是有现场,所以第一件事其实是在出问题之前就配好启动参数:

text
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/data/dump/
-Xlog:gc*:file=/data/log/gc.log:time,uptime:filecount=10,filesize=50M

没有 dump 文件的 OOM 基本只能靠猜。

有了现场之后,第一步先看 OOM 的类型,不同类型根因完全不同:

OOM 信息含义常见根因
Java heap space堆内存不足内存泄漏、一次查太多数据、缓存无上限
GC overhead limit exceededGC 占用超 98% 但回收不到 2%堆已经基本被存活对象占满,典型泄漏
Metaspace元空间不足动态生成类过多(CGLib、反射、脚本引擎)、类加载器泄漏
unable to create new native thread无法创建线程线程泄漏、线程池用错(无界创建)
Direct buffer memory直接内存不足Netty/NIO 的 DirectByteBuffer 未释放

第二步分析 dump:用 MAT 或 JProfiler 打开,看 Dominator Tree(谁占的内存最多)和 Leak Suspects 报告。重点看大对象的引用链——是谁一直持有它不放,这才是泄漏的根源。

第三步,没有 dump 时的在线手段:jmap -histo:live [PID] | head -30 直接看对象数量和占用排名(注意会触发 Full GC),或者用 Arthas 的 vmtool --action getInstances 在线查对象。

常见的真实根因就那么几类:静态集合无上限增长、ThreadLocal 没 remove(线程池场景下线程复用,ThreadLocal 永远不回收)、连接/流没关闭、一次性查询返回百万行数据、本地缓存没设最大容量和过期。

连环追问链

#追问答题要点
1内存泄漏和内存溢出什么区别泄漏(leak)是「该回收的对象一直被引用回收不掉」,是原因;溢出(OOM)是「申请不到内存了」,是结果。泄漏积累到一定程度就溢出,但溢出也可能是单次申请过大,不一定有泄漏
2为什么 ThreadLocal 在线程池里会泄漏线程池的线程会长期复用,ThreadLocalMap 挂在 Thread 上。虽然 key 是弱引用会被回收,但 value 是强引用,key 被回收后 value 变成「无法访问但无法回收」的脏数据。必须在 finally 里 remove()
3怎么判断是泄漏还是就是内存不够看 GC 日志里的「Full GC 后老年代占用」趋势:持续上升且回收不下去 = 泄漏;每次 Full GC 能降回低位但很快又涨满 = 容量/流量问题,考虑扩容或优化对象创建
4Metaspace OOM 怎么查看是不是有动态代理/CGLib/脚本引擎在反复生成类,用 -XX:+TraceClassLoading 或 Arthas 的 classloader 命令看类加载器数量和加载类数是否持续增长
5堆外内存 OOM 怎么查堆 dump 看不到堆外。要用 -XX:NativeMemoryTracking=detail 配合 jcmd VM.native_memory 查看,或者用 pmap / gperftools 分析
6止损上怎么处理先重启/摘流恢复服务(保留一台不重启用于取证),同时限流降低压力;如果是某个接口触发的,临时关掉该接口或加参数校验(比如禁止不带分页的查询)

✅ 项目关联话术

「我们线上出过一次 Java heap space,dump 分析发现一个 ConcurrentHashMap 占了 1.8G。根因是有人用静态 Map 做本地缓存但没设上限也没设过期,key 是 userId,随着用户量增长就越积越多。修复方式是换成 Caffeine 并显式设置 maximumSize 和 expireAfterWrite。这件事之后我们在代码规范里禁止了裸 static Map 做缓存,必须用带容量和过期策略的缓存库。另外我也把 HeapDumpOnOutOfMemoryError 加进了所有服务的统一启动模板——在那之前第一次 OOM 我们因为没留现场,白白浪费了两天。」


题 10:MQ 消息堆积了怎么处理? ​

精简口述答案(90 秒版)

堆积的本质就一个不等式:生产速率 > 消费速率。所以要么是生产突增,要么是消费变慢或挂了。

第一步,先判断方向:看生产 TPS 曲线有没有突增。如果生产正常,那就是消费侧的问题。

第二步,消费侧四查:

① 消费者是否存活:实例数、心跳、有没有因为 OOM 反复重启。 ② 消费线程栈:jstack 看消费线程卡在哪——最常见的是卡在下游 RPC 调用或数据库慢查询上。 ③ 是否在死循环重试:某条消息处理一直失败,又没有重试上限,导致队列卡死在这条消息上(特别是顺序消费模式下,一条毒消息能卡住整个队列)。 ④ 消费逻辑是否变慢:比如新加了一次外部调用、或者数据量增长让单条处理耗时从 5ms 涨到 500ms。

第三步,止损(按代价从低到高):

动作代价适用
扩容消费者实例低最优先,但受分区数限制——消费者数超过分区数就无效
调大单次拉取批量 / 消费线程数低单条处理是 IO 等待型时效果好
临时加分区 + 扩消费者中需要能接受分区变更带来的顺序性影响
临时把消息转存中新建一个临时 topic,先用一个「搬运消费者」把堆积消息快速转到临时 topic 分散消费
跳过部分消息(重置消费位点)高,可能丢数据只在确认这批消息可丢弃(如埋点、通知)时使用,核心业务绝对不能用

第四步,根因处理:如果是毒消息,捞出来进死信队列人工处理;如果是下游慢,优化下游或给消费端加熔断;如果是容量不够,长期扩分区并重新做容量评估。

连环追问链

#追问答题要点
1为什么加消费者有时候没用一个分区(RocketMQ 的 Queue / Kafka 的 Partition)同一时刻只能被同一消费组内的一个消费者消费。消费者数量超过分区数,多出来的会空转。所以要先扩分区
2顺序消费场景堆积了怎么办顺序消费不能简单扩并发。方向是:① 细化顺序粒度(从全局顺序改成按 key 顺序,比如按订单号 hash 到分区)② 把耗时的非顺序部分拆出来异步做 ③ 优化单条处理耗时
3怎么设置堆积告警才合理不要用绝对条数(不同业务量级差异大)。用「预估消化时间」= 堆积量 / 当前消费速率,超过 10 分钟告警。这个指标和业务影响直接相关
4消费失败的重试策略怎么设计必须有重试上限 + 退避,超限进死信队列。禁止无限重试——它会把队列卡死。退避可以用 RocketMQ 的延迟级别(1s/5s/10s/30s/1m/...)实现
5堆积期间消息会丢吗RocketMQ/Kafka 都是持久化的,堆积不会丢,但要注意消息保留时间(默认通常 3 天 / 7 天)。如果堆积超过保留期,老消息会被清理,这才是真丢
6怎么预防堆积① 容量评估时按峰值算消费能力并留余量;② 消费逻辑里禁止调用不可控的慢接口(必须配超时 + 熔断);③ 分区数预留扩展空间;④ 加消化时间告警 + 消费者水位监控

✅ 项目关联话术

「我们大促期间出过一次订单消息堆积 50 万。排查用了不到 5 分钟:生产 TPS 正常,jstack 看消费线程全部卡在调用营销服务的接口上——营销服务那边有个慢 SQL 把自己拖垮了。止损动作是给消费端的营销调用加熔断,熔断后营销部分走异步补偿,主流程继续落库,10 分钟内堆积就消化完了。复盘的改进有两条:一是消费逻辑里所有外部调用必须显式配超时和熔断,二是把堆积告警从『超过 10 万条』改成『预估消化时间超过 10 分钟』,这样告警才真正反映业务影响。」


题 11:你们系统能承载多少 QPS?这个数字是怎么算出来的? ​

精简口述答案(90 秒版)

我会给两条推导路径,并说清瓶颈在哪。

自上而下(业务视角)估算需求:

日活 100 万,人均下单相关访问 3 次,一天 300 万次请求,平均 QPS 约 35。但流量不均匀,按二八法则 80% 的流量集中在 20% 的时间(约 4.8 小时),峰值 QPS 约 139。大促突发按平时 10~50 倍算,目标是能扛 7000 QPS。

自下而上(资源视角)验证能力:

单机 QPS ≈ (1000 / 单请求 RT) × 有效线程数 × CPU 可用率。比如 RT 50ms、Tomcat 线程 200、CPU 压到 60%,理论值 2400,但实际压测会低于理论值(有锁竞争、有 GC、有下游瓶颈),取实测 1500。集群容量 = 1500 × 8 台 × 0.7 = 8400 QPS,能兜住 7000 的目标。

为什么要留 30% 余量:① 发布滚动和容器漂移时会少节点;② 水位超过 70% 后 RT 开始非线性恶化(排队效应);③ HPA 拉起新 Pod 要 1~3 分钟,需要缓冲。

最关键的是指出瓶颈:算出 8400 没有意义,要能说「这条链路的真实瓶颈是订单库写入,上限约 2000 TPS,所以我们做了分库分表拆成 16 份、并用 MQ 把写入削平到 1500 TPS 以内」。只报数字不指瓶颈,说明没真做过容量规划。

连环追问链

#追问答题要点
1常见组件的经验量级能报一下吗单台 MySQL 主键查询 5000~8000 QPS、写入 1000~2000 TPS、单行热点更新 1000~3000 TPS;单实例 Redis 8~10 万 QPS;单台 Nginx 3~5 万;RocketMQ 单 Broker 10 万级 TPS;同机房 RTT 0.1~0.5ms,跨机房 1~3ms
2为什么水位超过 70% 后 RT 会非线性恶化排队论:利用率 ρ 趋近 1 时,平均排队时间按 1/(1-ρ) 增长。70% 时排队时间约是服务时间的 2.3 倍,90% 时就变成 9 倍,所以必须留余量
3线程数设多少合适IO 密集型:线程数 ≈ 核数 × (1 + 等待时间/计算时间);CPU 密集型:核数 + 1。但线程不是越多越好,超过一定数量后上下文切换开销会反噬,必须靠压测确定
4怎么算连接池大小连接数 ≈ QPS × 单次 DB 操作耗时(秒) × 每请求 SQL 条数。还要注意所有应用实例的连接池总和不能超过数据库 max_connections,这是分库分表后很容易踩的坑
5理论值和压测值差很多,差在哪锁竞争、GC 停顿、连接池排队、下游服务的 RT 波动、序列化开销、日志同步写盘。所以理论计算只用来定量级和找方向,最终数字必须靠压测
6容量不够时扩容的顺序先扩无状态的应用层(最便宜最快)→ 再扩缓存和 MQ 的分区 → 最后才动数据库(扩从库 / 分库分表,代价最高)。同时准备限流降级作为扩容来不及时的兜底

✅ 项目关联话术

「我们每次大促前都要重做容量评估,流程是:业务给预估的峰值订单量 → 我按链路的扇出比换算到各组件的压力(一次下单对应约 8 次 Redis、5 次 DB、3 次 RPC、2 次 MQ)→ 对比各组件当前容量找出短板 → 扩容或优化 → 全链路压测验证。这个扇出换算很重要,1000 QPS 的下单对 Redis 来说是 8000 QPS 的压力,不算清楚就会漏掉真正的瓶颈。去年算出来短板在订单库写入,所以才有了 MQ 削峰这个改造。」


题 12:如果让你重构这个项目,你会怎么做?项目现在有什么不足? ​

精简口述答案(90 秒版)

我会先承认不足,再给出有优先级的改造路径,并且说清每一项的收益和成本。

当前最主要的三个不足:

第一,对账是定时任务驱动的,发现 Redis 和 DB 库存不一致最长要 5 分钟。业务能接受,但更优解是订阅 binlog 做实时双向校验,把发现时间降到秒级。成本是要额外维护一套 Canal 集群和消费逻辑。

第二,还是同城双机房,没有异地多活。一次机房级故障的 RTO 大约 30 分钟,业务目前接受。异地多活需要单元化改造 + 跨地域数据同步,成本相对我们现在的体量还不划算。

第三,分布式事务用的是 Seata AT,它有全局锁,高冲突场景性能不如 TCC。我们当前冲突率低所以可接受,但如果热点冲突上升,我会优先把库存这一个分支改成 TCC,而不是全链路改造。

如果重构,我的优先级是:

① 先补可观测和自动化(成本最低、收益最快):实时对账、更细的业务黄金指标告警、预案自动化演练。 ② 再做读写模型分离:订单的查询场景(列表、搜索、统计)和写场景诉求差异很大,可以把查询侧抽到 ES / CQRS 读模型,彻底解耦。 ③ 最后才动架构形态:单元化、异地多活这类改造要等业务体量真的需要,提前做就是过度设计。

连环追问链

#追问答题要点
1为什么不一开始就设计成最终形态架构是被业务逼着长出来的,不是一次设计出来的。提前上用不到的复杂度,带来的是维护成本、故障面扩大和团队学习成本,收益却为零——这就是过度设计
2怎么判断一个改造值不值得做三问:① 不改会死在哪(有没有明确的痛点和量化影响)② 改了换来什么(量化收益)③ 引入的新问题怎么兜。三个都答得上才值得做
3技术债怎么管理建立技术债清单(记录影响面和预估工作量),每个迭代固定留 10~20% 容量还债,并且**优先还「会引发故障的债」**而不是「看起来不优雅的债」
4重构怎么保证不出问题① 先补测试(尤其是集成测试)② 小步快跑,每次改动可独立回滚 ③ 新旧逻辑双跑比对结果(影子流量)④ 灰度放量 ⑤ 关键指标对比看板
5如果老板不给时间做改造怎么办把技术改造翻译成业务语言:「这个问题去年造成了 2 次 P1 故障、累计 40 分钟不可用、影响 X 万订单」。用风险和成本说话,而不是「代码不优雅」
6CQRS 的代价是什么读写模型分离后一致性变成最终一致(ES 有同步延迟)、要维护同步链路和重建机制、排查问题时要看两套数据。所以只在查询确实复杂到 DB 扛不住时才用

✅ 项目关联话术

「我讲项目不足的时候会按『承认现状 + 说清代价为什么可接受 + 给出改进方向』三段讲,不会停在『这块我们没做』。比如对账这块,我会说『目前是 5 分钟级定时任务,最长 5 分钟才能发现不一致,业务上可接受因为库存差异不会直接造成资损;更好的做法是订阅 binlog 实时校验,我做过方案评估,主要卡在要额外维护 Canal 集群,收益和成本在当前体量下还不够正,所以排在下一阶段』。这样讲既诚实,又能体现我是真的评估过、有判断力的,而不是没能力做。」


✍️ 手撕代码【核心实现 + 边界 Case 自测清单 + 变种拓展题】 ​

题 1:秒杀下单核心流程(Lua 原子预扣 + 幂等 + MQ 异步落单) ​

核心实现

先写最关键的 Lua 脚本——它要在一次原子执行里完成「售罄判断 + 限购判断 + 扣减 + 记录购买人」四件事。

lua
-- seckill.lua
-- KEYS[1] = stock:{activityId}       库存计数
-- KEYS[2] = bought:{activityId}      已购用户集合
-- KEYS[3] = soldout:{activityId}     售罄标记
-- ARGV[1] = userId
-- ARGV[2] = 购买数量
-- ARGV[3] = 单用户限购数

-- 1. 售罄快速失败(最廉价的一步放最前)
if redis.call('EXISTS', KEYS[3]) == 1 then
  return -1
end

-- 2. 限购校验
local bought = tonumber(redis.call('HGET', KEYS[2], ARGV[1]) or '0')
local qty    = tonumber(ARGV[2])
if bought + qty > tonumber(ARGV[3]) then
  return -2
end

-- 3. 库存校验
local stock = tonumber(redis.call('GET', KEYS[1]) or '0')
if stock < qty then
  redis.call('SET', KEYS[3], 1)   -- 打售罄标记,后续请求第 1 步就返回
  return -3
end

-- 4. 扣减 + 记账(两步都在同一次原子执行内)
redis.call('DECRBY', KEYS[1], qty)
redis.call('HINCRBY', KEYS[2], ARGV[1], qty)
if stock - qty == 0 then
  redis.call('SET', KEYS[3], 1)
end
return stock - qty   -- 返回剩余库存(>= 0 表示成功)

Java 侧的编排:幂等前置、预扣其次、投递最后,任何一步失败都要能回退前一步。

java
@Service
@RequiredArgsConstructor
public class SeckillService {

    private final StringRedisTemplate redis;
    private final RocketMQTemplate mq;
    private final DefaultRedisScript<Long> seckillScript;   // 加载上面的 lua

    private static final int LIMIT_PER_USER = 2;

    public SeckillResult submit(SeckillCmd cmd) {
        // ① 幂等 token:Lua 原子 GET+DEL,抢不到直接拒
        if (!idempotentService.tryConsume(cmd.getToken())) {
            return SeckillResult.fail("REPEAT_SUBMIT", "请勿重复提交");
        }

        // ② Lua 原子预扣
        Long rest = redis.execute(seckillScript,
                List.of(stockKey(cmd.getActivityId()),
                        boughtKey(cmd.getActivityId()),
                        soldoutKey(cmd.getActivityId())),
                String.valueOf(cmd.getUserId()),
                String.valueOf(cmd.getQty()),
                String.valueOf(LIMIT_PER_USER));

        if (rest == null || rest < 0) {
            return switch (rest == null ? -3 : rest.intValue()) {
                case -1, -3 -> SeckillResult.fail("SOLD_OUT", "已售罄");
                case -2 -> SeckillResult.fail("LIMIT_EXCEED", "超出限购数量");
                default -> SeckillResult.fail("UNKNOWN", "请稍后重试");
            };
        }

        // ③ 投 MQ 异步落单;投递失败必须回补预扣
        String orderNo = orderNoGenerator.next(cmd.getUserId());
        try {
            SendResult sr = mq.syncSend("seckill_order_topic",
                    MessageBuilder.withPayload(
                            new SeckillOrderMsg(orderNo, cmd)).build());
            if (sr.getSendStatus() != SendStatus.SEND_OK) {
                throw new MqSendException(sr.getSendStatus().name());
            }
        } catch (Exception e) {
            stockService.compensate(cmd, orderNo);   // 幂等回补
            return SeckillResult.fail("SYSTEM_BUSY", "系统繁忙,请重试");
        }

        // ④ 立刻返回「排队中」,前端轮询订单状态
        return SeckillResult.queueing(orderNo);
    }
}

落库侧依赖唯一索引作为幂等的最终防线:

java
@RocketMQMessageListener(topic = "seckill_order_topic",
        consumerGroup = "seckill_order_consumer")
public class SeckillOrderListener implements RocketMQListener<SeckillOrderMsg> {

    @Override
    @Transactional(rollbackFor = Exception.class)
    public void onMessage(SeckillOrderMsg msg) {
        try {
            // uk_order_no 唯一索引:重复消费时必然抛 DuplicateKeyException
            orderMapper.insertSelective(buildOrder(msg));
            // DB 扣真实库存,WHERE stock >= n 是超卖的最终防线
            int rows = stockMapper.deduct(msg.getSkuId(), msg.getQty());
            if (rows == 0) {
                throw new BizException("DB 库存不足,数据异常需对账");
            }
        } catch (DuplicateKeyException dup) {
            // ★ 重复消费不是错误:直接当成功,让 MQ 确认掉这条消息
            log.warn("duplicate order ignored, orderNo={}", msg.getOrderNo());
        }
    }
}

边界 Case 自测清单

#Case期望行为
1同一 token 并发提交两次只有一次通过(Lua GET+DEL 原子),另一次返回「请勿重复提交」
2库存恰好剩 1,两个请求同时抢一个成功、一个返回售罄,剩余库存为 0,绝不出现 -1
3库存扣到 0 后的后续请求命中售罄标记,第 1 步就返回,不再执行后面的逻辑
4同一用户买 3 次(限购 2)第 3 次返回 LIMIT_EXCEED,且不消耗库存
5预扣成功但 MQ 投递失败库存被幂等回补,用户收到「系统繁忙」,不产生幽灵占用
6回补逻辑被重复触发按订单号标记位拦住,库存只回补一次
7MQ 重复投递同一条消息唯一索引拦住,消费侧当成功处理,不产生重复订单
8消费侧 DB 扣减返回 0 行抛异常触发重试,超限进死信队列并告警,人工介入对账
9Redis 整体不可用降级为 DB 原子 UPDATE 扣减 + 收紧限流,宁可少卖不超卖
10活动结束但仍有请求进来活动状态校验前置(可缓存在本地),直接拒绝,不打 Redis

🚀 变种拓展题

  1. 库存分桶:把 1000 库存拆成 10 个 key,请求随机落桶。要求实现「某桶卖空时跨桶重试」且总量绝不超卖。
  2. 预占超时释放:下单后库存处于预占态,30 分钟未支付自动释放。用 RocketMQ 延迟消息实现,并保证与「支付成功」互斥。
  3. 多商品打包秒杀:一次下单含多个 SKU,要求 Lua 内按 skuId 升序依次扣减,任一不足则全部回滚。
  4. 排队号与进度查询:返回排队序号,并提供「前面还有多少人」的查询接口。
  5. 防黄牛增强:在 Lua 中加入设备指纹维度限购,并对同 IP 高频请求做滑动窗口计数拦截。

题 2:通用幂等框架(注解 + AOP + Redis token + 唯一索引兜底) ​

核心实现

目标是让业务代码只加一个注解就具备幂等能力。

java
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {

    /** 幂等键的 SpEL 表达式,如 "#cmd.bizNo" */
    String key();

    /** 幂等窗口,秒 */
    int expire() default 300;

    /** 命中重复时的提示 */
    String message() default "请求正在处理中,请勿重复提交";

    /** true=执行完成后删除键(允许重试);false=保留到过期(严格一次) */
    boolean releaseAfterDone() default false;
}

切面的关键在于三种状态要分清:未处理、处理中、已完成。

java
@Aspect
@Component
@RequiredArgsConstructor
public class IdempotentAspect {

    private final StringRedisTemplate redis;
    private final ExpressionParser parser = new SpelExpressionParser();

    /** 原子「取出并删除」,用于 token 型幂等 */
    private static final String GET_DEL = """
            local v = redis.call('GET', KEYS[1])
            if v then redis.call('DEL', KEYS[1]) end
            return v
            """;

    @Around("@annotation(ann)")
    public Object around(ProceedingJoinPoint pjp, Idempotent ann) throws Throwable {
        String key = "idem:" + resolveKey(pjp, ann.key());

        // ① SET NX 占位,value 标记「处理中」
        Boolean ok = redis.opsForValue()
                .setIfAbsent(key, "DOING", Duration.ofSeconds(ann.expire()));

        if (!Boolean.TRUE.equals(ok)) {
            // 已存在:区分「处理中」和「已完成」
            String val = redis.opsForValue().get(key);
            if (val != null && val.startsWith("DONE:")) {
                // ★ 真正的幂等:返回第一次的结果,而不是报错
                return JSON.parseObject(val.substring(5),
                        ((MethodSignature) pjp.getSignature()).getReturnType());
            }
            throw new IdempotentException(ann.message());
        }

        try {
            Object result = pjp.proceed();
            if (ann.releaseAfterDone()) {
                redis.delete(key);
            } else {
                // 缓存结果,让后续重复请求拿到相同响应
                redis.opsForValue().set(key, "DONE:" + JSON.toJSONString(result),
                        Duration.ofSeconds(ann.expire()));
            }
            return result;
        } catch (Throwable e) {
            // ★ 业务异常必须释放锁,否则用户改正参数后也无法重试
            redis.delete(key);
            throw e;
        }
    }

    private String resolveKey(ProceedingJoinPoint pjp, String spel) {
        MethodSignature sig = (MethodSignature) pjp.getSignature();
        EvaluationContext ctx = new StandardEvaluationContext();
        String[] names = sig.getParameterNames();
        Object[] args = pjp.getArgs();
        for (int i = 0; i < names.length; i++) {
            ctx.setVariable(names[i], args[i]);
        }
        Object v = parser.parseExpression(spel).getValue(ctx);
        if (v == null) {
            throw new IllegalArgumentException("幂等键不能为空: " + spel);
        }
        return sig.getDeclaringType().getSimpleName() + ":" + sig.getName() + ":" + v;
    }
}

第三道防线放在 DAO 层,捕获唯一键冲突后回查已有记录:

java
@Service
public class OrderService {

    @Idempotent(key = "#cmd.bizNo", expire = 600)
    @Transactional(rollbackFor = Exception.class)
    public OrderVO create(CreateOrderCmd cmd) {
        try {
            Order order = buildOrder(cmd);
            orderMapper.insert(order);   // uk_user_bizno(user_id, biz_no)
            return OrderVO.of(order);
        } catch (DuplicateKeyException e) {
            // ★ 不抛错,而是把第一次创建的订单原样返回
            Order exist = orderMapper.selectByBizNo(cmd.getUserId(), cmd.getBizNo());
            return OrderVO.of(exist);
        }
    }
}

边界 Case 自测清单

#Case期望行为
1同一 bizNo 并发两次请求一个执行、一个抛「正在处理中」,不产生两条订单
2第一次执行成功后再次请求返回与第一次完全相同的结果(而不是报重复)
3第一次执行抛业务异常Redis 键被删除,允许用户修正后重试
4执行过程中应用宕机键靠 TTL 自动过期释放,不会永久锁死该 bizNo
5Redis 不可用幂等切面降级放行(记录告警),由数据库唯一索引兜底
6bizNo 为 null 或空串启动即校验失败,直接抛参数异常,禁止「空键幂等」
7SpEL 表达式写错字段名解析阶段抛异常并明确提示表达式内容,便于定位
8返回值无法序列化降级为 releaseAfterDone=true 模式并打日志,不影响主流程
9分库分表场景下的唯一索引业务唯一键必须包含分片键,否则跨分片无法保证唯一
10客户端重试时换了新 bizNo所有防线失效(设计局限),需靠接口协议约定 + 监控告警发现

🚀 变种拓展题

  1. 把注解扩展为支持多级幂等策略(token 型 / 业务键型 / 结果缓存型),由枚举参数切换。
  2. 支持分布式环境下的「处理中」等待:重复请求阻塞等待第一次结果(带超时),而不是直接拒绝。
  3. 给 MQ 消费端做一个 @IdempotentConsume,自动用消息的业务键去重,并对接死信告警。
  4. 实现幂等记录持久化:Redis 之外落一张 idempotent_record 表,支持事后审计与排查。
  5. 结合 Spring 事务同步器,让幂等键的清理动作精确挂在事务提交/回滚之后,而不是方法返回时。

题 3:多级缓存组件(Caffeine + Redis + 穿透/击穿/雪崩三防) ​

核心实现

分层思路:本地缓存挡热点、Redis 挡主流量、DB 只兜底。三类问题分别对应三种手段。

java
@Component
@RequiredArgsConstructor
public class MultiLevelCache {

    private final StringRedisTemplate redis;
    private final RedissonClient redisson;

    /** L1:本地缓存,必须设上限和过期,否则就是内存泄漏 */
    private final Cache<String, Object> local = Caffeine.newBuilder()
            .maximumSize(10_000)
            .expireAfterWrite(Duration.ofSeconds(30))   // 本地 TTL 要短,容忍短暂不一致
            .recordStats()
            .build();

    private static final String NULL_HOLDER = "__NULL__";
    private static final Duration NULL_TTL = Duration.ofSeconds(60);

    public <T> T get(String key, Class<T> type, Duration ttl,
                     Function<String, T> loader) {
        // ① L1 本地
        Object l1 = local.getIfPresent(key);
        if (l1 != null) {
            return NULL_HOLDER.equals(l1) ? null : type.cast(l1);
        }

        // ② L2 Redis
        String l2 = redis.opsForValue().get(key);
        if (l2 != null) {
            if (NULL_HOLDER.equals(l2)) {           // ★ 防穿透:空值也缓存
                local.put(key, NULL_HOLDER);
                return null;
            }
            T val = JSON.parseObject(l2, type);
            local.put(key, val);
            return val;
        }

        // ③ 回源:★ 防击穿,同一 key 只允许一个线程回源
        RLock lock = redisson.getLock("lock:" + key);
        try {
            if (!lock.tryLock(200, 3000, TimeUnit.MILLISECONDS)) {
                // 抢不到锁:短暂等待后重读缓存,避免全部打到 DB
                Thread.sleep(50);
                return get(key, type, ttl, loader);
            }
            // 双检:可能别的线程已经回源完成
            String again = redis.opsForValue().get(key);
            if (again != null) {
                return NULL_HOLDER.equals(again) ? null : JSON.parseObject(again, type);
            }

            T db = loader.apply(key);
            if (db == null) {
                redis.opsForValue().set(key, NULL_HOLDER, NULL_TTL);
                local.put(key, NULL_HOLDER);
                return null;
            }
            // ★ 防雪崩:TTL 加随机抖动,避免大批 key 同时过期
            redis.opsForValue().set(key, JSON.toJSONString(db), jitter(ttl));
            local.put(key, db);
            return db;
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return loader.apply(key);     // 降级直接回源
        } finally {
            if (lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }

    /** 失效:先删 Redis,再广播让各节点清本地 */
    public void invalidate(String key) {
        redis.delete(key);
        local.invalidate(key);
        redis.convertAndSend("cache:invalidate", key);   // 其他节点订阅后清本地
    }

    private Duration jitter(Duration base) {
        long ms = base.toMillis();
        return Duration.ofMillis(ms + ThreadLocalRandom.current().nextLong(ms / 5));
    }
}

极端热点 key 可以进一步用逻辑过期,彻底消除「过期瞬间」:

java
/** value 里带逻辑过期时间,key 本身永不物理过期 */
record LogicalValue<T>(T data, long expireAt) {}

public <T> T getWithLogicalExpire(String key, Class<T> type,
                                  Duration ttl, Function<String, T> loader) {
    String raw = redis.opsForValue().get(key);
    if (raw == null) {
        return null;                       // 需要提前预热,未预热视为无数据
    }
    LogicalValue<T> lv = parse(raw, type);
    if (lv.expireAt() > System.currentTimeMillis()) {
        return lv.data();                  // 未过期,直接返回
    }
    // 已逻辑过期:只放一个线程去异步重建,其他线程先返回旧值
    RLock lock = redisson.getLock("rebuild:" + key);
    if (lock.tryLock()) {
        rebuildExecutor.execute(() -> {
            try {
                T fresh = loader.apply(key);
                redis.opsForValue().set(key, serialize(fresh, ttl));
            } finally {
                lock.unlock();
            }
        });
    }
    return lv.data();                      // ★ 永远不阻塞、永远不打 DB
}

边界 Case 自测清单

#Case期望行为
1查询不存在的 ID(恶意刷)空值被缓存 60 秒,后续请求不再打 DB
2热点 key 过期瞬间 1 万并发只有一个线程回源,其余等待后读缓存或返回旧值
3大批 key 同一时刻写入TTL 带随机抖动,过期时间被打散,不出现集中回源
4Redis 整体不可用降级直接回源 DB,同时靠本地缓存和限流保护 DB
5回源抛异常不写入缓存(避免缓存异常态),向上抛出并计入熔断统计
6数据更新后各节点本地缓存通过 Redis 发布订阅广播清理,最长不一致不超过本地 TTL
7本地缓存容量打满Caffeine 按 W-TinyLFU 淘汰,不会 OOM
8同一 key 的 null 与真实值切换数据从无到有时必须主动 invalidate,否则要等空值 TTL
9逻辑过期但从未预热的 key返回 null 并告警,提醒补预热任务
10锁等待超时不无限等待,超时后降级回源或返回旧值,避免线程堆积

🚀 变种拓展题

  1. 加入布隆过滤器前置拦截不存在的 ID,并设计「数据新增时如何更新布隆过滤器」。
  2. 把本地缓存失效广播从 Redis Pub/Sub 换成 MQ 广播消费,分析两者在可靠性上的差异。
  3. 实现缓存命中率监控:按 L1/L2/DB 三层分别上报命中率指标并接入告警。
  4. 支持批量查询(mget 语义),要求部分命中时只回源缺失的那部分。
  5. 实现一个热 key 自动探测:统计访问频次,超阈值自动提升到本地缓存并延长 TTL。

题 4:接口异步化改造(线程池 + CompletableFuture 编排 + 上下文透传) ​

核心实现

典型场景:订单详情页需要聚合订单、商品、物流、优惠券四份数据。串行调用 RT 是四者之和,并行后 RT 约等于最慢的那一个。

java
@Service
@RequiredArgsConstructor
public class OrderDetailService {

    private final ThreadPoolTaskExecutor bizExecutor;

    public OrderDetailVO detail(String orderNo) {
        // ① 主数据必须先拿到(后续调用依赖它)
        Order order = orderMapper.selectByOrderNo(orderNo);
        if (order == null) {
            throw new BizException("ORDER_NOT_FOUND");
        }

        // ② 四个无依赖关系的调用并行发起
        CompletableFuture<List<SkuVO>> skuF = CompletableFuture
                .supplyAsync(() -> skuClient.listByIds(order.skuIds()), bizExecutor)
                .orTimeout(300, TimeUnit.MILLISECONDS)
                .exceptionally(e -> {                // ★ 非核心数据降级为空
                    log.warn("sku degrade, orderNo={}", orderNo, e);
                    return List.of();
                });

        CompletableFuture<LogisticsVO> logisticsF = CompletableFuture
                .supplyAsync(() -> logisticsClient.query(orderNo), bizExecutor)
                .orTimeout(300, TimeUnit.MILLISECONDS)
                .exceptionally(e -> LogisticsVO.unknown());

        CompletableFuture<CouponVO> couponF = CompletableFuture
                .supplyAsync(() -> couponClient.detail(order.couponId()), bizExecutor)
                .orTimeout(200, TimeUnit.MILLISECONDS)
                .exceptionally(e -> null);

        // ③ 有依赖的用 thenCompose 串起来(先拿商家再拿商家评分)
        CompletableFuture<ShopVO> shopF = CompletableFuture
                .supplyAsync(() -> shopClient.get(order.getShopId()), bizExecutor)
                .thenCompose(shop -> CompletableFuture.supplyAsync(
                        () -> shopClient.withScore(shop), bizExecutor))
                .exceptionally(e -> ShopVO.unknown());

        // ④ 统一等待,整体超时保护(必须有,否则等于没设超时)
        try {
            CompletableFuture.allOf(skuF, logisticsF, couponF, shopF)
                    .get(500, TimeUnit.MILLISECONDS);
        } catch (TimeoutException te) {
            log.warn("detail aggregate timeout, orderNo={}", orderNo);
            // 不抛错:各 future 已有各自的 exceptionally 降级值
        } catch (Exception e) {
            throw new BizException("AGGREGATE_FAIL", e);
        }

        return OrderDetailVO.builder()
                .order(order)
                .skus(skuF.getNow(List.of()))
                .logistics(logisticsF.getNow(LogisticsVO.unknown()))
                .coupon(couponF.getNow(null))
                .shop(shopF.getNow(ShopVO.unknown()))
                .build();
    }
}

线程池必须显式配置并按业务隔离,禁止用 Executors 的无界工厂方法:

java
@Bean("bizExecutor")
public ThreadPoolTaskExecutor bizExecutor() {
    ThreadPoolTaskExecutor e = new ThreadPoolTaskExecutor();
    int cores = Runtime.getRuntime().availableProcessors();
    e.setCorePoolSize(cores * 2);              // IO 密集型,可适当放大
    e.setMaxPoolSize(cores * 4);
    e.setQueueCapacity(500);                   // ★ 必须有界,否则 OOM
    e.setKeepAliveSeconds(60);
    e.setThreadNamePrefix("biz-async-");
    // ★ CallerRuns:队列满时退化为调用线程执行,天然起到背压作用
    e.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
    // ★ 包一层装饰器,透传 traceId / 用户上下文
    e.setTaskDecorator(new ContextCopyingDecorator());
    e.initialize();
    return e;
}

上下文透传是异步化最容易漏的一环——换线程后 ThreadLocal 里的 traceId、用户信息全丢:

java
public class ContextCopyingDecorator implements TaskDecorator {

    @Override
    public Runnable decorate(Runnable runnable) {
        // 在「提交线程」里先抓取快照
        Map<String, String> mdc = MDC.getCopyOfContextMap();
        UserContext user = UserContextHolder.get();
        RequestAttributes attrs = RequestContextHolder.getRequestAttributes();

        return () -> {
            try {
                if (mdc != null) MDC.setContextMap(mdc);
                UserContextHolder.set(user);
                if (attrs != null) RequestContextHolder.setRequestAttributes(attrs);
                runnable.run();
            } finally {
                // ★ 线程会被复用,必须清理,否则下一个任务读到脏上下文
                MDC.clear();
                UserContextHolder.clear();
                RequestContextHolder.resetRequestAttributes();
            }
        };
    }
}

边界 Case 自测清单

#Case期望行为
1某个下游超时该字段走降级默认值,主流程正常返回,不整体失败
2所有下游都超时仍返回订单主数据 + 各降级值,接口不 5xx
3整体耗时超过 500msallOf.get 超时后用 getNow 取已有结果,不无限等待
4线程池队列打满CallerRunsPolicy 退化为同步执行,形成背压而不是丢任务
5异步线程里打日志traceId 与主线程一致,链路可完整串联
6异步线程读取用户上下文能取到提交线程的用户信息
7线程复用后的上下文残留finally 清理生效,不会读到上一个任务的上下文
8异步任务内抛未捕获异常被 exceptionally 捕获并记录,不出现静默丢失
9多个异步任务共用同一下游该下游压力是接口 QPS 的 N 倍(扇出),需单独评估容量
10事务方法内提交异步任务异步线程不在事务内,不能依赖事务可见性,必须在提交后触发

🚀 变种拓展题

  1. 把线程池改为按下游服务隔离(舱壁模式),分析它与共享线程池的优劣。
  2. 为异步任务加上Sentinel 熔断,并设计熔断后的降级数据来源(本地缓存 / 兜底文案)。
  3. 用 CompletableFuture 实现「任一成功即返回」(anyOf 语义)的多源查询,并取消其余任务。
  4. 把上下文透传改造为通用的 TransmittableThreadLocal 方案,对比 TaskDecorator 的差异。
  5. 实现一个异步编排 DAG:声明任务间依赖关系,由框架自动决定并行与串行顺序。

题 5:库存一致性对账与补偿任务(差异检测 + 分级处置 + 告警) ​

核心实现

对账的核心原则:能自动修的只修「安全方向」,可能涉及资损的只告警不自动改。

java
@Component
@RequiredArgsConstructor
public class StockReconcileJob {

    private final StringRedisTemplate redis;
    private final StockMapper stockMapper;
    private final AlertSender alert;

    /** 允许的误差:预占中的订单会造成合理差异 */
    private static final int TOLERANCE = 0;

    @Scheduled(cron = "0 */5 * * * ?")
    @DistributedLock(key = "job:stock:reconcile", expire = 280)   // 防多实例并发
    public void reconcile() {
        long cursor = 0L;
        ReconcileReport report = new ReconcileReport();

        // ① 分页扫描活跃 SKU(禁止一次性全量,大表会拖垮 DB)
        List<Long> skuIds;
        while (!(skuIds = stockMapper.scanActiveSkuIds(cursor, 500)).isEmpty()) {
            for (Long skuId : skuIds) {
                try {
                    check(skuId, report);
                } catch (Exception e) {
                    report.addError(skuId, e);     // 单个失败不中断整体
                    log.error("reconcile fail, skuId={}", skuId, e);
                }
            }
            cursor = skuIds.get(skuIds.size() - 1);
        }
        publish(report);
    }

    private void check(Long skuId, ReconcileReport report) {
        // ② 取三方数据:Redis 剩余、DB 剩余、处理中(预占)数量
        Integer redisLeft = parseInt(redis.opsForValue().get("stock:" + skuId));
        if (redisLeft == null) {
            report.addMissing(skuId);              // Redis 未预热,需补预热
            return;
        }
        StockDO db = stockMapper.selectForRead(skuId);
        int pending = orderMapper.countPending(skuId);   // 已预扣未落库

        // ③ 一致性等式:Redis剩余 + 处理中 == DB剩余
        int expected = db.getStock();
        int actual = redisLeft + pending;
        int diff = actual - expected;

        if (Math.abs(diff) <= TOLERANCE) {
            return;                                 // 一致
        }

        if (diff < 0) {
            // ④ 安全方向:Redis 比 DB 少(少卖),自动回补
            //    最坏结果只是少卖,不会超卖,可以自动修
            autoRepair(skuId, -diff, redisLeft);
            report.addRepaired(skuId, diff);
        } else {
            // ⑤ 危险方向:Redis 比 DB 多,意味着可能已超卖
            //    绝不自动改数据,立即告警 + 可选自动下架止损
            alert.p1("库存异常疑似超卖 skuId=" + skuId
                    + " redis=" + redisLeft + " db=" + expected
                    + " pending=" + pending + " diff=" + diff);
            report.addDanger(skuId, diff);
        }
    }

    /** 回补必须幂等 + CAS,避免并发扣减期间把正在变化的值覆盖掉 */
    private void autoRepair(Long skuId, int delta, int expectOld) {
        String script = """
                if tonumber(redis.call('GET', KEYS[1])) == tonumber(ARGV[1]) then
                  redis.call('INCRBY', KEYS[1], ARGV[2])
                  redis.call('DEL', KEYS[2])
                  return 1
                end
                return 0
                """;
        Long ok = redis.execute(new DefaultRedisScript<>(script, Long.class),
                List.of("stock:" + skuId, "soldout:" + skuId),
                String.valueOf(expectOld), String.valueOf(delta));
        if (!Long.valueOf(1L).equals(ok)) {
            log.info("repair skipped (value changed), skuId={}", skuId);  // 下轮再查
        }
    }
}

对账之外还需要单笔补偿,处理「预扣成功但落库失败」的幽灵占用:

java
@Component
public class StockCompensator {

    /** 按订单号打标,保证同一笔只回补一次 */
    public void compensate(SeckillCmd cmd, String orderNo) {
        String flag = "compensated:" + orderNo;
        Boolean first = redis.opsForValue()
                .setIfAbsent(flag, "1", Duration.ofDays(1));
        if (!Boolean.TRUE.equals(first)) {
            return;                       // 已回补过,直接跳过
        }
        try {
            redis.opsForValue().increment("stock:" + cmd.getSkuId(), cmd.getQty());
            redis.opsForHash().increment("bought:" + cmd.getActivityId(),
                    String.valueOf(cmd.getUserId()), -cmd.getQty());
            redis.delete("soldout:" + cmd.getActivityId());   // 有货了要清售罄标记
        } catch (Exception e) {
            redis.delete(flag);           // ★ 回补失败要撤标记,否则这笔永远补不回来
            // 落一条补偿记录,交给兜底任务重试
            compensateLogMapper.insert(CompensateLog.of(orderNo, cmd));
            throw e;
        }
    }
}

边界 Case 自测清单

#Case期望行为
1Redis 与 DB 完全一致无任何操作和告警,只记录一次对账成功指标
2Redis 比 DB 少(少卖方向)自动 CAS 回补,并记入已修复列表
3Redis 比 DB 多(疑似超卖)绝不自动改,立即 P1 告警并记入危险列表
4对账期间库存正在被并发扣减CAS 比对旧值失败则跳过本轮,下一轮重新判断,不覆盖新值
5存在预占中(已预扣未落库)的订单计入 pending 项,不被误判为差异
6Redis 中某 SKU 的 key 不存在判定为未预热,记入 missing 列表并触发预热任务,不当作 0 处理
7多实例同时触发定时任务分布式锁保证只有一个实例执行
8单个 SKU 校验抛异常捕获后继续下一个,整体任务不中断,异常计入报告
9单笔补偿被并发调用两次标记位拦住,库存只回补一次
10补偿执行到一半失败撤掉标记位 + 落补偿日志,由兜底任务重试,不留下不可恢复状态

🚀 变种拓展题

  1. 把定时对账改造为订阅 binlog 的实时对账,对比两者在发现延迟和实现复杂度上的取舍。
  2. 设计对账报告看板:每轮的差异数量、自动修复数、危险项趋势,并对趋势异常告警。
  3. 实现资金类对账(订单金额 vs 支付流水),说明为什么资金对账一律不允许自动修数。
  4. 给补偿日志表设计兜底重试任务:指数退避、最大重试次数、超限转人工工单。
  5. 实现一个可回放的对账:保存每轮快照,支持按时间点回溯查看差异是何时出现的。

⚠️ 当日踩坑实录 ​

❌ 坑✅ 解决方案
讲项目只罗列技术栈(「用了 Spring Cloud、Redis、MQ」),不讲业务痛点和量化结果改成「痛点 → 方案 → 量化收益」三段式。每个技术选择都要能回答「不用它会死在哪」和「用了之后数字变成多少」
八股只会背概念,一问「你项目里哪儿用到了」就卡住建立「知识点 ↔ 项目场景」双向映射表(见基础原理第 14 节)。每个考点都绑定一个本项目的真实落地点
简历写「负责核心模块开发」这类无信息量的描述换成「负责订单服务,主导库存扣减改造,将下单 P99 从 3s 降到 180ms,支撑大促 1 万 QPS 零超卖」——有范围、有动作、有数字
量化数字张口就来,被追问「怎么算的」答不上所有数字必须能复述推导过程(压测结论 / 监控截图 / 容量公式)。宁可说「大概 2000 TPS,是压测压出来的」,也不要编一个精确到个位的假数
讲架构图从上到下念组件,面试官听不出重点按请求链路讲,而不是按图层讲。沿着「用户点击下单 → 网关 → 订单服务 → 库存 → MQ → 落库」走一遍,重点停在你做过的那几跳
排查故障时先猜原因再验证,经常猜错方向浪费时间固化 SOP:先止损(能回滚就回滚)→ 看监控定范围 → 看链路定哪一跳 → 再按方向验证。任何性能问题都要先有数据定位再谈方案
库存回滚只回滚了 DB 事务,忘了回补 Redis 预扣把 Redis 回补挂在事务回滚回调里,并用订单号打标做幂等,防止重复回补导致库存虚高
幂等只做了 Redis token,Redis 抖动时漏过重复请求补上数据库唯一索引作为确定性兜底,并把 DuplicateKeyException 处理成「回查已有记录并返回」而不是报错
异步化改造后链路日志断了,traceId 查不到异步分支给线程池配 TaskDecorator 透传 MDC 和用户上下文,并在 finally 里清理,避免线程复用读到脏上下文
被问到没参与的模块时答「这不是我做的」,显得推责用三段式止损法:诚实划界 → 讲清我理解的整体设计 → 给出我的技术判断
面试官问「有什么想问我的」时答「没有了」,浪费最后一个加分位准备 2 个基于本场对话延伸的问题(团队当前最大技术痛点、希望我前三个月做成什么)
讲不足时停在「这块我们没做」,变成扣分项三段式讲不足:承认现状 → 说清代价为什么可接受 → 给出改进方向和成本评估

📌 当日复盘任务 ​

项目叙述(必须开口练)

  • [ ] 画一遍本项目的五层架构图,并能对每个组件回答「不用它会死在哪」
  • [ ] 按请求链路口述一遍下单主链路的 9 步同步 + 2 步异步,每步说出失败处理方式
  • [ ] 用 STAR 法则完整口述一次「大促性能攻坚」,控制在 2 分钟内,录音回听
  • [ ] 准备一个「项目最难问题」和一个「项目最大不足」,各练到 90 秒内讲完

简历与量化

  • [ ] 把简历项目段落改写成三段式(定位+规模 / 负责什么+关键设计 / 量化结果)
  • [ ] 列出简历里所有数字,逐个确认能复述推导过程,删掉说不清来源的
  • [ ] 自查简历是否还有「负责核心模块」这类无信息量描述,全部替换

八股与项目绑定

  • [ ] 复述基础原理第 14 节的知识地图,Day1~Day19 每个考点说出对应的项目落地点
  • [ ] 完成第 15 节「为什么不用 X」清单,每个组件按选型四步法答一遍
  • [ ] 把本文 12 道面试题的「精简口述答案」全部开口说一遍,卡顿处标记重练
  • [ ] 每道题至少答出 3 层连环追问,答不上来的回到对应 Day 补课

排障能力

  • [ ] 默写接口变慢、CPU 飙高、OOM、MQ 堆积四套排查 SOP 的关键命令
  • [ ] 在本地起一个服务,手动制造 CPU 100% 和 OOM,完整走一遍定位流程
  • [ ] 整理一份属于自己的「故障处置卡」:现象 → 排查方向 → 常用命令 → 止损动作

手撕代码

  • [ ] 不看答案手写秒杀 Lua 脚本 + Java 编排,自测 10 个边界 Case
  • [ ] 手写幂等注解 + AOP 切面,重点验证「业务异常要释放锁」这条
  • [ ] 手写多级缓存,验证穿透 / 击穿 / 雪崩三种防护是否都生效
  • [ ] 手写 CompletableFuture 聚合 + 上下文透传,验证 traceId 能串联
  • [ ] 手写对账任务,重点验证「危险方向绝不自动修数」的逻辑分支
  • [ ] 每题至少完成 2 个变种拓展题

面试临场

  • [ ] 准备 2 个反问问题,确认不触碰薪资、表现评价、可查信息三个禁区
  • [ ] 针对「没做过的模块」准备 3 个三段式回答模板(支付、分库分表、JVM 调优)
  • [ ] 找同伴做一次 40 分钟模拟面试,全程录音,复盘卡顿和逻辑漏洞