1. 这次完结篇,我把它定位成一个"实战收口"
写这个系列的时候我就一直在想,SqlSugar 的入门和基础用法其实不难,文档翻一翻就能上手。但真正让一个项目从"能跑"变成"能扛住线上压力、团队协作不打架、排错不靠猜"的,往往是那些没人写进官方文档里的东西。
所以这个完结篇我不打算再重复什么"什么是ORM""SqlSugar的安装和基本增删改查",那些前面的文章已经说了。这篇我要集中解决的是三个问题:
- 存储过程怎么用才不是"披着ORM外衣写ADO.NET";
- 事务、批量写入、多线程并发这些场景下,SqlSugar 的边界到底在哪里;
- 线上慢SQL、字段映射、时间精度、日志监控这些高频生产问题,挨个给你过一遍排查思路。
适合读这篇的人,我默认你已经用 SqlSugar 写过至少一个实际项目,踩过一些基础坑,想把自己的数据库访问层再往扎实了做。如果你是刚接触 C# 和 SqlSugar 的新手,建议先把基础语法和增删改查跑通再回来看,效果会好很多。
整个系列收尾,我也顺便聊一些架构层面的取舍,毕竟数据库操作从来不只是"写SQL",它牵扯到事务边界、并发策略、监控体系和团队协作方式。这篇我把这些点尽量讲透。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. SqlSugar存储过程调用:真正好用的封装长什么样
2.1 先说说传统ADO.NET调用存储过程有多别扭
很多 C# 老项目里,调用存储过程的代码长这样:
csharp复制using (var conn = new SqlConnection(connStr))
{
using (var cmd = new SqlCommand("usp_GetPagedOrders", conn))
{
cmd.CommandType = CommandType.StoredProcedure;
cmd.Parameters.Add(new SqlParameter("@pageIndex", SqlDbType.Int) { Value = pageIndex });
cmd.Parameters.Add(new SqlParameter("@pageSize", SqlDbType.Int) { Value = pageSize });
cmd.Parameters.Add(new SqlParameter("@totalCount", SqlDbType.Int) { Direction = ParameterDirection.Output });
conn.Open();
cmd.ExecuteNonQuery();
int total = (int)cmd.Parameters["@totalCount"].Value;
}
}
这段代码的问题不在于它不能跑,而在于它把 ORM 完全架空了。你用了 SqlSugar,结果存储过程调用还是手工维护连接、命令、参数集合,一旦参数多了、方向弄错了、输出参数忘记声明,调试起来又是半天。
SqlSugar 的存储过程封装,核心思路是把参数声明、命令类型、结果映射这些活都收进去,让你只关心业务逻辑。
2.2 参数方向与结果映射的推荐写法
SqlSugar 里调用存储过程最常用的方式是 Ado.UseStoredProcedure:
csharp复制var result = db.Ado.UseStoredProcedure()
.GetDataTable("usp_GetPagedOrders", new List<SugarParameter>()
{
new SugarParameter("@pageIndex", pageIndex),
new SugarParameter("@pageSize", pageSize),
new SugarParameter("@totalCount", 0, System.Data.Direction.Output)
});
int totalCount = (int)result["@totalCount"].Value;
这样写有几个好处:
UseStoredProcedure()明确标记了当前命令类型,不用再到处写CommandType.StoredProcedure。SugarParameter的构造重载直接支持传入方向,输出参数的值通过result["@参数名"].Value取回,不用自己维护SqlParameterCollection。- 返回结果是
DataTable,如果存储过程返回的是多结果集,可以用GetDataSetAll一次性拿完。
如果你习惯用实体接收,可以这样写:
csharp复制var orderList = db.Ado.UseStoredProcedure()
.SqlQuery<OrderEntity>("usp_GetOrdersByStatus", new SugarParameter("@status", status));
它会把存储过程返回的结果集自动映射到 OrderEntity 的公共属性上。前提是你的实体属性名和存储过程返回的列名对齐,这个对齐规则和普通查询完全一致——高度匹配属性名,[SugarColumn(ColumnName="...")] 可以覆盖列名不一致的情况。
2.3 输出参数和返回值的坑,正式上线前一定要测
我见过不止一次因为 Output 参数踩坑的。常见的有三个:
第一个坑:Direction.Output 漏写,或者把输出参数当成输入参数传值。
这会导致存储过程执行完也拿不到返回值,有些人卡了半天还以为是存储过程写错了。建议不管页面端传不传值,所有涉及输出参数的声明都统一走 new SugarParameter("@totalCount", 0, System.Data.Direction.Output) 这种显式写法,不要省略中间那个默认值。
第二个坑:Output 参数的类型不匹配。
比如 SQL Server 里的 INT 对应 .NET 的 int,BIGINT 对应 long。有些存储过程返回值定义成 DECIMAL(18,2),你拿 int 去接,结果是 0 或者抛转换异常。稳妥的办法是先用 DataSet 或者 DataTable 接下原始值,确认类型后再强转。
第三个坑:存储过程里既有临时表又有输出参数,SqlSugar 的默认 CommandTimeout 不够。
大报表场景经常遇到这个问题。如果存储过程内部有复杂临时表操作、循环、游标,超出默认超时时间就会直接中断,这时候 SQL Server 端可能已经把事务开了但没回滚。建议在调用前设置一下:
csharp复制db.Ado.CommandTimeOut = 300; // 单位是秒,按实际复杂度调整
我通常会把这个值做成配置文件项,方便线上环境临时调,不用重新发版。
3. 复杂事务、批量写入与多线程边界
3.1 事务边界设计:哪些操作必须进同一个事务
SqlSugar 的事务非常简单,UseTran 一把梭。但简单接口容易让人忽略边界设计的问题。
先看代码:
csharp复制var result = db.Ado.UseTran(() =>
{
db.Insertable(orderEntity).ExecuteCommand();
db.Updateable(orderDetailList).ExecuteCommand();
});
if (result.IsSuccess)
{
// 提交成功
}
else
{
// result.ErrorMessage 里是异常信息
}
这套写法本身没问题,但很多人不清楚 UseTran 和 UseTranAsync 的区别。异步场景就该用 UseTranAsync,同步场景用 UseTran,混用会导致线程上下文异常。在事务内部也不要再嵌套 Insertable(...).ExecuteCommandAsync() 然后又不等待,跨线程操作同一事务是有风险的。
真正要设计的是事务边界,我一般按这几条原则走:
- 跨表、跨业务主从操作必须同一个事务。比如"先插入主单据、再插入明细、再更新库存",这三步任何一步失败,前面都要回滚。
- 批量操作放进一个事务的前提是数据量可控。一次性插入几千条没问题,几万条就要考虑事务日志增大和锁持有时间。
- 外部接口调用不要放进事务里。事务里做了 HTTP 请求,一旦下游服务慢,你的数据库连接和事务会被拖死。
3.2 批量写入的三种路由:Insertable的实用玩法
SqlSugar 的 Insertable 针对不同数据量有不同处理,理解它们能省很多事。
第一种是单条插入,没什么好说的,ExecuteCommand() 返回受影响行数。
第二种是批量插入,Insertable(list).ExecuteCommand()。内部会根据数据库类型自动决定是用一条 INSERT 带多个 VALUES 的批量 SQL,还是循环单条执行。SQL Server 和 MySQL 的实现有差异,如果数据量特别大,建议显式设置 PageSize:
csharp复制db.Insertable(list)
.PageSize(1000)
.ExecuteCommand();
PageSize 的作用是把大列表拆成每 1000 条一批执行,避免单条 SQL 过于巨大导致数据库解析耗时。加了它之后,事务会按批次提交。如果你要求全量要么全成、要么全败,那就不要用 PageSize,而是自己包一层 UseTran,然后维持一个批次提交模型,失败时 whole rollback。
第三种是 ExecuteReturnIdentity(),插入后返回自增ID。主键是自增列的表很常用。还有 ExecuteReturnSnowflakeId() 是雪花ID场景,分布式分库分表时才需要,单机项目用不到就别纠结。
3.3 上位机/工控场景的多线程写入上限
热搜词里有"c#上位机"、"c# 连接 dcs"这类,我多说一下工控场景。上位机采集数据写数据库,最常见的模型是"采集线程把点位数据推到队列,写入线程消费队列批量落库"。
SqlSugar 不是线程不安全的,但它也不是完全没有约束。多个线程共用一个 SqlSugarClient 实例做并发写入,数据库连接池会兜底,但事务状态、AOP 监听这些是共享的,一旦某个线程的事务没提交或回滚,可能影响其他线程判断。
我实践下来的稳定写法是:
- 每个写入线程持有独立的
SqlSugarClient实例,不共享。 - 线程间通过
ConcurrentQueue传递待入库数据,写入线程只做"取一批、插一批"的循环。 - 批量插入配合
PageSize(500),单批次执行完再取下一批。 - 数据库连接字符串里开启连接池,并合理设置
Min Pool Size,避免写入峰值时频繁建连。
csharp复制// 伪代码结构,按此模板扩展即可
Task.Run(() =>
{
using (var db = new SqlSugarClient(new ConnectionConfig { ... }))
{
while (true)
{
if (queue.TryDequeue(out var item))
{
db.Insertable(item).ExecuteCommand();
}
else
{
Thread.Sleep(100);
}
}
}
});
这里没有魔法,重点是"每条线程一套连接、一个写入循环",你不会遇到跨线程事务交叉的诡异问题。
4. 慢SQL与内存消耗:一个线上报表接口的完整排查链路
4.1 当接口突然变慢,先看SqlSugar到底是快是慢
有次我排查一个报表接口,用户反馈从 200ms 变成 3 秒。第一反应不是去查代码,而是先把 SqlSugar 的 SQL 日志打出来。
SqlSugar 自带 AOP 钩子,可以监听每个操作的执行时间:
csharp复制db.Aop.OnLogExecuting = (sql, pars) =>
{
// 在这里记录 sql 和 pars
};
db.Aop.OnLogExecuted = (sql, pars) =>
{
// 在这里记录执行时间
};
如果你只想关注慢SQL,做一个超过阈值才输出的过滤:
csharp复制db.Aop.OnLogExecuted = (sql, pars) =>
{
if (/* 拿到执行耗时,超过 500ms 就写入日志文件 */)
{
LogHelper.Warn($"慢SQL: {sql}");
}
};
这种日志体系建议一开始就搭好,不要等项目出问题再补。线上没有慢SQL日志,排查慢接口就像在隧道里找东西,全靠猜。
4.2 三个最常见的性能陷阱
排查下来发现,慢接口往往不是 SqlSugar 本身的问题,而是使用姿势有偏差。
陷阱一:ToList() 时机不对。
csharp复制// 危险写法:先 ToList 再过滤
var allOrders = db.Queryable<OrderEntity>().ToList();
var todayOrders = allOrders.Where(o => o.CreateTime >= today).ToList();
这种写法会把整张表拉进内存,再靠 LINQ to Object 过滤。表一旦上万、十万条,内存和耗时立刻爆。正确做法是让 SqlSugar 生成最终的 SQL 时就把 WHERE 条件带上:
csharp复制var todayOrders = db.Queryable<OrderEntity>()
.Where(o => o.CreateTime >= today)
.ToList();
几万条数据的差距能到几十倍。
陷阱二:关联查询写成 N+1。
比如先查订单列表,再在循环里逐个查订单明细:
csharp复制foreach (var order in orderList)
{
var details = db.Queryable<OrderDetailEntity>()
.Where(d => d.OrderId == order.Id).ToList();
}
这是经典的 SELECT N+1。SqlSugar 里可以用 Mapper 或者 Includes 一次性把关联数据带出来:
csharp复制var list = db.Queryable<OrderEntity>()
.Includes(x => x.Details)
.ToList();
如果关联的数据量实在太大,也可以考虑先查一个范围的所有明细,再内存分组关联,循环里最多只有一两次查询。
陷阱三:无意义的全字段查询。
Queryable<T>() 默认查实体映射的所有列。有些表有十几个字段,包括大字段 Text、NText,页面根本用不上,结果每行都拉一堆用不到的东西。可以用 Select 指定字段:
csharp复制db.Queryable<OrderEntity>()
.Select(o => new { o.Id, o.OrderNo, o.Status })
.ToList();
返回匿名对象时,SqlSugar 生成只含这三个字段的 SQL,IO、内存、网络传输都会小很多。切记:这个优化在字段多、数据量大时效果极其明显。
4.3 参数化查询与执行计划缓存的意义
SqlSugar 提供了一个容易被忽略但很有用的特性:默认会把 SQL 参数化。这意味着你写 .Where(o => o.Status == status) 时,最终到数据库的是参数化 SQL,而不是把值硬拼进去。
参数化带来的好处不仅是防注入,更重要的是 SQL Server/MySQL 能复用执行计划。如果每次查询的 SQL 文本都不同(比如字符串拼接),数据库每次都要重新编译执行计划,性能会有明显损耗。
有些开发者在 SqlSugar 里为了图方便,用字符串拼接的方式构造动态条件:
csharp复制.Where($"Status = {status} AND CreateTime >= '{time}'")
语法确实能跑,但破坏了参数化。除非性能完全扛得住且 SQL 语句不会膨胀,否则我不建议这么干。用 Where(expr) 或 WhereIF 才是安全且高效的写法:
csharp复制var query = db.Queryable<OrderEntity>().Where(o => o.Status == status);
if (beginTime.HasValue)
{
query = query.Where(o => o.CreateTime >= beginTime.Value);
}
var list = query.ToList();
这段代码最终也是参数化的,执行计划可以被缓存复用。一个小细节,长期运行能省很多 CPU。
5. 高频埋坑与防御性编码:没人写进文档的那些条目
5.1 字段映射的隐性坑:Ignore、Delete 和实体类特性
实体类和表结构的对应关系,SqlSugar 用 SugarColumn 特性控制,用得好可以减少大量麻烦,用不好就是生产事故。
最常见的一个坑:实体里的某个属性只是计算字段,表里根本没有这一列。默认插入或查询时会报列不存在或插入失败。正确做法是标记 [SugarColumn(IsIgnore = true)]:
csharp复制public class OrderEntity
{
public int Id { get; set; }
public string OrderNo { get; set; }
public decimal TotalAmount { get; set; }
[SugarColumn(IsIgnore = true)]
public string TotalAmountText => TotalAmount.ToString("F2");
}
另一个坑:字段名不一致。数据库列名是 order_no,实体属性是 OrderNo。SqlSugar 默认按属性名映射,你可以全局开启"下划线转驼峰"的约定配置,也可以单独用 ColumnName 指定:
csharp复制[SugarColumn(ColumnName = "order_no")]
public string OrderNo { get; set; }
如果你不想在实体上堆一堆特性,可以在 EntityService 里做全局配置。SqlSugar 支持在启动时统一处理实体和列的映射关系,适合列名规范统一、不想每个属性都标注的场景。
还有 [SugarColumn(IsPrimaryKey = true)] 和 [SugarColumn(IsIdentity = true)]。主键不是自增而是 GUID 时,只标记主键,插入时不赋值的代码不要写进实体里去。
5.2 时间、精度与字符串截断:看着小,炸起来疼
数据库操作里最烦的往往不是复杂逻辑,是这类低级的边界问题。
时间字段精度。 SQL Server 的 DATETIME 精确到 3.33 毫秒,DATETIME2 可以精确到 100 纳秒。如果你的程序里是 DateTime.Now,数据库列是 DATETIME,存储时会被四舍五入,某些对精度敏感的对账、倒计时场景就会对不上。建议新的表直接用 DATETIME2,实体里也是 DateTime,两侧配合不会出乱子。
decimal 精度。 如果实体属性是 decimal,默认 (18, 2) 可能截断你的小数。金融场景建议显式指定:
csharp复制[SugarColumn(DecimalDigits = 4, Length = 18)]
public decimal Rate { get; set; }
否则插入 0.123456 时,数据库可能悄悄存成 0.12,等到对账才发现差值。这种问题在测试阶段几乎测不出来,因为测试数据往往精度要求不高。
字符串截断。 表列是 VARCHAR(50),你传了 60 个字符,数据库在某些模式下会报错,某些模式下会静默截断。防御性做法是在实体上标记长度,让 SqlSugar 在写入前校验:
csharp复制[SugarColumn(Length = 50)]
public string Name { get; set; }
SqlSugar 生成建表语句时会用这个 Length,写入时会做数据的长度校验提示。拿到这种报错信息,你会立刻知道是数据超长,而不是跑到数据库里去查半天。
5.3 拦截器与DiffLog日志体系:让所有写入都能回溯
SqlSugar 提供了数据变更监听的能力,用它可以实现类似"所有写操作自动记录操作日志"的效果。
核心是 Aop.OnLogExecuting 和 Aop.OnDiffLogEvent。前者可以拿到 SQL 和参数,后者可以拿到变更前后的实体数据。
我实际用 OnDiffLogEvent 做了一个操作审计模块。每个重要的业务表,在更新、删除时,自动把"谁在什么时间改了什么字段、从什么值改成什么值"记录成一条日志,存到独立日志表。这样一来,出问题时不用再翻各种业务表现东西对比,直接看审计日志就能还原现场。
csharp复制db.Aop.OnDiffLogEvent = it =>
{
var logData = new OperationAuditLog
{
BusinessData = it.BusinessData, // 实体数据
DiffType = it.DiffType.ToString(), // Insert/Update/Delete
BeforeData = it.OldData,
AfterData = it.NewData,
Sql = it.Sql,
Parameters = it.Parameters,
OperateTime = DateTime.Now,
Operator = // 从当前会话上下文取用户ID
};
// 写入审计日志表
};
这个功能适合对数据安全要求较高的项目,比如交易、工单、资产类系统。如果只是个人项目或者内部后台,装这个体系可能有点重,但把 SQL 日志先留出来还是值得做的。
6. 架构级取舍:SqlSugar在工控上位机与复杂项目中的定位
6.1 高点位历史的写入模型:别再一条一条Insert了
热搜词里"c#上位机"、"c#连接西门子opc"、"visionmaster与c#联合编程"这些我特别有感触,因为真的在工控项目里泡过。上位机软件最常见的场景是:从 PLC、OPC、读码器采集数据,然后要把成千上万个点位数据历史写到数据库里。
最开始进项目时,很多同事的写法是每来一个点位就 Insertable(pointEntity).ExecuteCommand()。采集频率一上来,几百上千个点位同时更新,数据库连接池直接被打满,CPU 飙升,查询接口也跟着遭殃。
后来我们做了队列缓存模型:
- 采集线程把点位数据放进内存队列,业务只做生产,不碰数据库。
- 独立写入线程每隔 1~2 秒或者队列积压到 1000 条时,批量消费一次。
- 写入时用
Insertable(list).PageSize(500).ExecuteCommand()。
这样改动之后,同样规模的采集频率,数据库负载降了一个量级。SqlSugar 的批量插入在这种场景下比单条循环插入有数量级的优势,因为它减少了解析 SQL、往返网络、事务开销。
6.2 读写分离与多库拆分,SqlSugar能做到什么程度
中大型项目经常要面对读写分离或者分库。SqlSugar 的 ConnectionConfig 里支持 ReadOnlyConnectionString 的配置,能实现简单读写分离。
csharp复制new ConnectionConfig
{
ConnectionString = "主库连接串",
ReadOnlyConnectionString = "只读库连接串",
DbType = DbType.SqlServer,
IsAutoCloseConnection = true
}
配置之后,查询操作默认走只读库,写操作走主库。这个机制适合"查询量大、写入量可控"的系统。要注意的是,主从库之间有同步延迟,如果你写入后立刻查询,可能读不到刚写的数据。需要强一致的场景,可以在查询时指定强制走主库。
如果你的项目需要多库拆分,SqlSugar 也支持通过多 SqlSugarClient 实例维护不同库连接,或者用 DbTenant 做多租户隔离。这个看项目复杂度来定,不要在单体小项目里引入太多基础设施,不然维护成本比收益还大。
6.3 和EF Core、Dapper的选型边界,别跟风也别迷信
完结篇最后聊一个大家纠结很久的问题:SqlSugar、EF Core、Dapper 到底怎么选。
我这些年做 C# 项目的体感是,这三者并不是同一纬度的东西:
- Dapper 是轻量级映射工具,相当于把 ADO.NET 的样板代码压缩掉,SQL 还是你写,适合对 SQL 有强掌控欲、追求极致性能的团队。
- EF Core 是重量级 ORM,功能全面,适合模型驱动开发、迁移方便、团队规范程度较高的场景。但它的复杂查询、性能调优有一定学习曲线。
- SqlSugar 更像是两者之间的务实派。它保留接近原生的 SQL 能力和存储过程支持,也提供实体映射、批量操作、AOP、仓储、读写分离等中大型项目需要的功能,上手比 EF Core 快,能力比 Dapper 丰富。
我的选型建议很简单:
- 微型项目、工具类代码,用 Dapper,干净利落。
- 大型业务系统,团队熟悉 EF Core 的用 EF Core,团队没有历史遗留包袱,想快速迭代的,SqlSugar 完全够用。
- 涉及大量报表、存储过程、上位机工控这种"数据库操作以逻辑复杂、写入量大"的场景,SqlSugar 的灵活度和开发效率真的加分。
没有什么必须用哪个,关键是团队能驾驭、能快速推进项目、出问题能找到人修。
这个系列写到现在,SqlSugar 的部分算告一段落了。最后分享一条我自己的经验:ORM 无论封装得多顺手,最终跑在数据库里的还是 SQL。遇到任何诡异问题,先打开 SqlSugar 的 AOP 日志看它生成的 SQL 长什么样,九成问题都能在 SQL 层找到答案。工具是帮你省时间的,不是替你背锅的。
