跳转至

归并写者:把竞争消灭在架构层

无锁系列一路都在数据结构层打转: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(多生产者收敛点)──→ 单线程业务核心 ──→ output
(散)              (唯一的竞争点)                  (无锁天堂)        (散)

所有竞争被压缩到 input ring buffer 这一个点上(用 07-1 的 per-slot 序列号解决);核心里的订单匹配、账务变更,全在单线程里裸奔——没有锁、没有 CAS、没有内存屏障,连 08.md 的缓存行填充都不需要。单线程不是性能缺陷,是把最贵的并发税全部免掉

事件溯源(Event Sourcing)是同一招的持久化版:单写者顺序 append journal(04-1 的 WAL 思想),读侧任意复制摊开。Kafka 的分区、Redis 的单线程事件循环、Go 社区的"一个 goroutine 拥有一份状态",全是同一个模式的不同投影。

4. 代价清单

天下没有免费的架构:

  1. 一跳转发延迟:请求多走一层 mailbox,P99 加一次调度(08-2 的 park 档,百纳秒级)
  2. 写者吞吐上限:唯一的写者是串行瓶颈,CPU 单核就是天花板。LMAX 用"业务操作极轻"换"天花板够高";操作重就得按状态分片(分账户、分会话),每个分片一个写者
  3. 背压传导:inbox 满了怎么办?channel 会阻塞投递方——这其实是特性不是 bug,背压自动沿调用链上传
  4. 请求-响应变异步:要拿返回值得带 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

评论