Appearance
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,堆 8G | Full 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 memory | Netty / 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 Spring | IOC/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 的优点等于没有做选型。
| 我们用了 | 为什么不用它的替代品 |
|---|---|
| Nacos | Eureka 已停止维护(2.x 闭源);ZooKeeper 是 CP 的,选主期间整个注册中心不可用,这对注册中心是硬伤;Consul 生态在国内偏弱。而 Nacos 注册 + 配置二合一,少维护一套组件 |
| Sentinel | Hystrix 已停更;Sentinel 有独立控制台 + 规则持久化 + 热点参数限流 + 系统自适应,而且默认用信号量隔离,没有 Hystrix 线程池隔离的上下文丢失和线程开销问题 |
| Gateway | Zuul 1.x是同步阻塞模型,每个请求占一个线程,网关这种高并发 IO 密集场景必须用异步非阻塞(WebFlux/Netty) |
| RocketMQ | RabbitMQ 单队列堆积能力弱、不支持大规模消息堆积;Kafka 更适合日志/数仓场景,事务消息和延迟消息的能力不如 RocketMQ,而我们强依赖这两个特性 |
| Seata AT | 2PC/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 | 中 | 高并发下失败率极高,大量重试放大压力 | 冲突少的场景 |
| ③ 原子 UPDATE | UPDATE 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 |
| 单台 Nginx | 3~5 万 QPS(静态/反代) |
| RocketMQ 单 Broker | 10 万级 TPS(顺序写 + 零拷贝) |
| 一次跨机房网络 RTT | 1~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 滚动更新 / 网关权重 | 纯技术改动(性能优化、重构) |
| 按用户 ID | userId % 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 归因问题。分布式系统最大的难点其实不是设计,而是出问题后能不能快速知道问题在哪——所以可观测不是附属品,是设计的一部分。
连环追问链
| # | 追问 | 答题要点 |
|---|---|---|
| 1 | CAP 里的 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 的原子 UPDATE | DB 单行热点更新上限约 1000~3000 TPS(行锁串行),秒杀峰值远超这个量级;Redis 单实例能到 10 万 QPS,且 Lua 保证原子 |
| 2 | Redis 挂了怎么办 | ① 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 TPS | InnoDB 行锁让同一行的更新完全串行,单次更新含加锁、写 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 单线程里整段执行,天然原子 |
| 3 | biz_no 从哪来 | 由客户端在进入下单流程时生成(UUID/雪花 ID)并全程携带,重试时必须复用同一个。如果客户端每次重试都换新的,所有服务端幂等手段都失效——这点必须在接口协议里明确约定 |
| 4 | token 过期了怎么办 | 返回明确的「页面已过期,请刷新重试」业务码,让客户端重新领 token。不要静默放行,也不要报系统错误 |
| 5 | MQ 消费侧怎么保证幂等 | 同理:消息里带业务唯一键,消费时先按唯一键查/插,依赖唯一索引拦重复。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 一般有线程剖析功能 |
| 5 | CPU 高但业务没变慢,要不要处理 | 要看。可能是定时任务或批处理在跑(正常),也可能是水位已经接近上限、只是还没触发排队。原则是水位超过 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 exceeded | GC 占用超 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 能降回低位但很快又涨满 = 容量/流量问题,考虑扩容或优化对象创建 |
| 4 | Metaspace 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 万订单」。用风险和成本说话,而不是「代码不优雅」 |
| 6 | CQRS 的代价是什么 | 读写模型分离后一致性变成最终一致(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 | 回补逻辑被重复触发 | 按订单号标记位拦住,库存只回补一次 |
| 7 | MQ 重复投递同一条消息 | 唯一索引拦住,消费侧当成功处理,不产生重复订单 |
| 8 | 消费侧 DB 扣减返回 0 行 | 抛异常触发重试,超限进死信队列并告警,人工介入对账 |
| 9 | Redis 整体不可用 | 降级为 DB 原子 UPDATE 扣减 + 收紧限流,宁可少卖不超卖 |
| 10 | 活动结束但仍有请求进来 | 活动状态校验前置(可缓存在本地),直接拒绝,不打 Redis |
🚀 变种拓展题
- 库存分桶:把 1000 库存拆成 10 个 key,请求随机落桶。要求实现「某桶卖空时跨桶重试」且总量绝不超卖。
- 预占超时释放:下单后库存处于预占态,30 分钟未支付自动释放。用 RocketMQ 延迟消息实现,并保证与「支付成功」互斥。
- 多商品打包秒杀:一次下单含多个 SKU,要求 Lua 内按 skuId 升序依次扣减,任一不足则全部回滚。
- 排队号与进度查询:返回排队序号,并提供「前面还有多少人」的查询接口。
- 防黄牛增强:在 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 |
| 5 | Redis 不可用 | 幂等切面降级放行(记录告警),由数据库唯一索引兜底 |
| 6 | bizNo 为 null 或空串 | 启动即校验失败,直接抛参数异常,禁止「空键幂等」 |
| 7 | SpEL 表达式写错字段名 | 解析阶段抛异常并明确提示表达式内容,便于定位 |
| 8 | 返回值无法序列化 | 降级为 releaseAfterDone=true 模式并打日志,不影响主流程 |
| 9 | 分库分表场景下的唯一索引 | 业务唯一键必须包含分片键,否则跨分片无法保证唯一 |
| 10 | 客户端重试时换了新 bizNo | 所有防线失效(设计局限),需靠接口协议约定 + 监控告警发现 |
🚀 变种拓展题
- 把注解扩展为支持多级幂等策略(token 型 / 业务键型 / 结果缓存型),由枚举参数切换。
- 支持分布式环境下的「处理中」等待:重复请求阻塞等待第一次结果(带超时),而不是直接拒绝。
- 给 MQ 消费端做一个
@IdempotentConsume,自动用消息的业务键去重,并对接死信告警。 - 实现幂等记录持久化:Redis 之外落一张
idempotent_record表,支持事后审计与排查。 - 结合 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 带随机抖动,过期时间被打散,不出现集中回源 |
| 4 | Redis 整体不可用 | 降级直接回源 DB,同时靠本地缓存和限流保护 DB |
| 5 | 回源抛异常 | 不写入缓存(避免缓存异常态),向上抛出并计入熔断统计 |
| 6 | 数据更新后各节点本地缓存 | 通过 Redis 发布订阅广播清理,最长不一致不超过本地 TTL |
| 7 | 本地缓存容量打满 | Caffeine 按 W-TinyLFU 淘汰,不会 OOM |
| 8 | 同一 key 的 null 与真实值切换 | 数据从无到有时必须主动 invalidate,否则要等空值 TTL |
| 9 | 逻辑过期但从未预热的 key | 返回 null 并告警,提醒补预热任务 |
| 10 | 锁等待超时 | 不无限等待,超时后降级回源或返回旧值,避免线程堆积 |
🚀 变种拓展题
- 加入布隆过滤器前置拦截不存在的 ID,并设计「数据新增时如何更新布隆过滤器」。
- 把本地缓存失效广播从 Redis Pub/Sub 换成 MQ 广播消费,分析两者在可靠性上的差异。
- 实现缓存命中率监控:按 L1/L2/DB 三层分别上报命中率指标并接入告警。
- 支持批量查询(
mget语义),要求部分命中时只回源缺失的那部分。 - 实现一个热 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 | 整体耗时超过 500ms | allOf.get 超时后用 getNow 取已有结果,不无限等待 |
| 4 | 线程池队列打满 | CallerRunsPolicy 退化为同步执行,形成背压而不是丢任务 |
| 5 | 异步线程里打日志 | traceId 与主线程一致,链路可完整串联 |
| 6 | 异步线程读取用户上下文 | 能取到提交线程的用户信息 |
| 7 | 线程复用后的上下文残留 | finally 清理生效,不会读到上一个任务的上下文 |
| 8 | 异步任务内抛未捕获异常 | 被 exceptionally 捕获并记录,不出现静默丢失 |
| 9 | 多个异步任务共用同一下游 | 该下游压力是接口 QPS 的 N 倍(扇出),需单独评估容量 |
| 10 | 事务方法内提交异步任务 | 异步线程不在事务内,不能依赖事务可见性,必须在提交后触发 |
🚀 变种拓展题
- 把线程池改为按下游服务隔离(舱壁模式),分析它与共享线程池的优劣。
- 为异步任务加上Sentinel 熔断,并设计熔断后的降级数据来源(本地缓存 / 兜底文案)。
- 用
CompletableFuture实现「任一成功即返回」(anyOf语义)的多源查询,并取消其余任务。 - 把上下文透传改造为通用的
TransmittableThreadLocal方案,对比 TaskDecorator 的差异。 - 实现一个异步编排 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 | 期望行为 |
|---|---|---|
| 1 | Redis 与 DB 完全一致 | 无任何操作和告警,只记录一次对账成功指标 |
| 2 | Redis 比 DB 少(少卖方向) | 自动 CAS 回补,并记入已修复列表 |
| 3 | Redis 比 DB 多(疑似超卖) | 绝不自动改,立即 P1 告警并记入危险列表 |
| 4 | 对账期间库存正在被并发扣减 | CAS 比对旧值失败则跳过本轮,下一轮重新判断,不覆盖新值 |
| 5 | 存在预占中(已预扣未落库)的订单 | 计入 pending 项,不被误判为差异 |
| 6 | Redis 中某 SKU 的 key 不存在 | 判定为未预热,记入 missing 列表并触发预热任务,不当作 0 处理 |
| 7 | 多实例同时触发定时任务 | 分布式锁保证只有一个实例执行 |
| 8 | 单个 SKU 校验抛异常 | 捕获后继续下一个,整体任务不中断,异常计入报告 |
| 9 | 单笔补偿被并发调用两次 | 标记位拦住,库存只回补一次 |
| 10 | 补偿执行到一半失败 | 撤掉标记位 + 落补偿日志,由兜底任务重试,不留下不可恢复状态 |
🚀 变种拓展题
- 把定时对账改造为订阅 binlog 的实时对账,对比两者在发现延迟和实现复杂度上的取舍。
- 设计对账报告看板:每轮的差异数量、自动修复数、危险项趋势,并对趋势异常告警。
- 实现资金类对账(订单金额 vs 支付流水),说明为什么资金对账一律不允许自动修数。
- 给补偿日志表设计兜底重试任务:指数退避、最大重试次数、超限转人工工单。
- 实现一个可回放的对账:保存每轮快照,支持按时间点回溯查看差异是何时出现的。
⚠️ 当日踩坑实录
| ❌ 坑 | ✅ 解决方案 |
|---|---|
| 讲项目只罗列技术栈(「用了 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 分钟模拟面试,全程录音,复盘卡顿和逻辑漏洞