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()
    • 设置常用选项(如 reuseportbacklog
  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 + Nworker
    • 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,再重新启用,内核会一次性报告所有积压的连接事件,不会遗漏
  • 在使用 EPOLLONESHOTEPOLL_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日
许可协议