Memcached 不是「一连接一线程」。它用一个监听线程接连接,用 Connection Queue 把连接交给多个 worker,每个 worker 用自己的 libevent 管理大量连接,并在本线程里直接执行 GET/SET。所有 worker 共享同一份 hash、slab 和 LRU,再用按 hash 分片的 item lock 保护这份缓存。
Memcached = 多 worker 线程 + 每 worker 一个 event loop + 每 worker 管多个连接 + 所有 worker 共享同一份缓存。
依据是已经对过的线程模型,以及 Memcached 源码里能直接对上的结构:独立监听线程、worker 自己的 libevent、连接队列 CQ、item locks,以及基于 hash 的锁分片。继续读源码时,优先看 thread.c:worker 初始化、CQ、wakeup、item locks 都在这里。不同版本的源码结构略有演进,基本思想不变。微信公众号原文当时没有逐段对照,下面按源码能确认的模型来记。
目录
1. 整体模型
官方资料的描述是:有一个专门线程监听新 socket,再把客户端连接 round-robin 分配给各 worker。连接交出去之后,GET/SET 不再回到监听线程。
多个 conn"] W1["Worker1
多个 conn"] W2["Worker2
多个 conn"] end CQ0 --> W0 CQ1 --> W1 CQ2 --> W2 W0 --> Shared W1 --> Shared W2 --> Shared subgraph Shared["进程内共享缓存"] direction LR Hash["Hash Table"] ~~~ Slab["Slab"] Slab ~~~ LRU["LRU / item"] end classDef client fill:#f1f5f9,stroke:#64748b,color:#1e293b classDef listen fill:#dbeafe,stroke:#2563eb,color:#1e3a8a classDef queue fill:#ffedd5,stroke:#ea580c,color:#7c2d12 classDef worker fill:#dcfce7,stroke:#16a34a,color:#14532d classDef cache fill:#ede9fe,stroke:#7c3aed,color:#4c1d95 classDef decision fill:#fef9c3,stroke:#ca8a04,color:#713f12 class Client client class Accept listen class RR decision class CQ0,CQ1,CQ2 queue class W0,W1,W2 worker class Hash,Slab,LRU cache
四层各管一件事:
| 层 | 做什么 | 不做什么 |
|---|---|---|
| Listener | accept(),按 round-robin 选 worker |
不执行 GET/SET |
| CQ | 把新连接从监听线程交给某个 worker | 不排队业务请求 |
| Worker | 自己的 event loop,解析协议并执行命令 | 不独占一份缓存 |
| Shared Cache | hash、slab、LRU,全进程一份 | 不按线程复制 |
2. 连接怎么进来
2.1 Main Thread
主线程的路径很短:listen → accept → 得到 socket fd → 选择 worker → 把连接交出去。
常见分发是 round-robin:第 5 个连接回到 Worker0。Main Thread 不负责执行每一个 GET/SET。
2.2 CQ:Connection Queue
CQ 是 Main Thread 和 Worker 之间的连接交接队列。源码里它大致带一把队列自己的锁:
1 | struct conn_queue { |
CQ_ITEM 保存的是新连接,不是一条命令:
1 | struct conn_queue_item { |
accept fd = 100"] --> Item["CQ_ITEM
sfd = 100"] Item --> CQ["Worker2.CQ"] CQ --> Worker["Worker2 之后接管这条连接"] classDef listen fill:#dbeafe,stroke:#2563eb,color:#1e3a8a classDef queue fill:#ffedd5,stroke:#ea580c,color:#7c2d12 classDef worker fill:#dcfce7,stroke:#16a34a,color:#14532d class Accept listen class Item,CQ queue class Worker worker
队列里排的是 socket fd。一次 push 对应一次连接交接,例如 fd 101、fd 102、fd 103。GET a / SET b 不会逐条进入 CQ。旧版源码可以直接看到 head/tail + mutex;新版本仍然保留 connection queue 和对应的锁。
2.3 CQ 为什么自己也要锁
队列两侧是不同线程。Main 正在 cq_push(),Worker 正在 cq_pop(),两边会同时改队列,所以 queue 自己需要 mutex。
这把锁只保护 connection queue。后面保护 KV 数据的 item lock 是另一套锁。
2.4 Worker 怎么知道 CQ 里有新连接
只做 CQ.push(conn) 不够。Worker 可能正停在 event loop 里等网络事件,根本不知道队列里多了东西。经典设计里,每个 worker 都有 wakeup pipe 或 eventfd。源码注释的意思是:每个 libevent 实例都有一个 wakeup pipe,其他线程可以通过它通知 worker,queue 里进了一个新连接。
2.5 新连接的完整调用链
连接交出去以后,后续 GET/SET 走的是该 worker 的 socket event,不再每个 request 都经过 CQ。
3. Worker 怎么处理请求
3.1 Worker 是什么
每个 Worker 是一个 pthread,加上自己的 event_base,加上挂在这个 event loop 上的多个 socket。初始化时为 worker 建自己的 event_base,然后进入 event_base_loop(...)。源码里有 worker_libevent(),核心就是进入 worker 自己的 event loop。
一个 worker 是一个单线程 event loop。
3.2 一个 Worker 管几个 Conn
很多个。4 个 worker 管 10000 个连接是正常的,因为模型是非阻塞 IO 加 event loop。
3.3 同一个 Worker 下多个 Conn 是否并行
同一个 worker 只有一个线程。A、B 可读、C 不可读时,event loop 先处理 A,再处理 B,然后继续循环。这是事件驱动并发,CPU 意义上的并行发生在不同 worker 之间。
3.4 一个 Conn 会不会跨 Worker
正常情况下,连接建立后分配给某一个 Worker,之后一直由那个 Worker 的 event loop 管理。GET a、GET b、SET c、GET d 都由同一个 worker 执行。这样连接状态不用跨线程同步。
3.5 Worker 不只是网络 IO 线程
Memcached Worker 同时做网络 IO、协议解析、命令执行、Hash lookup、GET/SET 和结果发送。
GET keyA"] --> Core0["CPU0"] W1["Worker1
GET keyB"] --> Core1["CPU1"] classDef worker fill:#dbeafe,stroke:#2563eb,color:#1e3a8a classDef cpu fill:#dcfce7,stroke:#16a34a,color:#14532d class W0,W1 worker class Core0,Core1 cpu
两个 worker 可以在两个 CPU core 上同时执行 KV 操作。这是 Memcached 和 Redis 经典线程模型的一个主要差别。
3.6 一次请求在 Worker 内怎么走
客户端发 get user:100 时,全程在这个 Worker 线程里完成:读 socket、解析协议、查缓存、写回结果。锁只包住很短的共享数据临界区。
read"] --> Proto["解析协议,识别 GET"] Proto --> Hash["hash(user:100)"] Hash --> Lock["获取 item lock"] Lock --> Lookup["查 hash table,得到 item"] Lookup --> Ref["维护 refcount / item 状态"] Ref --> Unlock["释放 item lock"] Unlock --> Write["写 socket,返回 Client"] classDef io fill:#dbeafe,stroke:#2563eb,color:#1e3a8a classDef cmd fill:#fef9c3,stroke:#ca8a04,color:#713f12 classDef lock fill:#ffe4e6,stroke:#e11d48,color:#881337 classDef cache fill:#ede9fe,stroke:#7c3aed,color:#4c1d95 class Read,Write io class Proto,Hash cmd class Lock,Unlock lock class Lookup,Ref cache
记成一条线即可:Network → Protocol → Command → Cache → Network。
4. 共享缓存与锁
4.1 为什么多 Worker 能访问同一份数据
这些 worker 都是同一个进程里的 pthread,共享虚拟地址空间,因此共享同一份堆上的缓存。Worker0 执行 set user:1 A 之后,Worker3 执行 get user:1 可以读到。
4.2 共享数据会有竞态
两个 worker 同时 SET k,没有同步就可能破坏 hash table、item metadata、LRU、refcount。源码里能看到多类锁:item_locks、lru_locks、stats_lock、conn_lock,以及前面的 CQ lock。它们保护的对象不同。
4.3 item_lock:锁分片
不是每个 key 一把 mutex。路径是 hash(key) 得到 hv,再用 hv & mask 选择 item_locks[x]。
1 | mutex_lock(&item_locks[hv & hashmask(item_lock_hashpower)]); |
这是 Lock Striping。例如 1024 把锁时,不同 key 可以落到不同锁上;hash 到同一槽的 key 即使不是同一个 key,也会竞争同一把锁。
A 和 B 可以同时访问。A 和 C 会在 lock 21 上排队。
4.4 同一个 KV 同时 GET / SET
Worker0: GET k 和 Worker1: SET k = A 映射到同一把 item lock。临界区串行化以后,一边不会在另一边已经 free 掉对象时继续使用它。
4.5 GET 也要加锁
关键路径上要加锁。GET 要碰到 item 生命周期、引用计数、LRU 状态,以及 hash table 里对象是否仍然安全。当前源码仍能看到 item_get_locked(...):先 hash(),再 item_lock(),再 do_item_get()。
GET 通常只保护很短的共享数据临界区,不会拿锁直到 socket 写完。
4.6 为什么不用 RWLock
理论上可以让 GET 拿读锁、SET 拿写锁,使 GET + GET 完全并发。实际 GET 还要碰 refcount、LRU、item metadata 和生命周期,本身就不是纯读。再加上 pthread rwlock 的成本、cache line contention 和实现复杂度,短临界区的 mutex 通常更实用。
Memcached 的思路是:锁很多、粒度小、持锁时间短。
4.7 item lock 和 LRU lock
两套锁保护的对象不同。
| 锁 | 主要保护 |
|---|---|
item_lock |
item、hash bucket、item metadata、生命周期 |
lru_lock |
对应的 LRU 数据结构 |
| CQ lock | 连接交接队列 |
stats_lock / conn_lock |
统计或连接相关的共享状态 |
源码注释的要点:修改 item / hash bucket 前需要 item lock;涉及 LRU 修改时还需要 LRU lock;LRU 访问 item 时要协调 item trylock。实际代码会固定锁顺序,否则容易死锁。
修改 item / hash bucket"] ItemLock --> LruLock["lru_lock
修改 LRU"] LruLock --> Done["按固定顺序释放"] classDef worker fill:#dcfce7,stroke:#16a34a,color:#14532d classDef item fill:#ffe4e6,stroke:#e11d48,color:#881337 classDef lru fill:#ede9fe,stroke:#7c3aed,color:#4c1d95 class Worker worker class ItemLock item class LruLock,Done lru
5. 业务原子性、CAS、Slab、LRU
5.1 线程安全不等于业务操作原子
内部锁都正确时,下面这段仍然可能丢更新:
1 | GET counter |
内部保证的是单次 GET 安全、单次 SET 安全。需要原子语义时用 incr、decr、cas、add。
5.2 CAS
gets 会带回 value 和 CAS token。两个客户端都读到 100, token = 51 时,先提交的 cas 成功并把 token 改成 52,后提交的 cas 因为 token 已经不是 51 而失败。这是乐观并发控制。
5.3 Slab 在体系里的位置
value 最终落在 slab 分配器里。Item 进入合适的 slab class,例如 96B、120B、152B 这类 chunk。
四个概念分开记:
| 概念 | 负责什么 |
|---|---|
| Hash | 按 key 找到 item |
| Slab | 决定 value 存在哪块内存 |
| LRU | 内存满时谁先被淘汰 |
| item lock | 多 worker 同时访问时的并发安全 |
5.4 LRU
内存满了要决定淘汰谁。Memcached 用 LRU 家族管理冷热:热数据靠近 HEAD,冷数据靠近 TAIL,压力大时从冷的一侧淘汰。GET 看起来只是读,访问模式仍可能改变 LRU 状态,所以 GET 不是纯粹的无状态读取。
6. 为什么适合简单高并发缓存
适合的负载是:大量客户端、大量简单 GET/SET、value 是 blob、不需要复杂结构。例如 user:123 -> protobuf、product:456 -> JSON、sql:hash -> DB result、page:x -> HTML fragment。
和 Redis 的关键区别
Memcached 的多个 worker 可以真正并行执行缓存命令。Redis 经典核心更接近「IO 进来之后,命令在核心执行线程上跑」,复杂结构也放在这份核心状态里。现代 Redis 已有多线程网络 IO 等机制,核心命令执行架构仍和 Memcached 不同。
Memcached 特别适合多核机器上的简单 KV cache workload。
7. 两条必背调用链
第一条是新连接,只走一次。第二条是这条连接上之后的每一次 GET/SET。
橙色是连接建立时的交接,只经过 CQ 一次。绿色是稳态:请求留在该 worker 的 socket event 上,直接打到共享缓存。
8. Java / Netty 类比
| Memcached | 接近的 Netty / Java 概念 |
|---|---|
| Main Listener | Boss EventLoop |
| Worker | Worker EventLoop |
| CQ | Boss 把连接注册到 Worker 的过程 |
| libevent | epoll selector / event loop |
| conn | Channel |
| item_lock | striped ReentrantLock |
底层实现不完全一样。用这个映射记模型即可:Boss 只接连接,Worker 既做 IO 也做命令,共享缓存靠分片锁。
9. 十条结论
- Memcached 是多线程服务。
- Main Thread 主要负责监听和接受连接。
- 新连接通过 CQ 分发到 Worker。
- CQ = Connection Queue,交接的是连接。
- 一个 Worker 可以管理大量 Conn。
- Worker 使用 libevent + 非阻塞 IO。
- 一个 Conn 一般归属一个 Worker。
- Worker 不仅做网络 IO,也执行 GET/SET。
- 多个 Worker 共享同一份缓存,所以需要 item lock、LRU lock 等同步机制。
- item lock 通过 hash 分片,而不是每个 key 一把 mutex。