服务自动发现

Nginx 采用 master–worker 多进程模型,其中 master 进程负责管理和信号交互,worker 进程执行网络 I/O、定时器和延迟队列事件循环 。

1、nginx的原理

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

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

  • 配置解析:在 main() 中,master 进程首先调用 ngx_init_cycle(),该函数基于 ngx_conf_parse() 逐行读取并解析 nginx.conf,生成模块级的配置上下文(cycle->conf_ctx
  • 构建 listening 数组:解析阶段,core 模块会为每个 listen 指令创建一个 ngx_listening_t 结构,并将其添加到 cycle->listening 数组中
  • 打开套接字:随后,master 调用 ngx_open_listening_sockets(),遍历 cycle->listening:为每个条目创建 socket(socket())、绑定地址(bind())并监听(listen()),同时设置常用选项(如 reuseportbacklog
  • 继承老进程 socket:若为平滑重启,通过 ngx_add_inherited_sockets() 从环境变量获取老进程的 socket 描述符,避免中断服务 。

2 Worker 进程:继承与初始化

  • Fork 产生:master 通过 ngx_start_worker_processes() 调用 ngx_spawn_process() fork 出多个 worker,继承 master 打开的所有监听 socket 。
  • 初始化调用:每个 worker 在 ngx_worker_process_cycle() 开始时调用 ngx_worker_process_init() 完成:设置进程类型标识、进程标题、初始化日志、事件模块、定时器与延迟队列,并为业务端口注册可读/可写事件回调。
  • 不再 bind/listen:worker 直接使用继承自 master 的 socket,无需再次调用 bind()listen(),确保端口监听唯一由 master 或平滑重启过程中创建一次 。

1.2、for循环工作原理

work只处理3种事件

1、网络IO事件(读写、新连接)

2、定时器

3、延迟队列

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);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
循环开始
|
|-- ngx_process_events() <- epoll_wait 等待事件
| |-- 已有连接的读写事件:直接调用 handler(如 ngx_http_process_request)
| |-- 新连接事件(accept) -> post 到 ngx_posted_accept_events
|
|-- ngx_event_process_posted(&ngx_posted_accept_events) <- 执行 accept
|
|-- 释放锁
|
|-- ngx_event_expire_timers() <- 红黑树定时器
| |-- 超时事件 -> post 到 ngx_posted_events
|
|-- ngx_event_process_posted(&ngx_posted_events) <- 执行普通延迟事件
循环结束

2、与zookeeper的交互

为了支持与 ZooKeeper 的动态配置交互,单 Worker 直连 ZooKeeper进行配置变更,多进程通过共享内存同步配置,但是会阻塞这个work处理其他事件,影响服务可用性与性。

2.1、 原始模式架构

早期架构中采用如下方案:

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

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

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

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

(3)不足:

  • 单个 Worker 除了负责网络事件、限流、负载均衡等核心逻辑,还需要连接 ZooKeeper、监听节点变更、处理推送等操作;一旦配置发生变化,该进程还要执行本地配置文件的写入和内存更新;这些操作会阻塞事件循环,延迟请求响应,导致实际服务性能下降,尤其在高并发场景下更加明显。
  • 每个 Worker 都需要包含 ZooKeeper 连接管理、会话重连、Watcher 重订阅、异常恢复等完整流程;这些逻辑与原有的业务处理无关,但会侵入 Worker 的主循环结构,增加维护成本与出错风险
  • 变更配置后,单个Worker 负责将数据写入本地配置文件,其它 Worker 则轮询共享内存标志后读取该文件,由于竞争关系需要加锁,work串行更新配置;同时读取文件的效率低下

2. 新模式设计:引入 0 号 manage 进程

1、优化点

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

2、设计方案

1、主体目标

  • 新增一个manage进程,只处理配置更新,包括注册中心的配置更新、热更新、与redis哨兵、rabbitMq的订阅消息,不处理实际的客户端请求
  • work专注于业务请求

2、详细设计

进程定位与初始化

  • 如果不是single模式,额外加一个work进程,然后修改进程名称为manage,即解析 "worker_processes" 时,如果为 auto 或数量 >1,则额外创建一个编号为 0 的 manage 进程,实际"worker_processes" 加1就可以
  • manage进程不监听业务端口,即不处理客户端的请求-响应,只做配置更新,初始化时需要关闭业务端口,同时也需要不启用accept锁
  • 考虑到还有热更新的功能,新增一个”管理端口”,manage只监听这个端口,客户端可以调用这个接口做部分配置更新,同时work不需要监听这个端口,初始化时关闭

功能层面的设计

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

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

  • 在lua层面,由manage进程处理redis哨兵、rabbitMq的推送消息,处理节点变更以及实际的业务数据变更

竞争关系-锁

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

  • 创建进程级别的共享锁,manage在更新配置时,禁止work读取,也就是work在更新配置时,需要先获取锁。这样的话,work是串行更新,因为拿到锁的才能更新内存中的配置,最好的场景是,manage更新时,work禁止更新,但是所有的work可以同时更新

  • 实际可以使用读写锁ngx_rwlock_rlock,写时不可读,读时不可写,但是可以一起读,这样work可以并行更新配置

  • manage还处理热更新,这其实是调用式触发,主动模式,原理一致,即接收客户端的数据,更新本地内存、共享内存、本地文件,然后修改共享内存的标识。对于热更新,还需要更新zookeeper的配置,满足集群多节点的配置同步更新。

异常场景

  • watcher连续推送。manage进程更新完配置后,会将标志位恢复,然后重新创建定时器进行扫描标志位,如果manage进程正在更新配置(更新部分),此时又有watcher消息推送,manage接着更新完配置恢复了标志位,就会丢失这期间所有的变更推送。加锁解决不了这种竞争关系,新增updating标志位,标识更新期间再次发生更新,配置更新完毕后,不恢复更新标志位,等待下一个定时器再次触发

3、方案实现

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

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

4. 分布式锁机制

4.1 锁的必要性

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

5. 问题

整个配置更新期间,结合work的for循环工作模式,其实会有一些问题

1、特点

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

2、work在更新配置时,这个进程无法处理业务

按照work的工作模式,依次处理网络IO(如果拿到锁还会处理新连接)、定时器、延迟队列,此时处于处理定时器阶段,如果要更新2s,那2s内其实处理不了其他的事件。

3、如果是多个进程,那么nginx还能正常处理业务吗?

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

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

4、连续推送问题的解决—-updating

本质上watcher可以连续推送消息,再加上这和manage相当于是多线程的关系,也就是manage在更新配置的时候,watcher是可以并发推送的。

如果是单进程,这是不可能发生的,因为一个进程在执行定时器时,网络io事件需要下一次for循环,但是zookeeper的c语言sdk会创建一个线程,导致manage进程和这个线程存在竞争关系,会有什么问题?

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

首先zookeeper的数据是快照数据,1,3获取的数据很可能是不同版本,因为一直在变更。第四步处理完毕后,虽然配置变更了很多次,但是manage只更新了一次,路由的配置是旧的,但是流控的是新的!事实上需要再次更新到最新的

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

多加一个updating标志位,具体逻辑为:

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

3、nginx关于accept锁

3.1、空白期?

在开始看这块代码时,我会有一个疑问,如果说只有抢到锁的进程,才能将监听socket放到自己的epoll,从而处理新连接,那么当前work释放锁之后,从epoll删除这个socket,在下一次多个work抢锁,并添加到epoll的时间差中,是否存在空白期,即没有一个work将这个socket放到epoll里面?这期间的新连接会怎么样,会丢失吗 这其实需要从2方面分析。

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

事实上完全不会

对于内核,即使在两次操作之间理论上会有时间差,内核不会丢失连接,因为未被 accept 的新连接一直保留在 socket 的 backlog 队列中,且 epoll 的事件会在重新启用时一次性报告所有待处理的连接(包括中间到达的)。

对于work,释放锁之后,不会立即从epoll删除这个socket,所以不会空白期。

也就是说,就算有空白期,内核也不会丢失连接,更何况,nginx通过机制,不会存在空白期,保持高性能处理新连接-accept。

3.2、内核的处理

1、 backlog 队列保证连接不丢失

  • TCP 新连接在完成三次握手后先放入内核的 backlog 队列,不会因为用户态未调用 accept() 而丢失;除非达到队列上限才会拒绝新连接。
  • 即便 nginx 在释放锁后删掉或禁用了自己 epoll 实例中的监听事件,内核 backlog 依然保留了所有已完成的连接请求,等待下一个 accept() 调用。

2 、epoll 的“待报告”事件语义

  • Level-triggered(默认)模式下,只要文件描述符对应的内核读缓冲区或 backlog 非空,epoll_wait() 每次都会报告该事件;禁用后重新启用即可一次性报告所有积压的事件,不会遗漏。
  • 即使使用 EPOLLONESHOTEPOLL_EXCLUSIVE 等高级选项,内核也会在重新 arm(重新添加或修改)后,将中间产生的事件一次性返回

3.3、nginx的处理

只有拿到锁的work才会epoll_wait,拿不到锁的work,虽然epoll里面有这个监听socket,但是不会立马epoll_wait。这个老的work,在没拿到锁时,会进行删除操作,把socket从epoll删除,然后再epoll_wait,这样新连接可以即时处理,也不会存在竞争关系。

1、举个例子

🕒 第 1 轮事件循环

  • 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(&ngx_accept_mutex),释放锁。
    7. 注意:此时并未调用 epoll_ctl(DEL),监听 socket 仍在 epoll中。
  • Worker 2:
    1. 尝试获取 accept_mutex,失败。
    2. 由于 ngx_accept_mutex_held == 0,未持有锁,因此不会将监听 socket 添加到 epoll 中。
    3. 调用 epoll_wait(),等待事件。
    4. 未接收到新连接事件,继续等待。

🕒 第 2 轮事件循环

  • Worker 2:
    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(&ngx_accept_mutex),释放锁。
    7. 注意:此时并未调用 epoll_ctl(DEL),监听 socket 仍在 epoll 中。
  • Worker 1:
    1. 尝试获取 accept_mutex,失败。
    2. 检测到 ngx_accept_mutex_held == 1,说明上一轮持有过锁。
    3. 调用 ngx_disable_accept_events(),将监听 socket 从 epoll 中删除。
    4. 设置 ngx_accept_mutex_held = 0
    5. 调用 epoll_wait(),等待事件。
    6. 未接收到新连接事件,继续等待。

✅ 总结

  • 在每轮事件循环中,只有成功获取 accept_mutex 的 worker 进程会将监听 socket 添加到其 epoll 中,并处理新连接。
  • 释放锁后,监听 socket 并不会立即从 epoll 中删除,而是在下一轮事件循环开始时,检测到未获取锁且上一轮持有过锁的情况下,才调用 ngx_disable_accept_events() 删除监听 socket。
  • 释放锁后,监听 socket 并不会立即从 epoll 中删除,而是在下一轮事件循环开始时,当获取锁失败(说明已经有其他work获取到了锁,开始listen)检测到未获取锁且上一轮持有过锁的情况下,才调用 ngx_disable_accept_events() 删除监听 socket。

3.4、源码分析

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,那自然不会有问题了


服务自动发现
https://zjfans.github.io/2025/04/19/服务自动发现/
作者
张三疯
发布于
2025年4月19日
许可协议