1. 一次偶发数据错乱的排障经历:从"玄学Bug"到锁定的真凶
先说个前段时间的真实经历。某个模拟项目X在上线后,运维那边陆续收到反馈:偶发出现订单状态与流水记录不一致的情况,有时候明明是"已支付",流水表里却查不到对应记录;有时候定时任务跑批,发现同一批数据被重复处理了一部分。这个项目用的是SqlSugar ORM,数据库是某国产关系型数据库,业务量不算大,QPS也就几百,但就是时不时冒出这种诡异问题。团队一开始怀疑是数据库主从延迟,后来排查了事务隔离级别,又怀疑是缓存穿透,折腾了好几天,最后才发现根子出在一个非常容易被忽略的地方——多线程环境下共用了同一个SqlSugarClient实例。
这个项目为了"减少连接开销",在服务注册时把SqlSugarClient配成了单例模式。请求进来后,多个线程同时操作这个Client执行查询和写入,结果就是一串连锁反应:连接被并发抢占、上下文状态互相污染、事务出现错乱,业务数据随之"随机损坏"。奇怪的是,问题不是稳定复现,而是压力上来、并发一高才暴露,所以排查难度非常大。
今天这篇内容,就是想把SqlSugar在多线程下的线程安全边界彻底讲清楚。包括:为什么官方说"实例非线程安全"并不意味着完全不能用,在哪些场景下用同一个实例会出问题,哪些场景反而可以安全共用,以及正确的隔离方案怎么设计。不管是正在用SqlSugar做新项目,还是维护老项目时遇到了类似"偶发灵异事件",这篇内容都应该能帮你省下不少排查时间。
2. SqlSugarClient的线程模型:先搞清楚它内部到底藏了什么状态
2.1 连接对象不是每次操作都新建的
要理解线程安全陷阱,必须先搞清楚SqlSugarClient内部的状态管理方式。一个SqlSugarClient实例在首次执行数据库操作时,会通过配置的连接字符串去创建数据库连接对象,这个连接对象被缓存在Client内部。后续的查询、插入、更新、删除,只要用的是同一个Client实例,大概率会复用这个连接。
这是它在性能上的优点——避免了频繁创建和销毁数据库连接的开销。但这也是线程安全问题的根源:如果两个线程同时拿到这个缓存的连接对象去执行各自的操作,底层ADO.NET的连接对象允许并发调用吗?答案是不能可靠地并发。虽然数据库连接本身有连接池机制,但那是针对不同连接对象的;同一个连接对象同时在多个线程里执行命令,轻则抛出"连接已打开"的异常,重则出现命令交叉执行、结果集错乱。
我用一个生活化的类比来解释:一个银行柜台窗口(连接对象),同一时间只能服务一个客户(线程的数据库操作)。如果两个客户同时挤到窗口前,柜员听混了两个人的需求,可能给A办了B的业务,给B记了A的账。多线程共用同一个SqlSugarClient,本质上就是这个场景。
2.2 上下文对象:比连接更隐蔽的"共享污染源"
如果说连接对象的并发问题还比较直观,那上下文对象的污染就隐蔽得多。SqlSugarClient内部有一系列用于维护状态的对象,比如当前是否在事务中、操作的实体类型映射、SQL构造过程中的临时参数等。这些状态被设计成"单次操作生命周期"内的临时数据,但由于存在Client实例内部,一旦多线程同时执行操作,这些状态就可能互相覆盖。
最典型的场景是事务。在SqlSugar中开启事务的常规写法是:
var client = GetSharedClient(); // 假设这里拿到的是全局单例 try { client.Ado.BeginTran(); // 执行多个写操作 client.Ado.CommitTran(); } catch { client.Ado.RollbackTran(); }当线程A执行BeginTran后,事务上下文被设置到Client内部;线程B此时也调用BeginTran,它的事务上下文会覆盖或干扰线程A的。线程A执行操作时,可能发现自己"莫名其妙不在事务里了",或者更糟,线程B的操作被卷进了线程A的事务。后果就是数据的部分提交、部分回滚,业务状态完全不可信。
除了事务,查询构造器中的表达式缓存也可能出现并发问题。SqlSugar为了性能,会缓存一部分表达式解析结果,这些缓存有些是基于实体类型的,有些则可能和具体查询条件相关。在多线程环境下,这种共享缓存如果设计不当,轻则影响查询结果准确性,重则抛出类型转换异常。
2.3 官方文档的"非线程安全"到底是什么意思
SqlSugar官方文档里对线程安全的表述比较简短,大致意思是:SqlSugarClient实例不是线程安全的,不应该在多线程中共享同一个实例。但很多开发者对这个表述的理解是模糊的——"不是线程安全"到底是"完全不能用"还是"某些场景下不推荐用"?这直接决定了后续方案设计。
从实际源码和运行行为来看,"非线程安全"包含三层含义:
- 连接对象的并发复用不安全,这是最底层的问题。
- 上下文状态(事务、临时参数等)在多线程下互相覆盖,这是中层的问题。
- 部分内部缓存结构(如表达式树缓存、实体映射信息)在并发读写时可能出现异常,这是更深层的问题。
其中前两层是几乎所有ORM框架在多线程复用实例时都会遇到的问题,第三层则和具体框架的实现细节有关。理解了这三层,就能理解为什么"每个线程都new一个Client"是彻底安全的姿势,而"全局单例Client"只是在单线程场景下安全的姿势。
3. 不同生命周期方案的风险等级评估:单例、作用域、瞬时,到底该怎么选
3.1 单例模式:性能诱人但陷阱重重
很多团队选择把SqlSugarClient注册为单例,初衷很好——降低创建开销、复用连接、减少资源占用。在纯查询场景、并发量很低的情况下,这种方案确实能跑,短期内不会暴露问题。但一旦并发上来,或者业务里掺入了事务、批量写操作,问题就会像开篇描述的那样,以极其难排查的方式出现。
我把单例模式的风险具体展开一下:
- 查询场景:并发执行SELECT操作时,多个线程从连接池中获取的不是同一个连接吗?这里要明确一点:SqlSugarClient缓存的连接对象和ADO.NET连接池中的连接是不同的概念。SqlSugarClient内部缓存的连接,在并发查询时可能出现连接状态竞争。在低并发下,SQL命令执行时间极短,两个线程撞车的概率低;但并发一高,撞车概率直线上升。即便没撞车,也可能因为连接被多个线程同时使用,导致性能反而下降。
- 写入场景:并发写入时,连接对象的命令交叉执行风险更高。INSERT/UPDATE通常涉及事务,哪怕隐式事务,也可能出现一个线程的写入被另一个线程的写入覆盖、回滚等问题。
- 事务场景:这是最危险的场景。两个线程同时开启事务,互相干扰的后果是灾难性的,业务数据直接出现逻辑错误。
所以我的结论很明确:只要项目存在任何并发可能,SqlSugarClient就不应该注册为全局单例。哪怕当前QPS只有个位数,也不能赌这个运气,因为并发问题的触发是概率性的,一旦触发就是数据级别的故障。
3.2 作用域模式:ASP.NET Core下的推荐姿势
在ASP.NET Core应用中,更稳妥的方案是把SqlSugarClient注册为作用域(Scoped)生命周期。这样每个HTTP请求会拿到一个独立的Client实例,请求结束就释放。不同请求之间天然隔离,不会出现连接复用和上下文污染。
这个方案有几个好处:
- 每个请求一个实例,彻底避免了多线程并发操作同一个实例的问题。
- 作用域实例的创建开销极低,远低于数据库连接的创建开销,性能上完全可接受。
- 与依赖注入容器(如内置的
IServiceCollection)集成得自然,代码整洁。
在.NET 8 / .NET 9项目中,典型的注册方式如下:
builder.Services.AddScoped<ISqlSugarClient>(sp => { var config = sp.GetRequiredService<IConfiguration>(); var db = new SqlSugarClient(new ConnectionConfig { ConnectionString = config.GetConnectionString("Default"), DbType = DbType.SqlServer, // 按实际数据库类型配置 IsAutoCloseConnection = true // 这个选项很关键 }); return db; });这里特别说明一下IsAutoCloseConnection = true的作用。SqlSugarClient在使用完连接后是否自动关闭连接,这个选项决定了连接何时归还到ADO.NET连接池。在作用域模式下,设置为true可以确保每次数据库操作完成后连接及时释放,避免长时间占用连接资源。
但是,作用域模式也有一个前提:请求必须是单线程模型。如果你的服务里手动开了Task.Run、Parallel.For等并行操作,这些并行子线程内部使用的SqlSugarClient仍然需要单独处理。也就是说,作用域模式解决的是"请求级并发",而不是"方法级并行"。
3.3 瞬时模式:最安全,但别滥用
瞬时模式意味着每次用到SqlSugarClient时都new一个。这个方案在安全性上是最高的,每个线程、每次操作都拿到独立实例,完全不存在并发共享问题。但代价是:每次都走一遍Client初始化流程,包括读取配置、创建连接池中的连接等。如果业务操作很频繁,瞬时模式会带来一定的性能开销。
不过实测下来,这个开销没有想象中那么大。数据库操作的耗时大头在SQL执行和网络IO上,Client实例本身的创建开销相对较小。如果你的项目是低并发、低频次的场景,瞬时模式完全没问题。
真正需要警惕的是把瞬时模式和"全局静态实例"混用。有些开发者在工具类里写了一个静态的SqlSugarClient,然后在每个方法里"临时创建一个新的Client",看起来是瞬时模式,实际上底层的连接池或某些静态缓存仍然被共享,同样存在风险。这一点在后面章节详细说。
3.4 三种生命周期方案的对比清单
| 生命周期 | 安全性 | 性能 | 适用场景 | 核心风险 |
|---|---|---|---|---|
| 单例(Singleton) | 低 | 高(初期) | 仅限单线程低频操作 | 连接竞争、上下文污染、事务错乱 |
| 作用域(Scoped) | 高 | 中高 | ASP.NET Core请求级并发 | 并行子线程需另行隔离 |
| 瞬时(Transient) | 最高 | 中 | 低频操作、批量任务 | 频繁创建开销,需规范管理 |
从上面这张表可以得出一个简单结论:常规Web项目优先选作用域;后台任务、控制台应用,每个任务或每个线程独立创建Client;只有在明确单线程的前提下才考虑单例。
4. 多线程场景下的正确隔离姿势:AsyncLocal、线程局部存储与按任务创建
4.1 高并发场景的"按任务创建"模式
在后台批处理、定时任务、消息队列消费等场景中,经常需要并行处理大量数据。正确做法是为每个"工作任务单元"创建独立的SqlSugarClient实例,任务结束后释放。这里的"工作单元"不是指每个SQL操作,而是指一个完整的业务操作流程。
举个例子,一个消息消费者每次处理一条订单消息,处理过程中可能包含查询用户、插入订单、更新库存等多个数据库操作。这些操作应该共享同一个Client实例,形成完整的事务边界或操作上下文。而不同的消息(可能由不同线程并行消费)则应该使用不同的Client实例。这个"一个任务一个Client"的模式,可以很好地平衡隔离性和资源效率。
代码示例:
public async Task ProcessMessageAsync(OrderMessage message, CancellationToken ct) { using var db = DbFactory.CreateClient(); // 每个任务独立Client await using var tran = await db.Ado.UseTranAsync(async () => { var user = await db.Queryable<User>().FirstAsync(u => u.Id == message.UserId); // 更新账户余额 // 插入订单记录 // 更新库存 }); }每个消息消费任务都通过using创建和释放Client实例,任务之间的隔离是物理级别的,彻底消除了并发共享问题。这个模式对理解成本低、代码改动量也小,非常适合对既有代码进行逐步改造。
4.2 AsyncLocal/ThreadLocal方案:让"上下文里的Client"随线程流动
有些项目在架构设计时,希望业务代码不显式感知Client的创建和传递,而是通过一个静态入口(比如Db.GetClient())来获取当前上下文可用的Client。这种做法在单线程下很优雅,但在多线程下就需要用特殊机制来保证"一个线程(或一个异步上下文)拿到的Client是独立的"。
.NET中提供了AsyncLocal<T>和ThreadLocal<T>两种工具。其中AsyncLocal<T>是异步上下文,它在异步方法链中自动传递值,适合Web请求、异步业务等场景;ThreadLocal<T>是线程局部存储,适合传统多线程模型。
实现一个简单的"上下文隔离":
public static class DbContextHolder { private static readonly AsyncLocal<SqlSugarClient?> _current = new AsyncLocal<SqlSugarClient?>(); public static SqlSugarClient? Current { get => _current.Value; set => _current.Value = value; } public static SqlSugarClient GetOrCreate(string connStr) { if (_current.Value is not null) { return _current.Value; } var client = CreateNewClient(connStr); _current.Value = client; return client; } }在业务代码中,每次数据库操作前通过GetOrCreate获取实例。如果当前异步上下文里已经有了Client,就复用;如果没有,就新建一个并存入当前上下文。这样既避免了重复创建,又保证了隔离性。
但这个方案有几个注意点:
AsyncLocal<T>的值会沿着异步调用链流动,这通常是我们期望的,但如果你在业务代码中故意用Task.Run切到新线程,AsyncLocal的值默认不会跨线程传递(除非指定flowContext参数),此时要小心是否拿到的是"旧上下文"的Client。AsyncLocal<T>本身有性能开销,不过现代.NET运行时已经做了优化,常规业务规模下不用过度担忧。- 这个方案容易在项目演进中被误用,比如某段代码不小心在底层缓存了
AsyncLocal的值,导致后续调用拿到"过期"的Client。
4.3 用依赖注入 + 工厂模式统一管理Client创建
如果不想在业务代码里到处判断"该用哪个Client",更整洁的方式是引入工厂模式,把所有Client创建逻辑收敛到一个类中。工厂负责根据调用方传入的上下文信息(如数据库标识、租户ID、业务线)返回对应的Client实例,同时负责生命周期管理。
这样做带来了几个直接好处:
- 业务代码不依赖具体的
SqlSugarClient构造过程,想调整数据库配置时只改工厂内部逻辑。 - 工厂内部可以优雅地融合
AsyncLocal、缓存等技术,对调用方完全透明。 - 便于测试——工厂可以被Mock,测试时替换为模拟实现。
public interface IDbClientFactory { SqlSugarClient GetClient(string dbKey); } public class DbClientFactory : IDbClientFactory { private readonly IConfiguration _config; private readonly IHttpContextAccessor _httpContextAccessor; public DbClientFactory(IConfiguration config, IHttpContextAccessor httpContextAccessor) { _config = config; _httpContextAccessor = httpContextAccessor; } public SqlSugarClient GetClient(string dbKey) { // 优先从当前HTTP上下文取;没有则新建 var context = _httpContextAccessor.HttpContext; var items = context?.Items; if (items != null && items.TryGetValue(dbKey, out var existing) && existing is SqlSugarClient client) { return client; } var newClient = CreateClient(dbKey); items?[dbKey] = newClient; return newClient; } private SqlSugarClient CreateClient(string dbKey) { // 根据dbKey读取对应的连接字符串和数据库类型,创建Client var connStr = _config.GetConnectionString(dbKey); return new SqlSugarClient(new ConnectionConfig { ConnectionString = connStr, DbType = ResolveDbType(dbKey), IsAutoCloseConnection = true }); } }这种模式下,HTTP请求范围内的多个服务调用共享同一个Client实例,但不同HTTP请求之间完全隔离。配合IHttpContextAccessor的Items字典,正好实现了"作用域"效果,甚至不用依赖容器的手动注册层级。
4.4 不要在静态字段里缓存Client实例
这里单独拎出来强调一个反模式:不要在静态字段或静态属性里缓存SqlSugarClient实例。很多团队为了"方便调用",写一个静态类:
public static class Db { public static SqlSugarClient Client { get; } = new SqlSugarClient(...); }然后业务代码到处写Db.Client.Queryable<User>()...。这个写法在单线程测试时很爽,但上线后就是一颗定时炸弹。所有线程共享同一个Client,相当于把连接、上下文、缓存全部裸露给并发环境。我见过太多项目是在这个写法的坑里翻了车。
如果你确实喜欢"静态入口"的便利,也可以参考4.2节的AsyncLocal方案,让"静态入口"只是一个获取当前上下文隔离实例的门面,而不是直接暴露共享的Client字段。
5. 千万别忽略的隐性坑:线程里的异步上下文与连接的自动关闭
5.1 await之后的线程切换会让AsyncLocal"断流"吗
很多人在做多线程改造时,以为用了AsyncLocal<T>就万事大吉了。但异步方法有一个特性:await之后不一定回到原来的线程。这就引发了一个经典疑问:AsyncLocal<T>的值在await之后还在吗?
结论是:AsyncLocal<T>在设计上就是为了跨异步点传递值的。只要整个异步调用链是在同一个"异步上下文"(AsyncLocalContext)中,那么await切换线程时,值会被自动传递到新线程。这也是为什么AsyncLocal<T>适合用于Web请求中传递HttpContext相关的状态。
但这里有个边界情况:如果你显式地使用Task.Run、Task.Factory.StartNew(默认参数)等方法创建新的任务,这个新任务的异步上下文默认是"空"的,AsyncLocal<T>的值不会自动传递过去。此时在新任务内部调用DbContextHolder.GetOrCreate(),会创建新的Client,而不是复用当前上下文里的那个。
这不是"pollution"问题,反而歪打正着符合隔离需求:新任务拿到新Client,与调用方互不干扰。但如果业务逻辑恰恰希望"新任务和当前上下文共享事务",那就需要你手动把客户端传递进去,而不是依赖AsyncLocal。
5.2 Task.Run和Parallel真实场景下的Client隔离策略
在后台服务中,我经常看到这样的代码:
var client = GetClient(); // 作用域级别的Client Parallel.ForEach(items, item => { client.Queryable<Order>().Where(o => o.Id == item).First(); });这个写法必然踩坑。Parallel.ForEach内部开多个线程同时执行client的操作,妥妥的并发访问同一个Client实例,连接、上下文全乱套。有些会在高并发时直接抛异常,有些则在低优先级线程池调度下侥幸通过,但数据正确性无从保障。
正确的写法应该是:每个并行任务内部创建独立Client。改造后:
Parallel.ForEach(items, new ParallelOptions { MaxDegreeOfParallelism = Environment.ProcessorCount // 控制并行度 }, item => { using var client = DbFactory.CreateClient(); var order = client.Queryable<Order>().Where(o => o.Id == item).First(); // 其他操作 });对于Task.Run批量创建任务,处理思路一样:每个Task自己负责Client的创建和释放。另外需要注意:数据库连接数是有限资源,创建多少个并行任务,就会对应多少条活跃连接。并行度一定要根据数据库规格合理设置,否则隔离性是做到了,数据库连接池却被打爆了。
5.3 连接自动关闭选项和连接池耗尽问题
SqlSugar的IsAutoCloseConnection选项,很多人没真正理解它的含义。把它设为true,意味着每次执行完SQL命令后,底层连接会自动关闭并归还到连接池。这通常是我们想要的,尤其是在使用短生命周期Client(作用域、瞬时)时。
但如果设为false,连接会在Client实例存活期间一直保持打开状态。在长生命周期Client(单例)场景下,这会导致连接一直被占用;再加上并发调用,连接池耗尽只是时间问题。经典特征是:系统前期正常,运行数小时后突然大量报"连接池已满"或"无法从连接池获取连接"。
排查连接池问题时,可以执行SELECT * FROM sys.dm_exec_connections(SQL Server)或等效的数据库侧查询,观察活跃连接数是否异常持续增长。如果在没有业务操作时连接数也居高不下,大概率是IsAutoCloseConnection = false配合了长生命周期Client。
推荐配置:作用域或瞬时Client都显式设置IsAutoCloseConnection = true。如果你确实需要在一个业务操作中保持连接较长(比如跨多个数据库操作复用事务),也要确保事务结束后客户端正确释放。
5.4 异步数据库操作并不能规避线程安全问题
有一种误解:既然SqlSugar支持异步方法(如QueryableAsync、ExecuteCommandAsync),用异步是不是就能避免多线程问题?答案是否定的。
异步方法解决的是"等待IO时不阻塞线程"的效率问题,它不会将两个并发调用自动序列化到同一个Client上。两个线程仍然可以同时调用同一个Client的异步方法,底层的连接对象和上下文状态仍然会被并发访问。简单说:异步解决的是线程利用效率,不是线程安全。
一个常见的错误写法是:
var task1 = client.Queryable<User>().ToListAsync(); var task2 = client.Queryable<Order>().ToListAsync(); var users = await task1; var orders = await task2;这段代码在单线程模型下可能没问题,但如果这个client被多个请求共享(比如单例),那么task1和task2并发读取同一个连接,仍然存在竞争风险。就算只考虑当前线程顺序执行异步调用,由于await的切换,底层状态也可能被其他进入的请求干扰。所以,不要试图用异步来掩盖实例共享问题。
6. 搭建一个多线程压测环境:用复现实验验证你的隔离方案
6.1 为什么"代码看一遍没事"不算数
线程安全问题的可怕之处在于:代码逻辑上看起来对了,不一定真的对。时序竞争问题往往在特定调度下才触发,常规测试根本复现不出来。所以,当你完成了SqlSugarClient的隔离改造后,必须用专门的并发压测来验证。
压测的核心目标是:在相同数据前提下,对比单例模式和隔离模式下,执行一批并发写操作后,数据是否保持一致。不一致的,说明方案还有问题;一致的,才能放心上线。
6.2 写一个小型压测程序
下面给出一个可直接使用的压测思路。使用.NET控制台应用或单元测试项目,创建两个线程组:第一组并发向同一张测试表写入1000条带唯一标识的记录;第二组每个线程读取这些记录并验证数量。
// 压测代码示例(关键结构) public class ConcurrencyStressTest { private readonly string _connectionString; public ConcurrencyStressTest(string connectionString) { _connectionString = connectionString; } public async Task RunSharedClientTestAsync() { var sharedClient = new SqlSugarClient(new ConnectionConfig { ConnectionString = _connectionString, DbType = DbType.SqlServer, IsAutoCloseConnection = true }); var tasks = Enumerable.Range(0, 50).Select(i => Task.Run(async () => { // 并发使用同一个sharedClient实例 var id = Guid.NewGuid().ToString(); await sharedClient.Insertable(new TestRecord { Id = id, Value = i }) .ExecuteCommandAsync(); })).ToArray(); await Task.WhenAll(tasks); var count = await sharedClient.Queryable<TestRecord>().CountAsync(); Console.WriteLine($"SharedClient模式写入总量: {count}"); } public async Task RunIsolatedClientTestAsync() { var tasks = Enumerable.Range(0, 50).Select(i => Task.Run(async () => { using var client = new SqlSugarClient(new ConnectionConfig { ConnectionString = _connectionString, DbType = DbType.SqlServer, IsAutoCloseConnection = true }); var id = Guid.NewGuid().ToString(); await client.Insertable(new TestRecord { Id = id, Value = i }) .ExecuteCommandAsync(); })).ToArray(); await Task.WhenAll(tasks); using var verifyClient = new SqlSugarClient(new ConnectionConfig { ConnectionString = _connectionString, DbType = DbType.SqlServer, IsAutoCloseConnection = true }); var count = await verifyClient.Queryable<TestRecord>().CountAsync(); Console.WriteLine($"隔离Client模式写入总量: {count}"); } }如果两种模式都写入50条,预期都是50。但单例模式在很多机器上实测会小于50,或者抛异常。这个实验能直观验证方案的正确性。
6.3 观察指标:异常率、数据缺失率和连接池状态
压测完不能只看"偶尔有一两条没写进去",那已经是严重问题了。建议记录以下指标:
- 写入成功率:理想是100%,低于98%必须排查。
- 异常类型:是连接相关的
InvalidOperationException,还是超时,还是静默丢失(没有异常但数据没写进去)。静默丢失最坑,说明数据错误可能比想象中更隐蔽。 - 连接池峰值连接数:压测期间到数据库侧观察活跃连接数,判断是否出现连接未释放的情况。
- 运行时长:多跑几轮,每轮之间加短暂休眠,模拟生产环境的请求间隙。
如果压测发现静默丢失,建议在写入代码里用"主键冲突检测"或"记录数核对"来严格校验。另外,不要在生产数据库上做这种压测,务必使用未上线的模拟环境。
7. 除了线程安全,多线程使用SqlSugar还要避开的几个隐藏问题
7.1 实体配置与AOP事件的全局共享
SqlSugar支持通过Aop事件(如OnLogExecuting、OnError)实现日志、审计、缓存等功能。这些Aop事件是配置在Client实例上的。如果多个请求共享同一个Client实例,Aop事件里如果引用了一些非线程安全的状态(比如记录SQL日志的List<string>),也会成为并发问题的帮凶。
这就提醒我们:不仅仅Client核心状态要隔离,附加在Client上的事件处理逻辑也要保证线程安全。如果用Aop记录审计日志,应该把日志写入操作设计成线程安全的(比如用线程安全的队列、直接写文件、独立日志库),不能在内存集合里无锁地Add。
7.2 多租户/多数据库场景下的Client管理
遇到多租户架构时,不同租户可能使用不同的数据库或连接字符串。如果还是"一个全局Client"的思路,那根本没有办法满足多数据库路由。常见做法是:用一个字典按租户ID缓存Client集合,但在多线程环境下,这个字典本身需要加并发控制(如ConcurrentDictionary)来避免读写冲突。
public class TenantDbManager { private readonly ConcurrentDictionary<string, SqlSugarClient> _clients = new(); public SqlSugarClient GetClient(string tenantId) { return _clients.GetOrAdd(tenantId, id => { var connStr = _config.GetConnectionString($"Tenant_{id}"); return new SqlSugarClient(/* 配置 */); }); } }这个方案有一个优点和一个隐患:优点是不同租户天然不同Client,隔离性够好;隐患是如果某个租户的Client长期不释放,连接会一直占着池子。建议为这个字典加上"过期淘汰"机制,或在租户停用时主动清理。
7.3 批量操作与大数据量导入时的线程模型
批量插入、大数据量导入是SqlSugar的高频使用场景。很多人为了提高导入速度,把一个大集合切分成多个小批次,用多线程并行写入。多线程并行本身没问题,但如果所有线程共用同一个Client,和前面的事务并发一样会出问题;如果每个线程独立Client,则要面对连接数和事务边界的管理。
建议:大数据量导入分成两个阶段。第一阶段:在单线程内使用SqlSugar的Insertable批量插入(SqlSugar本身对批量插入做了比较彻底的优化,一次插入几千条性能已经不错);第二阶段:如果单线程确实满足不了性能要求,再引入并行,但以任务为单位创建独立Client,每个任务处理一批独立的数据子集,任务内使用事务保证原子性。
还有一个容易忽略的点:批量插入也要小心唯一索引冲突在并发下的表现。并行任务各自插入不同子集,理论上不会冲突,但如果子集划分不正确(比如两个任务的数据范围有重叠),唯一索引冲突就会随机爆发。做好数据分片是把控这个风险的关键。
7.4 监控和诊断的建议
在完成隔离改造后,最好给项目加上一层轻量级的监控:记录每个Client实例的创建时间、创建线程、最后使用时间,以及一个会话内执行的SQL次数。平时不显眼,但当线上再次出现"偶发数据错误"时,这些日志能大幅加速定位。
SqlSugar的Aop.OnLogExecuting事件可以拿到执行SQL和执行时间,顺手写入结构化日志即可。注意:日志写入本身不能阻塞主流程,建议扔到后台队列异步落库。日志内容不要带敏感字段值,避免泄露用户数据。
8. 几个真实误用场景的复盘:这些代码看着没毛病,跑起来就出事
8.1 "我只查不写,应该没问题吧"
这是我最常听到的一句话。很多开发者认为查询操作是只读的,多线程共享Client只是并发读,不会有问题。实际操作中却是:SqlSugar的查询上下文、表达式解析、结果映射过程都可能修改Client内部状态。多个查询同时进行时,连接对象被并发使用照样会报错。
一家公司曾经用全局单例Client跑统计报表接口,纯查询,并发上来后时有"Timeout"异常。当时还以为是数据库慢查询,优化SQL之后还是偶发。最后排查到是Client连接复用冲突导致等待超时。所以说,"只读操作"不等于"线程安全操作"。
8.2 "我用了AsParallel,但没给内部创建独立Client"
LINQ的AsParallel()在集合很大时确实能把操作并行化,但如果并行循环体里操作的是同一个SqlSugarClient,那仍然是并发访问。有些开发者会误以为"LINQ的并行是框架处理的,数据库操作应该也能自动处理"。实际上AsParallel()不会给数据库操作做任何隔离或加锁,所有并行线程拿到的还是同一个Client。
这种场景下的正确姿势:在AsParallel()的循环体内,使用Parallel.ForEach的localInit委托为每个分区创建独立Client,或者直接让每个循环迭代创建自己的Client(如果迭代数量不大)。盲目并行是性能优化的大忌,一定要搞清楚每个并行单元的边界在哪里。
8.3 "我用lock把调用锁住了,应该安全了吧"
给SqlSugarClient的调用包一层lock,看起来完全解决了并发竞争问题——同一时间只有一个线程能操作Client。这在低并发下确实有效,但有两个致命问题:
- 锁的粒度太粗,所有数据库操作序列化,性能断崖式下跌。如果原本目标是并发提升吞吐,现在等于自废武功。
- 锁只能保证当前进程内的互斥,如果未来服务水平扩展为多实例部署,不同进程的互斥就无从谈起了。到那时,线程安全问题演变成了分布式一致性问题,复杂度直接上台阶。
而且,锁住一个"共享Client"并不能避免连接池资源浪费的问题。我的建议是:不要试图给共享Client加锁,而是直接从生命周期层面杜绝共享。
8.4 "我每个线程手动new了Client,总安全了吧"
手动new实例确实避免了共享Client的并发问题,但要注意另一个坑:如果你new出来的Client没有正确释放(没有调用Dispose,也没有包在using中),连接对象可能泄漏。尤其在大量线程反复创建Client的场景下,连接池会慢慢被泄漏的连接啃噬,最终导致连接池耗尽。
所以,手动newClient的姿势必须配合"用后即释放"的纪律。using语句、await using、或try-finally中的Dispose,三选一,不能省。
9. 长期演进建议:把线程安全从"踩坑"变成"架构习惯"
9.1 建立Client生命周期规范并写入代码评审清单
线程安全问题之所以反复出现,很大程度是因为团队里没有把"SqlSugarClient生命周期管理"当成一个评审必查项。建议在代码评审清单中增加以下检查点:
- 是否出现了静态Client字段?
- 是否在
Parallel.ForEach、Task.Run里复用了外层Client? - 是否在
AsyncLocal或依赖注入作用域外缓存了Client实例? - 是否在长时间运行的后台服务中持有单一Client实例?
- 是否每个手动创建的Client都有对应的释放逻辑?
这些检查点听起来繁琐,但一旦养成习惯,收益很大。毕竟,一行"共享Client"代码引起的故障,排查成本往往远超其带来的便利。
9.2 关注SqlSugar版本升级中的线程安全修复
SqlSugar本身也在不断迭代,一些版本可能修复了特定的并发问题,或者新增了更安全的API(比如更明确的生命周期管理工具)。升级版本时,除了关注新功能,还要看看发布说明里是否有关于线程安全、连接管理、上下文隔离的修复项。如果你当前项目里的高危模式暂时无法立即改造,升级到修复了相关问题的版本是过渡期内的一个有效缓解手段。
不过,版本升级是补充策略,不是核心策略。核心策略仍然是遵循正确的生命周期约定:请求级或其他逻辑任务级的隔离。
9.3 预留一个"发布前的并发烟雾测试"环节
如果你的项目已经经历过线程安全故障,那么"并发烟雾测试"应该是发布流程中的一环。所谓烟雾测试,就是针对关键业务路径,设计一个简单的并发脚本,验证高并发下数据不丢、不重、不错。这个测试不需要覆盖每个接口,只需要覆盖最核心的写操作和含事务的操作即可。
具体执行频率:核心业务每次发布前跑一次;非核心业务至少每两周抽跑一次。Python脚本、JMeter、cURL并发循环都可以,重点在于"模拟真实并发访问共享资源库的过程",而不在于工具本身。
9.4 团队培训:让每个人理解"连接对象不是水管而是柜台"
最后想说的是,线程安全的本质理解很难靠"背规范"建立。给团队的培训中,我喜欢用一个比喻:数据库连接对象不是一根随便接的水管,而是一个有状态的服务柜台。你在哪个柜台办事,柜员能记住你办到哪一步,但这个记忆只属于当前这个柜台。换柜台、多个客户挤同一个柜台,都会乱套。
在这个比喻的基础上,再去解释生命周期、连接池、事务上下文、AsyncLocal,会轻松很多。技术难点往往不是名字有多复杂,而是对底层模型的理解是否扎实。
10. 写在最后:一个小型的可复用检查工具
分享一个小工具思路:在开发环境启动时,扫描程序集中所有静态字段和静态属性,如果发现类型是SqlSugarClient或其子类,直接输出警告日志甚至抛异常(仅限开发环境)。这个工具能提前把"静态Client"这类最明显的反模式拦截掉。
实现思路很简单,用反射遍历当前程序集的所有类型,检查静态字段和属性类型是否可赋值给SqlSugarClient类型:
public static void DetectUnsafeStaticClient(Assembly assembly) { foreach (var type in assembly.GetTypes()) { foreach (var field in type.GetFields(BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public)) { if (typeof(SqlSugarClient).IsAssignableFrom(field.FieldType)) { throw new InvalidOperationException( $"检测到静态SqlSugarClient字段: {type.FullName}.{field.Name},存在线程安全风险"); } } // 对属性也做类似检查 } }把这个检查封装在一个#if DEBUG的启动逻辑中,开发阶段就能暴露问题。等团队代码规范成型后,可以视情况保留或移除。
多线程下的SqlSugarClient使用,其实只是一个更大命题的缩影:任何带有状态的共享对象,都必须明确其状态边界。Connection、Transaction、Aop事件、静态持有、并行任务——每个环节都要想清楚"这个状态归谁、生命周期多长、谁能并发访问"。把这个习惯带进所有涉及到数据库访问的代码里,你就不会再被"偶发灵异Bug"折磨了。