千磨万击还坚劲,任尔东西南北风
MySQL
探究并发插入 二级 uk 记录 导致的死锁问题
结合实践来理解理论,案例可复现。
1 | # 建表 |

1、我们尝试插入uk 冲突的数据 insert into t values(null, 50, 1);
,肯定会执行失败,此时查看事务上锁状态 select ENGINE_TRANSACTION_ID, index_name, lock_type, lock_mode, LOCK_STATUS, lock_data from performance_schema.data_locks;
:

可以看到上了两个锁,都很奇怪,一个个分析。
在 uk=50 上加的 S 型临键锁
有一些理论基础是,二级uk插入 record 的时候是分成了两个阶段:
- 判断当前的物理记录上是否有冲突的record(delete-marked 是不冲突,即允许走到第二步)
- 如果没有冲突, 那么可以执行插入操作
InnoDB 的“免锁”插入优化 (Lock-free Insert)
“判断 -> 插入”两个阶段之间需要锁,是完全正确的逻辑。但 InnoDB 实现这个“锁”的方式非常聪明,它不一定使用我们通常所说的、会持续到事务结束的重量级 Lock。
在REPEATABLE READ或READ COMMITTED隔离级别下,当向一个带有二级唯一索引的表插入一条新记录,并且没有发生唯一键冲突时,InnoDB会这样做:
快速检查,但不加间隙锁:
InnoDB 会首先检查这个即将插入的键值是否存在。在执行这个检查时,它并不会像 SELECT … FOR UPDATE 那样,预先加上一个会持续整个事务的间隙锁或 Next-Key Lock。
使用 Latch 保护内存:
在检查和插入的瞬间,它会使用一种比 Lock 更轻量级的并发控制机制——Latch (闩锁) 来保护 B+Tree 的索引页在内存中的数据结构。Latch 的作用时间极短,就是为了防止在内存中多个线程同时修改同一个索引页导致数据损坏。
执行插入并依赖新纪录的锁:
在 Latch 的保护下,InnoDB 确认没有重复键,然后将新记录插入到二级索引的 B+Tree 页面中。
一旦记录被插入,这条新记录本身就会带有一个排他记录锁 (Exclusive Record Lock, X-Lock)。这个锁是隐式的,会持续到事务提交。
操作完成后,Latch 会被立刻释放。
这个优化为什么是可行的?
在 阶段1 和 阶段2 之间,虽然没有长期的 Gap Lock,但有短期的 Latch 保护,足以防止并发的内存操作冲突。
一旦 阶段2 完成,新记录上的 X-Lock 就成了新的“守护者”。任何其他想插入完全相同键值的事务,在做冲突检查时,就会遇到这个 X-Lock 并被阻塞。
这种方式避免了为一个简单的 INSERT 操作锁定一大片间隙,极大地提高了并发插入的性能。如果两个事务要插入同一个间隙的不同位置(比如一个插 uk=20,另一个插 uk=30),它们将不会互相阻塞。
所以测试时看不到间隙锁,正是因为触发了这项优化。
那什么时候会看到锁呢?
这项优化虽好,但它只适用于“简单无冲突”的场景。一旦情况变得复杂,InnoDB 就会退回到更保守、更安全的加锁方式。
- 发生唯一键冲突时
这是最常见的情况。如果你的 INSERT 语句确实遇到了一个重复键(即使那个键是被另一个未提交的事务标记为删除的),InnoDB 就不能再使用上述的“免锁”优化了。
此时,InnoDB 会在它找到的那个冲突的二级索引记录上,加上一个共享型的 Next-Key Lock (S-Lock)。
这个 S-Lock 的目的是等待持有该记录(或标记删除该记录)的那个事务提交或回滚。
这个 Next-Key Lock 包含了间隙锁,因此在这种情况下,你就能观察到锁的存在了。它会阻止其他事务在你等待期间,在冲突记录附近插入新的数据。
总结
对于二级唯一索引的无冲突插入,InnoDB 出于性能考虑,通常不会设置长事务周期的间隙锁。它依赖于短期 Latch 和新纪录的 隐式 X-Lock 来保证唯一性。
这个优化有其适用边界。一旦发生唯一键冲突,InnoDB 就会毫不犹豫地使用共享的 Next-Key Lock (包含间隙锁) 这种更强的锁来保证事务的隔离性和数据一致性,防止幻读。
进一步的,上面发生唯一键冲突时还有细节:
为什么唯一键冲突 如果是因为遇到确实存在的记录,会在它上面加 S 临键锁,而如果是遇到 标记删除的记录,不仅在该记录上加,还会在该记录 uk 排序后面的一条记录上也加一个 S 临键锁?为什么有这个区别?
简单的说,这个区别的根源在于:
一个真实存在的记录是一个确定的状态,而一个被标记删除的记录是一个不确定的、模糊的状态,为了应对这种不确定性,数据库必须采用更强、范围更大的锁来保护数据的一致性。
场景一:冲突的记录是“真实存在”的 (Live Record)
在这种情况下,冲突的对象非常明确,就是 uk=50 这一个点。事务 B 的意图也很明确:“我需要等待 uk=50 这条记录本身的状态发生改变(比如被事务 A 删除并提交)”。
所以,InnoDB 只需要在 uk=50 这条二级索引记录上加上一个 S-Lock (共享锁)(通常是 S-Next-Key Lock,但主要作用点是记录本身)就足够了。这个锁的核心目的就是监视这个已存在的记录,等待持有该记录的 X-Lock 释放。这个锁的行为是确定的、聚焦的。
场景二:冲突的记录是“标记删除”的 (Delete-Marked Record)
因为这个“鬼魂”记录本身不是一个稳定的锁定目标,它随时可能被物理清除 (Purge)(如果清除了可能导致上的锁就不见了,所以在下一条记录也加锁)。InnoDB 需要锁住的是由于删除而产生的“真空地带”或者说“间隙”。
如何最可靠地锁住一个间隙?答案是:锁住这个间隙的边界。
所以 InnoDB 采取了以下策略:
在该记录上加锁:首先,在找到的那个被标记删除的 uk=50 记录上加上 S-Lock,这是为了等待事务 A 的结果。
在下一条记录上也加锁:这是关键一步。为了防止任何事务(包括事务 C)在这个新产生的、不稳定的间隙中插入数据(从而对事务 B 造成幻读),InnoDB 必须将这个间隙锁住。它通过在 uk=50 之后物理上存在的下一条记录(我们称之为 uk_next)上也加上一个 S-Next-Key Lock 来实现。
这个 Next-Key Lock 会锁住 uk_next 记录本身,以及它和 uk=50 之间的整个间隙。
通过锁住下一条记录,InnoDB 成功地将 uk=50 这个“可能为空”的坑以及它周围的空间全部封锁了,直到事务 A 提交或回滚,所有不确定性都消除之后,才会允许其他事务进入这片区域。
为什么用 S-Lock 来实现等待?
S-Lock(共享锁)准确地表达了事务B的意图:“我不想修改你(事务A)的这条记录,我只是想读它最终的、确定的状态。”
这允许多个像事务B这样的“等待者”(比如事务C、事务D都想插入同一个email),它们可以同时持有S-Lock来等待事务A的结果,就像大家一起在“等候室”里等。如果用 X-Lock,那么等待者之间还会互相排斥,没有必要。
所以,这个 S-Lock 本质上是一个“等待锁”或“观察锁”,它让并发的 INSERT 操作在遇到潜在冲突时,能够安全、正确地排队。

先删除 uk=50 的记录 delete from t where uk = 50;
:

然后插入 uk=50 的记录 insert into t values(10, 50, 1);
,区别于上面的插入语句,这条语句是能进入二阶段插入成功的,因为 uk=50 的记录已经被 delete marked 标记了,但这时上了很多意想不到的锁:

给被删的 uk=50 的记录上了 S GAP,刚插入的 uk=50 的记录上了 S GAP,被删的 uk=50 的记录的下一条记录 即 uk=70 处上了 S 临键锁。
先来看看 官方 对包含 唯一键 的 insert 语句的 pseudocode:
find the B-tree page in the secondary index you want to insert the value to
assert the B-tree page is latched
equal-range = the range of records in the secondary index which conflict with your value
if(equal-range is not empty){
release the latches on the B-tree and start a new mini-transaction
for each record in equal-range
lock gap before it, and the record itself (this is what LOCK_S does)
also lock the gap after the last(equal-range)
also (before Bug #32617942 was fixed) lock the record after last(equal-range)
once you are done with all of the above, find the B-tree page again and latch it again
}
insert the record into the page and release the latch on the B-tree page.翻译如下:
找到你想要插入值的二级索引所在的B树页。
确认该B树页已被闩锁 (latched)。
equal-range = 与待插入值冲突的二级索引记录范围。
if (equal-range 不为空) {
释放B树上的闩锁,并开启一个新的迷你事务 (mini-transaction)。
对于 equal-range 中的每一条记录:
锁定其之前的间隙 (gap) 和记录本身(这就是 LOCK_S 锁的作用)。
同时,锁定 equal-range 中最后一条记录之后的间隙。
同时(在修复 Bug #32617942 之前),还会锁定 equal-range 中最后一条记录之后的下一条记录。
完成以上所有操作后,重新定位到该B树页并再次对其进行闩锁。
}
将记录插入该页面,然后释放该B树页上的闩锁。
阶段1 和 阶段2 之间必须要有 latch(闩锁) 或者 lock 来保证原子性,否则随便就出现 uk 失效了,这里的伪代码描述的操作是通过一阶段 uk 检查后才进行的。
根据伪代码描述,当前的实现是,如果没有重复记录,也就是 equal-range
为空,那就借助 latch(Latch 是一种非常轻量级的锁,用来保护内存中的数据结构比如 B+ 树的一个页面在被多线程并发访问时不被破坏,它的持有时间通常非常短。)来实现两阶段的原子性,latch 我们是无法用 查询锁 操作看到的。
如果有重复记录(这里特指 delete-marked 的记录),开始上锁,发现上锁情况和我们执行结果基本符合,加了gap lock 以后就可以禁止其他事务在这个 gap 区间插入数据, 也就是通过 lock 来保证阶段1和阶段2的原子性。注意 uk=70 也被上了临键锁,那这样防的范围就太大了,也就是这个 issue 遇到的问题。
如果把这个next-key lock 去掉会有什么问题?
第一列是 uk,红的表示 delete-marked 的记录但是没有 purge 掉。
那么如果像官方一样把next-key lock 改成 record lock 以后, 如果这个时候插入两个record (13000, 99), (13000, 120).
第一个record 在unique check 的时候对 (13000, 100), (13000, 102), (13000, 108)..(13000, 112) 所有的二级索引加record S lock, insert 的时候对 (13000, 100) 加GAP | insert_intention lock.
第二个 record 在unique check 的时候对(13000, 100), (13000, 102), (13000, 108)..(13000, 112) 所有的二级索引加record S lock. insert 的时候对 (13000, 112)加 GAP | inser_intention lock.
那么这时候这两个record 都可以同时插入成功, 就造成了unique key 约束失效了.

假如 a 事务删除一条记录,b事务想插入这条记录,是会被阻塞的,因为 a 持有这条记录的 uk 上的 X 记录锁,而 b 事务想插入这条记录,b 会尝试获取一个 S 型临键锁,因为 a 事务还没 提交,此时上的 S 能保证不管 a 是否提交,b 都能执行对应的操作,比如 a 提交..
排查命令
查询 information_schema.INNODB_TRX 表来获取当前所有活跃的 InnoDB 事务
1 | SELECT |
trx_mysql_thread_id: 这是关联其他表的关键,它就是我们平时说的 connection id 或 process id。
1 |
|


可以看到 RR 级别的话在 IODKU 执行更新的情况下也会给 主键上的supremum pseudo-record 记录加上 X 型的临键锁,很可怕。
除了防止幻读,也为了 保证语句复制的安全性 (Statement-Based Replication Safety)
在基于语句的复制(SBR)模式下,主库只会把原始的IODKU语句发送到从库去执行。
如果主库上多个IODKU语句是并发执行的,它们的执行顺序可能会影响最终结果(比如,谁执行了INSERT,谁执行了UPDATE)。
为了保证从库重放时能得到和主库完全一致的结果,InnoDB必须对这类可能插入新记录(尤其是可能插入到末尾)的操作进行串行化处理。
锁住supremum记录就是一种简单有效的串行化手段。它相当于一个“队尾锁”,确保同一时间只有一个事务可以在表的末尾进行插入或更新操作,从而保证了在从库上以任何顺序重放,结果都是确定的。
这个强锁主要是为了满足REPEATABLE-READ的严格要求。在执行IODKU的事务开始前,将会话的隔离级别临时调整为 READ-COMMITTED。

读提交就没有这个问题。
事务1
insert into t values(null, 50, 50) on duplicate key update val=50;

事务2
insert into t values(null, 70, 70) on duplicate key update val=70;

事务1
insert into t values(null, 60, 60) on duplicate key update val=60;

进入等待锁的状态,等第四步之后发现死锁,事务2 回滚,插入语句执行成功。

事务2
insert into t values(null, 30, 30) on duplicate key update val=30;
死锁,事务2 回滚。
成功复现了 那篇博客 的场景,IODKU 遇到重复的 二级 uk 更新 非索引列 的值,并在重复的记录上加了 X 型临键锁,导致出现死锁。
这个应该对应我实际遇到的场景
来理一下发生的事情。
首先上面那种场景能理解了,稍微有点问题的是在记录存在的情况下(不是被delete marked 的记录),为什么上锁的时候还要上 S 锁(普通insert 是S,IODKU 是X 临键锁)。
实际上如果uk重复的记录是 被删除的,那上 临键锁是可以理解的,就像博客例子举的那样。
如果遇到被删除的重复记录,会在每个 uk 上给重复的记录加 临键锁,还会给 下一条 记录 也加一个 临键锁。
为什么 primary key 也是unique key index, 为什么primary key 没有这个问题?
本质原因是在secondary index 里面, 由于mvcc 的存在, 当删除了一个record 以后, 只是把对应的record delete marked, 在插入一个新的record 的时候, delete marked record 是保留的.
也就是说,主键索引树上记录和 二级索引树 记录都是 标记删除,此时插入一条 主键重复记录的话,会直接在原位置写这条记录,然后记一个 undolog,原来的二级索引是 标记删除,两种情况,一种是这条记录 uk 和原来的不一样,那就没什么考虑了,在二级索引树插入记录就好了,第二种是 uk 和原来的一样,这时:

可以发现,应该还是在索引树插入记录,然后会遇到重复键,因此给重复的记录和下一条记录都上了临键锁。
问题同样回到了 二级uk重复的时候上了很多锁,或者说,二级树为什么不能直接在 标记删除的记录上修改呢?正是因为允许存在多条uk相同的记录(包括标记删除的),所以才要上那么多锁。如同这个图所说的那样:

假如现在插入的新纪录 主键不同,但是uk相同,如果我们直接修改二级树上的记录,把二级索引的主键列改成新纪录的值,那就会出现,如果一个事务通过uk想找原来的记录就找不到了,因为现在的uk的主键列已经变了,所以这样就无法实现mvcc了。而如果保留原来记录的二级索引,那查找的时候就会找到这条记录,拿着主键去聚簇索引找,然后再通过mvcc看到“可见”的记录。我的理解是这样的,所以如果在 二级uk 重复的情况下,就会上锁,因为存在多条记录,光靠latch应该保护不了了。
在primary index 里面, 在delete 之后又insert 一个数据(主键相同), 会将该record delete marked 标记改成non-delete marked, 然后记录一个delete marked 的record 在undo log 里面, 这样如果有历史版本的查询, 会通过mvcc 从undo log 中恢复该数据. 因此不会出现多个相同主键的delete mark record 跨多个page 的情况, 也就不会出现上述case 里面(13000, 100) 在page1, (13000, 112) 在page3.
那么在insert 的时候, 和上面的二级索引插入2阶段类似, 需要有latch 或者lock 进行保护, 这里primary index 通过持有page X latch 就可以保证两个阶段的原子性, 从而两次的insert 不可能同时插入成功, 进而避免了这个问题.


第一种情况的先删后增就是很明显的在插入的时候uk遇到重复键,并且要重新插入一个二级索引,该索引的主键列是新的主键值。
第二种情况和第三种情况本质是一样的,其实和第一种也差别不大,因为都更新了主键,更新主键往往也是分成两步,先删除原纪录,然后插入新纪录,因为更新主键意味着在聚簇索引树的位置也要变了,所以是先删除后插入。
至于为什么 记录存在(非标记删除) 的情况也要上 S 锁,之前也查过了。
结论:
在delete + insert, insert … on duplicate key update, replace into 等场景中, 为了实现判断插入记录与现有物理记录是否冲突和插入记录这两个阶段的原子, unique check 的时候会给所有的相同的record 和下一个record 加上next-key lock. 导致后续insert record 虽然没有冲突, 但是还是会被Block 住, 进而有可能造成死锁的问题.
更新主键 (Clustered Index Key)
更新一条记录的主键值,在InnoDB存储引擎中,其底层操作等同于在聚簇索引中删除旧记录,然后插入一条新记录。
为什么是这样?
聚簇索引的本质: 在InnoDB中,表本身就是按主键顺序组织的一个B+树结构,这被称为聚簇索引。数据行的所有内容(所有列的值)都存储在B+树的叶子节点上。数据的物理存储顺序与主键的逻辑顺序是紧密相关的。
更新的后果: 当您更新一个主键的值(例如,UPDATE … SET id = 200 WHERE id = 100;),这条记录在B+树中的物理位置必须改变。原来id=100的记录存放在包含99、101等邻近键值的磁盘页(Page)上,而id=200的记录需要被移动到存放199、201等键值的磁盘页上。
实现方式: 对于B+树这种有序结构来说,最高效地“移动”一条记录的方式,就是将其视为两个独立操作:
删除 (DELETE): 在id=100的旧位置,将原记录标记为删除。
插入 (INSERT): 在id=200的正确新位置,插入一条包含所有新数据的记录。
这个“删除+插入”的操作会引发一系列连锁反应,代价非常高昂:
二级索引的连锁更新: 表上所有的二级索引都存储了对应的主键值作为“指针”来定位完整的数据行。当主键值发生变化时,这条记录在每一个二级索引中的条目也必须被更新。这同样是通过对每个二级索引进行“删除旧条目(包含旧主键值),插入新条目(包含新主键值)”来完成的。
磁盘I/O和日志: 这个过程会涉及对聚簇索引和所有二级索引的多次磁盘页面读写,并产生大量的redo log和undo log。
“直接修改主键列”不行,根本原因在于:
维护有序性: 直接修改会破坏二级索引按照 (二级索引键, 主键) 组合的严格排序规则。
物理结构: 索引值的改变意味着物理存储位置的改变,数据库引擎通过“逻辑删除+逻辑插入”来实现这种物理位置的移动。
算法统一性: “删除+插入”是一个统一且健壮的逻辑,它可以处理所有索引键值更新的情况,而“原地更新”只在极少数不影响排序的特殊情况下才可能实现,数据库引擎为了逻辑的简单和可靠,会统一采用前者。
技术上的解释:B+树的物理结构
有序存储: B+树的叶子节点是双向链表,所有索引条目在这些叶子节点上是严格有序存储的。这个顺序是物理上的,决定了数据存放在哪个磁盘页(Page)以及页内的哪个位置。
位置决定价值: 一个索引条目的值决定了它在B+树中的物理存放位置。
修改即移动: 当你修改一个索引条目中的任何一个排序列(无论是二级索引键本身,还是作为次要排序列的主键)时,这个条目的逻辑顺序就可能发生改变。只要逻辑顺序变了,它在B+树中的物理位置也必须改变。
“删除+插入”是“移动”的实现: 在B+树这种精密的结构中,最直接、最可靠的“移动”一个条目的算法,就是先在旧位置将其删除,再在新位置将其插入。这个过程可能只涉及在一个页内的移动,也可能涉及跨磁盘页的移动(如果新旧位置离得很远)。
Nginx & Openresty
非阻塞就是,事件没有准备好,马上返回EAGAIN,告诉你,事件还没准备好呢,你慌什么,过会再来吧。好吧,你过一会,再来检查一下事件,直到事件准备好了为止,在这期间,你就可以先去做其它事情,然后再来看看事件好了没。虽然不阻塞了,但你得不时地过来检查一下事件的状态,你可以做更多的事情了,但带来的开销也是不小的。所以,才会有了异步非阻塞的事件处理机制,具体到系统调用就是像select/poll/epoll/kqueue这样的系统调用。它们提供了一种机制,让你可以同时监控多个事件,调用他们(epoll_wait())是阻塞的,但可以设置超时时间,在超时时间之内,如果有事件准备好了,就返回。当事件没准备好时,放到epoll里面,事件准备好了,我们就去读写,当读写返回EAGAIN时,我们将它再次加入到epoll里面。这样,只要有事件准备好了,我们就去处理它,只有当所有事件都没准备好时,才在epoll里面等着。这样,我们就可以并发处理大量的并发了,当然,这里的并发请求,是指未处理完的请求,线程只有一个,所以同时能处理的请求当然只有一个了,只是在请求间进行不断地切换而已,切换也是因为异步事件未准备好,而主动让出的。这里的切换是没有任何代价,你可以理解为循环处理多个准备好的事件,事实上就是这样的。与多线程相比,这种事件处理方式是有很大的优势的,不需要创建线程,每个请求占用的内存也很少,没有上下文切换,事件处理非常的轻量级。并发数再多也不会导致无谓的资源浪费(上下文切换)。更多的并发数,只是会占用更多的内存而已。 我之前有对连接数进行过测试,在24G内存的机器上,处理的并发请求数达到过200万。现在的网络服务器基本都采用这种方式,这也是nginx性能高效的主要原因。
nginx为了更好的利用多核特性,提供了cpu亲缘性的绑定选项,我们可以将某一个进程绑定在某一个核上,这样就不会因为进程的切换带来cache的失效。
对于一个基本的web服务器来说,事件通常有三种类型,网络事件、信号、定时器。从上面的讲解中知道,网络事件通过异步非阻塞可以很好的解决掉。如何处理信号与定时器?
event模块的主要功能就是,监听accept后建立的连接,对读写事件进行添加删除。事件处理模型和Nginx的非阻塞IO模型结合在一起使用。当IO可读可写的时候,相应的读写事件就会被唤醒,此时就会去处理事件的回调函数。
特别对于Linux,Nginx大部分event采用epoll EPOLLET(边沿触发)的方法来触发事件,只有listen端口的读事件是EPOLLLT(水平触发)。对于边沿触发,如果出现了可读事件,必须及时处理,否则可能会出现读事件不再触发,连接饿死的情况。
在ngx_trylock_accept_mutex()函数里面,如果拿到了锁,Nginx会把listen的端口读事件加入event处理,该进程在有新连接进来时就可以进行accept了。注意accept操作是一个普通的读事件。下面的代码说明了这点:
1 | (void) ngx_process_events(cycle, timer, flags); |
Nginx多进程的锁在底层默认是通过CPU自旋锁来实现。如果操作系统不支持自旋锁,就采用文件锁。
ngx_process_events()函数是所有事件处理的入口,它会遍历所有的事件。抢到了accept锁的进程跟一般进程稍微不同的是,它被加上了NGX_POST_EVENTS标志,也就是说在ngx_process_events() 函数里面只接受而不处理事件,并加入post_events的队列里面。直到ngx_accept_mutex锁去掉以后才去处理具体的事件。为什么这样?因为ngx_accept_mutex是全局锁,这样做可以尽量减少该进程抢到锁以后,从accept开始到结束的时间,以便其他进程继续接收新的连接,提高吞吐量。
ngx_posted_accept_events和ngx_posted_events就分别是accept延迟事件队列和普通延迟事件队列。可以看到ngx_posted_accept_events还是放到ngx_accept_mutex锁里面处理的。该队列里面处理的都是accept事件,它会一口气把内核backlog里等待的连接都accept进来,注册到读写事件里。
而ngx_posted_events是普通的延迟事件队列。一般情况下,什么样的事件会放到这个普通延迟队列里面呢?我的理解是,那些CPU耗时比较多的都可以放进去。因为Nginx事件处理都是根据触发顺序在一个大循环里依次处理的,因为Nginx一个进程同时只能处理一个事件,所以有些耗时多的事件会把后面所有事件的处理都耽搁了。
为了更精细地控制对于客户端请求的处理过程,nginx把这个处理过程划分成了11个阶段。他们从前到后,依次列举如下:
NGX_HTTP_POST_READ_PHASE:
读取请求内容阶段
NGX_HTTP_SERVER_REWRITE_PHASE:
Server请求地址重写阶段
NGX_HTTP_FIND_CONFIG_PHASE:
配置查找阶段:
NGX_HTTP_REWRITE_PHASE:
Location请求地址重写阶段
NGX_HTTP_POST_REWRITE_PHASE:
请求地址重写提交阶段
NGX_HTTP_PREACCESS_PHASE:
访问权限检查准备阶段
NGX_HTTP_ACCESS_PHASE:
访问权限检查阶段
NGX_HTTP_POST_ACCESS_PHASE:
访问权限检查提交阶段
NGX_HTTP_TRY_FILES_PHASE:
配置项try_files处理阶段
NGX_HTTP_CONTENT_PHASE:
内容产生阶段
NGX_HTTP_LOG_PHASE:
日志模块处理阶段
四层和七层负载均衡的区别
层数越低,接触到的数据信息就越基础;层数越高,能理解的数据内容就越丰富。
四层负载均衡 (Layer 4 Load Balancing)
核心原理:它工作在 TCP/UDP 协议层。负载均衡器在接收到客户端请求后,会通过修改数据包的目标地址和端口(以及源地址,实现NAT),然后直接转发给后端某一台服务器。在这个过程中,它不会去读取数据包的具体内容。
决策依据:它做转发决策的唯一依据是 网络层传输层 层的信息,主要是源/目标 IP 地址和源/目标端口号。
工作模式:可以看作一个“包转发器 (Packet Forwarder)”。客户端和后端服务器之间建立的是一条完整的 TCP 连接,负载均衡器只是这条连接上的一个中转节点。
性能极高:因为它不关心包里面的具体内容,不需要解析应用层协议,所以处理速度非常快,开销极低,能应对巨大的流量。
通用性强:只要是基于 IP 和端口的协议,理论上都可以进行负载均衡,不限于 HTTP。
缺点:
功能单一:它无法感知应用层的状态。比如,它无法根据请求的 URL、浏览器类型、Cookie 等信息来做更智能的转发。
典型代表:LVS (Linux Virtual Server)、云服务商的网络负载均衡器 (NLB)。
七层负载均衡 (Layer 7 Load Balancing)
工作层面:应用层 (Application Layer)。
核心原理:它工作在 HTTP/HTTPS, FTP, SMTP 等具体的应用协议层。它会与客户端建立一次完整的 TCP 连接,接收并完整地读取应用层的数据(比如一个完整的 HTTP 请求),然后根据请求中的具体内容,再作为一个新的客户端与后端服务器建立另一条 TCP 连接,将请求转发过去。
决策依据:除了 L4 的所有信息外,它还能解析出应用层的各种信息,如 URL 路径、HTTP Headers (请求头)、Cookie、请求方法 (GET/POST) 等。
优点:
极其智能和灵活:可以实现复杂的路由规则。例如:
将 yourdomain.com/images/* 的请求转发到图片服务器集群。
将 yourdomain.com/api/* 的请求转发到 API 服务器集群。
根据用户的 Cookie 实现“会话保持 (Sticky Session)”,确保同一用户的请求总是发到同一台后端服务器。
可以修改 HTTP 报文,比如添加/删除 Headers。
可以卸载 SSL/TLS 加密,后端服务器只需处理 HTTP 即可。
缺点:
性能开销更大:因为它需要解析应用层协议,维持与客户端和服务器的两条连接,缓冲数据,所以 CPU 和内存消耗都比四层负载均衡大,性能也相对较低。
典型代表:Nginx、云服务商的应用负载均衡器 (ALB)。
OpenResty® 是一个功能完备的Web平台,它集成了增强版的Nginx核心、增强版的LuaJIT、大量精心编写的Lua库以及众多高质量的第三方Nginx模块及其外部依赖 。将Nginx服务器有效地转变为一个强大的Web应用服务器 。在这个服务器中,Web开发者可以使用Lua编程语言来编写脚本,驱动现有的Nginx C模块,从而处理复杂的业务逻辑。
这种架构的核心优势在于其将动态语言(Lua)的灵活性与底层C语言服务器(Nginx)的原始性能相结合。OpenResty® 旨在利用Nginx的事件模型,在Nginx服务器内部完整地运行服务器端Web应用,不仅能与HTTP客户端进行非阻塞I/O,还能与MySQL、PostgreSQL、Memcached和Redis等远程后端进行非阻塞I/O 。
应当总是使用 set_keepalive,同时连接池的大小应当设置得足够大。否则短连接很容易将你系统的临时端口用尽。
另外,在进行压力测试时,应当禁掉 DDEBUG 和 –with-debug,同时使用 warn 以上的 error_log
日志过滤级别。否则你的 nginx
都忙着刷不带缓冲的错误日志了。另外,你也应设置访问日志的缓冲区或者完全禁掉访问日志(如果你不需要的话),见
如果你的 nginx 进程的 CPU 占用比较高,可以使用“火焰图”(flamegraph)对内部的执行热点进行分析:
分析工具
基于Openresty开发的应用路由性能调优思路
Lua火焰图显示,CPU时间主要消耗在ngx_http_lua_var_set/get这两个函数上,而不是他自己编写的业务逻辑代码。这几乎是找到了“冒烟的枪”。它清晰地指明,性能瓶颈在于通过ngx.var这个API在Lua和Nginx之间传递数据所带来的巨大开销。ngx.var是用于读写Nginx变量的接口,频繁调用它意味着频繁地在Lua VM和Nginx C核心之间进行数据交换和上下文切换,而这个过程的成本远高于纯粹在Lua VM内部执行的计算。
ua代码大部分时间是在解释模式下运行,而不是被LuaJIT的即时编译器(JIT)所编译和优化。他在火焰图中观察到的lj_BC_xxx栈帧是这一判断的直接证据,这些栈帧代表LuaJIT正在执行字节码(ByteCode),这是其解释器的典型特征。
对于一个像LuaJIT这样以高性能著称的VM,JIT编译是其速度的关键来源。当JIT编译器成功运行时,它会将频繁执行的Lua代码(“热代码”)编译成本地的机器码,其执行效率可以逼近原生C代码。反之,如果代码路径由于某些原因无法被JIT编译,VM就会退回到逐条解释执行字节码的慢速模式。这两种模式之间的性能差异可以是数量级的。
需要理解为什么JIT会失效。LuaJIT的JIT编译器并非万能,它不支持Lua语言的所有特性和内建函数。那些不被支持的部分被称为“NYI”(Not Yet Implemented,尚未实现)原语。当JIT编译器在分析一个热代码路径时,如果遇到了一个NYI原语,它会放弃对该路径的编译,这个过程称为“JIT中止”(JIT abort)。
为了解决这个问题,OpenResty生态系统提供了一个关键的库:lua-resty-core 。这个库的核心作用是为那些在标准Lua中是NYI的常用函数(尤其是与ngx_lua模块API交互的核心部分)提供了JIT兼容的替代实现。通过在代码的init_by_lua阶段简单地执行require “resty.core”,开发者实际上是在“猴子补丁”(monkey-patching)当前的Lua环境,用JIT友好的版本覆盖了那些有问题的标准函数。
在OpenResty中,决定应用性能的最关键因素,是确保热点代码路径能够被LuaJIT的JIT编译器成功编译。章亦春的所有建议——使用lua-resty-core、避免NYI、预分配表——最终都服务于这一个目标。开发者必须建立一种“像JIT一样思考”的心智模型,在编写代码时,优先选择JIT友好的模式和API,而不是仅仅遵循常规的编码习惯。这意味着,对性能有极致要求的团队,不能将LuaJIT仅仅看作一个黑盒,而应主动去了解其工作原理、优势和限制。
因此,在设计应用架构时,应有意识地减少这种“跨界聊天”(必须警惕Nginx的C环境和Lua VM之间边界穿越的成本。每一次这样的穿越都涉及到数据结构的转换和上下文的切换,其开销远大于在单一环境(无论是纯C还是纯Lua)中的操作。)。例如,Guanglin Lv后来确认他主要使用ngx.ctx在不同的Lua处理阶段之间传递数据。ngx.ctx是一个请求级别的Lua table,其数据完全保留在Lua VM内部,因此在access_by_lua中存入、在balancer_by_lua中取出的操作,几乎没有额外的边界穿越开销。与之相比,如果使用ngx.var来传递大量或复杂的数据,则每次读写都会触发一次昂贵的C/Lua交互。选择正确的API来在不同阶段间共享状态,是降低这部分固定开销的关键。
将性能剖析融入开发生命周期:火焰图的生成和分析不应仅仅是解决线上问题时的最后手段,而应成为性能测试和回归分析的标准流程。在开发和测试阶段就主动进行剖析,可以在问题暴露于生产环境之前,识别并修复潜在的性能瓶颈。
epoll的工作模式:
epoll的一个实例(你可以想象成一块监控面板)可以同时监控一个监听fd和成千上万个连接fd。
当epoll_wait()返回时,它会告诉你:“监听fd响了,快去accept!” 或者 “连接fd 123有数据了,快去read!” 或者 “连接fd 456可以发送数据了,快去write!”
你的程序(Nginx的worker进程)在一个循环里调用epoll_wait(),然后根据返回的fd类型和事件类型,去执行相应的accept, read, write操作。
什么是惊群?
多个worker进程以fork()方式创建,它们继承了父进程(Master进程)打开的所有fd,其中就包括那个唯一的监听fd。于是,所有worker进程都持有同一个监听fd。在老的Linux内核中,当一个新连接到来时,内核会唤醒所有正在epoll_wait()并等待这个监听fd的进程。假设有8个worker进程,8个进程同时被唤醒,然后冲过去调用accept()。但连接只有一个,所以最终只有一个worker能accept()成功,剩下7个都失败返回(得到EAGAIN错误)。这7个进程白白被唤醒了一次,做了无用功,并引发了不必要的CPU上下文切换,浪费了系统资源。这就是“惊群”。
Linux 2.6 之前的解决方案 (Nginx的accept_mutex)(用户态锁):
Nginx引入了一个accept_mutex(接受互斥锁)。
在每个worker进程的事件循环中,它会尝试去非阻塞地获取这个锁 (trylock)。
获取锁成功:
这个worker进程成为“天选之子”,它负责去监听新连接。
它会把监听fd通过epoll_ctl()添加到自己的epoll监控集合中。
现在,只有它自己会因为新连接事件而被唤醒。
获取锁失败:
说明已经有其他worker在监听了。
它会把监听fd从自己的epoll监控集合中移除。
这样,它就只处理自己手上的已有连接的读写事件,完全不关心新连接的到来。
这样,通过这把锁,Nginx在同一时刻,确保了只有一个worker进程在处理新连接,从而在应用层完美地解决了惊群问题。
SO_REUSEPORT选项 (Linux 3.9+): 这是一个更现代、更高效的解决方案。
Nginx的现代实践:
Nginx会检测内核版本。在支持SO_REUSEPORT的现代Linux系统上,你可以在nginx.conf的listen指令后添加reuseport选项。
listen 80 reuseport;
启用后,Nginx将使用SO_REUSEPORT机制,并自动禁用旧的accept_mutex,从而获得更好的性能和连接分发均衡性。
无论是哪个版本的Nginx,也无论是否使用SO_REUSEPORT,初始化的流程都是一致的:
Master进程启动: 读取配置文件(如nginx.conf),根据listen指令得知需要监听哪些端口(如80, 443)。
创建和绑定:** Master进程调用socket()创建套接字,设置SO_REUSEADDR等选项,并调用bind()将其绑定到指定的IP和端口,最后调用listen()开始监听。这一步只由Master进程完成。
Fork子进程: Master进程随后fork()出指定数量的Worker子进程。
继承文件描述符: 根据Unix/Linux的特性,子进程会继承父进程所有已打开的文件描述符。这意味着,每一个Worker进程都拥有了那个由Master进程创建好的监听套接字的“副本”**。
关键区别在于SO_REUSEPORT如何影响这个继承来的套接字:
不使用reuseport(经典模式): 所有Worker进程共享同一个底层的内核监听队列。这就是产生“惊群”问题的根源,需要accept_mutex等机制来协调。
使用reuseport(现代模式): 当Master进程为一个套接字设置了SO_REUSEPORT选项并fork出多个Worker进程后,虽然每个Worker继承的fd数值上是同一个,但在内核层面,情况发生了根本性的变化:
独立的内核队列: 内核会为每一个使用了这个SO_REUSEPORT套接字的进程(也就是每个Worker进程)创建一个专属的、独立的监听队列。它不再是所有进程共享一个总队列。
内核级负载均衡:当一个新的TCP连接请求(SYN包)到达时,内核不会去唤醒任何人。相反,内核会根据这个连接的四元组(源IP、源端口、目的IP、目的端口)进行一次哈希计算。
精准投递: 根据哈希计算的结果,内核会精确地选择一个Worker进程,并将这个新连接放入它专属的那个监听队列中。
唯一唤醒:因为连接只被放入了一个队列,所以最终只有那一个被选中的Worker进程的epoll_wait()会被唤醒,因为它监控的专属队列(全连接队列)变“满”了。其他所有Worker进程的队列都没有变化,它们会继续安然休眠。
SO_REUSEPORT的出现,让内核改变了游戏规则。它在唤醒进程之前,就通过哈希算法做了一次“分流”,将连接请求“精准投递”到某个特定的Worker进程。因此,即使所有Worker都在等待,也只有一个会被“精准命中”并唤醒。这就在内核层面彻底、高效地解决了惊群问题。
在通常情况(没有 SO_REUSEPORT)下是完全正确的。一个内核中的socket对象,确实对应着一套自己的SYN队列和Accept队列。在Nginx的经典模式下,所有Worker进程继承并共享的是同一个内核socket对象,因此它们也就在争抢同一套队列资源。
但是,SO_REUSEPORT的出现就是为了打破这个规则。
当Master进程在socket上设置SO_REUSEPORT选项后,这个socket的性质就变了。它告诉内核:“我准备创建一个可以被多个进程共同绑定的服务端口,请为它们建立一个群组(group)”。
之后,当Master进程fork出Worker进程时:
虽然每个Worker进程继承的fd数值上是同一个,但在内核看来,由于SO_REUSEPORT的存在,内核会为每一个持有这个fd的Worker进程,都维护一套独立的、专属的SYN队列和Accept队列。
正是因为SO_REUSEPORT,内核才为每个Worker进程分配了专属的监听队列,即使它们最初源自同一个socket调用。
问:在reuseport场景下,各个工作进程在epoll里面注册的是什么?
答:每个Worker进程在自己的epoll实例中,注册的依然是那个从Master进程继承来的、数值相同的监听fd。
例如,Master创建的监听fd是5,那么所有Worker进程都会执行 epoll_ctl(worker_epoll_fd, EPOLL_CTL_ADD, 5, …)。它们看起来都在监控同一个东西。
问:在什么情况下会触发回调然后accept新连接?
答:神奇之处在于内核的“精准投递”,下面是完整的分解步骤:
启动阶段:
Master进程创建listening_fd(假设为5),并为其设置SO_REUSEPORT选项。
Master进程fork出3个Worker进程(W1, W2, W3)。
W1, W2, W3都继承了listening_fd=5。
W1将fd=5加入自己的epoll_1;W2将fd=5加入自己的epoll_2;W3将fd=5加入自己的epoll_3。
所有Worker都调用epoll_wait()进入休眠。
新连接到达 (SYN包):
一个客户端的SYN包到达服务器。
内核协议栈识别出该包的目标端口(如80)启用了SO_REUSEPORT。
内核哈希与分发:
内核提取该连接的四元组(例如 src_ip:12345, dst_ip:80)。
内核对这个四元组进行哈希计算,得出一个结果。根据这个结果,内核决定将这个连接分配给W2。
专属队列处理:
内核将这个半连接信息放入W2专属的SYN队列,并从W2的端口回复SYN-ACK。
客户端回复最终的ACK。
内核在W2专属的SYN队列中找到对应条目,完成握手,并将这个完整的连接放入W2专属的Accept队列。
关键点:从始至终,W1和W3的SYN队列与Accept队列都是空的,完全没有感知到这个新连接的存在。
精准Epoll通知与回调:
因为只有W2的Accept队列中有了新内容,所以只有W2的监听fd(fd=5)的内核状态变为了“可读”。
内核检查所有正在监控fd=5的epoll实例。但是,由于SO_REUSEPORT的队列隔离机制,内核知道这个“可读”事件只与W2有关。
因此,内核只唤醒正在epoll_2上等待的W2进程。
W2的epoll_wait()返回,事件循环发现是fd=5就绪了。
它调用注册在fd=5上的回调函数,即ngx_event_accept。
成功Accept:
ngx_event_accept函数调用accept(5, …)。
因为W2的专属Accept队列中确实有一个等待处理的连接,所以accept()调用立即成功,返回一个新的连接fd。
后续流程就和我们之前讨论的一样了,W2开始在这个新的连接fd上处理HTTP请求。
总结: 在reuseport场景下,所有Worker进程看似在epoll中监控同一个监听fd,但SO_REUSEPORT选项已经授权内核进行了一次预处理和分流。内核通过哈希,将不同的连接请求放入了绑定到同一个端口的不同socket的私有队列中,从而实现了只唤醒一个“天选”进程的效果,完美地解决了惊群问题。
为了精确,我们需要将“监听队列”拆分为两个在内核中真实存在的、不同的队列:
半连接队列 (SYN Queue): 当服务器收到客户端的SYN包后,会回复SYN-ACK,然后将这个“半成品”连接放入SYN队列,等待客户端最终的ACK。
全连接队列 (Accept Queue): 当服务器收到最终的ACK后,三次握手完成。内核会将这个连接从SYN队列中取出,放入Accept队列,等待应用程序调用accept()来取走。
通常我们口语中的“监听队列已满”,指的就是这个Accept Queue满了。
现在,我们结合这两个队列,重说一遍SO_REUSEPORT的流程:
初始化: Nginx的Master进程创建监听套接字时设置了SO_REUSEPORT。随后fork出的多个Worker进程虽然继承了fd,但内核因为这个选项,为每一个Worker进程都创建了一套独立的SYN队列和Accept队列。
新连接到达 (SYN包):
客户端的SYN包到达服务器网卡。
内核TCP/IP协议栈发现这个包的目标端口启用了SO_REUSEPORT。
内核对该连接的四元组(源IP/端口,目的IP/端口)进行哈希计算。
精准投递 (SYN Queue):
根据哈希结果,内核选择一个唯一的Worker进程。
内核将这个半连接状态放入该Worker进程专属的SYN队列中,并从此队列回复SYN-ACK。
关键: 其他Worker进程的SYN队列完全没有变化,它们对此一无所知。
握手完成 (ACK包):
客户端回复最终的ACK包。
内核根据TCP状态找到之前存放的半连接条目(就在那个被选中的Worker的SYN队列里),完成握手。
随后,内核将这个完全建立好的连接放入该Worker进程专属的Accept队列中。
唤醒:
由于只有这一个Worker进程的Accept队列从“空”变成了“非空”,因此只有它的监听套接字(listening fd)的“可读”状态被触发了。
因此,只有这个Worker进程的epoll_wait()会从阻塞中返回,并得到“监听套接字可读”的通知。
总结: SO_REUSEPORT的本质,就是在内核层面为每个Worker进程创建了专属的、私有的连接接收流水线(SYN队列 + Accept队列),并通过哈希算法在新连接进入的第一步就决定了它该走哪条流水线。这样就从根源上避免了所有Worker去争抢同一个公共资源。
epoll注册、等待队列与事件处理器
Q: 内核的唤醒机制是否通过socket的等待队列实现的?
您的直觉完全正确!这正是通过Socket自己的“等待队列”(Wait Queue)实现的。
epoll并非一个孤立的系统,它深度整合在Linux的I/O和进程调度机制中。
等待队列 (Wait Queue): 在内核中,几乎所有可能导致进程等待的资源(比如一个socket的接收缓冲区、一个锁)都有一个与之关联的“等待队列”。这个队列里记录了所有正在睡眠、等待该资源变为可用状态的进程。
epoll_ctl的作用: 当你调用epoll_ctl(epoll_fd, EPOLL_CTL_ADD, sock_fd, …)时,你不仅仅是在epoll实例中注册了一个fd。内核还会悄悄地做一件事:将当前进程(的一个引用)加入到sock_fd所对应的内核socket对象的等待队列中,并做一个标记:“这个进程是在等epoll事件”。
epoll_wait与休眠: 当进程调用epoll_wait()时,如果没有就绪事件,内核会让这个进程进入休眠状态。
状态改变与唤醒: 当一个数据包到达,内核协议栈将数据放入sock_fd的接收缓冲区。在完成这个动作后,内核会执行一个wake_up()操作,去唤醒所有在这个socket的等待队列中休眠的进程。
epoll响应: 被唤醒的进程中,epoll系统知道是它注册的等待事件发生了。于是,epoll会将这个就绪的fd信息放入自己的“就绪列表”中,最终epoll_wait()得以返回,将就绪列表交给用户程序。
所以,您的理解非常到位。epoll是高层管理者,它利用了底层每个socket自带的等待队列机制来实现高效的事件通知。
Q: &event 同时包含了读写事件的处理函数吗? 读、写要分开注册吗?
这两个问题也切中了epoll在应用层的使用精髓。
&event里有什么?:
在Nginx中,一个连接ngx_connection_t通常包含两个独立的ngx_event_t结构体:一个用于读(rev),一个用于写(wev)。rev->handler指向读回调,wev->handler指向写回调。
epoll_ctl中传递的epoll_data联合体非常小,它只能保存一个指针(或者一个整数)。
因此,当Nginx注册读事件时,它传递的是读事件结构体 rev 的指针。当注册写事件时,传递的是写事件结构体 wev 的指针。
所以,epoll每次返回的“回传凭证”要么是读事件的指针,要么是写事件的指针,不可能同时是两者。Nginx拿到这个指针后,就知道是哪个具体事件就绪了,然后执行其对应的handler。
读、写要分开注册吗?:
是的,读写事件的“兴趣”是独立管理的,可以分开注册,也可以合并注册。
epoll的事件掩码events是一个位掩码,你可以灵活地组合:
events = EPOLLIN; // 只对读事件感兴趣
events = EPOLLOUT; // 只对写事件感兴趣
events = EPOLLIN | EPOLLOUT; // 同时对读和写都感兴趣
epoll_ctl有三种操作:ADD(添加)、MOD(修改)、DEL(删除)。Nginx正是通过MOD来动态地改变对一个fd的兴趣。
一个典型的Nginx工作流:
连接建立后: Nginx只对读事件感兴趣。它会调用epoll_ctl(…, EPOLL_CTL_ADD, fd, {EPOLLIN, &read_event})。
请求处理完,准备响应: Nginx开始write()响应数据。如果数据没写完(发送缓冲区满了),说明现在需要关注“可写”状态了。
修改兴趣: 它会调用epoll_ctl(…, EPOLL_CTL_MOD, fd, {EPOLLIN | EPOLLOUT, &write_event})。
注意: 这里不仅添加了EPOLLOUT,也保留了EPOLLIN,因为在发送响应的同时,可能需要处理客户端发来的下一个请求(HTTP Keep-Alive)或连接关闭事件。同时,因为epoll_wait返回时会区分是读还是写事件触发的,Nginx会把写事件的指针作为回传凭证。
响应发送完毕: Nginx会再次调用epoll_ctl(…, EPOLL_CTL_MOD, fd, {EPOLLIN, &read_event}),移除对EPOLLOUT的兴趣,因为它暂时不需要写数据了,只关心客户端的下一次读取请求。
important
epoll的本质:监控“就绪状态”,而非“事件对象”
epoll里面直接存放的不是一个抽象的“事件”,而是对文件描述符(fd)的“就绪状态”的兴趣。
让我们走一遍全流程,看看数据包是如何唤醒进程的。
A. “注册”的到底是什么?
当Nginx调用 epoll_ctl(epoll_fd, EPOLL_CTL_ADD, sock_fd, &event) 时,它实际上是在对内核说:
“你好内核,我(这个进程)对 sock_fd 这个文件描述符的可读(EPOLLIN)和可写(EPOLLOUT)状态非常感兴趣。请把它加入我的epoll_fd这个监控列表里。哦对了,如果它真的就绪了,你在通知我的时候,请把这个 &event 的地址原封不动地还给我,这样我就知道是我当初注册的哪个具体任务了。”
epoll 并不理解 event 这个结构体(即Nginx的ngx_event_t)的内容。它只是把它当作一个回传凭证(在epoll_event结构中是epoll_data联合体)保存起来。
所以,epoll内部维护的是一个 <fd, 关注的状态, 回传凭证> 这样的兴趣列表。
B. 从数据包到进程唤醒的全流程
数据到达: 一个网络数据包通过网卡进入系统。
内核协议栈处理: 内核的TCP/IP协议栈处理这个数据包,识别出它属于哪个TCP连接。每个TCP连接在内核中都有一个struct sock对象,这个对象里包含了接收缓冲区和发送缓冲区。内核将包中的数据放入对应连接的接收缓冲区。
“就绪状态”改变: 当接收缓冲区从“空”变为“非空”时,这个socket在内核中的状态就从“未就绪”变成了“可读就绪”。
epoll的回调机制 (Kernel-Level): **内核在改变socket状态的同时,会检查这个socket是否被任何epoll实例所监控(等待队列)**。它发现你的Worker进程的epoll实例对这个socket的“可读”状态感兴趣。
加入就绪队列: 内核会将这个socket的fd和当初注册的那个“回传凭证”(也就是&event的地址) 一起,添加到一个专属于你的epoll实例的就绪队列(Ready List)中。
唤醒进程: 同时,内核会唤醒正在epoll_wait()调用上睡眠的你的Worker进程。
epoll_wait()返回: Worker进程被唤醒后,epoll_wait()函数从内核态返回到用户态。它的返回值就是就绪队列中项目的数量,并且它会把你注册的那些“回传凭证”(epoll_event结构体数组)从内核空间拷贝到你传入的用户空间缓冲区(有一个拷贝)。
Nginx的处理: Nginx的事件循环拿到这个epoll_event数组,遍历它。对于每一项,它取出“回传凭证”——那个ngx_event_t对象的指针,然后直接执行 event->handler(event),调用之前注册好的回调函数,开始进行read()数据、解析HTTP等操作。
C. 这是通用的机制吗?
是的,这是所有使用epoll的程序(不仅仅是Nginx)的标准工作模式。
无论是Redis、Netty(在Linux上)、Envoy还是任何其他使用epoll的高性能程序,其基本原理都是一样的:
用户态: 定义自己的事件/连接管理结构。
注册: 通过epoll_ctl向内核注册对某个fd的就绪状态的兴趣,并附上一个指向自己事件管理结构的指针/凭证。
等待: 调用epoll_wait()等待内核通知。
处理: 被唤醒后,从内核返回的凭证中找到自己的管理结构,执行相应的业务逻辑(回调)。
epoll本身是内核提供的一个高效的、通用的“就绪状态”通知机制,而Nginx则是在这个机制之上构建了自己精巧的事件处理框架。
Nginx的Master-Worker之间通信和管理机制非常轻巧和可靠。
通信机制:socketpair
Master进程在fork出每个Worker进程之前,会通过socketpair()系统调用创建一对Unix域套接字(Unix Domain Socket)。这对套接字就像一条私密的、双向的管道。
fork之后,Master进程关闭其中一端,保留另一端。
Worker进程也关闭其中一端,保留与Master相对应的一端。
这样,Master和每个Worker之间都建立了一条专属的、全双工的通信渠道
状态管理:信号 (Signal)
Master进程作为父进程,通过Unix信号机制来监控所有Worker子进程的状态。
注册信号处理器: Master进程在启动时会为关键信号(如SIGCHLD, SIGINT, SIGHUP等)注册好处理器函数。
SIGCHLD信号: 这是最重要的信号。当任何一个Worker子进程终止(无论是正常退出还是异常崩溃)时,操作系统内核都会向其父进程(也就是Master进程)发送一个SIGCHLD信号。
状态回收与重启: Master进程的SIGCHLD处理器被触发后,会调用waitpid()等函数来“回收”这个已终止的子进程,并获取其退出状态码。通过分析状态码,Master就知道子进程是正常退出还是异常崩溃。如果发现是异常崩溃,并且配置了需要保持Worker数量,Master就会重新fork一个新的Worker进程,以维持服务的稳定性。
总结:Nginx的父子进程管理,是经典的Unix“信号驱动”模式,稳定、高效且可靠。
epoll事件处理全流程详解 (含回调)
这是Nginx的心脏。我将为您描述一个HTTP请求从连接建立到响应完成的全过程。
A. 核心构件:事件与处理器(回调)
在Nginx中,核心数据结构是:
ngx_connection_t: 代表一个连接,包含了socket fd、读写缓冲区、SSL上下文等所有与连接相关的信息。
ngx_event_t: 代表一个事件,是我们很重要的事件管理结构。每个连接(ngx_connection_t)都关联着一个读事件和一个写事件。事件结构体中最重要的成员是:
handler: 这是一个函数指针,它指向一个处理该事件的函数。这就是Nginx的回调函数。
active: 一个标志,表示这个事件是否已经被添加到了epoll中。
B. 事件循环:永不停歇的心脏
每个Worker进程都在一个while(1)循环中工作,这个循环的核心是ngx_process_events_and_timers()函数。
调用epoll_wait(),并设置一个超时时间。Worker进程在此阻塞,等待内核通知事件的发生。
epoll_wait()返回,携带了一批“就绪”的事件。
Worker进程遍历这些就绪的事件(需要先拷贝到内核)。
对于每一个就绪的事件 event,直接执行:event->handler(event)。这就是回调函数的调用。
处理完所有I/O事件后,处理定时器队列中的超时事件(如连接超时)。
回到第1步,继续循环。
C. 流程1:处理新连接 (Accept Event)
注册: Nginx启动时,监听套接字的读事件的handler被设置为ngx_event_accept函数。这个读事件被加入到epoll中。
事件发生: 一个客户端发起TCP连接请求,三次握手完成。内核将这个新连接放入Accept队列,监听套接字变为“可读”。(通过等待队列将事件加入到就绪队列……)
处理:
epoll_wait()被唤醒,返回监听套接字的读事件。
事件循环调用其handler,也就是ngx_event_accept。
ngx_event_accept函数内部:
a. 调用accept()接收新连接,得到一个新的连接socket fd。
b. 从连接池中获取一个ngx_connection_t结构体来管理这个新连接。
c. 【关键:注册新回调】 为这个新连接设置初始的事件处理器。通常,会将新连接的读事件的handler设置为ngx_http_init_request,写事件的handler设置为一个空闲处理器(因为现在还不需要写)。
d. 将这个新连接的读事件添加到epoll中,开始等待客户端发送数据。
D. 流程2:处理请求数据 (Read Event)
事件发生: 客户端沿着新建立的连接发送HTTP请求(如GET /index.html …)。连接socket变为“可读”。
处理:
epoll_wait()被唤醒,返回这个连接的读事件。
事件循环调用其handler,也就是我们上一步设置的ngx_http_init_request。
ngx_http_init_request和后续的HTTP处理函数内部:
a. 调用read()或recv()从socket缓冲区读取数据到Nginx的内存缓冲区。
b. 开始解析HTTP请求(请求行、请求头等)。
c. 【状态机与回调变更】** Nginx的请求处理是一个状态机。例如,读完请求行后,读事件的handler可能会被修改为ngx_http_process_request_headers。整个请求处理过程,事件的handler会根据当前状态不断地被修改,指向下一个阶段该执行的函数**。
d. 如果一次read()没有读完整个请求,函数就直接返回。因为读事件还在epoll里,下次数据到来时,epoll会再次通知,并从上次的状态继续执行。
e. 请求完全接收并解析完毕后,Nginx根据配置找到对应的资源(文件或上游服务器),准备响应。
E. 流程3:发送响应数据 (Write Event)
事件发生与注册:
Nginx准备好了响应数据(比如读取了index.html文件内容),调用write()或send()发送。
由于TCP滑动窗口和内核发送缓冲区的限制,一次write()可能无法发送完所有数据。
当write()返回一个小于预期发送的字节数,或者返回EAGAIN错误时,Nginx知道现在不能再写了。
【关键:注册写回调】 此时,Nginx会将这个连接的写事件添加到epoll中,并将其handler设置为一个专门的发送函数,如ngx_http_writer。
处理:
当客户端接收了数据,内核的发送缓冲区有了空闲空间,连接socket变为“可写”。
epoll_wait()被唤醒,返回这个连接的写事件。
事件循环调用其handler,也就是ngx_http_writer。
ngx_http_writer函数内部:
a. 继续发送缓冲区中剩余的数据。
b. 如果数据全部发送完毕,就将这个连接的写事件从epoll中移除,因为暂时不再需要关注“可写”状态了。
c. 如果数据依然没有发完,就什么也不做,等待下一次epoll的“可写”通知。
这个“注册-等待-回调”的循环,就是Nginx用单线程高效处理海量并发I/O的全部奥秘。
uWSGI & Gevent
WSGI (Web Server Gateway Interface) - “协议与规范”
WSGI 本身不是一个服务器,也不是一个库,它是一个规范(一种标准接口)。你可以把它理解成是 Python 世界里的 “API 规范”,专门用来定义 Web 服务器 (比如 uWSGI) 如何与 Python Web 应用程序/框架 (比如 Django, Flask) 进行通信。
这个规范解决了什么问题?在 WSGI 出现之前,如果你写了一个 Python Web 框架,你可能需要为各种不同的 Web 服务器(Apache, Nginx 等)编写专用的适配器。这非常混乱。
WSGI 的出现统一了江湖。它规定:
服务器端 (uWSGI) 必须实现一个方式,能够调用应用程序。
应用程序端 (你的代码) 必须提供一个可调用对象(通常是一个函数),我们称之为 application。
这个 application 函数必须接受两个参数:
environ: 一个包含了所有 HTTP 请求信息的 dict 对象(比如请求头,路径,方法等)。
start_response: 一个由服务器 uWSGI 提供的回调函数。应用程序在准备好响应头(比如 200 OK, Content-Type: application/json)后,必须先调用这个函数,然后再返回响应体。
底层细节:
WSGI 的核心就是一个简单的函数签名 application(environ, start_response)。uWSGI 服务器负责把原始的 HTTP 请求解析成 environ 字典,并提供 start_response 函数,然后调用你的 Python 代码即 application。你的代码里面负责处理业务逻辑,调用 start_response,并返回响应体数据。它是一个纯粹的、解耦的“契约”。
uWSGI - “应用程序服务器”
uWSGI 是一个功能强大的应用程序服务器。它的核心职责是“承上启下”:
对上 (对 Nginx): 它能与专业的 Web 服务器(如 Nginx)高效通信。它们之间通常使用一种性能极高的二进制协议,叫做 uwsgi 协议(注意小写,以区别于软件本身)。这种通信可以通过 TCP 端口,也可以通过你提到的、性能更高的 Unix 域套接字(Unix Socket)进行。
对下 (对 Python 应用): 它负责加载并运行你的 Python WSGI 应用程序。它会管理一个或多个工作进程(Worker Processes),并将来自 Nginx 的请求分发给这些进程来处理。
核心功能和底层细节:
进程管理: uWSGI 通常会有一个 Master 进程和多个 Worker 进程。Master 进程负责监控和管理 Worker 进程,如果某个 Worker 挂了,Master 会重新拉起一个新的。
协议翻译: 它的核心工作之一就是将 Nginx 通过 uwsgi 协议发来的请求,翻译成符合 WSGI 规范的 environ 和 start_response,然后调用你的 Python application。反过来,它再把 Python 应用返回的响应,打包成 uwsgi 协议格式,发回给 Nginx。
性能: uWSGI 是用 C 语言编写的,性能极高。它能处理高并发请求,并有效地利用多核 CPU 资源。
Gevent - “并发魔法师”
Gevent 是一个基于协程的 Python 网络库。它解决的核心问题是高并发 I/O。
在传统的同步模型里,一个进程/线程在处理一个请求时,如果遇到 I/O 操作(比如查询数据库、请求外部 API),它就会阻塞,CPU 就在那里空等,直到 I/O 操作完成。这极大地浪费了 CPU 资源。
Gevent 引入了协程(也叫微线程或 Greenlet)。协程是一种非常轻量级的“线程”,它的切换开销极小,并且是由程序代码自己来控制切换时机(称为“协作式调度”)。
底层细节 (Gevent 的魔法:Monkey Patching):
Gevent 最神奇的地方在于它的“猴子补丁” (monkey.patch_all())。当你调用这个函数时,Gevent 会在运行时动态地替换掉 Python 标准库中所有会产生阻塞的 I/O 函数(比如 socket.connect, socket.recv, time.sleep 等),换成它自己实现的非阻塞版本。
工作原理: 当你的代码(已经打了猴子补丁)执行到一个 I/O 操作时,比如 requests.get(‘http://…’),底层的 socket.recv() 实际上是 Gevent 的版本。它不会真的阻塞整个进程,而是会:
向操作系统注册一个事件,告诉内核:“当这个 socket 有数据可读时,请通知我。”
让出 (yield) 当前协程的执行权。
Gevent 的事件循环 (Event Loop) 会接管控制权,去看有没有其他已经准备好(比如 CPU 计算任务,或者其他已完成的 I/O)的协程可以运行。
当最初的那个数据库查询/API 请求返回数据后,操作系统通知 Gevent 的事件循环,事件循环再把执行权交还给之前被挂起的那个协程,让它从刚才停下的地方继续执行。
这一切对于你的业务代码来说是完全透明的。你依然可以像写同步代码一样书写逻辑,但底层却实现了异步非阻塞的高并发效果。这使得单个 uWSGI Worker 进程可以同时处理成百上千个并发连接。
第二部分:一个请求的完整生命周期 (底层细节)
假设你的配置是:Nginx <–> Unix Socket <–> uWSGI (with Gevent workers) <–> Your WSGI App
第1步: 客户端 -> Nginx
用户浏览器发起一个 HTTP 请求,例如 GET /api/users/123。
经过 DNS 解析、TCP 三次握手、TLS 握手(如果是 HTTPS),请求数据包到达你的服务器的 80/443 端口。
Linux 内核协议栈处理数据包,将其递交给正在监听该端口的 Nginx Master 进程。Nginx Master 进程再将这个连接交给一个空闲的 Nginx Worker 进程处理。
第2步: Nginx 内部处理
Nginx Worker 进程解析 HTTP 请求报文,得到请求方法 (GET)、路径 (/api/users/123)、HTTP 版本、请求头等信息。
Nginx 查看自己的配置文件 (nginx.conf)。它根据 server_name 和 location 块匹配请求。它发现 /api/ 路径的请求应该被代理到后端。
配置中写的是 uwsgi_pass unix:///path/to/your/app.sock;。Nginx 知道它需要和 uWSGI 通信。
第3步: Nginx -> uWSGI (通过 Unix Socket)
这是关键的一步。Nginx 不会把原始的 HTTP 报文直接发过去。
它会按照 uwsgi 协议的格式,将解析好的请求信息(如 REQUEST_METHOD, PATH_INFO 等)打包成一个二进制的数据块。
Nginx 通过系统调用 write(),将这个二进制数据块写入到 /path/to/your/app.sock 这个 Unix 域套接字文件中。因为是文件系统 IPC,它绕过了整个网络协议栈,没有 TCP 的封包/解包、校验和、拥塞控制等开销,速度极快。
第4步: uWSGI 接收并分发
uWSGI Master 进程早已创建好了这个 app.sock 文件并监听它。当有数据写入时,操作系统会唤醒正在 accept() 上等待的 uWSGI Master 或 Worker。
uWSGI Master 进程将这个新连接交给一个空闲的 uWSGI Worker 进程来处理。
这个 Worker 进程从 Unix Socket 中通过 read() 读取 Nginx 发来的 uwsgi 协议数据。
第5步: uWSGI Worker 内部处理 & 调用 Python 应用
Worker 进程解析 uwsgi 二进制数据,将其翻译成 WSGI 规范所要求的 environ 字典。
Worker 进程创建一个 start_response 回调函数。
现在,它拥有了调用 Python 应用所需的一切。它调用你的 **WSGI 入口函数:application(environ, start_response)**。
第6步: Python 应用 & Gevent 的表演时刻
你的 Python 代码开始执行。假设处理 /api/users/123 需要查询数据库。
代码执行到 db_cursor.execute(“SELECT * FROM users WHERE id=123”)。
由于 monkey.patch_all() 的作用,这个数据库驱动底层的 socket 操作已经被换成了 Gevent 的非阻塞版本。
当前协程(Greenlet)被挂起,它会让出 CPU,同时告诉 Gevent 的事件循环:“当数据库连接上有数据返回时,请叫醒我。”
关键点:这个 uWSGI Worker 进程并没有被阻塞!Gevent 的事件循环会立刻检查是否有其他协程可以运行。如果此时 Nginx 又发来了第二个请求,这个 Worker 进程可以立即开始处理第二个请求,启动一个新的协程,而第一个请求的协程则在静静地等待数据库返回结果。这就是用 Gevent 实现高并发的核心。
第7步: 应用返回响应
数据库返回了查询结果。操作系统通知 Gevent 的事件循环,事件循环唤醒了第一个请求对应的协程(通过执行回调函数实现的,回调知道是哪个协程,并且将读到的socket上的数据库数据作为结果返回回去)。
协程从之前挂起的地方继续执行,处理数据库结果,生成 JSON 响应。
代码调用 start_response(‘200 OK’, [(‘Content-Type’, ‘application/json’)])。
函数 return [b’{“id”: 123, “name”: “Alice”}’],返回响应体。
第8步: uWSGI -> Nginx (返回路径)
uWSGI Worker 进程拿到了 Python 应用返回的响应头和响应体。
它将这些信息再次打包成 uwsgi 协议的二进制格式。
通过 write() 系统调用,将响应数据写回给来时的那个 Unix Socket 连接。
第9步: Nginx -> 客户端
Nginx Worker 进程正在 epoll 上监听着这个 Socket。当 uWSGI 写回数据时,Nginx 被唤醒。
Nginx 从 Socket 读取 uwsgi 协议的响应数据,将其解析并翻译成标准的 HTTP 响应报文(添加 HTTP/1.1 200 OK 状态行,组装响应头等)。
最后,Nginx 将这个完整的 HTTP 响应报文通过服务器的网卡发送回用户的浏览器。
第10步: 浏览器渲染
浏览器接收到 HTTP 响应,解析内容并将其渲染成用户看到的页面。
至此,一个请求的完整旅程结束。这个架构的精髓在于:
Nginx 负责处理一切与网络相关的脏活累活(连接管理、静态文件、安全、负载均衡)。
uWSGI 作为坚固的桥梁,高效地管理和运行 Python 进程。
Gevent 在应用层内部,用魔法般的方式让同步风格的代码实现了异步 I/O,极大地提升了单个进程的并发处理能力。
OpenResty Cosocket 是什么?
Cosocket (Coroutine Socket) 并不是一种新的 socket 类型,而是 ngx_lua 模块提供的一套 Lua API。这套 API 的目的是让用户可以在 Lua 代码中,以一种同步的、看起来是阻塞的方式,来执行底层的、异步的、非阻塞的网络 I/O 操作。
当你调用一个 cosocket API (例如 tcpsock:connect, tcpsock:receive) 时,它并不会阻塞整个 Nginx 的 Worker 进程。相反,它巧妙地利用了 Lua 协程和 Nginx 事件循环,实现了高效的并发。
Cosocket 如何与 Nginx 事件循环协作?可以分解为以下几个步骤:
发起 I/O 调用: 你的 Lua 代码在一个由 ngx_lua 模块创建和管理的 Lua 协程中运行。当代码执行到 local ok, err = tcpsock:connect(“google.com”, 80) 时,connect 函数被调用。
启动底层操作 & 注册事件:
connect 函数(内部是 C 实现)会调用 Nginx 底层的非阻塞 socket API 来真正发起一个 TCP 连接。因为是非阻塞的,这个调用会立即返回,通常会返回一个 EAGAIN 或 EINPROGRESS 的错误码,表示“操作正在进行中”。
同时,它将这个 socket 的文件描述符 (fd) 以及我们感兴趣的事件(对于 connect 来说是 “可写” 事件)当然还有回调函数注册到 Nginx 的主事件循环中。Nginx 的事件循环通常是基于 epoll (在 Linux 上) 的。
这个注册动作相当于告诉 Nginx:“请帮我盯着这个 socket,当它可以写入数据时(意味着连接已成功建立),请通知我。”协程让出 (Yield):
在注册完事件后,C 函数会**调用 coroutine.yield()**。这是最关键的一步。
yield 会暂停当前 Lua 协程的执行,并将控制权交还给 ngx_lua 模块的 C 代码。
此时,这个处理请求的 Nginx Worker 进程完全没有被阻塞。它可以去处理其他请求、处理其他已经就绪的 I/O 事件,CPU 资源被充分利用。你的那个 Lua 协程则进入“休眠”状态,等待被唤醒。事件就绪 & Nginx 唤醒:
一段时间后,TCP 连接成功建立。操作系统会通知 Nginx 的事件循环(epoll_wait 返回),告知之前注册的那个 socket fd 现在“可写”了。
(这一步之前有一些底层知识待补充)Nginx 的事件循环会调用与该事件关联的回调函数。这个回调函数是由 ngx_lua 模块在第 2 步设置好的。协程恢复 (Resume):
这个回调函数知道是哪个 Lua 协程在等待这个事件。它会调用 coroutine.resume(),唤醒之前休眠的那个协程,并将 I/O 操作的结果(成功还是失败,错误信息等)作为 resume 的参数传递回去。
Lua 协程从之前 yield 的地方继续执行,tcpsock:connect 函数现在才真正“返回”,并将结果赋值给 ok 和 err 变量。
总结一下:对于写 Lua 代码的你来说,tcpsock:connect 看起来就像一个普通的阻塞函数。但实际上,在它“阻塞”的期间,整个 Nginx 服务依然在全速运转,处理着成千上万的其他任务。Cosocket API 就像一个语法糖,让你能用同步的思维写出异步性能的代码。
与 Gevent 的比较:异同之处
Cosocket 的思想和 Gevent 非常相似。它们都致力于解决同一个问题:如何用同步的编码风格实现异步 I/O 并发。
相同点:
核心机制: 两者都依赖于“协程/微线程”(Lua Coroutine vs Gevent Greenlet)作为并发调度的基本单位。
用户体验: 两者都提供了看似阻塞的 API,让开发者无需手动管理复杂的回调函数(避免了“回调地狱”)。
底层思想: 都是通过“发起 I/O -> 注册事件 -> 让出执行权 -> 事件就绪 -> 恢复执行权”的模式来工作。
底层区别 (非常关键):

它们依靠什么来完成事件循环?这是对底层机制的终极追问。
OpenResty: 它完全依赖 Nginx 的事件循环。Nginx 的事件循环是其高性能的核心,它在底层会使用操作系统提供的最高效的 I/O 多路复用技术。
在 Linux 上是 epoll。
在 FreeBSD/macOS 上是 kqueue。
在 Windows 上是 IOCP (I/O Completion Ports)。
Nginx 会自动选择当前 OS 支持的最佳模型。所以,Cosocket 的事件驱动能力,实际上是由 Nginx 和操作系统内核共同提供的。
Gevent: 它依赖于独立的事件循环 C 库,最常见的是 libev 或 libevent。这些 C 库做的事情和 Nginx 的事件循环非常类似,它们也封装了底层的 epoll, kqueue 等系统调用,提供了一套统一的、跨平台的事件处理接口。所以,追根溯源,Gevent 最终也是依赖操作系统内核提供的 I/O 多路复用机制。
核心结论:
OpenResty 的做法是一种深度整合、天人合一的模式。Lua 代码和协程成为了 Nginx 事件驱动世界的一等公民,共享同一个强大的心脏(事件循环)。
Gevent 的做法是一种通用嵌入、自给自足的模式。它在 Python 进程这个独立的王国里,建立了一套属于自己的、完整的事件驱动系统。
如果把 ngx_lua 模块比作一块性能炸裂的顶级 CPU,那么 OpenResty 就是设计和制造了这块 CPU,并围绕它构建了一台功能完备、性能均衡的超级计算机。
简单来说,ngx_lua 模块以及基于它构建的整个 lua-resty- 非阻塞库生态系统*,就是 OpenResty 项目的核心产出和最大贡献。
第一部分:谁做了什么?OpenResty 快的根源归属
OpenResty 的快,是 Nginx、LuaJIT 和 OpenResty 自身贡献三者完美结合的成果,缺一不可。
- Nginx:提供 “骨骼与心脏”
高性能事件循环 (The Heart): Nginx 的核心是一个基于操作系统事件通知机制(如 epoll, kqueue)的、非阻塞的事件循环。这是整个架构得以运转的“心脏”。它能以极低的开销管理数以万计的并发连接。
成熟的 HTTP 服务器 (The Skeleton): Nginx 提供了稳定、高效的 HTTP 协议解析、请求/响应管理、连接管理等所有 Web 服务器的基础功能。
模块化架构: Nginx 允许第三方模块(比如 ngx_lua)深度嵌入到它的请求处理流程中,在不同的阶段挂载自己的处理逻辑。
Nginx 贡献了“快”的底层 I/O 模型。 - LuaJIT:提供 “超速大脑与轻功”
JIT (Just-In-Time) 编译器: LuaJIT 是 Lua 语言的一个超高性能实现。它的 JIT 编译器能将热点 Lua 代码编译成高效的本地机器码,使得 Lua 代码的执行速度可以接近甚至媲美 C 语言。这对于需要复杂计算和业务逻辑的场景至关重要。
原生的协程支持 (The Agility): Lua 语言原生就支持协程 (coroutine)。这是 yield 和 resume 的语言基础。没有这个,cosocket 的实现会困难得多。
FFI (Foreign Function Interface): LuaJIT 的 FFI 机制允许 Lua 代码以极高的性能直接调用 C 函数和使用 C 的数据结构,几乎没有性能损耗。ngx_lua 模块正是利用 FFI 实现了 Lua 与 Nginx (C代码) 之间的高效通信。
LuaJIT 贡献了“快”的计算能力和并发调度的语言基础。 - OpenResty:作为 “总设计师与神经系统”
OpenResty 是将 Nginx 的“心脏”和 LuaJIT 的“大脑”完美缝合在一起的“总设计师”,并为这具强大的身体打造了完整的“神经系统”。
ngx_lua_module (The Bridge): 这是 OpenResty 项目的核心创造。这个 C 模块是连接 Nginx 事件循环和 Lua 协程的桥梁。我们上一轮讨论的 cosocket API 就是由这个模块提供的。它实现了将 I/O 操作注册到 Nginx 事件循环并适时 yield/resume Lua 协程的全部魔法。
非阻塞库生态 (lua-resty-X) (The Nervous System): 只有 cosocket 这个底层 API 是不够的。开发者需要方便的工具来和 Redis, MySQL, Memcached 等后端服务通信。OpenResty 项目开发并维护了一整套 lua-resty-X 库。这些库全部基于 cosocket API 构建,确保了所有外部通信都是 100% 非阻塞的。lua-resty-mysql, lua-resty-redis 等都是 OpenResty 的官方作品。
OpenResty 贡献了将两者能力融合的关键技术(ngx_lua),并在此基础上构建了完整的、实用的、高性能的非阻塞业务开发平台(lua-resty-X 库)。
第二部分:实战拆解:lua-resty-mysql 的一次 query 操作
让我们看看 local result, err = db:query(“SELECT * FROM users WHERE id = 123”) 这行代码背后发生了什么,来体会上面讲的协作过程。
前提: 假设已经通过 resty.mysql:new() 创建了 db 对象,并通过 db:connect() 建立了连接(connect 本身也是一个非阻塞的 cosocket 操作)。
获取连接 (OpenResty 的贡献):
db:query 首先会从内部的连接池中获取一个可用的 MySQL 连接。连接池 (set_keepalive) 是 lua-resty-mysql 库提供的重要性能优化,它避免了为每个查询都重新建立 TCP 连接的巨大开销。
构建并发送请求包 (LuaJIT + OpenResty):
lua-resty-mysql 库(纯 Lua 代码)会将 SELECT 语句按照 MySQL 的通信协议格式化成一个二进制数据包。
然后它调用底层的 cosocket API:ok, err = tcpsock:send(packet)。
ngx_lua 模块接管,将这个数据包通过非阻塞的 send() 系统调用写入 socket。如果内核的发送缓冲区满了,当前协程就会 yield,并将 socket 的“可写”事件注册到 Nginx 事件循环中,等待缓冲区可用。
等待响应包 (Nginx + OpenResty):
发送成功后,代码需要等待 MySQL 服务器返回数据。lua-resty-mysql 会调用 data, err = tcpsock:receive(…)。
这是最关键的非阻塞等待点。receive 函数通过 ngx_lua 模块,将这个 socket 的“可读”事件注册到 Nginx 的事件循环中。(都是通过 ngx_lua 模块注册 IO 事件和对应的回调函数(该函数负责读写数据和resume 协程))
然后,当前 Lua 协程立刻 yield (让出),执行权返回给 Nginx。
Nginx Worker 进程此时完全空闲,可以去处理其他成百上千个并发请求。
数据到达并唤醒 (Nginx + OpenResty):
MySQL 服务器处理完查询,将结果通过网络发回。
数据到达服务器网卡,Nginx 的事件循环(epoll_wait)被唤醒,发现之前注册的那个 socket 变得“可读”。
Nginx 触发 ngx_lua 模块预设的回调,该回调 resume 之前休眠的那个 Lua 协程,并将从 socket 读到的数据块作为返回值。
解析并返回结果 (LuaJIT + OpenResty):
Lua 协程被唤醒,tcpsock:receive 函数“返回”了原始的二进制数据。
lua-resty-mysql 库(纯 Lua 代码)开始解析这个二进制数据包,将其转换成用户友好的 Lua table 格式。
最后,db:query 函数将这个 table 返回给调用它的业务代码。同时,将这个 MySQL 连接放回连接池,以备下次使用。
总结:
在这个过程中,Nginx 负责监听 socket 事件,LuaJIT 负责高效地执行协议解析和业务逻辑,而 OpenResty(通过 ngx_lua 和 lua-resty-mysql)则扮演了指挥官的角色,完美地编排了整个异步流程,让开发者感觉不到底层的复杂性,同时获得了极致的性能。
limit rate & limit conn
加入了nodelay参数之后的限速算法,到底算是哪一个“桶”,是漏桶算法还是令牌桶算法?当然还算是漏桶算法。考虑一种情况,令牌桶算法的token为耗尽时会怎么做呢?由于它有一个请求队列,所以会把接下来的请求缓存下来,缓存多少受限于队列大小。但此时缓存这些请求还有意义吗?如果server已经过载,缓存队列越来越长,RT越来越高,即使过了很久请求被处理了,对用户来说也没什么价值了。所以当token不够用时,最明智的做法就是直接拒绝用户的请求,这就成了漏桶算法,哈哈~
burst队列在哪里
只要excess < limit->burst限速模块就会返回NGX_OK,并没有把多余请求放入队列的操作,这是因为Nginx是基于timer来管理请求的,当限速模块返回NGX_OK时,调度函数会计算一个延迟处理的时间,同时把这个请求放入到共享的timer队列中(一棵按等待时间从小到大排序的红黑树)。
Nginx主要有两种限速方式:按连接数限速(ngx_http_limit_conn_module)、按请求速率限速(ngx_http_limit_req_module)。
并非所有连接都会被计数。只有当服务器正在处理请求并且已读取整个请求标头时,该连接才会被计数。
该 Lua 模块除了支持在超过并发级别阈值时立即拒绝连接之外,还支持延迟连接。
default_conn_delay是典型连接(或请求)的默认处理延迟。
该延迟作为因并发请求(或连接)过多而引入的额外延迟的基本单位
与上一种情况类似,此方法还返回第二个返回值,指示此时(包括当前请求)的并发请求数(或连接数)。第二个返回值可用于监控未调整的传入并发级别。
监控并发等级怎么做?
此类的每个实例不包含任何状态信息,但包含conn和burst 阈值。基于键的实际限制状态存储在新lua_shared_dict方法 中指定的共享内存区域中,所以限速器实例在工作进程级别之间的共享是安全的只要限速值和burst的组合不变。
即便变了也没关系,只要去更新实例的那两个关键成员变量即可。
幽灵计数器
在极端情况下,例如 nginx 工作进程在处理请求过程中崩溃,存储在共享内存区域中的计数器可能会不同步。这可能会导致灾难性的后果,例如永久地盲目拒绝所有传入连接。(请注意,标准ngx_limit_conn模块也存在此问题。)我们可能会在不久的将来为该 Lua 模块添加针对此类情况的自动保护功能。
此外,确保调用leaving首先出现在 log_by_lua处理程序代码中非常重要,以尽量减少其他log_by_luaLua 代码抛出异常并阻止leaving调用运行的机会。
两种主要的失败场景:
Worker 进程崩溃(最严重的情况)
一个请求进入,incoming() 执行成功,计数器 +1。
在处理这个请求的过程中(比如执行 proxy_pass 或者一段复杂的 Lua 代码时),这个 Nginx Worker 进程因为某种原因(比如 C 模块的 bug、内存溢出等)突然崩溃退出了。
由于进程都没了,这个请求的 log_by_lua* 阶段自然也永远不会被执行。
后果:计数器被成功地 +1,但对应的 -1 操作 (leaving()) 却人间蒸发了。这个计数器在共享内存里就产生了一个无法减少的“幽灵计数”。
1、使用外部存储(架构级解决方案)
共享内存的根本问题在于它的状态会随着 Nginx 的生命周期而持续,且缺乏 TTL (Time-To-Live) 这种自动过期机制。代价是引入了网络开销和对外部 Redis 服务的依赖。
2、实现“清道夫”后台任务(修复)
这个方案用于解决最棘手的 Worker 进程崩溃问题。resty.limit.conn 本身没有提供自动修复机制,我们需要自己实现一个“巡检员”。
思路:利用 ngx.timer.at 创建一个只在某个 Worker 进程中运行的后台定时任务,定期检查共享内存中的数据,清理那些明显“死亡”的连接计数。
但这有个难题:我们怎么知道哪个计数是“幽灵”?一个长时间保持高位的计数器可能是真实的高并发,也可能是幽灵。
改进思路:引入“租约”或“心跳”机制。 我们不能只存一个数字,需要存更丰富的信息。
改造数据结构:在共享内存中,不直接存一个 count,而是存一个 table,里面记录了每个连接的唯一 ID 和进入时间。
1 | -- shm_dict:set("some_ip", { |
incoming() 的作用是向这个 table 里插入一个新的请求记录。leaving() 则是删除对应的记录。连接数就是 table 的大小。
1 | -- 在 init_worker_by_lua* 阶段 |
不再需要一个独立的 count 变量。count 的值是动态地从这个 table 中派生出来的。count 就是这个 table 中键值对的数量。
我们为每个被监控的 key(比如一个 IP 地址)在共享内存中存储一个序列化后的 Lua table。这个 table 的键是唯一的请求 ID (ngx.var.request_id),值是请求开始的时间戳。
为了线程安全必须使用 lua-resty-lock 锁
通过序列化 + 外部锁的组合,我们既可以在共享内存中存储和操作任意复杂的表结构,又能完美地保证并发环境下的线程安全。而 count 则是通过实时计算 table 的大小得来,确保了数据的一致性。
稍微有点重
只读检查。不会真的去修改共享内存。这是一种“预检”或“试探”模式,允许你在不消耗配额的情况下,提前知道请求是否会被允许。
组合限速器
此模块可以考虑所有相关的限制器,而不会给当前请求带来任何额外的延迟。
ngx.ctx 是什么?是否是请求维度的上下文?支持多个限速器上下文吗?
1 | if lim3:is_committed() then |
这个方案虽然复杂,但非常稳健,可以有效地自动修复因崩溃导致的计数器不一致问题。
Nginx 的共享内存字典 (ngx.shared.DICT) 是一个基于红黑树实现的、高性能的 key-value 存储,不能存储嵌套表,所以要用 cjson。
resty.limit.req类的实例返回当前每秒的超额请求数(如果超过速率阈值),而resty.limit.conn类的实例返回当前并发级别。
所有具体的限制器对象必须遵循相同的粒度(通常是 NGINX 服务器实例级别,涵盖其所有工作进程)。
多限速器系统采用了类2PC的原子性实现机制,通过预执行+提交的两阶段操作,配合回滚机制,确保多个限速器的状态更新具有原子性。这种设计在保证数据一致性的同时,避免了TCC模式的复杂业务逻辑,适合限速这种相对简单的场景。
TCC的Try特征:
✅ 资源检查:检查是否超过burst限制
✅ 不实际提交:commit=false时不写入共享内存
✅ 快速失败:任一限速器检查失败立即返回
TCC的Confirm特征:
✅ 实际提交:commit=true写入共享内存
✅ 逐个确认:依次对每个限速器确认
TCC的Cancel特征:
✅ 补偿操作:uncommit减少excess值
✅ 业务回滚:恢复限速器状态
现有TCC实现的不足:
Try阶段不够纯粹:最后一个限速器在Try阶段就提交了(i == n时commit=true)
没有真正的资源预留:Try阶段只是计算,没有预留资源
Cancel操作有限:只能简单减少excess,无法完全撤销复杂状态
为什么说是TCC而不是2PC?
2PC特征(现有代码不符合):
❌ 没有全局事务协调者的Prepare/Commit投票机制
❌ 没有参与者的”准备好提交”确认过程
❌ 不是基于数据库事务的ACID特性
TCC特征(现有代码符合):
✅ 业务层面的三阶段操作
✅ Try阶段的业务检查和资源预留概念
✅ Confirm阶段的业务确认
✅ Cancel阶段的业务补偿