第14章 Memcached 内置代理的内部架构
内置代理与内部架构 内置代理与内部架构 本页详细描述 Memcached 内置代理的内部工作原理。
如需了解内置代理的总体概览,请参阅“内置代理”章节。
客户端连接与工作线程 客户端与代理的连接处理方式与普通 memcached 完全一致。一个独立线程负责监听新 socket,并以轮询方式将客户端连接分发到各个 worker 线程。
每个 worker 都配备一个独立的 Lua 虚拟机:Lua 数据无法在 worker 线程间直接共享。
系统没有专门负责代理的 worker 线程。同一个 worker 线程既可以处理缓存响应,也可以处理代理请求。用于请求处理的 Lua 代码只在 worker 线程中执行。
--- 标题:客户端 socket 处理 --- flowchart LR Client[客户端连接] --> Accept[监听/接收线程] Accept -->|轮询| Worker1[Worker 线程] Accept -->|轮询| Worker2[Worker 线程] Accept -->|轮询| Worker3[Worker 线程]
后端请求与连接 后端请求可能直接来自 worker 线程(当池配置中 iothread = false 时)。每个 worker 线程都会为每个后端服务器维持一条专用的 TCP 连接。
flowchart TD subgraph worker2 direction LR Worker{Worker 线程} <--> Backend1[后端服务器] Worker <--> Backend2[后端服务器] Worker <--> Backend3[后端服务器] end subgraph worker1 direction LR Worker2{Worker 线程} <--> Backend11[后端服务器] Worker2 <--> Backend12[后端服务器] Worker2 <--> Backend13[后端服务器] end
另一种情况是当 iothread = true 时,worker 会将请求提交给一个专门的 IO 线程,该线程会尽可能将发往同一后端的请求进行批量处理。这种模式下使用的总 TCP 连接数会减少。
这个 IO 线程不包含任何 Lua 处理逻辑,它由纯 C 编写,仅负责响应数据的网络传输和协议解析。
flowchart LR Worker1[Worker 线程] <--> IO{IO 线程} Worker2[Worker 线程] <--> IO IO <--> Backend1[后端服务器] IO <--> Backend2[后端服务器] IO <--> Backend3[后端服务器]
配置加载与重载 一个独立的 Lua 配置线程负责处理配置文件、执行 mcp_config_pools,并创建所有后端和池对象。随后,它将配置复制到每个 worker 虚拟机中。每个 worker 虚拟机在加载时都会执行 mcp_config_routes;务必保持该函数的轻量级。
配置线程拥有自己的专用 Lua 虚拟机。它通过一种特殊的跨虚拟机数据复制机制,将 mcp_config_pools() 返回的结果共享给 worker 虚拟机。
加载过程每次只针对一个 worker 线程运行。原因有二:首先,配置线程必须向每个 worker 复制部分数据;其次,这样可以最大限度降低重载带来的延迟影响。
flowchart LR Config{配置线程} -->|第一个| Worker1[Worker 线程] Config -->|第二个| Worker2[Worker 线程] Config -->|第三个| Worker3[Worker 线程]
客户端与后端请求的批处理 客户端请求的批处理 来自同一客户端 socket 的多个请求可以批量处理。通过减少系统调用和上下文切换的次数,可以提升性能,但存在一些注意事项。
对于代理而言,客户端请求的批处理与普通 memcached 完全相同,因为代码是共享的。差异体现在向客户端返回响应时。
举例说明,假设客户端为 A,代理为 P:
客户端 A 发起一次 TCP write() 调用,缓冲区中同时包含三个请求:
get foo\r\n get bar\r\n get baz\r\n
代理 P 上的一个 worker 线程被唤醒,得知客户端 A 的 socket 上有数据就绪。
代理随后对该 socket 发起 read(),最多读取 READ_BUFFER_SIZE(16kb)字节。
读取缓冲区足以容纳全部三个请求,代理开始解析缓冲区。
代理首先执行 get foo,该请求解析到后端 b1。
请求随即加入队列,等待 b1 执行,get foo 暂停执行,等待响应。
代理立即处理下一个请求,启动 get bar,它被发送到后端 b2,依此类推。
此过程重复进行,直到代理处理完所有客户端请求或达到 yield 限制(见下文)。
此时,由于代理正在等待三个并行请求返回不同后端的响应,没有内容可返回给客户端 A,因此暂停处理客户端 A。
当所有排队响应收集完毕后,代理 P 恢复执行,将全部结果发送回客户端 A。这意味着客户端 A 的响应首字节时间(Time To First Byte)取决于最慢的后端服务器速度。
如果客户端 A 的 socket 上已有更多请求到达,而这些请求处于暂停状态,它们必须等待原始批次完成才能开始处理。
注意:未来版本很可能会改变这一行为,允许代理在安全的前提下返回部分响应,并在旧请求等待期间处理新到达的请求。
在许多场景下,要么每次只到达一个请求,要么所有后端速度大致相当:memcached 通常不会在处理结果之前执行额外操作,因此所有请求耗时相同。大多数情况下性能是可以接受的。
sequenceDiagram 参与者 客户端 参与者 代理 Worker 参与者 后端 客户端->>代理 Worker: 3x GET key\r\n 备注 右侧 代理 Worker: 处理传入请求 备注 右侧 代理 Worker: 加入后端请求队列 代理 Worker->>后端: 3x GET etc\r\n 备注 右侧 代理 Worker: 将所有 GET 发送到后端 后端->>代理 Worker: 3x VALUE etc\r\n 备注 右侧 代理 Worker: 准备响应客户端 代理 Worker->>客户端: 3x VALUE etc\r\n
后端请求的批处理 与上文详述的客户端 socket 处理相比,后端 socket 使用全双工流式传输。
假设后端 B 同时从不同客户端收到三个请求:
get foo\r\n get bar\r\n get baz\r\n
后端不对排队请求进行处理:只需将这些请求写入连接后端服务器的 socket 即可。 具体操作如下:
- 将全部三个请求放入单个 write 系统调用 - 等待响应 - 一次性从 socket 读取最多 READ_BUFFER_SIZE(16kb) - 循环处理每个响应: - 解析响应,若正常则将响应对象返回给原始客户端 - 若缓冲区中有下一个响应就绪,立即处理 - 完成后回到等待状态
如果后端 B 在等待响应时收到更多请求,它会立即 write() 到同一 socket。若 socket 缓冲区已满,则等待直至可写入更多数据。因此,新请求不会因等待前一批处理完成而延迟。
响应在从 socket 读取后立即发送给各自对应的客户端,因此不存在等待批次处理的内部延迟。
若后端 B 因某种原因中断,队列会立即清空,并向所有等待的客户端发送错误响应。
待定:图表
后端连接故障检测 配置重载 后端与池对象管理 后端对象生命周期
以下 API 仅在内部创建后端的描述。实际的后端对象是在调用 mcp.pool() 时创建的。这使得我们可以将后端的生命周期与池的生命周期挂钩。
示例:若池 A 使用后端 1 和 2 创建,则这些后端保证在池 A 存活期间始终存在。若池 A 和 B 共享相同后端,则后端采用引用计数。池 A 和 B 都必须被释放后,后端 1 和 2 才会被移除。
部分出于性能考虑:在请求执行期间无需更改后端 1 和 2 的引用计数或对其进行加锁。基本规则如下:
- 只有当池对象仍然存在时,后端才可被查询。 - 在处理请求时,无法在任意位置释放池对象。 - 池使用更高一级的内存管理。
因此,在请求时无需引用计数或加锁即可使用全部资源。
请求流
flowchart TD A["get foo/username"] -->B["是否存在 get hook?"] B --> |是|C[在映射表中检查 'foo' 函数] B --> |否|D[从本地缓存响应] C --> |是|E["请求槽位是否空闲?"] E --> |是|F["执行 func(req)"] E --> |否|G["生成函数槽位"] G --> F F --> H[对一个或多个池执行] H --> I[池:将键映射到后端列表] I --> J[后端位于 worker 线程还是后台线程?] J --> |worker|K[在同一线程内执行] J --> |后台|L[将请求发送至共享后台线程] K --> M[远程 memcached 处理请求] L --> N[后台线程批量处理所有待处理请求] N --> M M --> O[收到远程 mc 的响应] O --> P["向客户端发送 VALUE 等响应"]
几乎所有实际处理都在 Lua 外部完成,Lua 主要用于管理配置代码。
基础设施示例 常见问题