归并写者:把竞争消灭在架构层
无锁系列一路都在数据结构层打转:07.md 立纪律,07-1 修前提,08-6 实测发现竞争之下手写队列反而输给 channel。数据结构层的全部手段——CAS、mutex、per-slot——本质都是 让竞争变便宜。
还有一条上限更高的路:让竞争根本不发生。竞争的根源是"多写者共享一份状态",那就别让多写者在任何数据结构上碰面——在进数据结构之前,先收敛成单写者。07-1 路线三的"归并写者"只给了一段,这篇展开成完整的架构方法。也是本系列的收官。
文中最小 Actor 的可运行版本在仓库 example/2026/09/08-7:Spawn 返回 mailbox,1000 个 goroutine 并发投递、actor 内无锁累加,go run ./2026/09/08-7 永远输出 1000。
1. 核心操作:N 个写者收敛成 1 个
type MergedQ struct {
inbox chan Request // 任意 goroutine 投递
state *Shared // 原本需要锁保护的共享状态
}
// 唯一的写者:所有状态变更收敛到这一个 goroutine
func (m *MergedQ) Run() {
for req := range m.inbox {
req.reply <- m.state.apply(req.op) // 单写者:不需要任何锁
}
}
业务方只做一件事:m.inbox <- req。所有曾经需要锁的 Shared,现在只有一个 goroutine 碰它——07.md 的单写者纪律,在架构层整体复活。不是给每个字段找唯一写者,是给整个状态找一个唯一写者。
2. 这不是新发明:Actor 模式
Erlang 把这个思路做成了语言的底座,Akka 把它搬进了 JVM。拆开一个 Actor,全是系列里的老朋友:
type Actor struct {
inbox chan Msg // mailbox:一个 MPSC 通道
handler func(Msg) // 行为
state State // 私有状态,只有自己碰
}
func (a *Actor) loop() {
for msg := range a.inbox { // 单消费者:07.md 的纪律
a.state = a.handler(a.state, msg)
}
}
- mailbox 是 MPSC:任意多方投递、单人收取——正是 08-1 拆过的 hchan 天然形态
- actor 循环是单消费者:
tail唯一写者,收取无锁 - 状态私有:根本不共享,08.md 的内存模型问题从源头消失
Erlang 的口号"share nothing"翻译成本系列的语言:每个状态有且只有一个写者,写者甚至不叫"生产者",叫"拥有者"。并发 bug 的整个类别——数据竞争、锁顺序死锁、忘记解锁——不是被修好的,是被类型系统排除的。
3. 工业化版本:LMAX
04-1 提过 LMAX 的 Disruptor 每秒处理六百万订单。常被忽略的是它的架构选择:业务逻辑核心是单线程的。
所有竞争被压缩到 input ring buffer 这一个点上(用 07-1 的 per-slot 序列号解决);核心里的订单匹配、账务变更,全在单线程里裸奔——没有锁、没有 CAS、没有内存屏障,连 08.md 的缓存行填充都不需要。单线程不是性能缺陷,是把最贵的并发税全部免掉。
事件溯源(Event Sourcing)是同一招的持久化版:单写者顺序 append journal(04-1 的 WAL 思想),读侧任意复制摊开。Kafka 的分区、Redis 的单线程事件循环、Go 社区的"一个 goroutine 拥有一份状态",全是同一个模式的不同投影。
4. 代价清单
天下没有免费的架构:
- 一跳转发延迟:请求多走一层 mailbox,P99 加一次调度(08-2 的 park 档,百纳秒级)
- 写者吞吐上限:唯一的写者是串行瓶颈,CPU 单核就是天花板。LMAX 用"业务操作极轻"换"天花板够高";操作重就得按状态分片(分账户、分会话),每个分片一个写者
- 背压传导:inbox 满了怎么办?channel 会阻塞投递方——这其实是特性不是 bug,背压自动沿调用链上传
- 请求-响应变异步:要拿返回值得带 reply channel,代码形态从函数调用变成消息往返
5. 选型判据
什么时候选归并写者,什么时候留在数据结构层:
| 判据 | 归并写者 | 数据结构层修 |
|---|---|---|
| 状态本需串行处理(账户、会话、游戏房间) | ✅ 天然匹配 | 白费劲:锁了还是要排队 |
| 写者天然分散、无共享热点 | ✅ 收敛点干净 | 竞争散落在多处,修不完 |
| 单次操作极轻、QPS 极高 | ✅ LMAX 路线 | 锁开销占比太高 |
| 共享的是大数组/缓存这类天然多写数据 | ❌ 单写者变瓶颈 | ✅ 分片 + 分桶锁 |
| 延迟极度敏感于一跳 | ❌ 多一次调度 | 视场景 |
Tip
两层修复不是对立选项,是同一问题的两个半径:数据结构层让 这一次 竞争变便宜,架构层让 这一类 竞争不存在。改动半径小的先做,天花板高的留给真正需要的场景。
小结:系列收官
五篇走完,把结论串成一张地图:
- 07.md:无锁的前提是单写者——纪律
- 07-1:多写者进场后的三条修法——CAS、mutex、改结构
- 08.md:一切同步的物理基础——内存模型
- 08-1 ~ 08-6:标准库的选择与实测验证——锁 + park 在竞争中反而赢
- 本篇:修复的最高形态——让前提从未被打破
一句话概括整个系列:并发问题的解法只有三种——让竞争变便宜(锁/CAS)、让等待变便宜(park/自旋)、让竞争不存在(单写者架构)。前两种是优化,第三种是设计。修 bug 的最高境界,从来不是修得精巧,是让 bug 的前提不成立。
最后更新:2026-09-08