网络与运行时:Netty 与 JVM
本文合并自 2 篇笔记,点击目录跳转。
目录
网络通信:Netty WebSocket 服务端
一句话速览:Netty 用 Reactor 线程模型 + ChannelPipeline 责任链,让少量线程处理海量长连接;本项目 IM 模块的所有坑都指向同一个认知——Pipeline 上”放行”不等于”能处理”,不匹配预期的事件会在责任链上被无声吞掉。
涉及原始记录:
07-面试问题/开发问题/02-框架与中间件集成问题.mdP0-04;07-面试问题/开发问题/01-依赖与配置问题.mdP0-04、P1-05、P0-05;07-面试问题/验证问题/01-业务逻辑问题.mdP0-04、P1-04最近修订:2026-08-07
目录
- 技术点 0:Netty 底层原理 —— Reactor 线程模型与零拷贝
- 技术点 1:Netty Pipeline —— 请求处理的流水线模型
- 技术点 2:会话管理 —— 单一数据源原则
- 技术点 3:WebSocket 鉴权 —— 网关模式和直连模式的区别
- 技术点 4:TCP 粘包拆包与心跳 —— 长连接的必修课
- 术语表
技术点 0:Netty 底层原理 —— Reactor 线程模型与零拷贝
难度:⭐⭐⭐ | 掌握要求:能画出 boss/worker 模型,说清 EventLoop 与 Channel 的绑定关系
这部分没有对应踩坑记录,但它是理解 Pipeline 行为、以及”为什么业务 Handler 里不能做阻塞操作”的地基。
Reactor 线程模型:少量线程扛住海量连接
传统 BIO 是”一个连接一个线程”,一万个长连接就要一万个线程,线程栈内存和上下文切换直接把机器拖垮。Netty 基于 NIO(IO 多路复用)实现 Reactor 模型:一个线程通过 Selector 监听成千上万个 Channel 的事件,事件到达才处理,没有事件的连接不占任何线程资源。
flowchart TD
C1[客户端连接] & C2[客户端连接] & C3[客户端连接] --> B[Boss EventLoopGroup
通常1个线程]
B -->|只负责 accept 新连接| R[把新 Channel 注册到
Worker 组的某个 EventLoop]
R --> W1[Worker EventLoop 1
负责 N 个 Channel 的读写]
R --> W2[Worker EventLoop 2
负责 M 个 Channel 的读写]
W1 --> P[ChannelPipeline
依次调用 Handler 链]
W2 --> P
三个必须记住的要点:
- Boss/Worker 分工:
bossGroup只干一件事——accept 新连接;workerGroup负责所有已建立连接的读写。这就是为什么启动代码里总是new NioEventLoopGroup()两次。 - Channel 与 EventLoop 终身绑定:一个 Channel 从注册到销毁,所有 IO 事件都由同一个 EventLoop 线程处理。这意味着同一个连接上的所有 Handler 回调天然是串行的,不需要加锁——这是 Netty 并发模型的精髓,也是
WebSocketSessionManager这类按 Channel 维度维护状态的代码可以写得比较简单的原因。 - 业务 Handler 里绝对不能做阻塞操作(查 DB、调 RPC、sleep):你阻塞的不是”这个连接”,而是这个 EventLoop 负责的几百上千个连接。耗时的业务逻辑必须扔到自己的业务线程池里异步执行,这是 Netty 应用性能的第一铁律。
ByteBuf 与零拷贝
- ByteBuf:Netty 自己实现的字节缓冲区,对比 JDK
ByteBuffer的优势是读写双指针(不用flip())、支持池化复用(PooledByteBufAllocator,避免频繁分配/回收大数组造成的 GC 压力)、支持引用计数(retain()/release())。 - 引用计数是一把双刃剑:ByteBuf 用引用计数管理内存,Handler 里拿到消息如果不再往下传,必须手动
release(),否则内存泄漏;反过来release()后再访问就是非法操作。前面 Pipeline 部分提到的”消息被无声丢弃”,底层往往就伴随着一次自动 release。 - 零拷贝:Netty 的
CompositeByteBuf(逻辑合并多个 buffer 不复制数据)、FileRegion(transferTo直接在内核态把文件发到 socket,不经过用户态)都是”零拷贝”思想的体现——零拷贝不是没有拷贝,而是避免”内核态↔用户态”之间的冗余拷贝。
技术点 1:Netty Pipeline —— 请求处理的流水线模型
难度:⭐⭐⭐ | 掌握要求:能默写本项目 Pipeline 编排顺序,说清”放行不等于处理”
核心概念
Netty 的核心抽象是 ChannelPipeline:一个连接上的每一次数据读写,都会依次经过 Pipeline 里注册的一串 Handler,前一个 Handler 处理完的结果会传给下一个。本项目 IM 服务的 Pipeline 编排是:
1 | IdleStateHandler(心跳超时检测) |
理解这个顺序很重要:一个 Handler 如果不满足某个条件就把请求”放行”给下一个(调用 fireChannelRead),并不代表这个请求最终会被正确处理——如果后面所有 Handler 都基于”这应该是一个 WebSocket 帧”的假设写代码,一个不匹配预期格式的普通 HTTP 请求被放行过去,就会在管道尽头被无声地丢弃。
我们踩过的坑
P0-04:WebSocket 握手不识别带 query string 的路径,请求被静默丢弃
浏览器原生 WebSocket API 有一个限制:不支持自定义请求头,所以像 JWT token 这种鉴权信息,没法放在 Header 里传,只能拼在 URL 上(ws://host/ws?token=xxx)。
但 Netty 4.1.109 的 WebSocketServerProtocolHandshakeHandler.isWebSocketPath() 内部实现是简单的字符串全等比较:拿请求 URI 跟配置的 websocketPath("/ws") 直接 equals,完全不识别 query string。/ws?token=xxx 显然不等于 /ws,判断结果是 false。
这里最关键、也是最容易被忽略的一点:判断为”不是 WebSocket 路径”之后,Netty 不会报错,而是把这个请求当作一个普通的 HTTP 请求,通过 fireChannelRead 继续往 Pipeline 下游传。而 Pipeline 下游的业务 Handler 只写了处理 TextWebSocketFrame 的逻辑,收到一个 FullHttpRequest 类型对象时什么也不做(甚至可能因为类型不匹配被自动 release 掉)。最终结果是:服务端不返回 101 Switching Protocols,不抛任何异常,不打印任何日志,客户端只能感知到”握手超时”,服务端这边完全没有线索。
修复思路是在真正的握手 Handler 之前插入一个自定义 Handler,把 URI 里的 query string 剥离掉,让路径能匹配上:
1 | private static class QueryStrippingHandler extends ChannelInboundHandlerAdapter { |
握手完成后(userEventTriggered 收到 HandshakeComplete 事件),再从 Channel 属性里取出之前存的原始 URI,解析出 token 做验签。
必须记住的结论
- Netty 的 Handler 链是”责任链”模式,一个 Handler 判断不满足条件时的”放行”操作本身不会报错,但可能导致请求在后面某个环节被无声吞掉。排查”客户端超时但服务端毫无日志”这类问题时,思路要从”业务逻辑哪里错了”切换成”这个请求是不是压根没有走到我的业务 Handler”。
- 浏览器原生 WebSocket 不支持自定义 Header,鉴权信息只能通过 URL 传递,这是设计 WebSocket 鉴权方案时的硬约束,不是能绕开的选择。
- 用到 Netty 内置组件(
WebSocketServerProtocolHandler之类)时,不能假设它的实现覆盖了所有实际场景,遇到”官方组件行为不符合预期”,先去翻一下具体版本的源码实现(这里是isWebSocketPath()方法),不要凭直觉猜。 - 排查责任链类框架(Netty Pipeline、Sentinel Slot、Spring Filter)问题的通用工具是”这个请求/事件实际走到了链上的哪一环“,而不是”我的代码逻辑对不对”。
技术点 2:会话管理 —— 单一数据源原则
难度:⭐⭐ | 掌握要求:能说清”两套独立实现”为什么必然出问题
核心概念
一个 WebSocket 服务需要维护”用户 ID 和物理连接(Channel)之间的映射关系”,用来支持”给指定用户推送消息””判断用户是否在线””踢掉旧连接实现单端登录”等功能。这份映射关系应该有且只有一处地方维护,其他所有代码都通过它读写,不能各写各的。
我们踩过的坑
验证问题 P0-04:两套独立实现的路由/存储机制,导致功能实际不通
这是 IM 模块里影响最大的架构性问题。项目里其实分别在两个地方实现了”消息应该发给谁、怎么发”的逻辑:
ImServiceImpl(WebSocket 层):自己维护了一个 Redis Channel(im:message:channel)用来跨节点转发消息,自己维护了一个 Redis List(im:offline:msg:{userId})做离线消息队列。ImMessageService(REST 层,供历史消息查询等 HTTP 接口使用):基于 MongoDB 持久化消息 + 独立的im:user:{userId}Pub/Sub Channel 做跨节点路由。
这两套实现是完全独立开发出来的,从未打通:WebSocket 收到聊天消息后,只走了 ImServiceImpl 自己的 Redis 逻辑,从未调用 ImMessageService.sendMessage 写入 MongoDB。后果是:用 WebSocket 发的消息,通过 HTTP 接口查历史记录永远查不到——因为查询走的是 MongoDB,而消息根本没存进去。
更隐蔽的是:ImServiceImpl 发布消息用的 Channel 名(im:message:channel)和 ImMessageService 那边的监听器实际订阅的 Pattern(im:user:*)根本不匹配,跨节点消息路由其实完全断链,只是因为本地开发环境永远是单节点部署,消息优先走”本节点直投”这条捷径,才没有暴露出这个问题。
修复方式是废弃 ImServiceImpl 里那套独立实现,把 WebSocket 收到消息后的处理统一委托给 ImMessageService.sendMessage,由后者一个入口完成”存 MongoDB + Redis 未读计数 + 推送(本地直投或跨节点 Pub/Sub)”这三件事:
1 | // ImServiceImpl.handleMessage 的 CHAT 分支 |
必须记住的结论
- 同一份状态(这里是”用户在哪个节点、消息该怎么路由”),项目里只应该有一处实现,其他所有入口(无论是 WebSocket 还是 HTTP)都应该调用同一个 Service,而不是各自为政写一套相似但不共享的逻辑。两套独立实现看起来能各自跑通(WebSocket 能聊天、HTTP 接口能查询别的数据),但一旦数据需要在两者之间流转(WebSocket 发的消息要能被 HTTP 接口查到),”看起来都对”的两套代码合起来就是错的。
- 排查这类问题的标志性信号:某个功能”看起来实现了”,但另一个应该消费同一份数据的功能”完全查不到”——这通常意味着背后有两条本该合并但没有合并的数据流。
- 单节点部署会掩盖”跨节点路由是否真的打通”这类问题,因为本地直投这条捷径永远存在。验证跨节点能力,必须要么真的启动多个实例测试,要么仔细做代码走查确认发布方和订阅方的 Channel/Pattern 完全对齐,不能只靠”单机跑起来没问题”下结论。
技术点 3:WebSocket 鉴权 —— 网关模式和直连模式的区别
难度:⭐⭐ | 掌握要求:能说清为什么 IM 服务必须自己持有公钥
核心概念
本项目里 REST 接口的鉴权是”网关统一验签,业务服务信任 Header”模式(详见 09-认证鉴权与网关架构.md),但 WebSocket 走的是完全不同的路径:客户端直连 IM 服务的独立端口(9999),完全绕开 Gateway。这意味着 IM 服务必须自己具备完整的 JWT 验签能力,不能像其他服务一样只读 Header 里现成的用户信息。
我们踩过的坑
开发问题 P0-05:忘记给 IM 服务配 JWT 公钥
其他 REST 服务因为信任 Gateway 已经验证过 token,本身不需要持有 JWT 公钥。但这个假设被直接套用到了 IM 服务上——IM 服务的 application.yml 一开始没有配置 livin.jwt.public-key。握手时调用 JwtUtil.parseToken 验签,getPublicKey() 因为配置为空抛出 IllegalStateException,这个异常又被 authenticateUser 方法的 catch 块吞掉、统一返回 null,最终表现是无论 token 有效还是无效,认证都必然失败,日志只有一句语焉不详的”Token 认证失败”,看不出真正原因是”公钥没配置”还是”token 真的不对”。
修复很简单,把 auth 服务签发 token 用的 RSA 公钥同步配置到 IM 服务(只需要公钥验签,不需要私钥):
1 | livin: |
关联问题:MongoDB 连接串密码含 @ 符号解析失败(依赖与配置问题 P0-04)
跟 WebSocket 本身无关,但同样发生在 IM 服务的配置阶段,放在这里一起记:mongodb://root:livin@2026@127.0.0.1:27017/... 这种写法里,密码 livin@2026 本身含有 @,MongoDB 驱动按照 user:pass@host:port 的语法解析连接串时,会把第一个 @ 就当成用户信息和主机的分界,导致解析错乱。任何连接串里的密码,只要包含 @、:、/ 这类连接串本身的语法保留字符,都需要做 URL 编码(@ 编码成 %40)。这是一个很基础但容易被忘记的规则,凡是密码里出现特殊符号,配置连接串前先过一遍 URL encode。
关联问题:缺少 DataSource 自动配置排除(依赖与配置问题 P1-05)
IM 服务的消息数据全部存 MongoDB,完全不需要 MySQL,但因为传递依赖引入了 mybatis-plus/mysql-connector/druid,Spring Boot 会自动尝试装配 DataSource,而项目里根本没配 spring.datasource.url,直接启动失败。这类”服务本不需要某个技术栈,但因为传递依赖被强制拉了自动配置”的问题,通用解法是在 application.yml 里显式排除对应的自动配置类:
1 | spring: |
必须记住的结论
- 不是所有服务的鉴权方式都一样,要看这个服务的入口流量是否经过网关。经过网关的可以信任 Header,不经过网关的(独立端口直连)必须自己具备完整的鉴权能力(持有公钥、自己验签)。引入一个新的、走独立协议/端口的服务时,要重新审视一遍它的鉴权链路是不是能直接照抄其他服务的做法。
- 鉴权失败类的异常不应该被 catch 块笼统吞掉、返回一个模糊的失败结果。本项目这里的教训是”公钥没配”和”token 真的无效”在日志里表现完全一样,导致排查者要靠猜。写鉴权逻辑时,内部区分具体的失败原因、分别打日志,能大幅减少这类问题的排查时间。
- 连接串密码含特殊字符要做 URL 编码;引入了用不到的技术栈依赖,主动在自动配置里排除,不要等它自己报错才处理。
技术点 4:TCP 粘包拆包与心跳 —— 长连接的必修课
难度:⭐⭐ | 掌握要求:粘包成因、三种解码方案、IdleStateHandler 三个时间参数
本项目用的是 WebSocket(协议自带帧边界,应用层感知不到粘包),但任何基于原生 TCP 的长连接协议都必须处理这两个问题,是 Netty 面试的必考题:
粘包/拆包
TCP 是字节流协议,没有消息边界:发送方连发两条消息,接收方可能一次读到一条半(拆包)或两条粘在一起(粘包)——TCP 层的 MSS 分段、Nagle 算法合并小数据包都会造成这种现象。注意这不是”故障”,是 TCP 的正常行为,应用层协议必须自己定义消息边界:
| 方案 | 原理 | 适用 |
|---|---|---|
| 定长消息 | 每条消息固定字节数,不足补空 | 协议极简场景,浪费带宽 |
| 分隔符 | 消息结尾加特殊分隔符(如 \n) |
文本协议(Redis RESP 等) |
| 长度字段 | 消息头带 body 长度(LengthFieldBasedFrameDecoder) |
最通用,二进制协议首选 |
本项目选择 WebSocket 作为应用层协议,等于把这个问题交给了协议本身(WS 帧自带长度字段),这也是”选现成协议 vs 自定义协议”的一个隐性收益。
心跳与空闲检测
长连接”看起来还连着”不代表对端还活着(NAT 超时、网络静默断开都不会触发 TCP 的 FIN)。IdleStateHandler 的三个时间参数分别检测:读空闲(多久没收到对方数据)、写空闲(多久没向对方发数据)、读写空闲——触发后抛出 IdleStateEvent,业务 Handler 在 userEventTriggered 里决定是发心跳 ping 还是主动断开。本项目 Pipeline 第一个 Handler 就是它,典型配置是”读空闲超过 N 秒判定连接已死,关闭释放资源”;客户端则定时发 PING,服务端回 PONG(本项目协议里的 PONG 控制帧就是干这个的,不需要持久化,走本地处理即可)。
术语表
| 术语 | 含义 |
|---|---|
| Reactor 模型 | 基于 IO 多路复用的事件驱动模型,少量线程处理海量连接 |
| Boss / Worker | Netty 两个 EventLoopGroup:accept 新连接 / 处理已建立连接的读写 |
| EventLoop | 一个绑死单线程的事件循环,Channel 与其终身绑定,同连接事件天然串行 |
| ChannelPipeline | Handler 责任链,IO 事件沿链依次传递处理 |
| ByteBuf | Netty 的池化字节缓冲区,读写双指针 + 引用计数管理 |
| 零拷贝 | 避免内核态与用户态间冗余数据拷贝的技术总称 |
| 粘包/拆包 | TCP 字节流无消息边界导致的多条合并/一条拆碎现象,应用层须自定边界 |
| IdleStateHandler | Netty 空闲检测 Handler,按读/写空闲时长触发事件,心跳保活的基础 |
| WebSocket 握手 | HTTP 请求带 Upgrade: websocket 头,服务端回 101 完成协议升级 |
| QueryStrippingHandler | 本项目自定义 Handler,剥离握手 URI 的 query string 使路径可匹配 |
JVM 与并发编程:OOM 排查 / ThreadLocal / DCL / 接口幂等性
一句话速览:这一篇收拢散落在各处的”JVM 与并发”知识——它们在本项目的踩坑记录里反复作为”底层原因”出现(Nacos OOM、UserContextHolder、缓存 DCL、MQ 幂等),但一直没有一篇文档把它们讲透。主线是:JVM 部分记住”排查靠工具不靠猜”,并发部分记住”可见性/原子性/有序性三个问题各由什么机制解决”,幂等部分记住”任何可能被重复执行的入口都要自问一遍幂等”。
涉及原始记录:
07-面试问题/开发问题/01-依赖与配置问题.mdP1-07(Nacos OOM);
关联模块:livin-common-security的UserContextHolder(ThreadLocal)、livin-house的缓存 DCL、各 MQ 消费者最近修订:2026-08-07
目录
- 技术点 1:JVM 内存结构与 OOM 排查
- 技术点 2:ThreadLocal —— 项目里 UserContextHolder 的底层
- 技术点 3:Java 并发三要素与 DCL / 分布式锁
- 技术点 4:接口幂等性设计
- 术语表
技术点 1:JVM 内存结构与 OOM 排查
难度:⭐⭐⭐ | 掌握要求:内存分区、两类 OOM 的区别、排查工具链
核心概念
JVM 运行时内存的分区,每个区放什么、满了报什么错,要能对应上:
flowchart TD
subgraph JVM内存
H[堆 Heap
对象实例
-Xms/-Xmx 控制]
M[元空间 Metaspace
类元数据
MaxMetaspaceSize]
S[虚拟机栈
方法调用栈帧
-Xss 控制]
D[直接内存
NIO/Netty ByteBuf
MaxDirectMemorySize]
end
H -->|溢出| E1["OutOfMemoryError: Java heap space"]
M -->|溢出| E2["OutOfMemoryError: Metaspace"]
S -->|溢出| E3["StackOverflowError"]
D -->|溢出| E4["OutOfMemoryError: Direct buffer memory"]
不同报错指向不同区,排查方向完全不同:堆 OOM 查”什么对象占内存”(内存泄漏/大集合),Metaspace OOM 查”类加载器泄漏”(常见于热部署、动态代理类无限生成),直接内存 OOM 查 Netty/NIO 的 ByteBuf 是否忘了 release()(呼应 05-网络通信Netty.md 的引用计数)。
关联踩坑:Nacos 容器 OOM(开发问题 P1-07)
完整复盘见 08-容器化部署Docker.md。这里提炼 JVM 侧的教训:容器里跑 JVM 要分清两层 OOM——
- JVM 堆 OOM:JVM 自己抛
OutOfMemoryError,应用通常还活着、日志有记录; - 容器 OOM Kill:进程总内存(堆+元空间+栈+直接内存+JIT 开销)超过容器 limit,Linux 内核直接 SIGKILL 掉进程,Java 日志里什么异常都没有,要用
docker inspect看OOMKilled: true才能确认。
注意堆不是全部:Xmx 256m 的 JVM 进程实际占用可能到 400M+(元空间、线程栈、直接内存、CodeCache 都在堆外)。容器内存预算要按”Xmx + 1.2~1.5 倍堆外冗余”估,不能 Xmx 给多少容器就限多少。
OOM 排查工具链(标准动作)
- 事前埋点:JVM 参数加
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=...,OOM 瞬间自动保存堆快照——事后想复现 OOM 现场几乎不可能,这个参数必须提前配。 - 事中观察:
jstat -gcutil <pid> 1000每秒看一次各代占用和 GC 次数——老年代(O 列)持续上涨且 Full GC 后降不下来,是内存泄漏的典型特征;jmap -histo <pid>看对象实例数排行。 - 事后分析:把 heap dump 拉到本地用 MAT(Eclipse Memory Analyzer)打开,看 Dominator Tree(谁占用了最多内存)和 Leak Suspects 报告,沿着 GC Roots 引用链找到”是什么在拽着这批对象不放”。
- GC 日志:
-Xlog:gc*(JDK 9+ 统一日志语法)打开 GC 日志,判断是”内存不够”还是”GC 效率问题”。
GC 基础速通
- 可达性分析:从 GC Roots(栈局部变量、静态引用、JNI 引用等)出发遍历引用链,走不到的对象判定可回收——这是”循环引用的对象也能被回收”的原因,区别于引用计数法。
- 分代假说:绝大多数对象朝生夕死 → 堆分新生代(Eden + 2 个 Survivor,复制算法)和老年代(标记-整理);新生代 Minor GC 频繁但快,老年代 Full GC 慢且要尽量避免。
- JDK 21 的收集器选择:默认 G1(把堆切成若干 Region,可预测停顿,吞吐与延迟平衡,8G 以下堆够用);大堆+极致低延迟选 ZGC(停顿 <1ms,与堆大小无关);记住结论”默认 G1,别乱换“即可应付大多数场景。
技术点 2:ThreadLocal —— 项目里 UserContextHolder 的底层
难度:⭐⭐⭐ | 掌握要求:实现原理、内存泄漏原因、线程池场景的坑
核心概念
UserContextHolder 能让”拦截器 set 的 userId 在 Service 层 get 到”,底层就是 ThreadLocal:每个线程内部维护一个 ThreadLocalMap(key 是 ThreadLocal 对象的弱引用,value 是存的值),同一个 ThreadLocal 在不同线程里存的是互相隔离的副本——它不是”全局变量”,是”线程私有变量”。
flowchart LR
subgraph 请求线程A
TA[ThreadLocalMap
userId=1001]
end
subgraph 请求线程B
TB[ThreadLocalMap
userId=1002]
end
TL[UserContextHolder
同一个ThreadLocal对象] --> TA & TB
三个必须记住的坑
- 线程池场景必须 remove():Tomcat/业务线程池的线程是复用的——上一个请求 set 的 userId,如果这个请求没 remove,下一个复用该线程的请求会读到上一个请求的用户(数据串号,严重安全事故)。所以拦截器的
afterCompletion里必须UserContextHolder.clear()。这也呼应07-可观测性.md里 MDC(同为 ThreadLocal)必须 clear 的要求。 - 内存泄漏的机制:ThreadLocalMap 的 key 是弱引用(ThreadLocal 对象被 GC 后 key 变 null),但 value 是强引用——key 没了 value 还在,线程不死(线程池场景)这个 value 永远不可达也不可回收。
remove()同时清掉 key 和 value,这就是为什么”用完必须 remove”是铁律而不是建议。 - 异步/线程切换即失效:
@Async、CompletableFuture、MQ 消费者里UserContextHolder.getUserId()拿不到值——值存在原线程里,不会跟着任务走。需要跨线程传递时,要么显式传参,要么用 TransmittableThreadLocal(阿里 TTL,线程池提交任务时把上下文”搬运”过去)。
技术点 3:Java 并发三要素与 DCL / 分布式锁
难度:⭐⭐⭐ | 掌握要求:可见性/原子性/有序性、DCL 为什么需要 volatile、synchronized 与 ReentrantLock 区别
并发三要素:所有并发问题都能归到这三类
| 问题 | 定义 | 解决机制 |
|---|---|---|
| 可见性 | 线程 A 改了变量,线程 B 看不到(CPU 缓存未刷回主存) | volatile(强制读写主存)、锁(释放锁时刷主存) |
| 原子性 | “读-改-写”多步操作被并发打断(count++ 实际是三步) |
synchronized/Lock、CAS(AtomicInteger)、数据库原子 SQL |
| 有序性 | 编译器/CPU 指令重排序,代码执行顺序与书写顺序不一致 | volatile(禁止重排序)、happens-before 规则 |
DCL 双检锁:为什么单例必须加 volatile
经典单例 DCL 代码(本项目缓存回源用的也是同一模式):
1 | public class Singleton { |
instance = new Singleton() 不是原子操作,它实际分三步:① 分配内存;② 调用构造函数初始化;③ 把引用指向这块内存。没有 volatile 时,② 和 ③ 可能被指令重排序——另一个线程在第一次检查时看到引用非空(③已执行),但对象还没初始化完(②没执行),拿到一个半构造对象。volatile 禁止了这次重排序。这是”volatile 保证有序性”最经典的考法。
synchronized vs ReentrantLock vs 分布式锁
- synchronized:JVM 内置,自动加解锁,JDK 6 后有锁升级优化(偏向锁→轻量级→重量级),大多数场景够用。
- ReentrantLock:手动 lock/unlock,多了可中断等待、超时尝试、公平锁、多条件变量能力,需要这些特性时才用。
- Redisson 分布式锁:
SET key value NX PX抢锁 + 看门狗自动续期(默认锁 30s,业务没执行完每 10s 续一次)+ Lua 脚本保证”加锁/解锁”原子性。记住两个考点:解锁要校验是不是自己加的锁(防误删别人的锁,value 存线程标识);RedLock 多节点方案在业界有争议(马丁·克莱普曼的著名论战),一般业务用主从单点 + 看门狗就够,资金级场景宁可用数据库乐观锁兜底。 - 选型主线同
06-数据库与并发控制.md:能下推成一条原子 SQL 的,不用任何锁。
技术点 4:接口幂等性设计
难度:⭐⭐ | 掌握要求:幂等定义、四种实现方案、本项目哪里需要
核心概念
幂等(Idempotent):同一个操作执行一次和执行 N 次,产生的业务效果相同。 为什么微服务下它从”加分项”变成”必答题”——因为重试无处不在:网关重试、Feign 重试、MQ 至少一次投递(02 篇)、用户双击、网络超时后客户端重发。任何”可能被重复执行”的入口,设计时都要自问一遍:重复执行会怎样?
四种标准实现方案
| 方案 | 原理 | 适用 |
|---|---|---|
| 数据库唯一索引 | 业务唯一键(如订单号)建唯一索引,重复插入直接冲突 | 新增类操作,最简单可靠 |
| 状态机前置校验 | UPDATE ... SET status='PAID' WHERE id=? AND status='UNPAID',影响行数 0 说明已处理过 |
状态流转类(支付回调、租约签约),本项目支付回调该用这招 |
| Token 机制 | 先领一次性 token,提交时服务端校验并删除,重复提交 token 已不存在 | 表单防重复提交 |
| 分布式锁/去重表 | Redis setnx 或专门的”已处理消息表”(消息ID 唯一),消费前查重 |
MQ 消费幂等(02 篇的重试/死信场景) |
与本项目的对应关系
- 支付回调(
livin-payment):第三方支付平台明确会重复回调(收不到正确响应就按策略重发),回调处理必须幂等——先查账单状态,已支付就直接返回成功,不重复流转租约状态。 - MQ 消费者(
livin-lease的支付/退款结果消费者):RocketMQ 至少一次投递 + Rebalance 必然产生重复消息,消费逻辑要以”业务单号”为准判断处理过没有。 - 冻结房源(
livin-house的freezeHouse):Seata 全局事务重试、Feign 超时重试都可能导致重复调用,status已经是 OCCUPIED 时再次冻结应该是无操作而不是报错。
判断一个写接口是否幂等的最快方法:问自己”这个接口被同一个参数连续调 10 次,数据库状态和第 1 次之后有区别吗”——有区别就不是幂等的,且这个接口一旦接入任何带重试的机制就会出脏数据。
术语表
| 术语 | 含义 |
|---|---|
| 堆 / 元空间 / 直接内存 | JVM 内存分区:对象实例 / 类元数据 / NIO 堆外缓冲,OOM 报错各不相同 |
| OOM Kill | 进程总内存超容器 limit 被内核强杀,Java 日志无异常,区别于堆 OOM |
| Heap Dump / MAT | OOM 时的堆快照 / 堆分析工具(Dominator Tree 找内存大头) |
| GC Roots / 可达性分析 | 引用遍历起点 / 从 Roots 出发走不到的对象即垃圾的判定法 |
| G1 / ZGC | JDK 默认分区式收集器(停顿可预测)/ 大堆超低停顿收集器 |
| ThreadLocal | 线程私有变量机制;线程池场景必须 remove() 防串号和泄漏 |
| TransmittableThreadLocal | 阿里 TTL,解决线程池切换时 ThreadLocal 上下文丢失 |
| 可见性 / 原子性 / 有序性 | Java 并发三要素,分别由 volatile/锁、锁/CAS、volatile/happens-before 解决 |
| DCL 双检锁 | 两次判空夹一次加锁的单例模式,必须配 volatile 防指令重排 |
| 看门狗(Watchdog) | Redisson 分布式锁的自动续期机制,防业务未执行完锁先过期 |
| 幂等 | 同一操作执行 N 次与 1 次效果相同;重试无处不在的微服务下的必备属性 |




