服务自动发现
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()
),同时设置常用选项(如reuseport
、backlog
) - 继承老进程 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 |
|
实际的处理:ngx_process_events_and_timers
1 |
|
1 |
|
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 竞争
- 进程结构调整
- 如果不是
single
模式,且"worker_processes"
为auto
或 > 1:- 实际创建
N + 1
个进程:manage
+N
个worker
manage
进程编号固定为0
,进程名修改为"manage process"
- 实际创建
- 端口绑定策略
- 所有进程都共享
master
解析好的配置,监听 socket 初始化在 master 完成 - 每个进程初始化时,根据端口的
server_name.manage_server
字段来判断要不要关闭该 fd - 每个进程都只会将应该处理的端口,加入自己的事件循环,(抢到锁的work)并执行
accept()
端口 | manage 进程 | worker 进程 |
---|---|---|
manage_server=1 (管理端口) |
保留 | 关闭 |
manage_server=0 (业务端口) |
关闭 | 保留 |
- accept_mutex 设置
- 初始化阶段,若当前是
manage
进程:- 设置
ngx_use_accept_mutex = 0
,不参与锁竞争(manage进程监听自己的端口,没有竞争关系)
- 设置
- 配置解析阶段
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 因版本更新不一致,可能出现:
- 已更新的 worker 请求正常;
- 正在更新的 worker 请求略有延迟;
- 未更新的 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进程和这个线程存在竞争关系,会有什么问题?
- manage定时器检测标志位变华,开始处理本次变更,获取zookeeper的数据,更新配置(假设是配置的第100个版本,这里更新了路由)
- watcher又推送了变更,更改了标志位(这里有可能发生多次的推送)
- manage多次获取zookeeper数据,更新配置(有可能是101版本,或者更大,这里更新了流控)
- manage处理完毕本次变更,恢复标志位,然后创建定时器
- 定时器继续扫描标志位,发现没有变化,等待下一次定时器
首先zookeeper的数据是快照数据,1,3获取的数据很可能是不同版本,因为一直在变更。第四步处理完毕后,虽然配置变更了很多次,但是manage只更新了一次,路由的配置是旧的,但是流控的是新的!事实上需要再次更新到最新的
一般这种竞争关系,第一反应是加锁,但是这怎么加锁?manage更新的时候,watcher别更新?zookeeper貌似没有这种机制,那只能是标志位了
多加一个updating标志位,具体逻辑为:
- manage定时器检测标志位变华,开始处理本次变更,首先将updating设置为1,标识正在更新,(假设是配置的第100个版本,这里更新了路由)
- watcher又推送了变更,更改了标志位(这里有可能发生多次的推送),同时将updating设置为0
- manage多次获取zookeeper数据,更新配置(有可能是101版本,或者更大,这里更新了流控)
- manage处理完毕本次变更,如果updating为1,就直接恢复标志位,否则,不恢复标志位,为0就说明此时发生了多次推送,然后创建定时器
- 定时器继续扫描标志位,发现标志位依旧需要更新,开始更新
3、nginx关于accept锁
3.1、空白期?
在开始看这块代码时,我会有一个疑问,如果说只有抢到锁的进程,才能将监听socket放到自己的epoll,从而处理新连接,那么当前work释放锁之后,从epoll删除这个socket,在下一次多个work抢锁,并添加到epoll的时间差中,是否存在空白期,即没有一个work将这个socket放到epoll里面?这期间的新连接会怎么样,会丢失吗 这其实需要从2方面分析。
- 内核如何处理socket
- 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()
每次都会报告该事件;禁用后重新启用即可一次性报告所有积压的事件,不会遗漏。 - 即使使用
EPOLLONESHOT
、EPOLL_EXCLUSIVE
等高级选项,内核也会在重新 arm(重新添加或修改)后,将中间产生的事件一次性返回
3.3、nginx的处理
只有拿到锁的work才会epoll_wait,拿不到锁的work,虽然epoll里面有这个监听socket,但是不会立马epoll_wait。这个老的work,在没拿到锁时,会进行删除操作,把socket从epoll删除,然后再epoll_wait,这样新连接可以即时处理,也不会存在竞争关系。
1、举个例子
🕒 第 1 轮事件循环
- Worker 1:
- 尝试获取
accept_mutex
,成功。 - 调用
ngx_enable_accept_events()
,将监听 socket 添加到自己的 epoll中。 - 设置
ngx_accept_mutex_held = 1
。 - 调用
epoll_wait()
,等待事件。 - 接收到新连接事件,处理
accept()
,建立连接。 - 处理完事件后,调用
ngx_shmtx_unlock(&ngx_accept_mutex)
,释放锁。 - 注意:此时并未调用
epoll_ctl(DEL)
,监听 socket 仍在 epoll中。
- 尝试获取
- Worker 2:
- 尝试获取
accept_mutex
,失败。 - 由于
ngx_accept_mutex_held == 0
,未持有锁,因此不会将监听 socket 添加到 epoll 中。 - 调用
epoll_wait()
,等待事件。 - 未接收到新连接事件,继续等待。
- 尝试获取
🕒 第 2 轮事件循环
- Worker 2:
- 尝试获取
accept_mutex
,成功。 - 调用
ngx_enable_accept_events()
,将监听 socket 添加到自己的 epoll 中。 - 设置
ngx_accept_mutex_held = 1
。 - 调用
epoll_wait()
,等待事件。 - 接收到新连接事件,处理
accept()
,建立连接。 - 处理完事件后,调用
ngx_shmtx_unlock(&ngx_accept_mutex)
,释放锁。 - 注意:此时并未调用
epoll_ctl(DEL)
,监听 socket 仍在 epoll 中。
- 尝试获取
- Worker 1:
- 尝试获取
accept_mutex
,失败。 - 检测到
ngx_accept_mutex_held == 1
,说明上一轮持有过锁。 - 调用
ngx_disable_accept_events()
,将监听 socket 从 epoll 中删除。 - 设置
ngx_accept_mutex_held = 0
。 - 调用
epoll_wait()
,等待事件。 - 未接收到新连接事件,继续等待。
- 尝试获取
✅ 总结
- 在每轮事件循环中,只有成功获取
accept_mutex
的 worker 进程会将监听 socket 添加到其 epoll 中,并处理新连接。 - 释放锁后,监听 socket 并不会立即从 epoll 中删除,而是在下一轮事件循环开始时,检测到未获取锁且上一轮持有过锁的情况下,才调用
ngx_disable_accept_events()
删除监听 socket。 - 释放锁后,监听 socket 并不会立即从 epoll 中删除,而是在下一轮事件循环开始时,当获取锁失败(说明已经有其他work获取到了锁,开始listen)检测到未获取锁且上一轮持有过锁的情况下,才调用
ngx_disable_accept_events()
删除监听 socket。
3.4、源码分析
1 |
|
上面的代码执行完毕,每个进程才会执行epoll_wait,那自然不会有问题了