适用读者:从单体转到微服务,被 Controller/Service/RPC/Feign/Gateway/ThreadLocal 绕晕的人。
前置:S04(Spring Boot 基础)、S07(Spring 框架)、S17-J03(可靠投递的幂等思想)。
覆盖旧文:2944(从单体到微服务:Controller/Service/RPC/Feign/Mapper 该怎么写)、2940(Gateway + Filter + ThreadLocal 传 userId)、2941(TransmittableThreadLocal 解决异步丢上下文)、2939(Gateway 如何把 userId 透传给下游)、2938(Gateway + Sa-Token 鉴权完整流程)、2937(Sa-Token 自定义 Token 格式:为什么用Authorization: Bearer)、2943(引入 OpenFeign 组件)→ 7 篇 / 26,706 字符。
扩展点:E-S04-039(微服务唯一变化:进程内调用变网络调用)、E-S04-040(身份透传三段链路与 TTL 的边界)、E-S04-041(鉴权放哪里:Gateway 粗判 + 服务内细判)。
下一篇:S04-X03 · Nacos 与杂项登记。
1. 一句话结论
微服务没有推翻你的 Spring Boot 写法,只改了一件事:原来一个 JVM 里的方法调用,现在变成了跨进程的网络调用。由此派生出三件事必须显式处理:身份怎么带过去、上下文怎么不丢、鉴权在哪一层做。
出处:
A-2944、A-2940、A-2938。
2. 分层怎么写(E-S04-039)
单体:请求 → Controller → Service → Mapper → MySQL (同进程,Service 之间直接方法调用)
微服务:请求 → Gateway → Service A →(RPC/Feign)→ Service B → Mapper → 各自 DB
| 概念 | 单体里的样子 | 微服务里的样子 |
|---|---|---|
| Controller | 接 HTTP | 不变(但对内可能接 RPC) |
| Service | 调别的 Service 方法 | 调别的服务的接口(网络、可能失败、有超时) |
| Mapper | 同库 | 只操作本服务自己的库 |
| RPC / Feign | 不存在 | 声明式远程调用(Feign 是"长得像本地方法"的 HTTP 客户端) |
| API 模块 / BIZ 模块 | 不存在 | API 放对外契约(DTO/接口),BIZ 放实现 |
判据:"跨服务的调用必须当成会失败的 I/O 对待"——超时、重试、降级、幂等,一个都不能省。同库跨服务直接连库是分库失败的开始。
Feign 的三个必设项:
// 环境:Spring Cloud OpenFeign 4.x / Spring Boot 3.x
@FeignClient(name = "user-service", configuration = FeignTimeoutConfig.class)
public interface UserClient {
@GetMapping("/internal/users/{id}")
UserDTO get(@PathVariable("id") Long id);
}
// 必设:connectTimeout / readTimeout / 重试(幂等方法才可重试)/ 降级(fallback)
判据:无关超时设置的 Feign 客户端 = 线程池泄漏源(默认超时可能长达数十秒)。
3. 身份透传三段链路(E-S04-040)
① Gateway:校验 Token → 解析 userId → 写入请求头 userId: 10001
② 下游服务:Filter 读头 → 存进 ThreadLocal → 业务层直接取
③ 服务间再调用:Feign 拦截器把 ThreadLocal 里的 userId 再写进新请求头
// ② 入口 Filter
public class UserContextFilter implements Filter {
@Override public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {
String uid = ((HttpServletRequest) req).getHeader("userId");
try {
UserContext.set(uid == null ? null : Long.valueOf(uid));
chain.doFilter(req, res);
} finally {
UserContext.clear(); // 必须清理,否则线程复用会串号
}
}
}
为什么必须 clear():容器线程池会复用线程,不清理就会把 A 用户的 id 带给 B 用户——这是最危险的串号 bug,且只在并发下偶发。
异步场景的边界(TTL):ThreadLocal 只在当前线程有效;CompletableFuture / @Async / 线程池会丢失上下文。TransmittableThreadLocal(TTL)配合 TtlExecutors 包装线程池可传递,但代价是必须包装每一个线程池——漏一个就丢。
| 方案 | 覆盖范围 | 代价 |
|---|---|---|
| ThreadLocal + Filter | 同步链路 | 异步丢 |
| TTL + 包装线程池 | 异步链路 | 每个池都要包装 |
| 显式传参 | 全部 | 侵入但最可靠 |
判据:"上下文传递的复杂度与线程池数量成正比"——线程池超过 3 个时,显式传参反而更省心。
4. 鉴权放哪一层(E-S04-041)
Gateway(粗判):Token 是否有效、是否过期、路由是否有权限 → 拒绝无效请求,减轻后端压力
服务内(细判):这个用户能不能操作这条数据(数据级权限)→ 必须做,不能只靠网关
Sa-Token 的两个实践点(2937/2938):
| 点 | 说明 |
|---|---|
Authorization: Bearer <token> |
标准语义:scheme + 凭证;不要用自定义头(网关/代理/日志工具会丢) |
网关只做 checkLogin |
数据级鉴权留在服务内,用 SaCheckPermission + 资源归属校验 |
判据:"网关是筛子,不是保险柜"——越过网关的内部调用(服务间 RPC、定时任务)不受网关保护,所以服务内校验不能省。
5. 反例:八种"链路串号与越权"
| 做法 | 后果 |
|---|---|
ThreadLocal 不 clear() |
线程复用导致跨用户串号 |
信任前端传来的 userId 头 |
直接伪造身份 |
| 网关鉴权后就信任所有内部调用 | 内部 RPC 绕过鉴权 |
| Feign 不设超时 | 线程池耗尽,服务雪崩 |
| 重试非幂等接口 | 重复下单/重复扣款 |
| 跨服务直连别人的库 | 表结构耦合,无法独立演进 |
| 用自定义头传 Token | 代理/网关剥离,偶发 401 |
| 异步任务用裸 ThreadLocal | 上下文丢失,写日志无 userId |
6. 自测
- 微服务相对单体"唯一的变化"是什么?由此派生哪三件事?
- 身份透传的三段链路?TTL 的代价是什么?
- 为什么 ThreadLocal 必须 clear?什么场景下会串号?
- 鉴权为什么不能只在 Gateway 做?
- 为什么 Token 用
Authorization: Bearer?
答案
- 进程内调用变网络调用;身份传递、上下文不丢、鉴权分层。
- 网关写头、Filter 存 ThreadLocal、Feign 拦截器再写头;每个线程池都要包装。
- 容器线程池复用线程,残留值会给下一个请求。
- 内部 RPC/定时任务绕过网关;数据级权限必须在服务内判。
- 标准语义明确、代理与网关默认识别、日志脱敏工具可识别。
7. 下一篇
S04-X03 · Nacos 与杂项登记 —— Nacos 五问(是什么/服务注册/Namespace/动态加载 Bean)、策略+工厂的文件上传、Java 网络编程与多线程双份重复的处置。
本篇属于系列《S04 · Java 与微服务基础》。旧文来源:A-2944/2940/2941/2939/2938/2937/2943;扩展点 E-S04-039/040/041。Sa-Token / Nacos / OpenFeign 版本细节按官方文档核对(待核)。