Nginx 服务自动发现与配置热更新架构

Nginx 服务自动发现与配置热更新架构

Nginx 采用 master-worker 多进程模型,其中:

  • Master 进程:负责配置解析、进程管理和信号交互
  • Worker 进程:执行网络 I/O、定时器和延迟队列的事件循环

本文深入分析 Nginx 如何与 ZooKeeper 集成实现服务自动发现和配置热更新,以及如何通过引入 Manage 进程优化架构设计。


1、Nginx 多进程模型原理

1.1、配置解析与端口初始化

Master 进程:解析配置与打开监听端口

sequenceDiagram
    participant Master as Master 进程
    participant Config as nginx.conf
    participant Socket as 监听 Socket

    Master->>Config: 1. ngx_init_cycle()
    Master->>Config: 2. ngx_conf_parse() 逐行解析
    Master->>Master: 3. 生成 cycle->conf_ctx
    Master->>Master: 4. 构建 cycle->listening 数组
    Master->>Socket: 5. ngx_open_listening_sockets()
    Note over Socket: socket() → bind() → listen()
    Master->>Master: 6. 设置 reuseport/backlog

详细流程:

  1. 配置解析:在 main() 中,master 进程首先调用 ngx_init_cycle(),该函数基于 ngx_conf_parse() 逐行读取并解析 nginx.conf,生成模块级的配置上下文(cycle->conf_ctx)

  2. 构建 listening 数组:解析阶段,core 模块会为每个 listen 指令创建一个 ngx_listening_t 结构,并将其添加到 cycle->listening 数组中

  3. 打开套接字:随后,master 调用 ngx_open_listening_sockets(),遍历 cycle->listening:

    • 为每个条目创建 socket(socket())
    • 绑定地址(bind())
    • 监听(listen())
    • 设置常用选项(如 reuseport、backlog)
  4. 继承老进程 socket:若为平滑重启,通过 ngx_add_inherited_sockets() 从环境变量获取老进程的 socket 描述符,避免中断服务

Worker 进程:继承与初始化

sequenceDiagram
    participant Master as Master 进程
    participant Worker as Worker 进程
    participant Socket as 监听 Socket

    Master->>Worker: 1. fork() 创建子进程
    Master->>Worker: 2. 继承所有监听 Socket
    Worker->>Worker: 3. ngx_worker_process_init()
    Note over Worker: 初始化日志、事件模块、定时器
    Worker->>Socket: 4. 注册事件回调(无需 bind/listen)

详细流程:

  1. Fork 产生:master 通过 ngx_start_worker_processes() 调用 ngx_spawn_process() fork 出多个 worker,继承 master 打开的所有监听 socket

  2. 初始化调用:每个 worker 在 ngx_worker_process_cycle() 开始时调用 ngx_worker_process_init() 完成:

    • 设置进程类型标识
    • 设置进程标题
    • 初始化日志、事件模块、定时器与延迟队列
    • 为业务端口注册可读/可写事件回调
  3. 不再 bind/listen:worker 直接使用继承自 master 的 socket,**无需再次调用 bind() 或 listen()**,确保端口监听唯一由 master 或平滑重启过程中创建一次

1.2、Worker 进程事件循环

Worker 进程只处理 3 种事件:

事件类型 说明
网络 I/O 读写事件、新连接(accept)
定时器 红黑树管理的定时器事件
延迟队列 延迟处理的事件(posted events)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
static void
ngx_worker_process_cycle(ngx_cycle_t *cycle, void *data)
{
ngx_int_t worker = (intptr_t) data;

ngx_process = NGX_PROCESS_WORKER;
// 记录当前工作进程的编号
ngx_worker = worker;

// 初始化工作进程,实际可以修改进程的相关配置
ngx_worker_process_init(cycle, worker);

////////////实际这里可以根据进程编号,设置名称
ngx_setproctitle("worker process");

// 进入无限循环,处理工作进程的各种事件
for ( ;; ) {
if (ngx_exiting) {
if (ngx_event_no_timers_left() == NGX_OK) {
ngx_log_error(NGX_LOG_NOTICE, cycle->log, 0, "exiting");
ngx_worker_process_exit(cycle);
}
}
ngx_log_debug0(NGX_LOG_DEBUG_EVENT, cycle->log, 0, "worker cycle");

// 处理事件和定时器
ngx_process_events_and_timers(cycle);
//.................................
}
}

核心处理函数:ngx_process_events_and_timers

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
ngx_process_events_and_timers(ngx_cycle_t *cycle)
{
//.........................
//判断进程是否启用了accept锁,很关键
if (ngx_use_accept_mutex) {
if (ngx_accept_disabled > 0) {
ngx_accept_disabled--;

} else {
// 尝试获取接受互斥锁
//!!!这个函数虽然叫这个,实际里面做了accept,如果抢到锁,那就把打开的socket,都放入自己的事件循环里面,一般就是epoll_ctl
if (ngx_trylock_accept_mutex(cycle) == NGX_ERROR) {
return;
}
//.............................................
}
}
//.....................................

delta = ngx_current_msec;

// 调用底层事件处理函数处理事件
(void) ngx_process_events(cycle, timer, flags);

// 计算事件处理所花费的时间
delta = ngx_current_msec - delta;

// 处理发布的接受事件
ngx_event_process_posted(cycle, &ngx_posted_accept_events);

// 如果持有接受互斥锁,则释放互斥锁
if (ngx_accept_mutex_held) {
ngx_shmtx_unlock(&ngx_accept_mutex);
}

// 过期并处理超时的定时器事件
ngx_event_expire_timers();

// 处理发布的普通事件
ngx_event_process_posted(cycle, &ngx_posted_events);
}

事件循环流程图

flowchart TD
    Start[循环开始] --> CheckLock{是否启用 accept_mutex?}
    CheckLock -->|是| TryLock[尝试获取 accept_mutex]
    TryLock -->|成功| AddSocket[将监听 socket 加入 epoll]
    TryLock -->|失败| ProcessEvents[ngx_process_events]
    CheckLock -->|否| ProcessEvents
    
    ProcessEvents[ngx_process_events<br/>epoll_wait 等待事件] --> HandleIO{事件类型}
    HandleIO -->|已有连接读写| DirectHandler[直接调用 handler<br/>如 ngx_http_process_request]
    HandleIO -->|新连接 accept| PostAccept[post 到 ngx_posted_accept_events]
    
    PostAccept --> ProcessAccept[ngx_event_process_posted<br/>执行 accept]
    DirectHandler --> ProcessAccept
    AddSocket --> ProcessEvents
    
    ProcessAccept --> ReleaseLock[释放 accept_mutex]
    ReleaseLock --> ExpireTimers[ngx_event_expire_timers<br/>处理超时定时器]
    ExpireTimers --> PostEvents[超时事件 post 到 ngx_posted_events]
    PostEvents --> ProcessPosted[ngx_event_process_posted<br/>执行普通延迟事件]
    ProcessPosted --> Start

关键步骤说明:

  1. **ngx_process_events()**:调用 epoll_wait 等待事件

    • 已有连接的读写事件:直接调用 handler(如 ngx_http_process_request)
    • 新连接事件(accept):post 到 ngx_posted_accept_events
  2. **ngx_event_process_posted(&ngx_posted_accept_events)**:执行 accept 操作

  3. 释放锁:如果持有 accept_mutex,此时释放

  4. **ngx_event_expire_timers()**:处理红黑树定时器,超时事件 post 到 ngx_posted_events

  5. **ngx_event_process_posted(&ngx_posted_events)**:执行普通延迟事件


2、与 ZooKeeper 的交互架构

2.0、问题背景

为了支持与 ZooKeeper 的动态配置交互,早期方案采用单 Worker 直连 ZooKeeper 进行配置变更,多进程通过共享内存同步配置。但这种方式会阻塞该 worker 处理其他事件,影响服务可用性与性能。

2.1、原始模式架构

架构设计

(1)单 Worker 负责注册 Watcher 与更新配置

  • 所有 Worker 中仅有一个被选定(编号为 0)连接 ZooKeeper
  • 该 Worker 注册 Watcher,并监听配置中心的变更事件
  • 当监听到变更后,直接将最新配置写入本地文件,并通过共享内存设置一个”配置更新标志位”(实际为版本号)

(2)其他 Worker 通过轮询方式感知并应用配置

  • 其他 Worker 启动定时器,定期比对本地进程内存和共享内存中的版本号是否一致
  • 一旦发现不一致,抢锁,然后读取配置文件,加载新配置并完成本地内存更新
  • 更新完成后,版本号修改为一致,释放锁

原始模式架构图

graph TB
    subgraph Nginx 进程
        Worker0[Worker 0<br/>连接 ZooKeeper]
        Worker1[Worker 1]
        Worker2[Worker 2]
        WorkerN[Worker N]
    end
    
    ZK[ZooKeeper<br/>配置中心]
    Shm[共享内存<br/>版本号标志位]
    File[本地配置文件]
    
    Worker0 -->|注册 Watcher| ZK
    ZK -->|配置变更通知| Worker0
    Worker0 -->|写入配置| File
    Worker0 -->|设置版本号| Shm
    
    Worker1 -->|定时轮询| Shm
    Worker2 -->|定时轮询| Shm
    WorkerN -->|定时轮询| Shm
    
    Worker1 -->|读取配置| File
    Worker2 -->|读取配置| File
    WorkerN -->|读取配置| File
    
    style Worker0 fill:#ffcccc
    style Shm fill:#fff4e1
    style File fill:#e1f5ff

存在的问题

问题类型 具体表现
性能问题 Worker 0 需要处理 ZooKeeper 连接、监听、配置写入等操作,阻塞事件循环,延迟请求响应,高并发场景下性能下降明显
维护成本 每个 Worker 都需要包含 ZooKeeper 连接管理、会话重连、Watcher 重订阅、异常恢复等完整流程,增加维护成本与出错风险
更新效率 配置变更后,Worker 0 写入文件,其他 Worker 轮询后读取文件,需要加锁导致串行更新,且文件 I/O 效率低下

2.2、新模式设计:引入 Manage 进程

架构图

graph TB
    Master[Master进程<br/>配置解析与端口初始化]
    Manage[Manage进程<br/>编号0<br/>配置更新专用]
    Worker1[Worker进程<br/>编号1]
    Worker2[Worker进程<br/>编号2]
    WorkerN[Worker进程<br/>编号N]
    
    ZK[ZooKeeper<br/>配置中心]
    Shm[共享内存<br/>配置数据与版本号]
    
    Client[客户端<br/>业务请求]
    Admin[管理端<br/>热更新请求]
    
    Master -->|fork| Manage
    Master -->|fork| Worker1
    Master -->|fork| Worker2
    Master -->|fork| WorkerN
    
    Manage -->|监听管理端口| Admin
    Manage -->|注册Watcher| ZK
    Manage -->|读取配置| ZK
    Manage -->|写入配置| ZK
    Manage -->|更新配置| Shm
    
    Worker1 -->|监听业务端口| Client
    Worker2 -->|监听业务端口| Client
    WorkerN -->|监听业务端口| Client
    
    Worker1 -->|读取配置| Shm
    Worker2 -->|读取配置| Shm
    WorkerN -->|读取配置| Shm
    
    style Manage fill:#e1f5ff
    style Shm fill:#fff4e1
    style ZK fill:#e8f5e9

优化点总结

优化项 说明
职责分离 将与 ZooKeeper 交互、动态配置管理等耗时操作与网络 I/O 分离,减轻 worker 负担,简化逻辑
避免阻塞 避免单个 worker 因配置更新而阻塞服务,有助于提高整体吞吐与可用性
内存更新 配置更新后存入共享内存,worker 从内存读取数据,而不是读文件,提高更新效率
并行更新 使用读写锁,worker 可以进行并行更新,提升性能

设计方案

2.1、主体目标
  • 新增 Manage 进程:只处理配置更新,包括:
    • 注册中心(ZooKeeper)的配置更新
    • 热更新接口
    • Redis 哨兵、RabbitMQ 的订阅消息
    • 不处理实际的客户端请求
  • Worker 专注业务:专注于处理客户端请求-响应
2.2、进程定位与初始化

进程创建策略:

  • 如果不是 single 模式,额外创建一个进程,修改进程名称为 manage
  • 解析 "worker_processes" 时,如果为 auto 或数量 > 1,则额外创建一个编号为 0 的 manage 进程
  • 实际 "worker_processes" 数量加 1

端口监听策略:

端口类型 Manage 进程 Worker 进程 说明
业务端口 关闭 监听 处理客户端请求-响应
管理端口 监听 关闭 接收热更新 API 调用

初始化要求:

  • Manage 进程不监听业务端口,初始化时关闭业务端口
  • Manage 进程不启用 accept 锁(因为只监听管理端口,无竞争关系)
2.3、功能层面的设计

Manage 进程职责:

  • 向 ZooKeeper 注册 Watcher
  • 创建 5s 定时器,扫描与 ZooKeeper 的数据交互结构体是否变更
  • 如果有变更,说明有 ZooKeeper 的配置更新推送,开始获取数据更新到:
    • 本地内存
    • 共享内存
    • 本地文件

Worker 进程职责:

  • 创建 2s 定时器,扫描共享内存配置主版本号
  • 针对功能的不同,细化为功能版本号:
    • 路由版本号
    • 流控版本号
    • 访问控制版本号
  • 主版本号发生变化时,获取更细致的功能配置变更,进行增量更新,抛弃原有的全量更新模式

Lua 层面处理:

  • 由 Manage 进程处理 Redis 哨兵、RabbitMQ 的推送消息
  • 处理节点变更以及实际的业务数据变更
2.4、竞争关系与锁机制

问题分析:

很明显会发生竞争关系,比如 worker 正在读取共享内存的新配置时,如果 manage 再次更新,那就会发生异常,需要上锁。

锁的设计要求:

  • Manage 更新配置时,禁止 worker 读取
  • Worker 更新配置时,需要先获取锁
  • 理想场景:Manage 更新时,worker 禁止更新,但所有 worker 可以同时读取

解决方案:读写锁

使用读写锁 ngx_rwlock_rlock:

操作类型 锁类型 说明
Manage 写锁 更新配置时获取写锁
Worker 读锁 读取配置时获取读锁
特性 - 写时不可读,读时不可写,但可以一起读

优势:Worker 可以并行更新配置,提升性能。

热更新处理:

  • Manage 还处理热更新,这是调用式触发(主动模式)
  • 原理一致:接收客户端的数据,更新本地内存、共享内存、本地文件,然后修改共享内存的标识
  • 对于热更新,还需要更新 ZooKeeper 的配置,满足集群多节点的配置同步更新
2.5、异常场景处理

问题:Watcher 连续推送

场景描述:

  1. Manage 进程更新完配置后,会将标志位恢复,然后重新创建定时器进行扫描标志位
  2. 如果 Manage 进程正在更新配置(更新部分),此时又有 Watcher 消息推送
  3. Manage 接着更新完配置恢复了标志位,就会丢失这期间所有的变更推送

问题分析:

  • 加锁解决不了这种竞争关系(因为 Watcher 是异步推送的)
  • 需要额外的机制来标识”更新期间再次发生更新”

解决方案:updating 标志位

新增 updating 标志位,标识更新期间再次发生更新:

  • 配置更新完毕后,如果 updating 为 1,直接恢复标志位
  • 如果 updating 为 0,说明更新期间发生了新的推送,不恢复更新标志位
  • 等待下一个定时器再次触发,继续更新

2.6、方案实现

核心原则:所有配置统一在 master 解析完成后共享,子进程(worker/manage)在 init 阶段根据端口属性决定是否关闭 socket,并根据是否为 manage 调整是否参与 accept 竞争。

2.6.1、进程结构调整
  • 如果不是 single 模式,且 "worker_processes" 为 auto 或 > 1:
    • 实际创建 N + 1 个进程:manage + N 个 worker
    • manage 进程编号固定为 0,进程名修改为 "manage process"
2.6.2、端口绑定策略
  • 所有进程都共享 master 解析好的配置,监听 socket 初始化在 master 完成
  • 每个进程初始化时,根据端口的 server_name.manage_server 字段来判断要不要关闭该 fd
  • 每个进程都只会将应该处理的端口,加入自己的事件循环(抢到锁的 worker)并执行 accept()
端口 Manage 进程 Worker 进程
manage_server=1(管理端口) 保留 关闭
manage_server=0(业务端口) 关闭 保留
2.6.3、accept_mutex 设置
  • 初始化阶段,若当前是 manage 进程:
    • 设置 ngx_use_accept_mutex = 0,不参与锁竞争(Manage 进程监听自己的端口,没有竞争关系)
2.6.4、配置解析阶段
  • server 块中增加一个布尔字段 manage_server
  • ngx_http_core_srv_conf_t 等结构体中加入该字段
  • Master 解析配置时不做分支判断,所有进程继承同一份配置

2.7、分布式锁机制

锁的必要性
  • Manage 与 Worker 分别会读写配置文件,需互斥防止并发导致更新的数据不一致
  • ZooKeeper 模块触发更新时,需等待上一次更新完成:采用 updating 标志避免连续变更冲突

2.8、配置更新期间的问题分析

2.8.1、Worker 更新配置的特点
  • 当某个 worker 进程执行配置更新,其 for 循环在定时器阶段进行,不会抢到 accept 锁,新连接自动落到其他空闲 worker 上,保证无中断接入
  • 不同 worker 因版本更新不一致,可能出现:
    1. 已更新的 worker:请求正常
    2. 正在更新的 worker:请求略有延迟
    3. 未更新的 worker:因服务节点变更导致请求异常
2.8.2、Worker 更新配置时的阻塞问题

按照 worker 的工作模式,依次处理:

  1. 网络 I/O(如果拿到锁还会处理新连接)
  2. 定时器
  3. 延迟队列

此时处于处理定时器阶段,如果要更新 2s,那 2s 内其实处理不了其他的事件。

2.8.3、多进程场景下的业务处理

需要看每个 worker 处在 for 循环的哪个阶段,只要 worker 触发定时器的配置更新后,就无法处理其他事件,但是不同的 worker,处的阶段可能不一样。

同一时间,不同 worker 的状态:

  • 有 worker 抢到了 accept 锁,处理新连接
  • 有的 worker 处理自身的读写事件
  • 有的 worker 触发了定时器的事件
  • 有的 worker 处理延迟队列的新连接数据

结论:只要不是所有 worker 同时更新配置,Nginx 就能正常处理业务。

2.8.4、连续推送问题的解决方案

问题本质:

  • Watcher 可以连续推送消息
  • ZooKeeper 的 C 语言 SDK 会创建一个线程,导致 manage 进程和这个线程存在竞争关系

问题场景:

sequenceDiagram
    participant Timer as Manage 定时器
    participant Watcher as ZooKeeper Watcher 线程
    participant ZK as ZooKeeper
    participant Config as 配置数据

    Timer->>ZK: 1. 检测标志位变更,获取数据(版本100,路由配置)
    Watcher->>ZK: 2. 推送变更(版本101,流控配置)
    Watcher->>Config: 3. 更改标志位(可能多次推送)
    Timer->>ZK: 4. 再次获取数据(版本101,流控配置)
    Timer->>Config: 5. 更新配置,恢复标志位
    Note over Timer: 问题:路由配置是旧的,流控配置是新的!

问题分析:

  1. Manage 定时器检测标志位变更,开始处理本次变更,获取 ZooKeeper 的数据,更新配置(假设是配置的第 100 个版本,这里更新了路由)
  2. Watcher 又推送了变更,更改了标志位(这里有可能发生多次的推送)
  3. Manage 多次获取 ZooKeeper 数据,更新配置(有可能是 101 版本,或者更大,这里更新了流控)
  4. Manage 处理完毕本次变更,恢复标志位,然后创建定时器
  5. 定时器继续扫描标志位,发现没有变化,等待下一次定时器

核心问题:ZooKeeper 的数据是快照数据,步骤 1、3 获取的数据很可能是不同版本,因为一直在变更。第四步处理完毕后,虽然配置变更了很多次,但是 manage 只更新了一次,路由的配置是旧的,但是流控的是新的!

解决方案:updating 标志位

一般这种竞争关系,第一反应是加锁,但是这怎么加锁?Manage 更新的时候,Watcher 别更新?ZooKeeper 貌似没有这种机制,那只能是标志位了。

具体逻辑:

  1. Manage 定时器检测标志位变更,开始处理本次变更,首先将 updating 设置为 1,标识正在更新(假设是配置的第 100 个版本,这里更新了路由)
  2. Watcher 又推送了变更,更改了标志位(这里有可能发生多次的推送),同时将 updating 设置为 0
  3. Manage 多次获取 ZooKeeper 数据,更新配置(有可能是 101 版本,或者更大,这里更新了流控)
  4. Manage 处理完毕本次变更:
    • 如果 updating 为 1:直接恢复标志位(说明更新期间没有新的推送)
    • 否则,不恢复标志位(updating 为 0 说明此时发生了多次推送)
    • 然后创建定时器
  5. 定时器继续扫描标志位,发现标志位依旧需要更新,开始更新

3、Nginx 关于 Accept 锁的深入分析

3.1、是否存在”空白期”?

问题提出:

如果说只有抢到锁的进程,才能将监听 socket 放到自己的 epoll,从而处理新连接,那么当前 worker 释放锁之后,从 epoll 删除这个 socket,在下一次多个 worker 抢锁,并添加到 epoll 的时间差中,是否存在空白期,即没有一个 worker 将这个 socket 放到 epoll 里面?这期间的新连接会怎么样,会丢失吗?

分析角度:

  1. 内核如何处理 socket
  2. Nginx 如何处理 accept

结论:事实上完全不会

  • 对于内核:即使在两次操作之间理论上会有时间差,内核不会丢失连接,因为未被 accept 的新连接一直保留在 socket 的 backlog 队列中,且 epoll 的事件会在重新启用时一次性报告所有待处理的连接(包括中间到达的)
  • 对于 Worker:释放锁之后,不会立即从 epoll 删除这个 socket,所以不会存在空白期

总结:内核不会丢失连接(全连接队列未满的情况下),更何况,Nginx 通过机制,不会存在空白期,保持高性能处理新连接-accept。

3.2、内核的处理机制

3.2.1、Backlog 队列保证连接不丢失

  • TCP 三次握手完成后的连接,会先进入内核的全连接队列(也称 backlog 队列,accept() 队列)
  • 用户态程序即使暂时没有调用 accept(),这些连接也会在内核中排队等待,不会立即丢失
  • 只有在全连接队列已满的情况下,新来的连接才会被丢弃或收到 RST(具体行为取决于内核配置和协议栈实现)

3.2.2、Epoll 的事件语义

  • 在水平触发(Level-triggered,默认)模式下,只要 backlog 队列中还有未处理的连接,epoll_wait() 就会持续报告事件
  • 如果禁用了监听 fd,再重新启用,内核会一次性报告所有积压的连接事件,不会遗漏
  • 在使用 EPOLLONESHOT、EPOLL_EXCLUSIVE 等选项时,虽然唤醒机制不同,但当监听重新被 arm(重新添加或修改到 epoll)时,中间产生的事件仍然会被正确返回

3.2.3、半连接队列与全连接队列

队列类型 说明 大小控制
半连接队列 保存已收到客户端 SYN,但还未完成三次握手的请求 tcp_max_syn_backlog
全连接队列 保存已完成三次握手、等待用户态调用 accept() 的连接 listen(fd, backlog) + somaxconn

队列满时的行为:

  • 半连接队列满:新的 SYN 包可能会被丢弃,导致客户端需要重传
  • 全连接队列满:新连接会被拒绝

结论:从内核的角度看,如果全连接队列不满时,不会出现连接丢失的情况。

3.3、Nginx 的处理机制

在 Nginx 的多 worker 模型下,只有拿到 accept_mutex 的 worker 才真正监听并处理新连接。没有拿到锁的 worker,即便手里还有监听 socket,也会在下一轮事件循环中将其从 epoll 删除,避免竞争。

3.3.1、示例:两轮事件循环

第 1 轮事件循环

Worker 操作步骤
Worker 1 1. 获取 accept_mutex 成功
2. 调用 ngx_enable_accept_events(),把监听 socket 加入自己的 epoll
3. 设置 ngx_accept_mutex_held = 1
4. 进入 epoll_wait() 等待事件
5. 收到新连接事件,执行 accept(),建立连接
6. 处理完后,释放锁 ngx_shmtx_unlock()
7. 释放锁时并没有立刻 epoll_ctl(DEL),监听 socket 依然留在 epoll 中
Worker 2 1. 尝试获取锁失败
2. 因为没有锁,不会把监听 socket 添加到自己的 epoll
3. 直接进入 epoll_wait()
4. 未收到连接事件,继续等待

第 2 轮事件循环

Worker 操作步骤
Worker 2 1. 获取 accept_mutex 成功
2. 调用 ngx_enable_accept_events(),把监听 socket 加入 epoll
3. 设置 ngx_accept_mutex_held = 1
4. 进入 epoll_wait(),收到连接事件并 accept()
5. 处理完连接后释放锁,但 socket 依然留在 epoll 中
Worker 1 1. 尝试获取锁失败
2. 检测到自己上一轮曾经持有过锁(ngx_accept_mutex_held == 1)
3. 调用 ngx_disable_accept_events(),把监听 socket 从 epoll 删除
4. 将 ngx_accept_mutex_held 标记为 0
5. 进入 epoll_wait(),未收到新连接事件

3.3.2、为什么要”延迟删除”?

Nginx 并不是在释放锁的瞬间就删除监听 socket,而是等到下一轮事件循环确认自己没拿到锁时才删除。这样做有几个好处:

好处 说明
避免抖动 如果锁一释放就 DEL,下一轮又可能 ADD,系统调用频繁、开销大。延迟删除 → 只有真的”锁换人”了,才去删
逻辑简洁 代码路径简单:拿到锁就 enable,没拿到锁但之前拿过就 disable
减少竞态 锁的释放和 epoll 修改解耦,减少竞态

关于”空白期”的说明:

实际上,Nginx 的延迟删除机制并不是为了防止”空白期”。内核的 backlog 队列会缓存未 accept 的连接,即使短暂没有 worker 监听,连接也不会丢失(除非 backlog 队列已满)。延迟删除主要是为了避免频繁的 ADD/DEL 操作,减少系统调用开销,让锁的交接更平滑。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
ngx_int_t
ngx_trylock_accept_mutex(ngx_cycle_t *cycle)
{
// 尝试获取接受互斥锁
if (ngx_shmtx_trylock(&ngx_accept_mutex)) {

// 尝试启用接受事件,----------- 1、拿到锁的进程,在epoll添加socket
if (ngx_enable_accept_events(cycle) == NGX_ERROR) {
// 如果启用失败,释放互斥锁
ngx_shmtx_unlock(&ngx_accept_mutex);
return NGX_ERROR;
}

// 重置接受事件计数
ngx_accept_events = 0;
// 标记已经持有互斥锁
ngx_accept_mutex_held = 1;
return NGX_OK;
}

// 如果之前已经持有互斥锁
if (ngx_accept_mutex_held) {
// 尝试禁用接受事件,------- 2、拿不到锁的进程,在epoll删除socket
if (ngx_disable_accept_events(cycle, 0) == NGX_ERROR) {
return NGX_ERROR;
}
// 标记不再持有互斥锁
ngx_accept_mutex_held = 0;
}

return NGX_OK;
}

上面的代码执行完毕,每个进程才会执行 epoll_wait,那自然不会有问题了。


总结

本文深入分析了 Nginx 服务自动发现与配置热更新的架构设计:

核心要点

  1. Master-Worker 模型:Master 负责配置解析和端口初始化,Worker 继承 socket 并处理业务请求
  2. Manage 进程优化:引入独立的 Manage 进程处理配置更新,避免阻塞 Worker 进程
  3. 共享内存同步:通过共享内存和版本号机制实现多进程配置同步
  4. 读写锁机制:使用读写锁实现 Worker 并行读取配置,提升性能
  5. Accept 锁机制:通过延迟删除机制避免频繁的 ADD/DEL 操作,保证连接不丢失

架构优势

特性 原始模式 新模式(Manage 进程)
性能 Worker 阻塞 Worker 专注业务
维护成本 高(逻辑耦合) 低(职责分离)
更新效率 文件 I/O,串行 内存读取,并行
可用性 配置更新时下降 配置更新时保持

最佳实践

  • 使用 Manage 进程处理所有配置更新相关操作
  • 通过共享内存实现高效配置同步
  • 使用读写锁支持 Worker 并行读取
  • 采用增量更新模式,提升更新效率

Nginx 服务自动发现与配置热更新架构
https://zjfans.github.io/2025/04/19/服务自动发现/
作者
张三疯
发布于
2025年4月19日
许可协议