ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C# 8.0 异步流封神之战:我用 IAsyncEnumerable 把国产数据库批量存储过程调用性能拉爆,内存直降 95%!

C# 8.0 异步流封神之战:我用 IAsyncEnumerable 把国产数据库批量存储过程调用性能拉爆,内存直降 95%!

业务里有个极变态的场景:每天凌晨,上游系统会推送 200万条性能测试日志(PerformanceTestInfo),我们需要把这些数据批量写入国产数据库。

为了复用原有的复杂校验逻辑,DBA 要求必须调用数据库的存储过程(Callable Statement) 来写入,不能用普通的 INSERT。

我一开始没当回事,写了个传统的 Task<List> 批量处理:

// ❌ 墨夶当年的“找死”代码 public async Task ProcessAsync(List<PerformanceTestInfo> logs) { foreach (var log in logs) { await ExecuteStoredProcedureAsync(log); // 逐条调用存储过程 } }

结果上线第一天凌晨,服务器直接 OOM(内存溢出)崩溃,IIS 进程池 recycle,业务中断 2 小时。

运维老哥拿着监控截图找我:“墨夶,你这程序吃内存比猪吃饲料还猛!200万条数据全在内存里排队,线程池也被你打满了!”

我盯着屏幕,冷汗直冒。

痛定思痛,我花了三天三夜,把 C# 8.0 引入的 IAsyncEnumerable(异步流) 和国产数据库 ADO.NET 驱动的底层源码扒了个底朝天,重构了整个批量执行引擎。

重构后:
内存占用:从 4GB 峰值直降到 50MB(降幅 95%+)。
吞吐量:从 1500条/秒 飙升到 8500条/秒。
线程池:稳如老狗,零饥饿。

今天,我就把这套生产级、抗高并发、防 OOM 的异步流批量调用方案,毫无保留地掏出来。

二、先搞懂:为什么 IAsyncEnumerable 是批量处理的“神仙 API”?

2.1 传统方案的“死穴”

在处理百万级数据时,传统方案只有两条路,但都是死路:
方案 代码示例 致命缺陷
一次性加载 Task<List> GetAll() 200万条数据全塞内存,直接 OOM。

同步流式 IEnumerable GetStream() 数据库 IO 是阻塞的,会卡死调用线程,导致线程池饥饿(Thread Pool Starvation)。

2.2 IAsyncEnumerable 的降维打击

💡 一句话说透:IAsyncEnumerable = 按需异步拉取 + 边拉边处理 + 绝不阻塞线程。

它允许你用 async yield return 像挤牙膏一样,一条一条(或一批一批)地从数据源异步拉取数据,消费者用 await foreach 边拉边处理。

魔性比喻:
Task<List> = 包场吃自助餐,把菜全端到你桌上,吃不完就馊了(OOM)。
IEnumerable = 服务员端菜,但他端菜的时候你在旁边干等着(阻塞线程)。
IAsyncEnumerable = 回转寿司,寿司转过来你拿一个吃一个,不耽误你刷手机(异步非阻塞),且后厨按需做(内存友好)。

三、国产数据库 ADO.NET 驱动的“假异步”陷阱(高能预警!)

在写代码前,必须揭露一个国产数据库驱动的“惊天大坑”。

很多新手用了 IAsyncEnumerable,发现性能没提升,甚至更慢了。为什么?

🚫 坑点:Sync over Async(假异步)

部分国产数据库(如某些版本的达梦、金仓早期驱动)的 ADO.NET 实现中,DbCommand.ExecuteNonQueryAsync() 底层其实是同步阻塞的!它只是在同步方法外面包了一层 Task.Run 或者直接 return Task.FromResult()。

后果:你以为你在异步 IO,其实你在疯狂消耗线程池线程!

✅ 破局之道:
升级驱动:务必使用最新版的官方 ADO.NET 驱动(如 DmProvider 最新版已修复部分假异步)。
连接池隔离:在连接字符串中开启 Async=true(如果驱动支持)。
代码层兜底:在核心循环中,结合 ConfigureAwait(false) 和合理的 Chunking(分块)策略,避免单条 IO 阻塞拖垮全局。

四、硬核代码实战:手搓生产级异步流批量执行引擎

老铁们,咖啡续上!下面这段代码是整个文章的灵魂。

我们将实现一个 BatchCallableExecutor,它接收一个 IAsyncEnumerable 数据流,按批次(Chunk)调用国产数据库的存储过程,并完美处理事务、重试和资源释放。

4.1 实体定义:性能测试信息模型

// ============================================================ // 实体类:性能测试日志模型 // 设计思想:保持轻量,避免在内存中持有大量冗余对象。 // ============================================================ public class PerformanceTestInfo { public long Id { get; set; } public string TestName { get; set; } = string.Empty; public double ResponseTimeMs { get; set; } public int StatusCode { get; set; } public DateTime ExecuteTime { get; set; } // 💡 易错点:国产数据库对大字段(如 CLOB/BLOB)处理极慢, // 尽量用 VARCHAR 截断,避免在批量存储过程中传递超大文本。 public string? ErrorMessage { get; set; } } 4.2 核心引擎:基于 IAsyncEnumerable 的流式分块执行器 using System; using System.Collections.Generic; using System.Data; using System.Data.Common; using System.Linq; using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; namespace MoDa.XinChuang.BatchEngine { /// <summary> /// 批量存储过程执行引擎 /// 💡 设计思想: /// 1. 消费 IAsyncEnumerable 实现流式处理,内存占用 O(ChunkSize)。 /// 2. 支持背压(Backpressure),生产者慢则消费者等,消费者慢则生产者等。 /// 3. 完美兼容达梦/金仓等国产数据库的 ADO.NET 驱动。 /// </summary> public class BatchCallableExecutor : IAsyncDisposable { private readonly DbConnection _connection; private readonly ILogger<BatchCallableExecutor> _logger; // ⚠️ 性能调优参数:批次大小。 // 太小:网络 RTT 开销大,存储过程调用频繁。 // 太大:内存峰值高,且单次事务过大可能导致国产库 Undo/Redo 日志爆满。 // 💡 经验值:对于包含 5-10 个字段的实体,1000~2000 是甜点。 private const int DefaultChunkSize = 1000; public BatchCallableExecutor(DbConnection connection, ILogger<BatchCallableExecutor> logger) { _connection = connection ?? throw new ArgumentNullException(nameof(connection)); _logger = logger; } /// <summary> /// 核心方法:流式消费异步流,并批量执行存储过程 /// </summary> /// <param name="dataSource">上游提供的异步数据流</param> /// <param name="storedProcedureName">存储过程名称</param> /// <param name="cancellationToken">取消令牌,支持优雅停机</param> public async Task<long> ExecuteBatchAsync( IAsyncEnumerable<PerformanceTestInfo> dataSource, string storedProcedureName, CancellationToken cancellationToken = default) { if (dataSource == null) throw new ArgumentNullException(nameof(dataSource)); long totalProcessed = 0; // 💡 关键设计:确保连接打开。 // 国产库驱动在连接未打开时调用异步方法,有时会抛出莫名其妙的 NRE。 if (_connection.State != ConnectionState.Open) { await _connection.OpenAsync(cancellationToken).ConfigureAwait(false); } // 💡 关键设计:使用 await foreach 消费异步流。 // ConfigureAwait(false) 防止死锁,特别是在 ASP.NET Core 以外的环境(如 WinForm/WPF 或旧版 ASP.NET)。 // 在 ASP.NET Core 中其实不需要,但加上是良好的底层库习惯。 await foreach (var chunk in ChunkAsync(dataSource, DefaultChunkSize, cancellationToken).ConfigureAwait(false)) { // 检查取消请求,支持优雅停机(Graceful Shutdown) cancellationToken.ThrowIfCancellationRequested(); try { // 执行当前批次的存储过程 await ExecuteChunkAsync(chunk, storedProcedureName, cancellationToken).ConfigureAwait(false); totalProcessed += chunk.Count; _logger.LogDebug("✅ 批次执行成功,本批 {Count} 条,累计 {Total} 条。", chunk.Count, totalProcessed); } catch (DbException ex) { // ⚠️ 边界处理:国产库的超时异常、死锁异常通常继承自 DbException。 // 这里可以根据 ErrorCode 做精准重试,为了简化,这里直接抛出并记录。 _logger.LogError(ex, "🚫 批次执行失败!存储过程: {SpName}, 批次大小: {Count}", storedProcedureName, chunk.Count); throw; // 业务层决定是否重试或跳过 } } return totalProcessed; } /// <summary> /// 内部方法:将异步流按指定大小分块(Chunking) /// 💡 设计思想:这是实现“背压”和“内存控制”的核心! /// 我们不一次性把流读完,而是读满一个 Chunk 就 yield 出去执行。 /// </summary> private async IAsyncEnumerable<List<PerformanceTestInfo>> ChunkAsync( IAsyncEnumerable<PerformanceTestInfo> source, int chunkSize, [EnumeratorCancellation] CancellationToken cancellationToken) { var buffer = new List<PerformanceTestInfo>(chunkSize); // 使用 GetAsyncEnumerator 手动控制迭代,以便传入 CancellationToken await using var enumerator = source.GetAsyncEnumerator(cancellationToken); while (true) { bool hasMore; try { // ⚠️ 易错点:MoveNextAsync 可能会阻塞(如果上游是假异步), // 配合 ConfigureAwait(false) 释放同步上下文。 hasMore = await enumerator.MoveNextAsync().ConfigureAwait(false); } catch (OperationCanceledException) { _logger.LogWarning("⚠️ 数据流读取被取消。"); yield break; } if (!hasMore) { // 流结束了,把缓冲区里剩下的数据吐出去 if (buffer.Count > 0) { yield return buffer; } yield break; } buffer.Add(enumerator.Current); if (buffer.Count >= chunkSize) { yield return buffer; // 💡 性能优化:复用 List 对象,避免频繁 GC。 // 注意:这里不能直接 Clear() 后 yield return buffer, // 因为 yield return 是延迟执行的,外部拿到的是引用! // 必须重新 new 一个 List。 buffer = new List<PerformanceTestInfo>(chunkSize); } } } /// <summary> /// 内部方法:执行单个 Chunk 的存储过程调用 /// 💡 设计思想: /// 国产数据库对“数组绑定(Array Binding)”支持不一。 /// 达梦支持较好的数组绑定,金仓在某些版本下支持较弱。 /// 这里采用“单连接 + 事务 + 循环调用 Prepared Command”的兼容性最强方案。 /// </summary> private async Task ExecuteChunkAsync( List<PerformanceTestInfo> chunk, string storedProcedureName, CancellationToken cancellationToken) { // 开启本地事务,保证一个 Chunk 要么全成功,要么全失败。 // ⚠️ 易错点:国产库在不开启事务时,每条语句自动提交,性能极差! await using var transaction = await _connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false); try { // 💡 核心性能优化:复用 DbCommand 对象! // 不要每次循环都 new Command,参数化查询的 Prepare 开销很大。 await using var cmd = _connection.CreateCommand(); cmd.Transaction = transaction; cmd.CommandType = CommandType.StoredProcedure; cmd.CommandText = storedProcedureName; // 预先定义参数(以达梦/金仓通用写法为例) var pId = cmd.CreateParameter(); pId.ParameterName = "p_id"; pId.DbType = DbType.Int64; cmd.Parameters.Add(pId); var pName = cmd.CreateParameter(); pName.ParameterName = "p_name"; pName.DbType = DbType.String; pName.Size = 200; cmd.Parameters.Add(pName); var pTime = cmd.CreateParameter(); pTime.ParameterName = "p_resp_time"; pTime.DbType = DbType.Double; cmd.Parameters.Add(pTime); var pStatus = cmd.CreateParameter(); pStatus.ParameterName = "p_status"; pStatus.DbType = DbType.Int32; cmd.Parameters.Add(pStatus); var pExecTime = cmd.CreateParameter(); pExecTime.ParameterName = "p_exec_time"; pExecTime.DbType = DbType.DateTime; cmd.Parameters.Add(pExecTime); var pErr = cmd.CreateParameter(); pErr.ParameterName = "p_err_msg"; pErr.DbType = DbType.String; pErr.Size = 500; cmd.Parameters.Add(pErr); // 💡 性能杀手锏:Prepare! // 告诉数据库预编译这个存储过程调用,后续只传参数值,大幅降低 CPU 和 网络开销。 await cmd.PrepareAsync(cancellationToken).ConfigureAwait(false); foreach (var item in chunk) { // 赋值参数 pId.Value = item.Id; pName.Value = item.TestName; pTime.Value = item.ResponseTimeMs; pStatus.Value = item.StatusCode; pExecTime.Value = item.ExecuteTime; // ⚠️ 边界处理:DBNull 处理,国产库驱动对 null 和 DBNull.Value 极其敏感! pErr.Value = string.IsNullOrEmpty(item.ErrorMessage) ? DBNull.Value : (object)item.ErrorMessage; // 执行! await cmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); } // 提交事务 await transaction.CommitAsync(cancellationToken).ConfigureAwait(false); } catch { // 发生异常,回滚事务 await transaction.RollbackAsync(cancellationToken).ConfigureAwait(false); throw; } } public async ValueTask DisposeAsync() { if (_connection != null) { await _connection.DisposeAsync().ConfigureAwait(false); } } } }

4.3 数据源:如何生成 IAsyncEnumerable?

有了消费端,我们还得有生产端。假设数据来自一个超大 CSV 文件,或者另一个分页 API。

// ============================================================ // 数据源:从超大 CSV 文件流式读取性能测试日志 // 💡 设计思想: // 永远不要把大文件一次性 ReadAllLines! // 用 StreamReader 逐行读取,配合 async yield return,内存占用几乎为 0。 // ============================================================ public class CsvLogStreamProvider { public async IAsyncEnumerable<PerformanceTestInfo> ReadLogsAsync( string filePath, [EnumeratorCancellation] CancellationToken cancellationToken = default) { using var reader = new StreamReader(filePath); // 跳过表头 await reader.ReadLineAsync().ConfigureAwait(false); string? line; while ((line = await reader.ReadLineAsync().ConfigureAwait(false)) != null) { cancellationToken.ThrowIfCancellationRequested(); if (string.IsNullOrWhiteSpace(line)) continue; // 简单解析 CSV(生产环境建议用 CsvHelper 库) var parts = line.Split(','); if (parts.Length < 6) continue; yield return new PerformanceTestInfo { Id = long.Parse(parts[0]), TestName = parts[1], ResponseTimeMs = double.Parse(parts[2]), StatusCode = int.Parse(parts[3]), ExecuteTime = DateTime.Parse(parts[4]), ErrorMessage = parts[5] == "NULL" ? null : parts[5] }; } } }

4.4 串联调用:一行代码搞定百万级数据

// ============================================================ // 最终调用示例(以 ASP.NET Core 后台任务为例) // ============================================================ public class DailyLogImportJob : BackgroundService { private readonly ILogger<DailyLogImportJob> _logger; private readonly IServiceProvider _serviceProvider; public DailyLogImportJob(ILogger<DailyLogImportJob> logger, IServiceProvider serviceProvider) { _logger = logger; _serviceProvider = serviceProvider; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { _logger.LogInformation("🚀 每日性能日志导入任务启动..."); // 1. 获取数据源(异步流) var provider = new CsvLogStreamProvider(); var logStream = provider.ReadLogsAsync("/data/logs/perf_20260704.csv", stoppingToken); // 2. 获取数据库连接(以达梦为例,金仓同理) // ⚠️ 易错点:必须使用 using 或 await using 确保连接归还连接池! var connString = "Server=192.168.1.100;Port=5236;Database=PERF_DB;UserId=SYSDBA;Password=YourPwd;"; await using var conn = new DmConnection(connString); // 达梦驱动 // 3. 创建执行引擎 await using var executor = new BatchCallableExecutor(conn, _logger); try { // 4. 一键执行! long total = await executor.ExecuteBatchAsync( logStream, "SP_INSERT_PERF_LOG", stoppingToken); _logger.LogInformation("🎉 任务完成!共处理 {Total} 条日志。", total); } catch (OperationCanceledException) { _logger.LogWarning("⚠️ 任务被外部取消(如服务重启)。"); } catch (Exception ex) { _logger.LogError(ex, "🚫 任务执行发生致命错误!"); } } }

五、设计思想深度拆解:为什么这么写?

上面的代码不是随便写的,每一行都暗藏玄机。咱们来拆解三个核心设计思想:

5.1 背压控制(Backpressure):让生产者等消费者

在异步流中,如果生产者(读 CSV)极快,消费者(写数据库)极慢,数据会在内存中堆积。

我们的 ChunkAsync 方法完美解决了这个问题:
当 buffer.Count < chunkSize 时,MoveNextAsync() 会向生产者请求数据。
当 buffer 满了,yield return buffer 会把控制权交给消费者(ExecuteChunkAsync)。
在消费者执行数据库 IO 期间,生产者是被挂起的(不会去读文件)!

这就形成了天然的“背压”,内存永远只维持在 ChunkSize 级别。

5.2 事务边界控制:Chunk 级事务

为什么不用全局大事务(200万条一个事务)?
Undo/Redo 日志爆炸:国产数据库(特别是达梦和 OceanBase)在大事务下,事务日志会撑爆磁盘。
锁竞争:大事务会长时间持有表锁或行锁,阻塞其他业务。

为什么不用单条事务(每条一个事务)?
性能极差:每次 Commit 都要刷盘(fsync),IO 延迟直接拖死吞吐量。

💡 甜点设计:Chunk 级事务(每 1000 条一个事务)。兼顾了性能(减少刷盘次数)和安全性(控制日志大小和锁范围)。

5.3 资源释放:IAsyncDisposable 的优雅退场

注意看 BatchCallableExecutor 实现了 IAsyncDisposable,并且使用了 await using。
在 C# 8.0 中,对于持有非托管资源(如数据库连接、网络流)的对象,必须使用 await using 而不是 using。
因为 Dispose() 是同步的,可能会阻塞线程;而 DisposeAsync() 是异步的,能平滑归还连接池。

六、性能压测对比:数据不会撒谎

我在测试环境(8核16G,达梦 DM8 单节点,NVMe SSD)跑了 100 万条 PerformanceTestInfo 数据,对比三种方案:

方案内存峰值耗时吞吐量线程池状态
传统 Task<List<T>>2.8 GB(OOM 边缘)412 秒2427 条/秒饥饿,频繁创建线程
同步 IEnumerable + 多线程850 MB285 秒3508 条/秒线程数飙升至 200+
IAsyncEnumerable(本方案)45 MB 🟢115 秒 🟢8695 条/秒 🟢稳定在 10 个左右 🟢

结论:内存降了 98%,速度提了 3.5 倍。这就是异步流的降维打击!

七、避坑指南:国产数据库批量调用的 4 个“暗坑”

代码写得再好,也怕底层驱动挖坑。以下是我踩过的血泪坑:

🚫 暗坑 1:参数类型不匹配导致隐式转换,索引失效

// ❌ 傻操作:不指定 DbType,让驱动自己猜 cmd.Parameters.Add(new DmParameter("p_name", "TestValue")); // 后果:达梦驱动可能把它当成 NVARCHAR,而表字段是 VARCHAR。 // 结果:存储过程内部的查询发生隐式转换,全表扫描! // ✅ 正确姿势:严格指定 DbType 和 Size var p = cmd.CreateParameter(); p.ParameterName = "p_name"; p.DbType = DbType.String; p.Size = 200; // 必须和表结构一致! 🚫 暗坑 2:DateTime 精度丢失 // ⚠️ 坑点:C# 的 DateTime 精度是 100ns(Ticks), // 而达梦/金仓的 TIMESTAMP 精度通常是 ms 或 us。 // 直接传 DateTime 可能导致存储过程报错或精度截断。 // ✅ 解法:在赋值前做精度对齐 pExecTime.Value = new DateTime(item.ExecuteTime.Ticks / 10000 * 10000); // 截断到毫秒 🚫 暗坑 3:连接池耗尽(Connection Pool Exhaustion) // ⚠️ 坑点:如果在 IAsyncEnumerable 的迭代中,每次 yield 都去 new 一个连接, // 瞬间就会把连接池(默认 100)抽干! // ✅ 解法:连接必须在外部创建,作为单例传入执行器! // 就像我们在 4.4 节中写的那样,await using var conn = ... 在最外层。 🚫 暗坑 4:CancellationToken 未透传 // ❌ 傻操作:只在外层判断取消 while(!token.IsCancellationRequested) { ... } // ✅ 正确姿势:把 token 透传给每一个异步方法! await reader.ReadLineAsync().ConfigureAwait(false); // 错!没传 token await reader.ReadLineAsync(token).ConfigureAwait(false); // 对! // 否则服务重启时,底层的 Socket 还在傻傻地等网络 IO,导致进程无法退出!

八、血泪总结:批量处理的“三字真言”

流、块、复。

翻译成大白话:

流:用 IAsyncEnumerable 让数据流动起来,坚决不一次性加载到内存。
块:按 Chunk 分块处理,控制事务大小,实现背压,保护数据库和内存。
复:复用 DbCommand、复用 DbConnection、复用参数对象,把对象创建的 GC 开销压到最低。

九、写在最后

说实话,从 SQL Server 迁到国产数据库,最痛苦的不是 SQL 语法的差异,而是底层驱动和生态工具的成熟度。

很多在 SQL Server / MySQL 下理所当然的异步操作,在国产库下可能会踩到“假异步”的坑。

但只要我们掌握了 IAsyncEnumerable 这种语言级别的流式处理利器,配合严谨的工程实践(分块、事务控制、资源释放),就能在信创迁移的深水区里游刃有余。

“不要抱怨驱动不够好,要用架构设计去兜底。”

这句话,我贴在了工位上。每天看一眼,省得又犯傻。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进