最近在忙一个 .NET Core 老项目的重构,数据访问层从手工 ADO.NET 迁到了 SqlSugar,整个过程比预期顺利很多,也让我彻底确定了一个观点:中小型系统里,SqlSugar 这种开箱即用的 ORM 比 EF Core 和 Dapper 都更省心。EF Core 功能全但太重,随便一个查询都要过一遍状态追踪;Dapper 够快但业务一复杂,SQL 和映射全得自己写,维护成本很高。SqlSugar 正好在两者之间,API 直白、性能不差、事务分页这些高频能力全都内置。这篇文章把 .NET Core 集成 SqlSugar 的最简实战拆开讲透,从接入配置到日常开发,代码可以直接抄,坑全是真实踩过的。适合刚接触这个 ORM 的 .NET 开发同学,也适合正在做技术选型的团队。
1. 选型思考:.NET Core 项目里为什么值得给 SqlSugar 一个机会
1.1 主流 ORM 的现状与 SqlSugar 的生态位
先聊实际的。你在 .NET Core 里做数据访问,绕不开三选一:EF Core、Dapper、或者 SqlSugar 这类第三方 ORM。EF Core 有微软官方维护,功能和生态都强,团队里如果有偏爱全托管式开发的人,选它没错;但 EF Core 的启动和迁移成本不低,尤其你只想快速改表结构、不想走 Migration 那套流程的时候,会感觉被框架绑住了。Dapper 则是另一个方向,SQL 全自己写,性能拉满,但实体赋值、批量插入、分页这些都要自己搭轮子,项目一复杂,重复代码多了也烦人。
SqlSugar 正好填了中间这块。第一,它的 API 设计更贴近 SQL 思维。你用 EF Core 写一个多表查询,心里要把 LINQ 翻译成 SQL;用 SqlSugar 写,方法名基本都是 Where、OrderBy、GroupBy、LeftJoin 这种单词,几行代码读下来,脑子里能直接想象出生成的 SQL 长什么样。第二,它把项目里常见的活儿直接内置了,不用自己写工具类。分页、事务、批量插入、缓存、AOP 日志,都给你留好了入口。第三,性能上它并不吃亏,内部做了表达式缓存,同一条查询表达式第二次执行时不用重新解析。我拿一个几百万行数据的订单表做过简单测试,简单条件查询和分页的场景下,SqlSugar 的延迟表现和 Dapper 差不了太多,但书写效率高一大截。
1.2 "开箱即用"具体指什么:你省下的样板代码
"开箱即用"这个词容易被当成营销话术,但在我看来它是可以量化的。一个清爽的数据访问层要解决四件事:连接管理、实体映射、增删改查模板、事务处理。用 EF Core,你得建 DbContext、注册 DbSet、配实体配置类、迁移还要走命令行;用 Dapper,除了写 SQL,你还得自己处理连接开关、参数化、结果映射。而 SqlSugar 的做法是:一个 ConnectionConfig 告诉它数据库连哪、哪种类型,一个 SqlSugarClient 实例搞定后面所有请求。实体映射靠约定加特性,约定覆盖大多数场景;连接自动开关,不需要担心忘记释放;事务用 db.Ado.UseTran 包一层,回滚和提交都是自动的。
我印象最深的是一个带三十多张表的小型后台管理系统,从零开始用 SqlSugar,第一天就把所有表的 CodeFirst 建表脚本跑通了。它不是没有学习曲线,但曲线很短:看完这篇文的配置部分,再去翻一下官方文档的 API 列表,就能开始写业务代码。所以这篇文章不堆概念,全部围绕真实开发场景,你照着敲基本能跑。
2. 接入姿势:从 NuGet 包到第一个数据库连接
2.1 安装 SqlSugarCore 与最小可运行代码
先建一个 .NET Core 项目,然后装包,用 CLI 一条命令就行:
dotnet add package SqlSugarCore也可以用 Visual Studio 的 NuGet 管理器搜索 SqlSugarCore。注意别装成旧版的 SqlSugar,那个是给 .NET Framework 用的;.NET Core 项目认准名字里带 Core 的这个包。装完之后,先写一段最小的连接初始化代码来验证环境通不通,我用一个静态工厂方法做演示:
using SqlSugar; public static class DbFactory { public static SqlSugarClient CreateClient() { return new SqlSugarClient(new ConnectionConfig { ConnectionString = "Server=localhost;Port=3306;Database=blog;Uid=root;Pwd=123456;charset=utf8mb4;", DbType = DbType.MySql, IsAutoCloseConnection = true, InitKeyType = InitKeyType.Attribute }); } }这里四个配置项一个都不能马虎。ConnectionString 不用解释;DbType 是数据库类型,写在枚举里,SqlServer、MySql、PostgreSQL、Sqlite、Oracle 都支持;IsAutoCloseConnection 设为 true 表示每次操作后自动关闭连接,这是 SqlSugar 连接池友好使用的关键,不然连接一直挂着;InitKeyType.Attribute 是告诉框架,主键、自增列这些信息以实体上的特性为准,而不是去数据库系统表里读。这两者在性能和自由度上有差别,后面讲到 CodeFirst 时会更清楚。
做个连接自检也很方便,十秒钟就能暴露环境问题:
using var db = DbFactory.CreateClient(); var ok = db.Ado.IsValidConnection(); Console.WriteLine(ok ? "数据库连接正常" : "连接失败");IsValidConnection 会真实执行一次轻量查询,能很快暴露连接串错误、网络不通、账号权限这类环境故障。很多人一上来就写业务代码,结果卡在连不上数据库,不如先跑这段验证一下。我自己每次换数据库环境都会先执行它,省得后面定位半天。
2.2 结合 .NET Core 依赖注入的正确注册方式
直接用 new 创建实例适合控制台验证,但 Web 项目里还是要把 SqlSugarClient 交给容器统一管理:
builder.Services.AddSingleton<ISqlSugarClient>(sp => { var config = new ConnectionConfig { ConnectionString = builder.Configuration.GetConnectionString("Default"), DbType = DbType.SqlServer, IsAutoCloseConnection = true, InitKeyType = InitKeyType.Attribute }; return new SqlSugarClient(config); });注册成单例还是作用域,是我被问过最多的问题之一。SqlSugar 官方文档采取的是单例使用方式,原因是它的连接是"用到才开、用完即关"的模式,内部的查询状态不会长期占着连接不放,而且框架对线程安全做了处理,绝大多数业务场景下单例没有问题。但如果你在项目里大量使用事务,或者在同一个请求里频繁开多个并发任务,我建议考虑 AddScoped,让每个请求拿到独立的 client,避免事务状态串了的诡异问题。代价是每次请求都新建 client,几张表的轻量项目感知不到差别,真到了压力很大的系统再用单例也不迟。这算是我实际项目里总结出的中庸策略。
注册完之后,控制器或者服务类里直接构造函数注入:
public class StudentService { private readonly ISqlSugarClient _db; public StudentService(ISqlSugarClient db) { _db = db; } public List<Student> GetAdults() { return _db.Queryable<Student>().Where(s => s.Age >= 18).ToList(); } }到这里,一个 .NET Core 项目和数据库之间的最小闭环就搭好了。后面所有增删改查,都是围绕注入进来的这个 _db 展开。
2.3 连接配置细节与三分钟自检方法
连接串是"第一次集成"翻车最多的地方,列几个常见的点。MySQL 的字符集设置对中文支持影响很大,连接串里忘了写 charset=utf8mb4,插入中文可能直接变问号或者报编码错误。国内项目几乎都有中文数据,所以建议一开始就带上。SQL Server 用 Integrated Security 或账号密码都行,注意 Data Source 的写法在不同环境格式略有差别;SQLite 的路径最好用绝对路径或者通过配置拼出来,不然换环境就找不到库文件。
还有一个隐蔽坑:SqlSugar 对实体名和表名的默认映射是保持一致的,class Student 默认对应表 Student。如果你的数据库表名是小写 student,或者业务表带 t_ 前缀,要么在实体上标 [SugarTable("t_student")],要么开 MoreSettings.IsUnderLine 让框架自动转下划线命名。很多人第一次跑查询返回空表,查了半天发现是表名对不上。高版本 SqlSugar 还内置了全局的 EntityService,可以在实体创建时统一处理表名,也派得上用场。
自检方法三步就够了:先跑一次 IsValidConnection;再用 db.DbMaintenance.GetTableInfoList(true) 看能不能列出所有表;最后找一个已知表名,写一条最简单的 Queryable 查询打印出来。三步全过,说明环境 OK,可以放心写业务。
3. 高频实操:增删改查、分页、事务一次讲透
3.1 插入、更新、删除的写法与行为边界
直接上代码,这是日常最常用的四条:
// 插入 var s = new Student { Name = "张三", Age = 18 }; var id = db.Insertable(s).ExecuteReturnIdentity(); // 自增主键 var snowId = db.Insertable(s).ExecuteReturnSnowflakeId(); // 雪花ID // 更新 db.Updateable(s).ExecuteCommand(); // 按主键更新整行 // 部分字段更新 db.Updateable<Student>() .SetColumns(x => new Student { Age = 20, Remark = "改年龄" }) .Where(x => x.Id == id) .ExecuteCommand(); // 删除 db.Deleteable<Student>().Where(x => x.Id == id).ExecuteCommand();重点说 ExecuteReturnIdentity 和 ExecuteReturnSnowflakeId 的区别。如果主键是数据库自增列,用前者拿回自增值;如果系统用的是雪花 ID 方案,主键由代码生成,用后者更方便。这两种拿回主键的方式解决的问题是一样的:插入之后马上要用这个 ID 继续做关联操作,否则你还得再查一遍刚插的数据。
Updateable(entity) 这个写法有个容易踩的坑:默认情况它会更新这个实体上所有非 null 的字段。CreateTime、Remark 这些你没打算动的字段,只要对象上有值,也会被一起写进 update 语句。我最早用的时候就没注意,把一个实体从缓存取出来改了一个字段,整行都被刷新,CreateTime 被旧值覆盖,差点酿成一次数据事故。所以"只更新某几个字段"这种需求,建议直接用带 SetColumns 的写法,字段控制权握在自己手里。如果一定要用整行更新,也可以配合 .IgnoreColumns(x => new { x.CreateTime }) 把不想动的字段排除掉。
3.2 条件查询、表达式函数与多表联查
查询是 ORM 的核心能力,SqlSugar 的 Queryable 链式写法用起来很顺:
// 基础条件 + 排序 + 取前几条 var list = db.Queryable<Student>() .Where(s => s.Age >= 18 && s.Name.Contains("张")) .OrderBy(s => s.Id, OrderByType.Desc) .Take(10) .ToList(); // 主键查单条 var one = db.Queryable<Student>().Where(s => s.Id == 10086).First(); // IN 查询 var ids = new List<int> { 1, 2, 3 }; var rows = db.Queryable<Student>().Where(s => ids.Contains(s.Id)).ToList(); // 范围查询 var between = db.Queryable<Student>() .Where(s => SqlFunc.Between(s.Age, 18, 25)) .ToList();Where 里就是普通的 C# 表达式。Contains 会翻译成 like,StartsWith、EndsWith、日期比较、null 判断都有对应翻译。比较有意思的是 WhereIF,它接受一个 bool,为 true 才追加条件。这个在做列表筛选时极其好用,搜索框没填就不拼 where,填了才拼,不用再写一堆 if 把查询条件拆来拆去:
var keyword = "张"; var list2 = db.Queryable<Student>() .WhereIF(!string.IsNullOrEmpty(keyword), s => s.Name.Contains(keyword!)) .WhereIF(age > 0, s => s.Age >= age) .ToList();多表联查用 LeftJoin 写法,和 SQL 的心智模型一致:
var joinList = db.Queryable<Student>() .LeftJoin<ClassInfo>((s, c) => s.ClassId == c.Id) .Where((s, c) => c.Name == "三年二班") .Select((s, c) => new { StudentId = s.Id, StudentName = s.Name, ClassName = c.Name }) .ToList();这个写法的好处是只要你的 SQL 功底在线,几乎不用查文档。但我要提醒一句:别一上来就全表 Select 成实体,按需投影成匿名对象或 DTO,传输字段少很多,性能提升是实打实的。
3.3 分页的正确打开方式
分页是每个后端都躲不开的功能,SqlSugar 做得很省事:
var pageIndex = 2; var pageSize = 20; var total = 0; var pageList = db.Queryable<Student>() .Where(s => s.Age >= 18) .OrderBy(s => s.Id, OrderByType.Desc) .ToPageList(pageIndex, pageSize, ref total); Console.WriteLine($"总记录数:{total},本页条数:{pageList.Count}");ToPageList 会自动拼 limit 语句,并通过 ref 参数把总记录数带出来,一条查询完成"列表加总数"两件事。注意 total 必须先声明变量再传 ref,很多人问了半天为什么总数是 0,都是没传 ref 或者传了一个常量进去。如果你不喜欢 ref 这种风格,还有 ToPageResult 方法,返回一个带 TotalCount 和 Items 属性的分页对象,参数上更优雅。异步版本对应 ToPageListAsync 和 ToPageResultAsync,在 ASP.NET Core 环境务必用异步,这个后面讲异步陷阱时还会提到。
分页相关的排序不要放在内存里做。有同事写过先 ToList 再 OrderBy 再 SkipTake 的写法,数据量小没问题,几万条以上内存和时间都会很难看。SqlSugar 的 OrderBy 是拼进 SQL 的,顺序别搞反——先过滤、再排序、最后分页,这是写查询代码的固定心法。
3.4 事务封装与并发一致的注意事项
多表写操作要"要么都成功、要么都失败",就得上事务。SqlSugar 的写法是包一个委托:
var result = db.Ado.UseTran(() => { db.Insertable(new Student { Name = "李四", Age = 20 }).ExecuteCommand(); db.Updateable<Student>() .SetColumns(x => new Student { ClassId = 5 }) .Where(x => x.Name == "李四") .ExecuteCommand(); }); if (result.IsSuccess) { Console.WriteLine("提交成功"); } else { Console.WriteLine($"回滚了,原因:{result.ErrorMsg}"); }UseTran 内部帮你把 BeginTran、CommitTran、RollbackTran 都包好,委托里抛异常会自动回滚,result.IsSuccess 就是判断开关。异步场景用 UseTranAsync,结构一模一样。
这里有个关键点:事务必须在同一个 SqlSugarClient 实例上执行。如果事务里 A 方法用的 client 和 B 方法用的 client 不是同一个连接,第二个操作实际跑在另一个连接上,事务根本罩不住它。所以我在 2.2 节说注册成单例还是作用域要看项目,本质原因就在这——单例天然共享同一个实例;作用域则要确保同一个请求里所有服务注入的是同一个作用域实例,千万别在一个事务里手 new 一个 client 去执行操作。
还有一个容易被忽略的:事务里不要混用同步和异步方法。混用不一定报错,但某些驱动在连接就绪状态和命令交错执行时,会出现难以复现的异常。我的习惯是:入口是异步,整个事务链路上全部用 Async 后缀方法。
4. 进阶:CodeFirst 建表、仓储封装与性能调优
4.1 CodeFirst 建表与字段特性设计
SqlSugar 的 CodeFirst 不需要专门建实体映射配置类,直接在实体类上用特性声明就行:
[SugarTable("student", tableDescription: "学生表")] public class Student { [SugarColumn(IsPrimaryKey = true, IsIdentity = true, ColumnDescription = "主键")] public int Id { get; set; } [SugarColumn(ColumnDataType = "varchar(50)", Length = 50, IsNullable = false, ColumnDescription = "姓名")] public string Name { get; set; } [SugarColumn(ColumnDataType = "int", IsNullable = false, ColumnDescription = "年龄")] public int Age { get; set; } [SugarColumn(ColumnDataType = "datetime", IsNullable = true, ColumnDescription = "创建时间")] public DateTime? CreateTime { get; set; } }[SugarTable] 指定表名和表描述,[SugarColumn] 控制字段类型、长度、是否可空、是否主键自增。建表就一行:
db.CodeFirst.InitTables(typeof(Student));InitTables 是"有则跳过、无则创建"的语义,多次执行不会重复建表,放在项目启动时执行是安全的,非常适合快速迭代的开发阶段。它默认不会去改已经存在的表结构,字段有变化时需要你手动处理。这不是 SqlSugar 的缺点,反而避免了很多 ORM 自动迁移导致的生产故障——生产环境里改表结构,本来就该走人工审核流程。
我个人的建议是:正式环境别光靠 CodeFirst 维护表结构,建表脚本由专人评审后执行;但在开发环境和演示项目里,CodeFirst 负责把实体和表的关系快速对齐,效率非常高。
4.2 轻量仓储封装:该不该包、怎么包
SqlSugar 本身 API 已经够丰富,很多人会问还要不要再套一层仓储。这里说点不太中听但真实的话:别把仓储包成万能壳。见过不少项目,明明 SqlSugar 三行能写完的查询,硬是包出 GetAsync、GetByIdAsync、QueryAsync 全家桶,使用方还得猜该调哪个方法,调试时在仓库层和技术层之间跳来跳去,极其痛苦。
但如果项目确实需要统一审计字段、统一软删除、统一分页返回模型,那做一层薄封装是值得的。注意封装的是"横切逻辑",而不是"把 CRUD 换个皮":
public abstract class BaseRepository<T> where T : class, new() { protected readonly ISqlSugarClient _db; protected BaseRepository(ISqlSugarClient db) { _db = db; } public virtual async Task<List<T>> GetListAsync(Expression<Func<T, bool>> where) { return await _db.Queryable<T>().Where(where).ToListAsync(); } public virtual async Task<PagedResult<T>> GetPageAsync( Expression<Func<T, bool>> where, int pageIndex, int pageSize, Expression<Func<T, object>> orderBy, OrderByType orderType) { var total = 0; var list = await _db.Queryable<T>() .Where(where) .OrderBy(orderBy, orderType) .ToPageListAsync(pageIndex, pageSize, ref total); return new PagedResult<T> { Total = total, Items = list }; } }PagedResult 自己定义一个只带 Total 和 Items 的简单 DTO 就行。这套封装的好处是,审计字段和软删除条件可以在基类里统一拼进去,保证"所有查询默认过滤已删除数据",这个价值远大于省那几行 CRUD 代码。真要写复杂查询的时候,直接注入 ISqlSugarClient 写裸查询,不要试图把复杂 SQL 硬塞进仓储的通用方法里。
4.3 AOP 日志、SQL 追踪与性能优化建议
SqlSugar 的 AOP 钩子很适合做 SQL 日志,是排查问题的神器:
db.Aop.OnLogExecuting = (sql, pars) => { Console.WriteLine(sql); foreach (var p in pars) { Console.WriteLine($" {p.ParameterName}: {p.Value}"); } };在开发环境下,把 Aop 日志打到控制台或日志文件,等于免费获得一条 SQL 审核通道。上线出了问题,翻日志能看到某一步到底执行了什么 SQL、参数是什么,不用靠猜。我做过一个工具类项目,所有数据操作都经过这个钩子记录,后期线上数据被莫名修改时,顺着日志一分钟就定位到了问题 SQL 和调用链路。
性能优化方面,除了前面说的按需投影、先在数据库过滤再分页,还有几个实际有效的点。第一,给高频查询加查询缓存,SqlSugar 支持 Redis 和内存缓存,配置好缓存服务后,查询上加 .WithCache() 即可。第二,批量插入用 Fastest 的 BulkCopy,几千行以上的数据差别非常明显,一条条 Insertable 会慢到让人怀疑机器坏了。第三,多表联查时注意外键列和 Where 条件列上的索引,ORM 再快,没有索引的大表查询一样是灾难。以上优化里,AOP 日志必须最先做好,因为它决定了你之后能不能高效定位和优化。
5. 真实踩坑记录与排查速查表
5.1 常见问题速查表
把这几年用 SqlSugar 遇到频次最高的问题整理成一张表,方便直接对照。
| 现象 | 原因 | 解决办法 |
|---|---|---|
| 插入中文变成问号 | 连接串缺少字符集配置 | 增加 charset=utf8mb4,并保证数据库默认字符集一致 |
| Updateable 更新了不该更新的字段 | Updateable(entity) 默认更新全部非 null 字段 | 改用 SetColumns 指定字段,或用 IgnoreColumns 排除 |
| 查询返回空但数据库有数据 | 实体名表名映射不一致 | 用 [SugarTable] 显式指定表名,检查大小写与命名约定 |
| 分页 total 始终为 0 | 没有用 ref 传 total 变量 | 先声明 total 变量再传入 ref total |
| 事务里某个操作回滚不生效 | 事务中混用了不同 client 实例 | 确保事务内所有操作共用同一个 SqlSugarClient |
| 异步方法报错或不稳定 | 同步异步混用,或对异步方法使用 .Result/.Wait() | 全链路使用 Async 方法,避免请求线程上阻塞等待 |
| 日期字段出现 MinValue 转换错误 | 给不可空 datetime 列传了 DateTime.MinValue | 改用 DateTime?,插入前对默认时间做空值处理 |
| 查出的实体导航属性为 null | 没有配置 Mapper 或没有使用联查 | 用 .Mapper() 配置导航映射,或用 LeftJoin 拼查询 |
这张表覆盖环境配置、基础 API、事务并发、异步、类型这几大类。遇到问题先对照一遍,大部分能直接解决。
5.2 调试思路与定位技巧
最后聊几个排查方法论。SqlSugar 报错时,不要只盯着最下面的异常信息,先看它生成的 SQL。大多数运行时错误,比如列名不存在、字段类型不匹配、语法错误,把 SQL 复制到数据库客户端里跑一下,立刻能看出问题。所以我建议任何环境都把 Aop.OnLogExecuting 开着,最少也要在异常日志里带上 SQL。
如果遇到摸不着头脑的奇怪行为,比如某个查询只在某种数据量下不正常,可以通过逐步简化表达式来定位。先用最简单的常量条件能查出结果,再一点点加条件,看是哪一步引入问题。这种二分排查在表达式翻译类 ORM 上效率很高,毕竟再聪明,也不能保证每个 C# 表达式翻译出来的 SQL 都和预期一致。
还有一个容易被忽视的:实体类的类型别乱用。DateTime 和 DateTime?、int 和 int?、decimal 和 decimal?,表面上只是多一个问号,实际影响 SQL 参数的 DbType 和条件判断的生成。比如一个可空的 CreateTime 字段,你声明成非空 DateTime,查询时对 null 的处理就会很奇怪。实体字段类型要和数据库列严格对应,能标注可空就标注可空,省得排查半天。
我的习惯是每遇到一个新问题,先在本地写一个最小复现的测试方法,把问题场景缩到最小,再回填到业务代码里。以前直接在生产日志里猜问题,效率低,还容易被各种无关错误干扰。把排查路径固化下来:看 SQL、简化条件、核对类型、查连接串,这四步走完,九成以上的问题都已经浮出水面。
6. 一段个人使用体会
写这篇之前,我又把一个小项目的 SqlSugar 版本从 5.0 升到了最新,顺带把几个老接口的查询改成了异步版本。说实话,这类 ORM 的版本迭代没那么激进,接口很稳定,升级基本无痛。最后分享一个个人心得:SqlSugar 上手快,但不代表你可以跳过对数据库本身的理解。ORM 帮你省掉的是样板代码,而不是数据库设计的功课——索引、查询计划、事务隔离这些底层能力,最终还是决定系统能走多远的变量。先认真学会用,再想着怎么穿透它去做优化,这才是和 ORM 相处的正确姿势。