SqlSugarClient线程安全陷阱:多线程并发下的连接占用与解决方案

发布时间:2026/10/17 7:53:03
SqlSugarClient线程安全陷阱:多线程并发下的连接占用与解决方案 并行查询一上来服务端直接抛“连接已被占用”查了好几天索引、慢SQL、连接池配置最后才发现问题根本不在数据库而在 SqlSugarClient 自己身上。这不是冷门问题.NET 项目里只要用 SqlSugar 且并发量一上来几乎都会撞上这面墙。我把 SqlSugarClient 在多线程下的线程安全陷阱完整拆一遍再给出几种可以直接落地的解决方案和排查套路。1. 先拆开 SqlSugarClient它到底藏了多少共享状态1.1 一个 Client 不只是一个“连接”先纠正一个常见误解SqlSugarClient不是传统意义上的无状态工具类它更像一个“数据库操作上下文”。实例内部通常持有一个核心DbConnection对象同时还带着当前正在执行的命令对象、参数集合、事务状态、AOP 回调、实体映射缓存和查询条件拼装的临时状态。这些状态在单线程下没有任何问题但一旦进入多线程就成了一个个临界区。把想象力打开一点SqlSugarClient就像一位厨师。一个厨师同时服务几十桌客人菜谱是同一本锅只有一口。客人 A 点鱼香肉丝客人 B 点宫保鸡丁厨师刚切完肉丝就被 B 喊走去切鸡丁最后端上桌的菜大概率是混杂的。多线程共用SqlSugarClient就是这种场景查询条件、参数、命令、连接全混在一个实例里。线程 A 刚拼好 SQL 准备执行线程 B 把参数对象改了A 实际拿到的就是 B 的查询结果。这类问题表现非常随机可能一会儿正常一会儿查出脏数据一会儿又报连接错误而且大概率在低并发压测时测不出来。1.2 第一次并发踩踏现场典型的翻车代码最经典的翻车写法是这样的// 一个静态全局单例全站共享 private static readonly SqlSugarClient Db new SqlSugarClient(new ConnectionConfig { ConnectionString Server...;Database...;Uid...;Pwd...;, DbType DbType.SqlServer }); // 并行查询一批订单 Parallel.ForEach(orderIds, id { var order Db.QueryableOrder().Where(o o.Id id).First(); Console.WriteLine(order?.Id); });这段代码在并发量低的时候可能跑很久都不报错。因为数据库操作很快线程 A 执行完释放连接线程 B 才拿到来用刚好错开。可一旦两个线程同时进入查询方法底层同一个DbConnection会被同时下发两条命令通常就会报类似这样的异常SqlSugarException: connection is already occupied ...如果是底层 ADO.NET 的驱动直接接手还可能出现There is already an open DataReader associated with this Command which must be closed first.这两个异常已经非常能说明问题同一个连接对象在同一时间被多个执行流使用数据库驱动根本扛不住。2. 高频踩坑场景你是哪种翻车姿势2.1 静态全局单例 Parallel 并发我见过不少项目把SqlSugarClient定义成public static readonly的全局单例然后在服务层到处用。这种做法在小型内部系统、管理后台里可能几年不炸因为并发低到可以忽略。但只要哪天对外提供服务、或接入了消息队列的批量处理并发请求一多异常立刻爆发。而且由于问题是“概率性”的往往很难复现容易让团队浪费大量时间排查 SQL、数据库锁、连接池。这里要特别强调一个迷惑点不是并发就一定会报错。SqlSugarClient内部很多查询是先拼 SQL、再执行、最后关闭连接整个过程只有几毫秒。两个线程在毫秒级窗口内踩踏的概率本来就低所以低并发下表现是“看起来正常”。但一旦每个请求的操作时间变长比如慢 SQL、大表查询、事务包裹重叠窗口变大异常就会显著增加。2.2 DI 容器里 AddSingleton 注册 ClientASP.NET Core 项目里另一个高频问题出在依赖注入注册方式上。// 错误示范 services.AddSingletonISqlSugarClient(new SqlSugarClient(new ConnectionConfig { ConnectionString ..., DbType DbType.SqlServer, IsAutoCloseConnection true }));AddSingleton意味着容器里只有唯一实例所有请求注入的都是同一个SqlSugarClient。这和前面的静态单例本质上没有区别只是换了一个容器来管理生命周期。更隐蔽的是有些人用了AddSingletonSqlSugarClient短期压测没问题因为IsAutoCloseConnection true让每次操作后连接都关了。但连接关闭不等于线程安全同一个实例内部的查询上下文、事务状态、命令状态依然共享。2.3 手动 new Client 但从不释放还有一种情况是没有用 DI而是每次请求手动new SqlSugarClient(...)。这个本身没问题前提是操作完要释放并且要开启IsAutoCloseConnection。但我在实际项目里见过不少代码只 new 不 dispose也不开启IsAutoCloseConnection true。这时候问题会变成每来一个请求就 new 一个 Client 并保持一个数据库连接数秒甚至更久数据库连接池很快被打满后续请求全部排队等连接表现为服务整体变慢最后连接超时。// 错误示范new 了不释放且连接长期持有 public Order GetOrder(long id) { var client new SqlSugarClient(new ConnectionConfig { ConnectionString ..., DbType DbType.SqlServer }); return client.QueryableOrder().Where(o o.Id id).First(); }这种写法在并发低时不会立刻暴露因为 Client 被局部变量持有方法结束后虽然没显式调用Dispose但最终会被 GC 回收连接会延迟释放。不过一旦并发上来连接池一直被占用情况就非常糟糕。2.4 在 Task 里共享同一个 Client还有一种比较隐蔽的用法数据上下文通过构造函数注入Controller 里又开Task.Run或Parallel.ForEach执行多个子任务直接共享注入进来的同一个实例。public async TaskIActionResult Batch() { // orders 来自同一个作用域注入的 ISqlSugarClient await Task.WhenAll(ids.Select(async id { var order await _db.QueryableOrder().Where(o o.Id id).FirstAsync(); _ order?.Id; })); return Ok(); }这种情况哪怕_db是 Scoped 注册也只代表“同一个请求内共享”。如果请求内部自行并发那等于还是在多个线程里共享同一个 Client只是把踩踏范围缩小到了单个请求内。并发子任务一多照样会出问题。3. 正确姿势落地三种可上线的多线程方案3.1 方案一SqlSugarScope 全局单例官方推荐的省心方案SqlSugar 官方提供了一种专门解决这个问题的类型SqlSugarScope。它同样实现了ISqlSugarClient但从设计上就面向多线程场景。注册方式services.AddSingletonISqlSugarClient(sp { return new SqlSugarScope(new ConnectionConfig { ConnectionString Server...;Database...;Uid...;Pwd...;, DbType DbType.SqlServer, IsAutoCloseConnection true, MoreSettings new ConnMoreSettings { // 可按需开启其他配置 IsAutoRemoveDataCache true } }); });看到没还是AddSingleton但 Client 类型换成了SqlSugarScope。它内部通过AsyncLocal为每个异步控制流维护一份独立的SqlSugarClient实例外部所有线程拿着同一个ISqlSugarClient引用实际访问到的却是各自上下文里的独立实例。可以这样理解AsyncLocal每个异步调用链都背着一个隐形背包背包里装着自己的SqlSugarClient。同一个异步流程内await来回切换背包不会丢不同线程、不同异步流程之间各拿各的包互不干扰。这样既保证了线程隔离又不需要频繁创建和销毁底层 Client。高并发 Web 服务、后台任务、长期运行的服务进程都适合这个方案。实际操作中需要注意几点接口层面全部面向ISqlSugarClient。SqlSugarScope和SqlSugarClient的公开方法基本一致所以业务代码几乎零改动只需要把注入类型换掉。AOP 回调仍然是共享的。SqlSugarScope虽然是单例但你在初始化时挂的Aop.OnLogExecuting、Aop.OnError这些回调本身就是同一个委托。如果回调里写了共享集合、静态变量多线程下依然有竞争问题需要自己保证回调逻辑线程安全。不要在超长生命周期任务里一直挂着 Scope 不动。SqlSugarScope为每个异步流创建底层实例后会跟随该异步流存活。如果一个 Task 长期存在相当于有一个 Client 一直被留着底层连接也会一直被持有。3.2 方案二AddScoped 按请求隔离传统 Web 项目最稳如果项目结构比较简单都是请求-响应模式没有大量后台任务那用AddScoped也是完全正确且不容易出错的方案。services.AddScopedISqlSugarClient(sp { return new SqlSugarClient(new ConnectionConfig { ConnectionString ..., DbType DbType.SqlServer, IsAutoCloseConnection true }); });每个请求第一次注入时创建一个SqlSugarClient请求结束由 DI 容器自动调用Dispose。并发请求之间天然隔离不存在共享状态问题。这个方案的优点是简单、直观、符合依赖注入的默认心智模型缺点也明显如果请求内部自己用Task.Run并发依然会踩踏。因此在使用时需要和团队成员约定清楚不要在一个请求内部去开多个并行子任务操作同一个注入的 Client。另外如果项目里大量使用后台轮询任务、消息队列消费、控制台程序没有“请求作用域”的概念这时候再用AddScoped就比较别扭更适合SqlSugarScope。3.3 方案三并行任务中创建独立实例需要手动管理生命周期对于明确的并行批处理场景比如后台定时任务要同时处理 100 个文件每个文件独立操作数据库那可以用每个任务独立创建 Client 的方式await Task.WhenAll(fileIds.Select(async fileId { using var client new SqlSugarClient(new ConnectionConfig { ConnectionString ..., DbType DbType.SqlServer, IsAutoCloseConnection true }); var record await client.QueryableFileRecord() .Where(f f.Id fileId) .FirstAsync(); // 业务处理... }));每个任务都有自己的独立 Client互不干扰。using确保任务结束后释放。不过这种方式有一个性能注意点底层连接池是按连接串维度复用的频繁创建 Client 本身不算特别昂贵的操作但如果任务数量很大连接池的分配和回收压力会上升。更推荐的做法是直接用SqlSugarScope单例然后在任务里正常使用既隔离又高效。4. 坑边实录排查线程安全问题的几个硬办法4.1 看异常信息快速定位问题类型多线程下的异常有时候会被吞掉有时候会层层包装但只要方向对了就能快速判断。下面是我在实践里整理的异常速查表异常现象根因处理方向运行时报连接被占用同一个 DbConnection 被多线程并发使用换 SqlSugarScope 或每线程独立 Client报 DataReader 未关闭连接对象同时执行多条命令同上并检查是否有未释放的查询偶发数据串号、结果错误查询上下文、参数被其他线程覆盖线程隔离避免共享 Client事务互相干扰、随机回滚事务状态被多线程切换事务不要跨线程用 Scope 隔离连接池耗尽、超时Client 未释放、连接长期占用开启 IsAutoCloseConnection 确保 Dispose需要说明的是异常信息不一定每次都一样。低并发下可能毫无异常高并发下才冒出 DataReader 相关错误所以如果出现“间歇性数据库异常”第一时间要怀疑 Client 的共享方式而不是先去调数据库。4.2 用日志和连接池状态验证如果项目里已经挂了 AOP 日志比如Aop.OnLogExecuting可以直接在这里打印线程 ID 和 SQLclient.Aop.OnLogExecuting (sql, parameters) { Console.WriteLine($Thread {Environment.CurrentManagedThreadId}: {sql}); };当并发请求进来会看到多个不同线程 ID 同时往同一个输出流里打日志。如果这些日志对应的 SQL 经常交错、或者同一个请求里出现其他请求的 SQL基本可以断定是共享实例导致的上下文污染。另一个硬核验证手段是查数据库侧连接SELECT DB_NAME(dbid) AS DatabaseName, COUNT(*) AS ConnectionCount, loginame FROM sys.sysprocesses GROUP BY dbid, loginame ORDER BY ConnectionCount DESC;如果应用侧某台服务器的活跃连接数异常高且一直不回落配合代码里 Client 的生命周期检查很容易定位到连接泄漏或未释放的问题。4.3 事务和多线程的坑位最多优先排查多线程共享 SqlSugarClient 时事务是最容易出问题的重灾区。典型场景是线程 A 开启事务向订单表插入数据线程 B 也在同一实例上开启事务。A 的BeginTran被 B 覆盖或者 A 提交的时候把 B 的事务状态一起处理了结果就是随机回滚、数据丢失、死锁。正确的做法是事务操作必须限制在同一个异步流内完成并且优先使用SqlSugar提供的事务包装方法而不是手动跨线程管理事务对象。await _db.UseTranAsync(async () { await _db.Insertable(order).ExecuteCommandAsync(); // 业务校验失败时抛异常事务自动回滚 if (order.Amount 0) throw new Exception(金额不合法); });UseTranAsync会把事务控制在当前调用链的上下文内配合SqlSugarScope的异步流隔离能避免跨线程事务互相覆盖的问题。如果业务代码里直接用了client.Ado.BeginTran()并且和并发任务混在一起我建议尽快改造。5. 避坑清单与选型建议5.1 多线程下使用 SqlSugar 的五条军规根据我踩过和看过的坑整理了一份合规清单直接贴在项目文档里当约定绝不共享同一个 SqlSugarClient 实例做并发操作。不管静态、单例、还是注入到多个服务里只要多线程执行就是风险。全局并发优先选用 SqlSugarScope AddSingleton。它内部做了异步流隔离是最接近“既能复用、又线程安全”的方案。必须开启 IsAutoCloseConnection true。这是连接及时回池的基本保障尤其在频繁创建独立 Client 时。事务、异步任务都保持在同一个上下文内。不要在一个 Task 里开启事务跑到另一个 Task 里去提交。AOP 回调里不要直接写共享集合。比如把 SQL 日志统一收集到一个线程安全的队列里然后再由单独消费者处理而不是直接往静态 List 里 Add。5.2 不同项目形态下的选型建议项目形态推荐方案原因ASP.NET Core Web API常规请求响应AddScoped SqlSugarClient 或 SqlSugarScope请求天然隔离简单直接高并发对外服务、网关、微服务AddSingleton SqlSugarScope支持高并发性能和隔离兼顾后台任务、消息队列消费、控制台程序AddSingleton SqlSugarScope没有请求作用域Scope 模型更合适批处理、并行跑大量任务每个任务独立 Client 或 SqlSugarScope避免上下文污染也能控制连接池压力迁移老项目历史代码用全局单例直接替换为 SqlSugarScope接口一致改动量小风险最低这里再补一个个人经验很多人习惯在 Controller 的构造函数里直接注入ISqlSugarClient这本身没问题。但如果项目里已经开始引入类似UnitOfWork的思想建议把ISqlSugarClient的注册统一放到一个扩展方法里并强制规范团队成员通过容器获取实例防止某些人为了“省事”直接new SqlSugarClient到处飞。5.3 如果已经出了线上事故按这个顺序排查真遇到线上并发异常先别慌我一般按这个顺序处理先看异常日志确认是不是连接占用、DataReader、事务回滚这几种多线程经典异常。找出SqlSugarClient的注册方式看是不是AddSingleton或静态单例。如果是先把实现替换成SqlSugarScope做一轮小流量验证。再把IsAutoCloseConnection确认打开检查所有手动 new 的地方是否有using或Dispose。最后检查 AOP 回调里有没有写共享集合比如往static List里塞日志。这套排查流程走完大概率能找到问题根源。数据库层面的表锁、慢 SQL 当然也可能引发并发问题但和 ORM 上下文共享引发的随机异常相比前者错误信息更稳定、更容易复现。结尾我个人在多个项目里把SqlSugarClient从全局单例切到SqlSugarScope之后线上因为“连接占用”和“DataReader 冲突”导致的报错率基本归零而且业务代码改动量非常小。关键在于理解SqlSugarScope到底隔离了什么它隔离的是每个异步流底层的 Client 实例而不是把所有线程安全责任都包揽下来。AOP 回调、共享静态数据、跨线程事务这类问题依然要靠自己的代码习惯来控制。最后分享一个小技巧升级到SqlSugarScope后保留一段时间的 AOP 日志观察是否有异常线程交叉。如果日志里同一个请求的 SQL 不再和其他请求穿插那说明线程隔离已经生效如果还出现串号、错乱优先检查是不是业务代码里自己又额外缓存了某个SqlSugarClient实例。多线程 ORM 的坑大多不在框架而在对象生命周期的边界上边界画对了问题自然就没了。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询