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.AddScopedISqlSugarClient(sp { var config sp.GetRequiredServiceIConfiguration(); 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.QueryableUser().FirstAsync(u u.Id message.UserId); // 更新账户余额 // 插入订单记录 // 更新库存 }); }每个消息消费任务都通过using创建和释放Client实例任务之间的隔离是物理级别的彻底消除了并发共享问题。这个模式对理解成本低、代码改动量也小非常适合对既有代码进行逐步改造。4.2 AsyncLocal/ThreadLocal方案让上下文里的Client随线程流动有些项目在架构设计时希望业务代码不显式感知Client的创建和传递而是通过一个静态入口比如Db.GetClient()来获取当前上下文可用的Client。这种做法在单线程下很优雅但在多线程下就需要用特殊机制来保证一个线程或一个异步上下文拿到的Client是独立的。.NET中提供了AsyncLocalT和ThreadLocalT两种工具。其中AsyncLocalT是异步上下文它在异步方法链中自动传递值适合Web请求、异步业务等场景ThreadLocalT是线程局部存储适合传统多线程模型。实现一个简单的上下文隔离public static class DbContextHolder { private static readonly AsyncLocalSqlSugarClient? _current new AsyncLocalSqlSugarClient?(); 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就复用如果没有就新建一个并存入当前上下文。这样既避免了重复创建又保证了隔离性。但这个方案有几个注意点AsyncLocalT的值会沿着异步调用链流动这通常是我们期望的但如果你在业务代码中故意用Task.Run切到新线程AsyncLocal的值默认不会跨线程传递除非指定flowContext参数此时要小心是否拿到的是旧上下文的Client。AsyncLocalT本身有性能开销不过现代.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.QueryableUser()...。这个写法在单线程测试时很爽但上线后就是一颗定时炸弹。所有线程共享同一个Client相当于把连接、上下文、缓存全部裸露给并发环境。我见过太多项目是在这个写法的坑里翻了车。如果你确实喜欢静态入口的便利也可以参考4.2节的AsyncLocal方案让静态入口只是一个获取当前上下文隔离实例的门面而不是直接暴露共享的Client字段。5. 千万别忽略的隐性坑线程里的异步上下文与连接的自动关闭5.1 await之后的线程切换会让AsyncLocal断流吗很多人在做多线程改造时以为用了AsyncLocalT就万事大吉了。但异步方法有一个特性await之后不一定回到原来的线程。这就引发了一个经典疑问AsyncLocalT的值在await之后还在吗结论是AsyncLocalT在设计上就是为了跨异步点传递值的。只要整个异步调用链是在同一个异步上下文AsyncLocalContext中那么await切换线程时值会被自动传递到新线程。这也是为什么AsyncLocalT适合用于Web请求中传递HttpContext相关的状态。但这里有个边界情况如果你显式地使用Task.Run、Task.Factory.StartNew默认参数等方法创建新的任务这个新任务的异步上下文默认是空的AsyncLocalT的值不会自动传递过去。此时在新任务内部调用DbContextHolder.GetOrCreate()会创建新的Client而不是复用当前上下文里的那个。这不是pollution问题反而歪打正着符合隔离需求新任务拿到新Client与调用方互不干扰。但如果业务逻辑恰恰希望新任务和当前上下文共享事务那就需要你手动把客户端传递进去而不是依赖AsyncLocal。5.2 Task.Run和Parallel真实场景下的Client隔离策略在后台服务中我经常看到这样的代码var client GetClient(); // 作用域级别的Client Parallel.ForEach(items, item { client.QueryableOrder().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.QueryableOrder().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_connectionsSQL Server或等效的数据库侧查询观察活跃连接数是否异常持续增长。如果在没有业务操作时连接数也居高不下大概率是IsAutoCloseConnection false配合了长生命周期Client。推荐配置作用域或瞬时Client都显式设置IsAutoCloseConnection true。如果你确实需要在一个业务操作中保持连接较长比如跨多个数据库操作复用事务也要确保事务结束后客户端正确释放。5.4 异步数据库操作并不能规避线程安全问题有一种误解既然SqlSugar支持异步方法如QueryableAsync、ExecuteCommandAsync用异步是不是就能避免多线程问题答案是否定的。异步方法解决的是等待IO时不阻塞线程的效率问题它不会将两个并发调用自动序列化到同一个Client上。两个线程仍然可以同时调用同一个Client的异步方法底层的连接对象和上下文状态仍然会被并发访问。简单说异步解决的是线程利用效率不是线程安全。一个常见的错误写法是var task1 client.QueryableUser().ToListAsync(); var task2 client.QueryableOrder().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.QueryableTestRecord().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.QueryableTestRecord().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日志的Liststring也会成为并发问题的帮凶。这就提醒我们不仅仅Client核心状态要隔离附加在Client上的事件处理逻辑也要保证线程安全。如果用Aop记录审计日志应该把日志写入操作设计成线程安全的比如用线程安全的队列、直接写文件、独立日志库不能在内存集合里无锁地Add。7.2 多租户/多数据库场景下的Client管理遇到多租户架构时不同租户可能使用不同的数据库或连接字符串。如果还是一个全局Client的思路那根本没有办法满足多数据库路由。常见做法是用一个字典按租户ID缓存Client集合但在多线程环境下这个字典本身需要加并发控制如ConcurrentDictionary来避免读写冲突。public class TenantDbManager { private readonly ConcurrentDictionarystring, 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但没给内部创建独立ClientLINQ的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折磨了。
阅读完成 · 觉得有帮助?