☰
SqlSugarClient多线程不安全:状态污染原理与SqlSugarScope解决方案
2026/10/17 5:09:38 网站建设 项目流程

上周排查一个偶发异常,期间我几乎把日志翻出花来。一批后台统计数据任务,单测里跑得全绿,部署到服务上却每隔十几分钟就冒一次Connection is already open,偶尔还出现几条订单记录“张冠李戴”。更折磨人的是,代码重试一遍又好了,看日志完全找不到规律。最后真正定位到根因,才发现是极其常见、但在官方文档里不起眼的一句话:SqlSugarClient 本身不是线程安全的,而我在多线程任务里共享了同一个实例。

这篇文章就把这个坑完整拆开,讲讲 SqlSugarClient 在多线程下到底会踩哪些陷阱、为什么偶发、怎么定位,以及最终正确的工程化做法。无论你是在写 Web API、后台 Job,还是批量任务,只要能跟这块沾上边,都建议先看完再动手。

1. 陷阱为何难察觉:SqlSugarClient 本质是一个“带状态的会话”

1.1 Client 实例到底藏了哪些可变状态

很多同学把 SqlSugarClient 当成 Dapper 一样的轻量连接器来用,习惯在代码里写一个静态全局变量:

public static SqlSugarClient Db = new SqlSugarClient(new ConnectionConfig() { ConnectionString = "your connection string", DbType = DbType.SqlServer, IsAutoCloseConnection = true });

项目小、并发低的时候,这套代码确实跑得很顺。可一旦并发上去,问题就来了。要理解这个问题,必须搞清楚SqlSugarClient实例内部塞了多少状态:

  • Context:当前上下文信息、数据表映射缓存、查询表达式缓存;
  • Ado:当前 ADO.NET 连接对象、Command、事务对象;
  • CurrentConnectionConfig:当前连接配置,包括连接字符串、数据库类型、是否自动关闭连接;
  • AOP 事件:OnLogExecuting、OnError等事件订阅状态;
  • 全局过滤器、查询过滤器、租户隔离器等运行时条件集合;
  • 临时表、缓存的DataReader、延迟查询等执行期中间状态。

换句话说,SqlSugarClient不是一个“无状态连接工具”,而更像一个正在持续对话的会话对象。它负责在多次查询之间维持数据库连接上下文、事务状态和筛选条件。

1.2 为什么“只读查询”也会翻车

很多人一开始都会说:“我只是查数据,又没改东西,怎么会线程不安全?”

实际上,只读查询在并发下同样会出问题。比如线程 A 正在执行一个查询,内部刚打开连接、设置了查询参数,线程 B 也在同一时间对这个实例发起查询。两个线程互相干扰的是实例内的连接状态和当前上下文。你可能看到的现象是:

  • 偶发的Connection is already open异常;
  • 查询超时或连接被占用;
  • 返回的数据行数和预期不符;
  • 某些列的数据串到了别的查询里;
  • 同一个查询第一次正常、第二次报错。

最麻烦的是这些异常不固定,因为多线程调度是随机的,能不能踩中完全靠“缘分”。这也就是为什么单测环境很难复现,一上生产就三天两头报警。

用生活里的例子好理解:你手里只有一支笔和一张纸,两个同事同时在桌上跟你借这支笔写内容,写着写着就会互相覆盖。SqlSugarClient 被多线程共享,本质上就是这支笔被抢着用。

2. 三种最常见的“线程安全翻车现场”

2.1 并行任务中的查询串线

先看第一个高频翻车场景:后台统计任务。

我当时的代码大致长这样:

var orderRepository = new OrderRepository(); var userIds = GetUserIds(); Parallel.ForEach(userIds, new ParallelOptions { MaxDegreeOfParallelism = 8 }, userId => { var list = orderRepository.GetUserOrders(userId); var amount = list.Sum(o => o.Amount); statistics.Add(userId, amount); });

而OrderRepository内部是同一个静态 SqlSugarClient 实例:

public class OrderRepository { public List<Order> GetUserOrders(int userId) { return StaticDb.Db.Queryable<Order>() .Where(o => o.UserId == userId) .ToList(); } }

这个写法的核心隐患在于:Parallel.ForEach会让 8 个线程同时进入GetUserOrders,所有线程都在访问同一个StaticDb.Db。一开始可能只是偶发异常,扛过一两次后会演变成统计数据错乱——某个用户的订单混进了另一个用户的数据,某些订单被重复统计。

我当时查了很久,最后用ThreadId + InstanceId打日志才看清:多个线程确实在同一个实例上并发执行查询,SqlSugarClient内部保存的查询信息被后进入的线程覆盖了。

2.2 事务与多线程混用

第二个场景更隐蔽:涉及事务。

有人会在并行任务里这样写:

Task.Run(() => { var tran = StaticDb.Db.Ado.BeginTran(); // 执行一批写入 StaticDb.Db.Ado.CommitTran(); }); Task.Run(() => { var tran2 = StaticDb.Db.Ado.BeginTran(); // 另一批写入 StaticDb.Db.Ado.CommitTran(); });

看起来两个任务各开各的事务,好像互不干扰。但实际上,这两个Task可能被调度到不同线程,而它们操作的却是同一个 SqlSugarClient 实例。这样就会出现:

  • 事务 A 还没提交,事务 B 就开始了;
  • 事务 B 的CommitTran把事务 A 的连接状态也搞乱了;
  • 出现 “事务已存在” 或者 “连接未打开” 的异常。

更麻烦的是,由于事务之间共享了同一个底层连接对象,你无法判断当前连接上的事务到底是哪一个。这种问题一旦出现,数据一致性基本得不到保障。

2.3 异步方法不等待导致的连接早关

第三个场景出现在异步代码里。

很多同学习惯这样写:

private static async Task BadAsync(int userId) { var task = StaticDb.Db.Queryable<Order>() .Where(o => o.UserId == userId) .ToListAsync(); // 没 await,立刻去做别的事 DoSomethingElse(); var orders = await task; }

看上去没什么问题,但如果多个异步任务是并发的,且共享同一个SqlSugarClient实例,事情就变复杂了:

  • 线程 A 发起了ToListAsync(),此时连接未关闭;
  • 线程 B 在这个窗口期也调用了查询,SqlSugarClient 内部可能复用同一个连接;
  • 线程 A 的异步结果回来时,连接已经被线程 B 使用了,数据自然对不上。

正确的做法是await操作完整串行化在同一个异步流中,或者干脆为每个任务使用独立实例。

3. 一次完整排查:从偶发异常到根因定位

3.1 先把问题稳定复现出来

这类问题想修,第一步不是直接改代码,而是把它稳定复现出来。我当时写了一个最小化的控制台程序:

static void Main() { var tasks = new List<Task>(); for (int i = 0; i < 100; i++) { int userId = i % 10; tasks.Add(Task.Run(() => { var list = StaticDb.Db.Queryable<Order>() .Where(o => o.UserId == userId) .ToList(); Console.WriteLine($"userId={userId}, count={list.Count}, thread={Thread.CurrentThread.ManagedThreadId}"); })); } Task.WaitAll(tasks.ToArray()); Console.ReadKey(); }

用 100 个 Task 并发查询同一个订单表,结果跑完看到的输出里确实出现了偶发的count异常和Connection is already open错误。这说明问题不是偶发玄学,而是并发量到达一定程度必然出现的状态竞争。

3.2 对比实验:换实例后现象立刻消失

为了确认“共享实例”是否是根因,我又做了一组对比实验:

static void TestByNewInstance() { var tasks = new List<Task>(); for (int i = 0; i < 100; i++) { int userId = i % 10; tasks.Add(Task.Run(() => { using var db = new SqlSugarClient(new ConnectionConfig { ConnectionString = "your connection string", DbType = DbType.SqlServer, IsAutoCloseConnection = true }); var list = db.Queryable<Order>() .Where(o => o.UserId == userId) .ToList(); Console.WriteLine($"userId={userId}, count={list.Count}, thread={Thread.CurrentThread.ManagedThreadId}"); })); } Task.WaitAll(tasks.ToArray()); }

结果很明显:换成每个线程自己 new 一个实例后,同样的并发量下异常完全消失了。

3.3 根因定位:实例状态被并发抢占

到这步答案已经浮出水面:SqlSugarClient 实例内部有可变状态,且在实现上并没有做线程安全保护。多线程并发访问同一个实例时,状态会被相互覆盖。官方文档也明确指出SqlSugarClient是线程不安全的,推荐使用SqlSugarScope。

到这里再看一开始的偶发异常就很好解释了:

  • 单线程下,连接状态和上下文总是干净的,所以全绿;
  • 多线程下,谁先抢到实例、谁后抢到实例,完全由调度器决定;
  • 状态被覆盖后,可能出现连接占用的异常,也可能出现查询条件被替换后的“数据错乱”;
  • 重试会建立新的连接状态,所以看起来又恢复了。

4. 正确替代方案:SqlSugarScope 的工作原理与正确用法

4.1 SqlSugarScope 为什么能应对多线程

官方给的标准答案是:多线程下使用SqlSugarScope替代SqlSugarClient。

看名字容易误会成“SqlSugarClient 的包装类”,但它的核心价值在于内部通过上下文隔离,让不同线程在使用同一个配置的同时,持有各自的执行上下文。也就是说,配置可以共用,但连接状态和查询上下文互不污染。

你在依赖注入容器里注册单例也没问题:

services.AddSingleton<ISqlSugarClient>(sp => { return new SqlSugarScope(new ConnectionConfig() { ConnectionString = "your connection string", DbType = DbType.SqlServer, IsAutoCloseConnection = true }); });

这样注册后,整个进程里只有一个SqlSugarScope实例,但每个线程/每个异步流程内部拿到的执行上下文是独立的,查询之间不会互相覆盖状态。

4.2 自己 new 实例的兜底做法

如果项目里没接依赖注入,或者就是纯控制台/后台任务场景,也可以直接在每个任务里new SqlSugarClient。

这里有个细节要注意:new操作本身有少量性能开销,主要体现在初始化配置、装载表映射缓存。不过这些缓存很多是进程级的,并不会真的每个实例都去数据库查元数据,所以多数情况下性能开销可以接受。

如果你的并行任务量非常大,更推荐做法是:先创建少量 SqlSugarScope,再按任务粒度复用,而不是无脑为每一行数据都 new 一个实例。因为实例的创建属于轻量操作,但过于频繁也会带来系统资源浪费。

我自己实际项目中的选择是:

  • Web 请求场景:使用AddSingleton<ISqlSugarClient>(SqlSugarScope);
  • 纯并行任务场景:直接在每个Task或Parallel.ForEach内部 new 一个SqlSugarClient;
  • 混合场景:为每个业务模块单独建一个SqlSugarScope,模块内串行处理。

4.3 事务在并行中怎么设计才稳妥

多线程里最忌讳“跨线程共享事务对象”。如果你要在后台并行执行一批写入,推荐两种姿势:

一种是每个并行任务内部自己开事务,自己提交:

Parallel.ForEach(dataList, item => { using var db = new SqlSugarClient(Config); try { db.Ado.BeginTran(); // 写入逻辑 db.Ado.CommitTran(); } catch { db.Ado.RollbackTran(); throw; } });

另一种是把写入任务交给专门的串行通道,队列逐个执行。这样事务归属清晰,不会发生两个事务在同一个实例上互相打架的情况。

请切记:SqlSugarScope的线程安全不等于“事务可以跨线程共享”。事务依然要绑定到同一个执行上下文里,提交和回滚都必须在同一上下文中完成。

5. 即便用了 Scope 也会踩雷的边界场景

5.1 全局过滤器在并发下被动态修改

SqlSugarScope解决了连接状态隔离的问题,但如果你在运行时动态改了全局过滤器,那这层隔离就不一定兜得住。

比如某些项目会在过滤器里动态设置当前用户下的数据权限:

Db.QueryFilter.AddTableFilter<IEntity>(it => it.UserId == currentUserId);

如果currentUserId是一个静态可变字段,并发请求下就会出现用户 A 查到了用户 B 数据的问题。因为过滤器是全局的,它和应用当前登录用户的变量绑定后,等于把“当前用户”状态放进了共享区。

这类问题不要靠 SqlSugarScope 解决,正确做法是:

  • 过滤器中使用请求上下文中取到的数据,而不是共享静态变量;
  • 或者把权限过滤写在查询条件里,避免使用全局动态过滤器;
  • 如果一定要用过滤器,请保证过滤条件本身是线程安全的(比如放在AsyncLocal中)。

5.2 多库切换的并发冲突

项目里用到多库也是常见场景。有人习惯写完查询后动态切换数据库:

Db.ChangeDatabase("db2"); var list = Db.Queryable<Order>().ToList(); Db.ChangeDatabase("db1");

单线程下这样没问题。但两个并发线程同时调用同一个Db时,ChangeDatabase改的是当前实例的数据库配置,必定会互相覆盖。常见症状是:本来该查订单库的代码,查到了用户库,或者直接抛“数据库不存在”的错误。

SqlSugarScope同样需要留意这一点,因为 SqlSugarScope 针对的是“同一连接配置”的上下文隔离,而不是并发时的多数据库隔离。多库切换场景更稳妥的方案是:

  • 每个库配置单独的作用域实例;
  • 或查询时通过As/ 显式条件指定库,而不是依赖可变的ChangeDatabase状态;
  • 再或者为每个库创建一个独立的SqlSugarClient,不做运行时切换。

5.3 异步 + 阻塞导致的死锁表现

最后一个边界问题不属于严格意义的状态争抢,但和线程切换关系很大:

var task = db.Queryable<Order>().ToListAsync(); var result = task.Result; // 阻塞等待

在同步上下文里使用.Result或.Wait()容易引发死锁。现象是任务卡住不返回,超时后才报错。这个过程里如果你还重复使用了同一个 SqlSugarScope 实例,后续任务也会被连带卡住,看起来像是线程安全问题。

我的经验是:异步代码统一用async/await,后台任务里尽量用Task.Run配合await,绝对不要用同步方式去等异步结果。

6. 我的最终建议与最小实践清单

经过这一轮折腾,我现在面对 SqlSugar 并发问题的处理习惯基本固定成下面这套流程:

  • 排查并发异常时,先看代码里是否存在共享的 SqlSugarClient 静态字段/单例字段;
  • 不确定就先做“换独立实例”的对比实验,能稳定消除异常就基本锁定根因;
  • 日常新项目直接使用 SqlSugarScope 配合依赖注入注册,不使用裸的静态 SqlSugarClient;
  • 并行任务内部保持实例的独立生命周期,任务结束即释放;
  • 不跨线程共享事务、DataReader、临时表、动态过滤器这类带上下文的资源;
  • 动态修改全局过滤器和多库切换都当作“高风险操作”处理,能避免就避免。

最后再分享一个小技巧:如果你已经在线上环境,没法随便改代码,可以先调整并行度来降低踩中概率,但那只适用于应急。真正的修法还是把实例共享问题彻底解决,否则并发一上来一定还会犯。

这段经历给我留下的印象特别深,因为这类问题不会在低并发下暴露,容易让人误以为是环境或数据库问题。希望这篇文章能帮你少走几个月的弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询