从单体到微服务:订单系统拆成 5 个进程后,解决了什么又失去了什么?
配套仓库:order-system(单体版)→ order-system-cloud(微服务版,本文主角)
很多人讲微服务,讲的是「微服务有什么好处」的 PPT。但真到了生产上,比「好处」更重要的是代价——拆开之后你失去了什么,怎么把它补回来。这篇文章用一个真实落地的订单交易系统,把两边的账都算清楚。
一、业务背景:单体订单系统在哪一刻开始「撑不住」
先看业务。这是一个电商后台的订单交易系统,核心链路包括:
- 用户域:注册登录、JWT 签发、Token 黑名单登出
- 商品域:商品 CRUD、上架状态、Redis 缓存三防(穿透/击穿/雪崩)、RBAC 权限
- 交易域:购物车、下单、支付、取消,下单前有一串责任链校验
- 库存域:扣减、恢复
- 通知域:下单后送积分、发短信、App 推送
单体版的实现非常"顺":一个进程跑全部业务,Service 之间是 Java 方法调用,一个 @Transactional 从头管到尾,库存扣减就写在 order 模块里,最后打成一个 jar 包 java -jar 启动。
顺,但有三个时刻它会开始疼:
① 团队协作的摩擦成本 五个人以上改同一个仓库。改库存逻辑的人,要连带把订单模块重新构建、全量回归。一次发版,所有人都得停下手里的活等——因为只有一个制品。
② 故障不隔离 短信服务商的 SDK 偶发阻塞,把公共线程池占满。结果不是"短信发不出去",而是整个下单接口全部超时——因为它们在同一个进程里抢同一批线程。
③ 扩容只能整体扩容 大促时压力全在下单链路上,但单体只能整体加机器。给库存加的那部分 CPU 和内存,被用户查询、商品列表一起白白吃掉。
这才是拆微服务的真实驱动:团队拆分协作、故障隔离、独立扩容。注意这三条,没有一条是"性能"——单体在性能上其实往往更快,因为省掉了网络开销。拆微服务换来的是组织能力,不是性能。
反过来,拆完就必须埋单:服务发现、远程调用、分布式事务。下面把这笔账一笔一笔算。
下面是单体订单系统的痛点分析图:
#mermaid-svg-PIRzkl5VElfm8K0L{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-PIRzkl5VElfm8K0L .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-PIRzkl5VElfm8K0L .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-PIRzkl5VElfm8K0L .error-icon{fill:#552222;}#mermaid-svg-PIRzkl5VElfm8K0L .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-PIRzkl5VElfm8K0L .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-PIRzkl5VElfm8K0L .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-PIRzkl5VElfm8K0L .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-PIRzkl5VElfm8K0L .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-PIRzkl5VElfm8K0L .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-PIRzkl5VElfm8K0L .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-PIRzkl5VElfm8K0L .marker{fill:#333333;stroke:#333333;}#mermaid-svg-PIRzkl5VElfm8K0L .marker.cross{stroke:#333333;}#mermaid-svg-PIRzkl5VElfm8K0L svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-PIRzkl5VElfm8K0L p{margin:0;}#mermaid-svg-PIRzkl5VElfm8K0L .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-PIRzkl5VElfm8K0L .cluster-label text{fill:#333;}#mermaid-svg-PIRzkl5VElfm8K0L .cluster-label span{color:#333;}#mermaid-svg-PIRzkl5VElfm8K0L .cluster-label span p{background-color:transparent;}#mermaid-svg-PIRzkl5VElfm8K0L .label text,#mermaid-svg-PIRzkl5VElfm8K0L span{fill:#333;color:#333;}#mermaid-svg-PIRzkl5VElfm8K0L .node rect,#mermaid-svg-PIRzkl5VElfm8K0L .node circle,#mermaid-svg-PIRzkl5VElfm8K0L .node ellipse,#mermaid-svg-PIRzkl5VElfm8K0L .node polygon,#mermaid-svg-PIRzkl5VElfm8K0L .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-PIRzkl5VElfm8K0L .rough-node .label text,#mermaid-svg-PIRzkl5VElfm8K0L .node .label text,#mermaid-svg-PIRzkl5VElfm8K0L .image-shape .label,#mermaid-svg-PIRzkl5VElfm8K0L .icon-shape .label{text-anchor:middle;}#mermaid-svg-PIRzkl5VElfm8K0L .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-PIRzkl5VElfm8K0L .rough-node .label,#mermaid-svg-PIRzkl5VElfm8K0L .node .label,#mermaid-svg-PIRzkl5VElfm8K0L .image-shape .label,#mermaid-svg-PIRzkl5VElfm8K0L .icon-shape .label{text-align:center;}#mermaid-svg-PIRzkl5VElfm8K0L .node.clickable{cursor:pointer;}#mermaid-svg-PIRzkl5VElfm8K0L .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-PIRzkl5VElfm8K0L .arrowheadPath{fill:#333333;}#mermaid-svg-PIRzkl5VElfm8K0L .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-PIRzkl5VElfm8K0L .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-PIRzkl5VElfm8K0L .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-PIRzkl5VElfm8K0L .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-PIRzkl5VElfm8K0L .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-PIRzkl5VElfm8K0L .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-PIRzkl5VElfm8K0L .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-PIRzkl5VElfm8K0L .cluster text{fill:#333;}#mermaid-svg-PIRzkl5VElfm8K0L .cluster span{color:#333;}#mermaid-svg-PIRzkl5VElfm8K0L div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-PIRzkl5VElfm8K0L .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-PIRzkl5VElfm8K0L rect.text{fill:none;stroke-width:0;}#mermaid-svg-PIRzkl5VElfm8K0L .icon-shape,#mermaid-svg-PIRzkl5VElfm8K0L .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-PIRzkl5VElfm8K0L .icon-shape p,#mermaid-svg-PIRzkl5VElfm8K0L .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-PIRzkl5VElfm8K0L .icon-shape .label rect,#mermaid-svg-PIRzkl5VElfm8K0L .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-PIRzkl5VElfm8K0L .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-PIRzkl5VElfm8K0L .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-PIRzkl5VElfm8K0L :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
单体订单系统
痛点① 团队协作摩擦
痛点② 故障不隔离
痛点③ 扩容只能整体扩容
5人+改同一仓库
一次发版全员等待
短信SDK阻塞
公共线程池被占满
整个下单接口超时
压力集中在下单链路
库存加的资源被查询/列表白白吃掉
驱动拆微服务
换来的是组织能力,不是性能
二、架构:1 个进程 → 5 个进程的数据流向
拆完之后的整体架构:
浏览器 / Postman
│
▼
┌─────────────┐
│ Gateway │ :9000 — 统一入口,根据路径转发
└──────┬──────┘
│
┌────┼────────┬────────┐
▼ ▼ ▼ ▼
user product order stock
:8081 :8082 :8083 :8084
│ │ │ │
└─────┴────────┴───────┘
│
Nacos :8848 — 服务注册发现
│
MySQL + Redis — 数据存储(学习阶段共享,生产独立)
│
RocketMQ :9876 — 异步解耦(积分/短信/推送)
服务间的调用关系非常克制,只有三条:
order-service ──Feign──▶ stock-service (扣库存)
order-service ──Feign──▶ product-service (查商品信息)
order-service ──Feign──▶ product-service (购物车校验)
一次「提交订单」请求的完整数据流,建议背下来,面试画得出来:
Vue 前端
│ POST /api/order/create (带 JWT)
▼
Gateway :9000 ← 路由断言匹配 /api/order/**,转发到 order-service
│
▼
order-service :8083
├─ 1. LoginInterceptor 校验 JWT,解析出 userId 放进上下文
├─ 2. 下单责任链校验(商品是否上架 / 购物车归属 / 数量合法性)
├─ 3. 生成订单号(雪花算法 + 基因法)
├─ 4. Feign → stock-service :8084 扣库存(不在本地事务里!)
│ └─ 原子 UPDATE + 幂等键,见第四节
├─ 5. 本地事务:写 t_order + t_order_item + t_event_record(WAIT)
├─ 6. 本地事务失败 → compensateDeducts 把已扣的按幂等键还回去
└─ 7. 返回 Result<OrderVO>
│
▼ (异步,由 EventRetryScheduler 每 5s 扫描补发)
RocketMQ :9876
│
▼
OrderEventConsumer → 写积分 / 短信 / 推送 → t_message
单体的对比很直观:
| 进程数 | 1 | 5 |
| 调用方式 | Java 方法调用 | OpenFeign HTTP 调用 |
| 事务 | 全程 @Transactional | 各服务独立事务 |
| 消息队列 | 无 | RocketMQ 异步处理积分/短信/推送 |
| 部署 | java -jar 一条命令 | 按需独立部署 |
| 扩容 | 整体扩容 | 哪里瓶颈扩哪里 |
下面是「提交订单」的完整时序图:
渲染错误: Mermaid 渲染失败: Parse error on line 23: …Result<OrderVO> Note over OS,M ———————–^ Expecting '()', 'SOLID_OPEN_ARROW', 'DOTTED_OPEN_ARROW', 'SOLID_ARROW', 'SOLID_ARROW_TOP', 'SOLID_ARROW_BOTTOM', 'STICK_ARROW_TOP', 'STICK_ARROW_BOTTOM', 'SOLID_ARROW_TOP_DOTTED', 'SOLID_ARROW_BOTTOM_DOTTED', 'STICK_ARROW_TOP_DOTTED', 'STICK_ARROW_BOTTOM_DOTTED', 'SOLID_ARROW_TOP_REVERSE', 'SOLID_ARROW_BOTTOM_REVERSE', 'STICK_ARROW_TOP_REVERSE', 'STICK_ARROW_BOTTOM_REVERSE', 'SOLID_ARROW_TOP_REVERSE_DOTTED', 'SOLID_ARROW_BOTTOM_REVERSE_DOTTED', 'STICK_ARROW_TOP_REVERSE_DOTTED', 'STICK_ARROW_BOTTOM_REVERSE_DOTTED', 'BIDIRECTIONAL_SOLID_ARROW', 'DOTTED_ARROW', 'BIDIRECTIONAL_DOTTED_ARROW', 'SOLID_CROSS', 'DOTTED_CROSS', 'SOLID_POINT', 'DOTTED_POINT', got 'NEWLINE'
下面是微服务架构的整体拓扑图:
#mermaid-svg-JC9bPKmpH5SmBtK6{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-JC9bPKmpH5SmBtK6 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-JC9bPKmpH5SmBtK6 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-JC9bPKmpH5SmBtK6 .error-icon{fill:#552222;}#mermaid-svg-JC9bPKmpH5SmBtK6 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-JC9bPKmpH5SmBtK6 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-JC9bPKmpH5SmBtK6 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-JC9bPKmpH5SmBtK6 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-JC9bPKmpH5SmBtK6 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-JC9bPKmpH5SmBtK6 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-JC9bPKmpH5SmBtK6 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-JC9bPKmpH5SmBtK6 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-JC9bPKmpH5SmBtK6 .marker.cross{stroke:#333333;}#mermaid-svg-JC9bPKmpH5SmBtK6 svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-JC9bPKmpH5SmBtK6 p{margin:0;}#mermaid-svg-JC9bPKmpH5SmBtK6 .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-JC9bPKmpH5SmBtK6 .cluster-label text{fill:#333;}#mermaid-svg-JC9bPKmpH5SmBtK6 .cluster-label span{color:#333;}#mermaid-svg-JC9bPKmpH5SmBtK6 .cluster-label span p{background-color:transparent;}#mermaid-svg-JC9bPKmpH5SmBtK6 .label text,#mermaid-svg-JC9bPKmpH5SmBtK6 span{fill:#333;color:#333;}#mermaid-svg-JC9bPKmpH5SmBtK6 .node rect,#mermaid-svg-JC9bPKmpH5SmBtK6 .node circle,#mermaid-svg-JC9bPKmpH5SmBtK6 .node ellipse,#mermaid-svg-JC9bPKmpH5SmBtK6 .node polygon,#mermaid-svg-JC9bPKmpH5SmBtK6 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-JC9bPKmpH5SmBtK6 .rough-node .label text,#mermaid-svg-JC9bPKmpH5SmBtK6 .node .label text,#mermaid-svg-JC9bPKmpH5SmBtK6 .image-shape .label,#mermaid-svg-JC9bPKmpH5SmBtK6 .icon-shape .label{text-anchor:middle;}#mermaid-svg-JC9bPKmpH5SmBtK6 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-JC9bPKmpH5SmBtK6 .rough-node .label,#mermaid-svg-JC9bPKmpH5SmBtK6 .node .label,#mermaid-svg-JC9bPKmpH5SmBtK6 .image-shape .label,#mermaid-svg-JC9bPKmpH5SmBtK6 .icon-shape .label{text-align:center;}#mermaid-svg-JC9bPKmpH5SmBtK6 .node.clickable{cursor:pointer;}#mermaid-svg-JC9bPKmpH5SmBtK6 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-JC9bPKmpH5SmBtK6 .arrowheadPath{fill:#333333;}#mermaid-svg-JC9bPKmpH5SmBtK6 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-JC9bPKmpH5SmBtK6 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-JC9bPKmpH5SmBtK6 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-JC9bPKmpH5SmBtK6 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-JC9bPKmpH5SmBtK6 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-JC9bPKmpH5SmBtK6 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-JC9bPKmpH5SmBtK6 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-JC9bPKmpH5SmBtK6 .cluster text{fill:#333;}#mermaid-svg-JC9bPKmpH5SmBtK6 .cluster span{color:#333;}#mermaid-svg-JC9bPKmpH5SmBtK6 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-JC9bPKmpH5SmBtK6 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-JC9bPKmpH5SmBtK6 rect.text{fill:none;stroke-width:0;}#mermaid-svg-JC9bPKmpH5SmBtK6 .icon-shape,#mermaid-svg-JC9bPKmpH5SmBtK6 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-JC9bPKmpH5SmBtK6 .icon-shape p,#mermaid-svg-JC9bPKmpH5SmBtK6 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-JC9bPKmpH5SmBtK6 .icon-shape .label rect,#mermaid-svg-JC9bPKmpH5SmBtK6 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-JC9bPKmpH5SmBtK6 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-JC9bPKmpH5SmBtK6 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-JC9bPKmpH5SmBtK6 :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
基础设施
业务服务层
网关层
客户端
POST /api/order/create带 JWT
/api/user/**
/api/product/**
/api/order/**
/api/stock/**
Feign 扣库存
Feign 查商品
Feign 购物车校验
注册/发现
注册/发现
注册/发现
注册/发现
读写
读写
读写
读写
异步事件
浏览器 / Postman
Gateway :9000统一入口,按路径转发
user-service:8081
product-service:8082
order-service:8083
stock-service:8084
Nacos :8848服务注册发现
MySQL + Redis数据存储
RocketMQ :9876异步解耦
三、代码实现:远程调用与鉴权分离
3.1 声明式远程调用:OpenFeign 客户端
Feign 的价值是把 HTTP 调用写成像本地方法调用——它不是魔法,底层就是「查 Nacos 拿实例地址 → 拼 HTTP 请求 → 发出去 → 反序列化响应」,但把这一坨样板代码收敛成了一个接口。
/**
* 库存服务远程调用客户端
* name 必须与 stock-service 在 Nacos 上注册的服务名一致
*/
@FeignClient(name = "stock-service", configuration = FeignConfig.class)
public interface StockFeignClient {
/** 扣减库存(幂等:同一 orderNo + productId 重复调用不会双扣) */
@PostMapping("/internal/stock/deduct")
Result<DeductResult> deduct(@RequestBody DeductRequest request);
/** 恢复库存(取消订单时由调度器补发调用) */
@PostMapping("/internal/stock/restore")
Result<Void> restore(@RequestBody RestoreRequest request);
}
3.2 服务间鉴权:为什么不能复用用户 Token
这是拆微服务踩的第一个真坑。原来的设计是所有服务统一用用户 JWT 鉴权,结果 order → stock 的 Feign 调用直接返回 401——因为这段 HTTP 请求是服务自己发出去的,请求头里根本没有用户的 Token,stock 的 LoginInterceptor 一看没凭证就拦了。
错误修法是「把当前请求的 Token 透传下去」。但这条路越走越歪:后台定时任务(EventRetryScheduler 补发库存恢复)发起调用时根本没有用户上下文,透传什么?
正确做法是服务凭证与用户身份分离:
- X-Internal-Key:服务间凭证,证明「这是一个可信的内部服务」,用于服务间接口鉴权
- X-User-Id:业务身份,有用户上下文时才带(比如下单时要知道给谁扣)
- X-Trace-Id:链路追踪,跨进程串起整条调用链
/**
* Feign 统一配置:出站请求的凭证与身份注入
* 关键点——所有 order → 其他服务 的调用都从这里统一出口,业务代码零感知
*/
@Configuration
public class FeignConfig {
@Value("${internal.key}")
private String internalKey;
/**
* 出站拦截器:给每个 Feign 请求补上服务凭证 / 用户身份 / 链路 ID
*/
@Bean
public RequestInterceptor internalAuthInterceptor() {
return template -> {
// ① 服务凭证:证明"我是可信内部服务",与用户是否登录无关
template.header("X-Internal-Key", internalKey);
// ② 用户身份:有上下文才带。定时任务/补偿链路没有用户,这里是 null
Long userId = UserContext.getUserId();
if (userId != null) {
template.header("X-User-Id", String.valueOf(userId));
}
// ③ 链路追踪:没有就新建,保证跨进程可串
template.header("X-Trace-Id", TraceContext.getOrCreate());
};
}
/**
* 超时重试策略:首次间隔 100ms,最大间隔 1000ms,最多重试 3 次
* ⚠️ 重试的前提是下游接口幂等——见第四节
*/
@Bean
public Retryer feignRetryer() {
return new Retryer.Default(100, 1000, 3);
}
}
服务端 stock-service 用 InternalKeyInterceptor 校验进来的是不是内部调用:
/**
* 服务间凭证校验拦截器(注册在 /internal/** 路径上)
*/
@Slf4j
public class InternalKeyInterceptor implements HandlerInterceptor {
private final String expectedKey;
public InternalKeyInterceptor(String expectedKey) {
this.expectedKey = expectedKey;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
String key = request.getHeader("X-Internal-Key");
// 常量时间比较,避免通过响应耗时逐字节猜测密钥
if (key == null || !MessageDigest.isEqual(
key.getBytes(StandardCharsets.UTF_8),
expectedKey.getBytes(StandardCharsets.UTF_8))) {
log.warn("非法内部调用,path={}, remoteAddr={}", request.getRequestURI(), request.getRemoteAddr());
response.setStatus(HttpStatus.UNAUTHORIZED.value());
return false;
}
return true;
}
}
这样做的好处:后台定时任务补发库存恢复时,不需要伪装成某个用户,带服务凭证就能调通;而用户身份只在真正需要时透传,职责彻底分开。
四、拆完之后「失去了什么」:本地事务没了
这是拆微服务最该埋单的地方,也是面试最容易被追问的点。
单体版扣库存和写订单在同一个 @Transactional 里,一个回滚全回滚。拆开之后,@Transactional 管不到远程调用——order 的事务回滚了,stock 那边已经扣掉的库存不会自动还回来。
项目用四招把这个问题解掉(都有测试覆盖):
4.1 防超卖:原子 UPDATE,不用分布式锁
<!– 一步到位:数据库行锁天然串行化同一商品的并发扣减 –>
<update id="deductStock">
UPDATE t_product
SET stock = stock – #{quantity}, update_time = NOW()
WHERE id = #{productId} AND stock >= #{quantity}
</update>
影响行数 = 0 就代表库存不足,直接抛业务异常。
为什么不用 Redisson 分布式锁当闸门? 两个原因:
原子 UPDATE 靠数据库行锁天然串行化,一步到位,这也是 order-service Feign 重试 3 次之后仍然不超卖的根因。
4.2 幂等键:t_stock_operation 唯一键
重试能保证不超卖,但不能保证不重复扣。所以每次扣减/恢复都要先抢幂等键:
@Transactional(rollbackFor = Exception.class)
public DeductResult deduct(DeductCommand cmd) {
// ① 抢占幂等键 (order_no, product_id, operation) 唯一索引
try {
stockOperationMapper.insertClaim(
cmd.getOrderNo(), cmd.getProductId(), Operation.DEDUCT, cmd.getQuantity());
} catch (DuplicateKeyException e) {
// ② 撞唯一键 = 这个业务操作已经处理过 —— 回放,绝不再扣一次
return replay(cmd);
}
// ③ 首次执行:原子扣减
int affected = productMapper.deductStock(cmd.getProductId(), cmd.getQuantity());
if (affected == 0) {
throw new BizException(ErrorCode.STOCK_NOT_ENOUGH);
}
return DeductResult.of(cmd);
}
回放时还会比对已记录的数量:同一个幂等键却传了不同的 quantity,明确报「参数冲突」(400)——防止有人拿同一个业务键偷改数量。
补充一个实测结论:DuplicateKeyException 在单个 @Transactional 方法内部被 catch 时,不会触发 UnexpectedRollbackException——异常没有穿过事务拦截器边界,事务照常提交。UnexpectedRollbackException 只出现在"内层 @Transactional(REQUIRED) 抛异常被外层 catch"或 JPA/Hibernate 场景。这条结论由 5 个 Testcontainers 真 MySQL 集成测试锁定,mvn -P it test 可复跑。
4.3 远程操作与本地事务解耦
- 下单:先生成订单号当幂等键,逐商品远程扣减(不在本地事务里)。本地落库失败时,把"已成功扣减的项"按幂等键补偿还回去(compensateDeducts)。
- 取消:抢到取消权后不同步远程还库存——远程超时会卡住本地事务,回滚还会把"已取消"状态一起撤掉。正确做法是写一条 STOCK_RESTORE 的 Outbox 事件,与取消操作同事务落库,交给 EventRetryScheduler 独立补发。
OrderService.cancel()
│
├── 1. 条件抢占取消权(影响行数闸门)
├── 2. 同事务写 t_event_record(STOCK_RESTORE, WAIT)
└── 3. 返回成功 —— 不等待远程库存恢复
EventRetryScheduler(每 5s)
└── 条件抢占 WAIT → SENDING(影响行数=0 说明被别的实例抢走,跳过)
└── 抢占成功才发 MQ / 调 Feign → 成功标 SUCCESS / 失败退避重试 → 超限标 FAIL
Feign 配了 Retryer.Default(100, 1000, 3),配合幂等键,重试就从"可能重复扣"变成了"无害回放"。
4.4 下单幂等:clientRequestId
网络抖动导致客户端重试,用户会看到两个订单。t_order 加 uk_client_request_id 唯一索引(MySQL 允许多个 NULL,旧客户端不带键不受影响):同一键只落一个订单。请求先预检查回放已有订单(并校验归属,防拿别人的键回放别人的单);并发竞态下两个同键请求都越过预检查,各自远程扣库存,一个 insert 命中唯一键,另一个撞 DuplicateKeyException 走补偿还库存。唯一键是最终闸门,预检查挡掉常见重复。
下面是拆微服务后「本地事务丢失」的解决方案全景图:
#mermaid-svg-C2U8ah0fQCW644ak{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-C2U8ah0fQCW644ak .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-C2U8ah0fQCW644ak .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-C2U8ah0fQCW644ak .error-icon{fill:#552222;}#mermaid-svg-C2U8ah0fQCW644ak .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-C2U8ah0fQCW644ak .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-C2U8ah0fQCW644ak .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-C2U8ah0fQCW644ak .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-C2U8ah0fQCW644ak .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-C2U8ah0fQCW644ak .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-C2U8ah0fQCW644ak .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-C2U8ah0fQCW644ak .marker{fill:#333333;stroke:#333333;}#mermaid-svg-C2U8ah0fQCW644ak .marker.cross{stroke:#333333;}#mermaid-svg-C2U8ah0fQCW644ak svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-C2U8ah0fQCW644ak p{margin:0;}#mermaid-svg-C2U8ah0fQCW644ak .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-C2U8ah0fQCW644ak .cluster-label text{fill:#333;}#mermaid-svg-C2U8ah0fQCW644ak .cluster-label span{color:#333;}#mermaid-svg-C2U8ah0fQCW644ak .cluster-label span p{background-color:transparent;}#mermaid-svg-C2U8ah0fQCW644ak .label text,#mermaid-svg-C2U8ah0fQCW644ak span{fill:#333;color:#333;}#mermaid-svg-C2U8ah0fQCW644ak .node rect,#mermaid-svg-C2U8ah0fQCW644ak .node circle,#mermaid-svg-C2U8ah0fQCW644ak .node ellipse,#mermaid-svg-C2U8ah0fQCW644ak .node polygon,#mermaid-svg-C2U8ah0fQCW644ak .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-C2U8ah0fQCW644ak .rough-node .label text,#mermaid-svg-C2U8ah0fQCW644ak .node .label text,#mermaid-svg-C2U8ah0fQCW644ak .image-shape .label,#mermaid-svg-C2U8ah0fQCW644ak .icon-shape .label{text-anchor:middle;}#mermaid-svg-C2U8ah0fQCW644ak .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-C2U8ah0fQCW644ak .rough-node .label,#mermaid-svg-C2U8ah0fQCW644ak .node .label,#mermaid-svg-C2U8ah0fQCW644ak .image-shape .label,#mermaid-svg-C2U8ah0fQCW644ak .icon-shape .label{text-align:center;}#mermaid-svg-C2U8ah0fQCW644ak .node.clickable{cursor:pointer;}#mermaid-svg-C2U8ah0fQCW644ak .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-C2U8ah0fQCW644ak .arrowheadPath{fill:#333333;}#mermaid-svg-C2U8ah0fQCW644ak .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-C2U8ah0fQCW644ak .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-C2U8ah0fQCW644ak .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-C2U8ah0fQCW644ak .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-C2U8ah0fQCW644ak .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-C2U8ah0fQCW644ak .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-C2U8ah0fQCW644ak .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-C2U8ah0fQCW644ak .cluster text{fill:#333;}#mermaid-svg-C2U8ah0fQCW644ak .cluster span{color:#333;}#mermaid-svg-C2U8ah0fQCW644ak div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-C2U8ah0fQCW644ak .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-C2U8ah0fQCW644ak rect.text{fill:none;stroke-width:0;}#mermaid-svg-C2U8ah0fQCW644ak .icon-shape,#mermaid-svg-C2U8ah0fQCW644ak .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-C2U8ah0fQCW644ak .icon-shape p,#mermaid-svg-C2U8ah0fQCW644ak .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-C2U8ah0fQCW644ak .icon-shape .label rect,#mermaid-svg-C2U8ah0fQCW644ak .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-C2U8ah0fQCW644ak .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-C2U8ah0fQCW644ak .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-C2U8ah0fQCW644ak :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
拆微服务后本地事务失效
四招解决
4.1 防超卖原子 UPDATE
4.2 幂等键t_stock_operation 唯一键
4.3 远程操作与本地事务解耦
4.4 下单幂等clientRequestId
数据库行锁天然串行化
影响行数=0抛库存不足
撞唯一键=回放绝不双扣
同键不同数量报参数冲突 400
下单:先扣后写失败补偿还库存
取消:写 Outbox 事件不同步远程调
t_order 唯一索引uk_client_request_id
唯一键是最终闸门预检查挡常见重复
五、不报错的坑最要命
拆微服务的坑里,最耗时的不是报错的,而是不报错但静默失效的:
| Feign 调用返回 401 | order→stock 的 HTTP 请求没带凭证,被 stock 拦截器拦截 | 服务间改用 X-Internal-Key 服务凭证鉴权,不再依赖用户 Token |
| 公共模块 Bean 启动报错 | LoginInterceptor 构造函数强制需要 RedisTemplate,而 stock-service 没引入 Redis | @Autowired(required = false) 让依赖可选 |
| 子模块启动找不到主类 | 父 POM 的 spring-boot-maven-plugin 干扰 | 父 POM 改为 pluginManagement,子模块各自声明 |
| 主从复制静默失效,从库查不到数据 | 只配了 server-id/log-bin 等静态参数,没执行 CHANGE MASTER 建立连接,而且完全不报错 | 补 init-replication.sh:建复制账号 + CHANGE MASTER/START SLAVE,并端到端验证(主库写→从库查) |
| docker-compose up 报端口占用 | 本机 MySQL80 服务开机自启占着 3306 | 停本机服务 + 改手动启动。注意 PowerShell 里 sc 是 Set-Content 别名,必须写 sc.exe |
第 4 条特别值得说:docker-compose 只负责把两个 MySQL 起起来、各自配好参数,但"从库连主库"是运行时动作,不会自动完成。这类"配置写了、进程起了、日志干净、功能不通"的问题,只能靠端到端验证暴露——这也是为什么我坚持每个改动都要有可复跑的验证脚本。
下面是「不报错的坑」的排查决策图:
#mermaid-svg-2RLLVXaEP9aV4mnN{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-2RLLVXaEP9aV4mnN .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-2RLLVXaEP9aV4mnN .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-2RLLVXaEP9aV4mnN .error-icon{fill:#552222;}#mermaid-svg-2RLLVXaEP9aV4mnN .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-2RLLVXaEP9aV4mnN .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-2RLLVXaEP9aV4mnN .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-2RLLVXaEP9aV4mnN .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-2RLLVXaEP9aV4mnN .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-2RLLVXaEP9aV4mnN .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-2RLLVXaEP9aV4mnN .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-2RLLVXaEP9aV4mnN .marker{fill:#333333;stroke:#333333;}#mermaid-svg-2RLLVXaEP9aV4mnN .marker.cross{stroke:#333333;}#mermaid-svg-2RLLVXaEP9aV4mnN svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-2RLLVXaEP9aV4mnN p{margin:0;}#mermaid-svg-2RLLVXaEP9aV4mnN .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-2RLLVXaEP9aV4mnN .cluster-label text{fill:#333;}#mermaid-svg-2RLLVXaEP9aV4mnN .cluster-label span{color:#333;}#mermaid-svg-2RLLVXaEP9aV4mnN .cluster-label span p{background-color:transparent;}#mermaid-svg-2RLLVXaEP9aV4mnN .label text,#mermaid-svg-2RLLVXaEP9aV4mnN span{fill:#333;color:#333;}#mermaid-svg-2RLLVXaEP9aV4mnN .node rect,#mermaid-svg-2RLLVXaEP9aV4mnN .node circle,#mermaid-svg-2RLLVXaEP9aV4mnN .node ellipse,#mermaid-svg-2RLLVXaEP9aV4mnN .node polygon,#mermaid-svg-2RLLVXaEP9aV4mnN .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-2RLLVXaEP9aV4mnN .rough-node .label text,#mermaid-svg-2RLLVXaEP9aV4mnN .node .label text,#mermaid-svg-2RLLVXaEP9aV4mnN .image-shape .label,#mermaid-svg-2RLLVXaEP9aV4mnN .icon-shape .label{text-anchor:middle;}#mermaid-svg-2RLLVXaEP9aV4mnN .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-2RLLVXaEP9aV4mnN .rough-node .label,#mermaid-svg-2RLLVXaEP9aV4mnN .node .label,#mermaid-svg-2RLLVXaEP9aV4mnN .image-shape .label,#mermaid-svg-2RLLVXaEP9aV4mnN .icon-shape .label{text-align:center;}#mermaid-svg-2RLLVXaEP9aV4mnN .node.clickable{cursor:pointer;}#mermaid-svg-2RLLVXaEP9aV4mnN .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-2RLLVXaEP9aV4mnN .arrowheadPath{fill:#333333;}#mermaid-svg-2RLLVXaEP9aV4mnN .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-2RLLVXaEP9aV4mnN .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-2RLLVXaEP9aV4mnN .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-2RLLVXaEP9aV4mnN .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-2RLLVXaEP9aV4mnN .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-2RLLVXaEP9aV4mnN .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-2RLLVXaEP9aV4mnN .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-2RLLVXaEP9aV4mnN .cluster text{fill:#333;}#mermaid-svg-2RLLVXaEP9aV4mnN .cluster span{color:#333;}#mermaid-svg-2RLLVXaEP9aV4mnN div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-2RLLVXaEP9aV4mnN .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-2RLLVXaEP9aV4mnN rect.text{fill:none;stroke-width:0;}#mermaid-svg-2RLLVXaEP9aV4mnN .icon-shape,#mermaid-svg-2RLLVXaEP9aV4mnN .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-2RLLVXaEP9aV4mnN .icon-shape p,#mermaid-svg-2RLLVXaEP9aV4mnN .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-2RLLVXaEP9aV4mnN .icon-shape .label rect,#mermaid-svg-2RLLVXaEP9aV4mnN .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-2RLLVXaEP9aV4mnN .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-2RLLVXaEP9aV4mnN .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-2RLLVXaEP9aV4mnN :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
静默失效问题
Feign 401
公共模块 Bean 启动报错
子模块找不到主类
主从复制静默失效
端口占用
原因:HTTP 请求没带凭证
解决:X-Internal-Key 服务凭证
原因:LoginInterceptor 强制要 RedisTemplate
解决:@Autowired(required=false)
原因:父 POM 插件干扰
解决:父 POM 改 pluginManagement
原因:没执行 CHANGE MASTER
解决:init-replication.sh + 端到端验证
原因:本机 MySQL80 占 3306
解决:停服务 + sc.exe 改手动
六、并发与异常边界:测试锁住行为
拆微服务带来的并发问题,光靠"看起来对"是不够的,必须用测试锁死:
mvn test # 70 个用例
mvn -P it test # + 5 个 Testcontainers 真 MySQL 集成测试(需 Docker)
核心场景:
- 20 线程抢 10 件库存,只有 10 个成功,绝不超过库存(原子 UPDATE 闸门)
- 同键重复扣减/恢复回放不双扣双还;同键不同数量明确报参数冲突
- 下单落库失败补偿已扣库存
- 取消只写 STOCK_RESTORE 事件、不同步远程调
- 调度器超时退回 WAIT 重试,且重试不双还
- 库存接口 quantity/productId/orderNo 缺参/非数字/0/负数/超长全部 400
- 带 clientRequestId 重复下单回放已有订单且不碰库存
- 生产密钥校验(防止把默认密钥带上生产)
七、面试/简历亮点
简历怎么写(可直接抄的句式):
主导订单系统从单体到微服务的架构演进,拆分为 Gateway + 4 个业务服务(Nacos 注册发现 / OpenFeign + LoadBalancer 远程调用),实现故障隔离与独立扩容;针对拆分解耦后本地事务失效的问题,落地「原子 UPDATE 防超卖 + 幂等键防重复 + Transactional Outbox 解耦远程调用」三件套,配合 clientRequestId 客户端幂等,覆盖 75 个测试用例(含 5 个 Testcontainers 真 MySQL 集成测试),20 线程并发压测下零超卖、重试零双扣。
能扛住追问的四个点:
⚠️ 明确标注:分库分表属于技术储备,当前未落地。项目已落地的是前置的「ID 设计」——订单号用雪花算法 + 基因法(低 8 位嵌入 userId % 256 推导的分片号),为将来分表演进留好路由能力;detail(id)/pay/cancel 的按 ID 查询尚未改造携带分片信息。面试时要说"学会"与"用上"的区别,硬说落地了会被追穿。
八、场景题暴击
场景题 1:stock-service 扩到 3 个实例后,EventRetryScheduler 每 5s 扫一次 t_event_record,怎么保证同一条事件不会被三个实例重复补发?
思路拆解:
关键不在于"用分布式锁",而在于把抢占做进 SQL:
UPDATE t_event_record
SET status = 'SENDING', claim_owner = #{owner}, claimed_at = NOW()
WHERE id = #{id} AND status = 'WAIT';
- 影响行数 = 1:抢到了,发 MQ
- 影响行数 = 0:已被别的实例抢走,直接跳过
数据库行锁保证了这条 UPDATE 的原子性,多实例天然互斥,不需要引入任何额外的分布式协调组件。claim_owner 记录实例标识便于排查,claimed_at 用于回收"发送中僵尸"。
追问:如果实例在 SENDING 状态时进程被 kill 了,这条事件会永远卡住吗?
不会。reclaimStuck 会扫描 SENDING 且 claimed_at 超过 10 分钟的记录,回退成 WAIT 并清空 claim_owner 重新补发。判定基准必须是领取时间 claimed_at,不是 next_retry_time——如果按后者判断,一个刚被领取、还没来得及发送的在途事件会被误判断超时被回收,导致重复补发。
场景题 2:order-service 调用 stock-service 扣库存,Feign 配了 3 次重试。现在线上出现"库存少扣了"(用户下单成功但库存没减),你怎么排查?
先分方向,"少扣"意味着请求根本没到达下游或被静默吞掉,按链路从外往内查:
这个问题的本质:幂等设计保证了"不会多扣",但"不会少扣"要靠事务边界正确 + 对账兜底。两件事不能混为一谈。
项目仓库:order-system-cloud
从单体到微服务再到消息驱动,三步演进,每一步都在解决上一步的瓶颈。如果你也在做类似的架构演进,欢迎交流——架构没有银弹,只有取舍。
推荐标签:微服务、Spring Cloud Alibaba、OpenFeign、Nacos、架构演进
网硕互联帮助中心



评论前必须登录!
注册