news 2026/11/2 20:37:05

IOCP高并发服务端:完成端口队列与SAEA复用避坑

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
IOCP高并发服务端:完成端口队列与SAEA复用避坑

简介:一套聚焦 C# 高性能并发服务端的实战示例,基于 SocketAsyncEventArgs 封装完成端口(IOCP)通讯模型,面向已掌握基础 Socket 编程、希望深入理解高并发网络架构的开发者。示例覆盖服务端日志查看、连接列表管理、文件上传下载、远程文件流传输与吞吐量协议等模块,可在 127.0.0.1 环境下压测命令交互性能与连接承载能力,最长连接数支持 65535,用于验证 IOCP 在极端压力下的表现。压缩包共 300 个文件,约 3.35MB,主体为 pas、dfm、dpk、dpr 等 Delphi/C++Builder 工程文件与 cs 源码,辅以 res、bmp、cur 界面资源、dll 依赖库以及 cfg、xml、ini 等配置项,构成可直接编译调试的完整工程结构,已有 1166 人学习。读者可从中获得一套可运行的并发通讯框架参考实现,理解 SocketAsyncEventArgs 的缓冲复用与异步回调组织方式,借鉴 log4net 日志接入与吞吐量统计协议的设计思路,并借助现成压测场景定位粘包、连接回收等常见问题。

1. 一万个连接只用八个线程:完成端口到底省掉了什么

很多人第一次接触 IOCP 时的直觉是:连接数上去了就加线程。完成端口的设计恰恰相反——它把「等 socket 数据」这件事从用户线程手里拿走,交给内核去记账,用户线程只在真有结果时被叫醒。在 C# 里写高性能并发服务端,真正决定上限的不是你 new 了多少个 Thread,而是完成队列怎么排队、重叠结构怎么保活、SocketAsyncEventArgs 怎么复用。这套机制没搞明白,压测到几千连接就会开始出现一些难以解释的现象:掉线、CPU 空转、内存阶梯式上涨;搞明白了,一万连接跑在几个线程上是可预期的,不是靠调参碰运气。下面从内核队列一路拆到托管层实现。

2. 完成队列:内核把 I/O 结果投递到哪里去了

socket 发起一次异步读,数据还没到,调用已经返回了。那结果去哪了?答案是内核里的一个队列对象——完成端口。它本质上是一个「已完成 I/O 的通知收件箱」:设备(socket 句柄)与它关联之后,每一次重叠 I/O 结束,内核就往这个收件箱里塞一个完成包,包上带着三样东西:完成键、重叠结构指针、这次操作的字节数和状态码。用户态线程调GetQueuedCompletionStatus去取,取不到就睡,取到了就干活。整个高并发 socket 模型的骨架,就是这条「投递—排队—唤醒」的链路。

理解这一点很关键:完成端口不是「回调框架」,它不自带任何业务语义,甚至不知道你读的是哪个连接。它只保证一件事——I/O 完成时,那个当初发起操作时给的指针,会原样还给你。所有连接管理、协议解析、超时控制,都是你在拿到这个指针之后自己做的事。

2.1 并发上限:为什么默认线程数等于核数

创建完成端口时有一个参数叫并发线程数(NumberOfConcurrentThreads)。传 0 表示取系统核数,这是绝大多数场景下的正确选择。这个数字约束的不是「能创建多少线程」,而是「同一时刻允许多少个线程从队列里取包并运行」。

内核的调度逻辑大致是这样:线程调用GetQueuedCompletionStatus时,如果队列非空,直接取走一个包继续跑;如果队列为空,该线程被挂到完成端口的等待队列上进入休眠。当新完成包入队,内核检查当前正在运行的关联线程数是否已达上限——没到,唤醒一个等待线程;到了,包就留在队列里,等有线程处理完上一个包回来再取。

这条规则解释了一个常见困惑:为什么几百个线程绑在同一个完成端口上,实际同时跑的永远只有核数个。多出来的线程是备用的,它们只在某个线程被阻塞时才会被放进来顶班。这也解释了另一个坑——如果你在取到包之后的回调里做了一次同步数据库查询,内核会认为这个线程「不干活了」,于是再放一个线程进来,线程数就会悄悄突破上限,上下文切换随之暴涨。这是后面避坑章节要展开讲的事。

2.2 一次 GetQueuedCompletionStatus 的完整路径

把一次取包拆开看,会清楚很多。线程调用该函数,传入完成端口句柄、一个接收完成键的变量、一个接收重叠结构指针的变量,以及超时时间。函数返回时,如果拿到了包,返回值为真;如果超时,返回值为假且重叠指针为空;如果出错,返回值为假但重叠指针非空,此时要去读状态码。

这里有个容易踩的细节:返回值假不等于失败。判断标准是重叠指针是否为空。很多早期实现把「返回 false」直接当错误处理,结果把正常超时和ERROR_OPERATION_ABORTED(连接被取消时常见,错误码 995)混在一起,日志里一片报错,其实是连接正常关闭。

取到包之后,典型处理顺序是:先看状态码,如果是取消或错误,走关闭流程;否则看传输字节数,为 0 通常意味着对端优雅关闭;最后才是把数据交给协议解析。这个顺序反了会出问题——先解析再判错,会把半截数据当完整包处理。

2.3 完成键与句柄上下文:连接对象挂在哪

完成端口允许你给每个关联的句柄绑定一个整数完成键(completion key)。监听 socket 关联时给一个特殊值,客户端 socket 关联时给连接 ID 或指针。取包时这个键会原样带出来,于是你能在 O(1) 时间内判断「这个包是新连接还是老连接的数据」。

但完成键只有一个机器字宽,装不下整个连接对象。真正的连接上下文一般靠重叠结构反推:把 OVERLAPPED 作为连接结构体的第一个字段,拿到指针后用偏移量算回外层对象地址(C 里的 CONTAINING_RECORD 宏,C# 里对应的是把重叠结构包在一个类里,用 GCHandle 或对象引用维持关系)。这个技巧在原生代码里是标准做法,在 C# 里则被SocketAsyncEventArgs封装掉了——它内部持有 USER_OBJECT 之类的位置来挂你的自定义状态。

理解这层映射关系的意义在于:完成包到达时,内核并不会替你找回「这是哪个用户的哪个连接」,它只还给你一个指针。指针到对象的还原,是你自己的责任。搞错这一层,轻则数据串到别的连接,重则访问已释放内存。

3. 重叠 I/O 与 OVERLAPPED:为什么异步操作的内核侧身份证不能丢

重叠 I/O 这个名字听起来抽象,说白了就是「发起操作时把结果写到一个你提供的结构里,操作没完成就立刻返回」。这个结构就是 OVERLAPPED,它至少有四个字段被内核使用:内部状态、内部高/低位、偏移量、事件句柄。你不需要关心前三个,但必须保证它在整个 I/O 生命周期内地址稳定、内容不被覆写。

为什么强调「地址稳定」?因为内核在发起时记下的是这个结构的指针,完成时往这个地址写状态。如果你在中途把它移动了(比如托管对象被 GC 压缩搬走),或者释放了,内核写进去的就是一块无效内存。这类问题的可怕之处在于它不是每次都崩——可能只是偶尔数据错乱,排查成本极高,属于典型的黑匣子式故障。

3.1 同步与异步的分界:WSA_IO_PENDING 不是错误

发起一次重叠接收,返回值有三种情况:成功,说明数据已经就绪且操作完成,完成包可能已经入队;失败且错误码是WSA_IO_PENDING(997),说明操作已受理但还没完成,完成包稍后到达;失败且是其他错误码,说明这次操作根本没发出去。

第三种的典型原因是缓冲区地址非法、socket 已关闭、或者上一个操作还没完成就重复投递。第二种不是错误,但很多实现会把它当错误打日志,把正常路径污染成一片红。正确做法是把它归入「继续等待」,不做任何处理。

还有一点值得说清楚:即使返回成功,完成包仍然会被投递。所以你不能在「返回成功」的分支里直接处理数据然后又等完成包,否则同一份数据会被处理两次。统一走完成包路径是更稳的写法,代码只有一条主干。

3.2 零字节接收与缓冲区投递的两种策略

原生实现里有个流传很广的技巧:投递一个缓冲区长度为 0 的接收。它的作用是「预约通知」——内核在有数据到达时立刻完成这次操作,但不拷贝任何数据,相当于告诉你「可以来读了」。这样数据只在你真正读的时候拷贝一次,省掉一次内存复制。

代价是多一次系统调用,而且逻辑上变成两段式:先收到「有数据」的通知,再发一次真正的读。在高吞吐场景下,这次额外调用的开销未必划算。托管层的SocketAsyncEventArgs走的是另一条路:直接投递带缓冲的接收,数据到达时一次性拷进你给的缓冲区,完成包里带字节数。少一次往返,代价是缓冲区必须提前备好。

两种策略没有绝对优劣,取决于你的包大小分布。小包为主、连接数极大的场景,零字节方式能显著降低常驻内存;大包为主、追求低延迟的场景,直接投递更合适。

3.3 生命周期错配:释放早了会怎样

这是重叠 I/O 里最致命的一类问题,值得单独拎出来说。假设一个连接被判定超时,代码路径直接Dispose了 socket 并释放了缓冲区,但此时内核里还有一个挂起的接收没有完成。几毫秒后完成包到达,内核把字节数写进那块已经归还给池子的缓冲区——池子可能已经把它分给了另一个连接,于是两个连接的数据互相污染。

正确的做法是引用计数:每个连接维护一个「挂起操作数」,发起接收加一,完成包处理完减一;关闭时先把 socket 关掉触发取消,等所有挂起操作的完成包都回来、计数归零,再真正释放缓冲区和连接对象。取消本身也是异步的,CancelIoEx只是发起取消,完成包仍会以错误码 995 的形式回来,必须走同一条回收路径。

注意:关闭 socket 后完成包仍然会到达,这是设计使然,不是 bug。回收逻辑必须能处理「连接已标记关闭但完成包还在路上」的中间状态。

4. SocketAsyncEventArgs 复用:托管层怎么把 GC 压力摁下去

到了 C# 这一层,SocketAsyncEventArgs(下称 SAEA)就是 OVERLAPPED 的托管封装。它同时承担三个角色:承载缓冲区、携带完成回调、作为可复用对象避免频繁分配。第三点在高并发下尤其重要——如果每个连接每次收发都 new 一个 SAEA,每秒十万次操作就是十万个短命对象,GC 会频繁进入代际回收,暂停时间直接反映为业务抖动。

SAEA 的设计意图就是「用完还回去」。它有一个Completed事件和一个SocketError属性,调用ReceiveAsync(saea)后要么同步完成(返回 false),要么挂起(返回 true)并在完成时触发事件。注意这个返回值语义和直觉相反:返回 true 表示「还在进行中」,返回 false 表示「已经完成了」。这个反直觉的设计是很多 bug 的来源,写的时候必须对照文档确认。

4.1 一个连接为什么至少要两个 SAEA

同一个 SAEA 在同一时刻只能用于一个挂起操作。如果拿它同时做接收和发送,第二次调用会直接抛异常。所以每个连接通常需要两个:一个常驻挂起用于接收,一个按需用于发送。

接收侧的做法是「循环投递」:完成回调里处理完数据,立刻用同一个 SAEA 再投一次ReceiveAsync,保持连接上永远有一个挂起的接收。这样数据到达时不需要额外的调度,直接进回调。发送侧则不同,它是由业务触发的,没有数据要发时不该占用 SAEA,所以发送用的 SAEA 通常从池子里借,发完归还。

这里有个资源账要算清楚:一万连接,每连接一个常驻接收 SAEA 加一块接收缓冲区,如果缓冲区按 4KB 算就是 40MB,可以接受;如果每连接预分配 64KB,就是 640MB,直接爆掉。缓冲区大小必须和连接规模一起考虑,不能拍脑袋。

4.2 大块缓冲池与切片

比「每连接一块缓冲」更省内存的做法是池化加切片:启动时一次性分配若干大块(比如每块 1MB),每个连接需要缓冲时从池里切一段固定大小的片出来,归还时把片标记为空闲。这样分配次数极少,内存布局连续,对 CPU 缓存也友好。

// 固定大小切片池:所有切片来自少数几块大数组,避免 LOH 碎片 public sealed class SlabPool { private readonly byte[] _slab; // 一整块大缓冲,例如 1MB private readonly int _sliceSize; // 每片大小,例如 4KB private readonly Stack<int> _free; // 空闲切片的下标 private readonly object _gate = new(); public SlabPool(int slabSize, int sliceSize) { _slab = new byte[slabSize]; _sliceSize = sliceSize; _free = new Stack<int>(slabSize / sliceSize); // 倒序压栈,保证第一次分配从下标 0 开始,内存访问局部性更好 for (int i = slabSize / sliceSize - 1; i >= 0; i--) _free.Push(i); } public ArraySegment<byte> Rent() { lock (_gate) { if (_free.Count == 0) throw new InvalidOperationException("切片池耗尽"); int idx = _free.Pop(); return new ArraySegment<byte>(_slab, idx * _sliceSize, _sliceSize); } } public void Return(ArraySegment<byte> seg) { // 归还时校验偏移对齐,防止把非本池的段还回来导致池状态错乱 int idx = seg.Offset / _sliceSize; lock (_gate) _free.Push(idx); } }

这段代码的关键点在两处。一是所有切片共享同一个底层数组,ArraySegment只记录偏移和长度,不产生新数组,因此没有额外的堆分配;二是归还时用偏移除以片大小反算下标,池子不持有任何引用,GC 压力为零。参数slabSize决定单块上限,sliceSize要和你的协议最大包长匹配——片太小会频繁触发扩容逻辑,片太大则连接数一多就浪费。

锁的粒度也值得注意。lock保护的是栈的入出,不是 I/O 本身,临界区只有几个指令,竞争不会成为瓶颈。如果连接数到十万级,可以改成每线程一个池(thread-local slab),代价是内存利用率下降,需要根据实际连接分布权衡。

4.3 发送串行化:为什么 Send 不能并发

接收可以并行,发送不行。原因是 TCP 是字节流,两次SendAsync如果同时在途,内核可能把第二段数据插到第一段前面,对端收到的就是错乱的内容。哪怕你的业务逻辑是单线程调用发送,只要有两个线程分别触发,问题就会出现。

解决办法是给每个连接配一个发送队列加一个「发送中」标志。入队时如果标志为假,就把它置真并启动发送;发送完成回调里检查队列是否还有数据,有就继续发,没有就把标志复位。整个过程中,同一时刻永远只有一个在途发送。

public sealed class SendQueue { private readonly Queue<ArraySegment<byte>> _pending = new(); private bool _sending; // 是否有在途发送 private readonly object _gate = new(); // 返回 true 表示调用方需要负责启动一次发送 public bool Enqueue(ArraySegment<byte> data) { lock (_gate) { _pending.Enqueue(data); if (_sending) return false; // 已有在途发送,等回调接力 _sending = true; return true; } } // 发送完成回调里调用,取出下一段;返回 null 表示队列已空 public ArraySegment<byte>? DequeueNext() { lock (_gate) { if (_pending.Count == 0) { _sending = false; return null; } return _pending.Dequeue(); } } }

Enqueue返回布尔值而不是直接发,是为了把 I/O 调用挪到锁外面。锁里只做队列状态变更,临界区极短,即使每秒几十万次入队也不会成为热点。DequeueNext在队列空时顺手把_sending复位,保证下一次入队能重新启动发送,这个复位必须和出队在同一把锁里完成,否则会出现「标志已复位但队列里其实还有数据」的漏发。

队列本身要有上限。无界队列在對端接收慢的时候会一直涨,最后把内存吃光。常见做法是设一个字节数阈值,超了就断开这个连接或者暂停读取上游数据,把压力反馈回去——这就是背压,最后一章会展开。

5. 避坑排查:高并发 socket 跑起来才暴露的五个问题

下面这些问题的共同点是:单元测试和几百连接的压力测试都发现不了,只有上到几千连接、跑够几个小时才会浮现。每一条按「现象 → 原因 → 解决」写,都是血泪经验换来的。

5.1 现象:内存阶梯式上涨,不回落

压测跑一段时间,进程内存每隔几分钟上一个台阶,连接断开后也不下降。

原因通常有两个。一是 SAEA 或缓冲区借出后没有在所有路径上归还——尤其是异常路径和超时路径,正常路径写了归还,catch里忘了。二是每连接预分配的缓冲区过大,一万连接乘以 64KB 就是六百多 MB,看起来像泄漏其实是常驻开销。

解决办法是把借还做成配对的结构:用try/finally保证归还,或者在连接对象的关闭方法里统一回收所有资源,只留一个出口。同时给池子加计数器,记录「借出数」和「归还数」,压测时定期打点,两个数对不上就说明有路径漏了。这个计数器比任何内存分析工具都直接。

5.2 现象:CPU 占用不高,但吞吐怎么都上不去

连接数几千,CPU 看着只有三成,QPS 却卡在一个值上不动,加大压力延迟反而涨。

原因多半是在完成回调里做了阻塞操作。写日志刷盘、同步查缓存、甚至一次Thread.Sleep,都会让内核认为当前线程不干活,于是从等待队列里再放一个线程进来。线程数一超上限,上下文切换成本上升,每个线程的有效工作时间被切碎,吞吐自然上不去。

解决思路是把回调做薄:回调里只做解包和入队,业务逻辑丢给独立的工作线程池或队列异步处理。判断标准很简单——回调里不允许出现任何可能阻塞超过一毫秒的调用。如果必须做,就把它挪出去。这一条改完之后吞吐通常会有明显改善,因为瓶颈本来就不在 I/O 上。

5.3 现象:偶发数据错乱,两个连接的数据串了

日志里看到某个连接的请求体里出现了另一个连接的字段,概率极低,重启后消失,复现困难。

两种可能。第一种是协议层没处理粘包拆包:一次接收可能拿到半个包,也可能拿到两个半包,如果直接把整个缓冲区当一条消息解析,边界就错了。第二种是 SAEA 在操作还没完成时被归还池子,又被分配给了另一个连接,两块数据写进同一片内存。

解决粘包靠长度前缀:每条消息前四个字节写长度,接收侧维护一个可增长的累积缓冲区,凑够一条才交给业务。解决 SAEA 重用靠借用计数:借出时标记占用,完成回调处理完才允许归还,中途任何异常路径都不能提前还。这两件事都属于「不写对就一定会出问题」的类型,没有侥幸空间。

5.4 现象:关闭连接时进程崩溃,或者句柄数只增不减

压测中途主动断开一批连接,进程偶尔直接退出,没有托管异常栈;或者跑一天后句柄数涨到几万。

前者是释放时机错了:socket 关掉时还有挂起的重叠操作,完成包随后到达,回调里访问了已经释放的连接对象。后者是关闭路径不完整:只关了 socket 没取消挂起操作,或者只标记了关闭状态没走回收流程。

解决要引入引用计数和统一回收。连接对象维护挂起操作数,关连接时先置关闭标志、调用取消,等计数归零再释放。取消触发的完成包错误码是 995,要当作正常路径处理,不能打进错误日志,否则日志会被淹掉。回收函数要保证幂等,重复调用不出错。

5.5 现象:短连接场景下大量端口占用、延迟抖动

服务端作为客户端去连下游时,短连接一多就报端口耗尽,或者连接建立明显变慢。

原因是每次连接关闭后,主动关闭方会进入 TIME_WAIT 状态,占着四元组一段时间。高频率短连接会把这个池子耗光。这不是代码 bug,是 TCP 的正常行为,但设计上可以规避:能用长连接就用长连接,配心跳保活;必须短连接的话,控制建连速率,或者启用连接复用。

另一个相关的小坑是 Nagle 算法。默认开启时,小包会攒着一起发,对交互式协议会引入几十毫秒的额外延迟。对延迟敏感的场景要在 socket 上关掉它,代价是网络上小包变多。

6. 线程数、缓冲区与背压:三个必须自己拍板的设计取舍

完成端口这套机制把「怎么等 I/O」解决了,剩下的三个参数没有标准答案,必须按业务拍板。

参数保守取值激进取值代价
并发线程数等于核数核数的 1.5~2 倍线程多了切换成本上升,只对含阻塞逻辑的回调有意义
每连接接收缓冲4KB 固定片按需增长到 64KB固定片省内存但大包要多次拼接,增长则连接一多就吃内存
发送队列上限256KB 每连接无上限无上限在對端慢时会把内存吃光,有上限则需要处理超限断连

线程数这一项,我的建议是先用默认值跑压测,观察两个指标:完成队列的深度和线程的上下文切换次数。队列深度长期大于零,说明消费不过来,可以考虑加线程;但如果回调里本来就没有阻塞调用,加线程通常没用,瓶颈在业务处理而不是 I/O。反过来,上下文切换次数远高于取包次数,说明线程加多了,该减。

缓冲区这一项,取决于包长分布。如果 95% 的包小于 2KB,用 4KB 固定片就够了,偶尔的大包走扩容路径;如果包长普遍在几十 KB,固定片会造成大量浪费,不如按连接实际用量动态申请。这里没有银弹,只有测量。

背压是最容易被忽略的一项。发送队列无界,等于把内存当缓冲,对端一慢就雪崩;有界但不处理超限,等于随机丢数据。正确做法是超限时暂停读取该连接的上游数据(TCP 的滑动窗口会自动把压力传回去),或者直接断开并记录。选哪个取决于业务能不能接受丢连接。

验证设计是否成立,我一般看三个数:每连接的常驻内存、完成队列的平均深度、以及在 80% 压力下连续跑两小时的内存曲线是否平直。前两个数在运行时打点就能拿到,第三个必须跑够时间,短时间压测看不出慢泄漏。

从那以后我每次写这类服务端,都强制先把「借出/归还计数器」和「每连接内存」两个指标埋进去,再写业务逻辑——因为 IOCP 的问题几乎都藏在这两个数里,等出事再回头加埋点,成本高得多。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/11/2 20:36:37

Java在工业物联网中的OPC UA实现与应用

1. 工业物联网与Java的融合背景工业物联网&#xff08;IIoT&#xff09;正在重塑传统制造业的运营模式&#xff0c;而Java凭借其跨平台特性和成熟的生态系统&#xff0c;成为实现IIoT系统的理想选择。在工业4.0时代&#xff0c;设备互联产生的数据量呈指数级增长&#xff0c;根…

作者头像 李华
网站建设 2026/11/2 20:36:28

嵌入式OSAL操作系统抽象层实战指南

1. 项目概述&#xff1a;这不是一份普通的学习笔记&#xff0c;而是一份嵌入式系统开发者的“操作系统抽象层”实战手记“OSAL-学习笔记”这六个字&#xff0c;乍看平平无奇&#xff0c;像是某位学生在课后随手记下的几行潦草心得。但如果你在嵌入式开发、无线通信协议栈移植、…

作者头像 李华
网站建设 2026/11/2 20:33:56

虚拟电厂在碳交易与需求响应中的协同优化策略

1. 虚拟电厂与碳交易的基本概念解析虚拟电厂&#xff08;Virtual Power Plant, VPP&#xff09;作为能源互联网时代的新型电力系统运营模式&#xff0c;其核心在于通过先进的信息通信技术和软件系统&#xff0c;将分布式发电、储能设备和可控负荷等分散资源聚合起来&#xff0c…

作者头像 李华
网站建设 2026/11/2 20:27:54

智表ZCELL的Canvas渲染与极速排序技术解析

1. 项目概述&#xff1a;智表ZCELL的排序性能突破去年在优化财务报表系统时&#xff0c;我遇到了一个棘手问题&#xff1a;当用户尝试对超过5万行的交易记录进行排序时&#xff0c;Excel开始明显卡顿&#xff0c;而专业数据库方案又过于笨重。直到测试了智表ZCELL&#xff0c;才…

作者头像 李华
网站建设 2026/11/2 20:25:45

动态规划解决网格路径计数问题

1. 问题背景与理解"不同路径"这个题目在编程领域通常指代一个经典的动态规划问题——网格路径计数问题。想象一个mn的网格&#xff0c;机器人位于左上角&#xff0c;每次只能向右或向下移动一步&#xff0c;问到达右下角有多少种不同的路径。这个问题看似简单&#x…

作者头像 李华
网站建设 2026/11/2 20:22:20

MySQL与PostgreSQL事务隔离级别及幻读防御机制对比

1. 事务隔离级别基础概念解析在数据库系统中&#xff0c;事务隔离级别是保证数据一致性的关键机制。SQL标准定义了四种隔离级别&#xff0c;按照隔离强度从低到高依次为&#xff1a;读未提交(READ UNCOMMITTED)、读已提交(READ COMMITTED)、可重复读(REPEATABLE READ)和串行化(…

作者头像 李华