☰
FreeSql 实战:从 CRUD 到并发控制,看清 .NET ORM 选型
2026/10/20 21:29:46 网站建设 项目流程

做 .NET 后端这几年,几乎每个新项目的第一步都在纠结 ORM 选型。最早跟风用 EF Core,功能确实全,可项目一复杂就觉得像背着一整套家具在跑步——模型配置、状态跟踪、延迟加载,光理解这些概念就要花掉不少时间,性能出问题还得一层层剥开去看。后来换 Dapper,SQL 全手写,灵活性是满足了,可实体多了以后,Insert、Update、分页、关联查询这些重复代码写得我想骂人。

直到朋友推荐我试了 FreeSql——也就是很多人打成 FreeSQL 的那款 .NET 生态开源轻量 ORM 框架——我才算找到一个平衡点。它确实轻:一个IFreeSql实例、一个实体类,增删改查就能跑起来;但功能一点没缩水:CodeFirst 自动建表、表达式查询、导航属性、仓储模式、多数据库支持全部内置。这篇文章就按我实际走过的路线,从入门到实战把 FreeSql 的用法和踩过的坑整理出来,适合刚开始接触 ORM 的新人,也适合正在 EF Core 和轻量框架之间纠结的朋友。

1. FreeSql 的定位不是"又一个封装":先想清楚它为谁解决什么问题

1.1 轻量体现在结构,而不是阉割功能

拿到 FreeSql 之后,我第一反应是看看它到底“包”了什么。它不像 EF Core 那样要求你先建一个 DbContext、配一堆 Fluent API,它甚至不强制你用什么固定模式。围绕一个IFreeSql接口,把Insert、Update、Delete、Select四大块能力组织起来,看起来是轻型骨架,内里却塞满常用功能。

最典型的就是多数据库支持。以前用 EF Core 切库,很多项目要换 Provider、改模型配置、调整迁移脚本,折腾半天。FreeSql 在同一个模型上,改一行DataType和连接字符串就能换库跑。我有个内部工具从 MySql 切到 SqlServer,业务代码几乎没动。这背后是它自己实现了一套 SQL 方言翻译层,把不同数据库在分页、自增、类型映射上的差异尽量挡在框架内部。

打个比方,EF Core 是装修公司,什么都替你干了,但费用和等待时间也跟着上来;Dapper 是一把好用的美工刀,灵活但所有细节都要自己动手;FreeSql 更像一个顺手的工具箱,常用工具都给你备好,拿起来就能干活,还不占地方。

1.2 和 EF Core、Dapper 的选型对比,我自己的判断标准

很多人问我有 EF Core 和 Dapper 了,为什么还要用 FreeSql。我的看法是它们并不是单纯的好与坏,而是各自服务的场景不同。我习惯先列一张对比表再下结论:

维度FreeSqlEF CoreDapper
是否必须手写 SQL不需要,但也支持原生 SQL不需要主要手写
自动建表CodeFirst 直接同步Migration 管理不支持
导航属性支持支持不支持
表达式树查询强强弱
性能接近 Dapper有差距最快
学习成本中低中高低
多数据库支持很全,含国产数据库主流数据库主流数据库
中文资料与社区丰富一般一般

这个表基本就是我选型时的真实判断。如果你的项目追求极致查询性能,又希望 SQL 完全可控,Dapper 依然是对的;如果团队有严格的数据库建模规范、需要版本化迁移文件,EF Core 的价值不可替代。FreeSql 的生态位是中等复杂度的业务系统 + 不想被繁琐配置拖累的开发团队,尤其是国内很多中小项目,库表多、迭代快,FreeSql 的入手成本和学习曲线都更友好。

另外它还有很多开箱即用能力,比如逻辑删除过滤器、审计日志、动态过滤、按时间分表这些都是框架内置的,不需要自己写 AOP 或者重复模板代码。这些特性在我以前用 Dapper 的项目里全部要靠手写,维护成本不低。

2. 从零搭建:安装、初始化与依赖注入的关键细节

2.1 主包和 Provider 包怎么装

用 .NET CLI 是最直接的方式:

dotnet add package FreeSql dotnet add package FreeSql.Provider.MySql

第一行装的是核心包,第二行是数据库 Provider。具体装哪个 Provider 取决于项目连什么库。我这边常用的包有这些:

  • FreeSql.Provider.SqlServer
  • FreeSql.Provider.MySql
  • FreeSql.Provider.PostgreSQL
  • FreeSql.Provider.Sqlite
  • FreeSql.Provider.Oracle

这里提醒一个容易踩的坑:主包和 Provider 包的版本号要尽量保持一致。我经历过主包升级、Provider 没跟着升的情况,编译能通过,但运行时驱动行为不一致,排查起来非常费时间。建议用一个确定的版本号,别每次装都默认最新版。

2.2 FreeSqlBuilder 与全局单例

初始化 FreeSql 通常用一个静态单例,直接在应用启动时就建好:

using FreeSql; public static class Db { public static IFreeSql Instance { get; } = new FreeSqlBuilder() .UseConnectionString(DataType.MySql, "Server=localhost;Port=3306;Database=my_db;Uid=root;Pwd=123456;Charset=utf8mb4;") .UseAutoSyncStructure(true) .UseMonitorCommand(cmd => Console.WriteLine(cmd.CommandText)) .Build(); }

这段配置里的每个成员都有自己的讲究。UseConnectionString里的DataType必须和 Provider 包对应,它是方言引擎的开关;UseAutoSyncStructure(true)表示开发期实体结构变化后自动同步表结构,相当于把建表脚本交给框架维护;UseMonitorCommand会把每次执行的 SQL 打到控制台,是我调试 SQL 依赖度最高的功能。

重要:不要每次请求都 new FreeSqlBuilder() 创建新实例。IFreeSql内部维护连接池和表达式编译缓存,官方推荐用法就是全局单例。我之前在一个早期项目里犯过这个错,请求量一上来连接池直接被打爆,后来才意识到这个框架本身是线程安全的,建一个实例到处用反而是性能最优解。

2.3 注册到依赖注入,拿一个可使用的入口

ASP.NET Core 里注册非常简单:

builder.Services.AddSingleton(Db.Instance);

之后任何服务通过构造函数就能拿到IFreeSql。如果项目想用仓储风格,还可以注册泛型仓储:

builder.Services.AddScoped(typeof(IBaseRepository<>), typeof(BaseRepository<>));

仓储模式不是必须的,但是项目一旦规模上来,它能省很多重复代码,我放到第 5 章详细说。

第一次跑通之后你一定会想试试增删改查,不过在那之前要先让 FreeSql 认识你的实体和表结构,这就是第 3 章的实体映射。

3. 实体映射和 CodeFirst 建表:从写类开始,表是"长出来"的

3.1 约定与特性:Table、Column、Navigate

FreeSql 有自己的默认约定:实体类名对应表名,属性名对应列名,主键默认是Id或者实体名 + Id。如果只是做 Demo,什么都不配也行。但真实项目里通常要建一套自己的命名规范,我一般会主动使用特性让表和列名显式可控:

[Table(Name = "tb_user")] public class User { [Column(IsPrimary = true, IsIdentity = true)] public int Id { get; set; } [Column(Name = "nick_name", DbType = "varchar(50)")] public string NickName { get; set; } public int Age { get; set; } [Column(IsIgnore = true)] public string Tips { get; set; } }

IsPrimary和IsIdentity代表主键和自增;DbType用来干预生成列的类型;IsIgnore表示这个属性不参与表结构映射。还有IsNullable、控制字段是否允许为空等,处理 bool、枚举字段的类型映射时非常有用。举个例子,C# 的bool默认可能映射成tinyint(1),如果你希望映射成bit,直接在列上指定DbType就能解决。

导航属性的关联也靠特性。比如一个用户有多条订单:

[Table(Name = "tb_order")] public class Order { [Column(IsPrimary = true, IsIdentity = true)] public int Id { get; set; } public int UserId { get; set; } [Navigate(nameof(User.Id))] public User User { get; set; } }

Navigate告诉 FreeSql 当前外键对应目标实体的哪一列。它既能描述一对多,也能描述多对多,后续查询直接当普通属性用就行。

3.2 自动同步表结构的正确姿势

UseAutoSyncStructure(true)开启后,应用启动时框架会比较实体和现有表,发现缺表会自动建表,发现缺字段会自动加列。开发环境里这个功能太省心了,我再也不用写一堆CREATE TABLE脚本,改完实体重启就能看到新列。

但这个功能有个使用边界:生产环境尽量不要开。原因很简单,如果多个服务实例同时启动,可能发生多个进程同时改表结构,轻则锁表,重则直接报错。而且生产表结构变更必须走 review 和迁移流程,让框架默默改表风险太大。我的习惯是:开发环境开,生产环境关,上线前用测试环境把建表脚本重新生成一遍,交给 DBA 审核执行。

如果你更习惯手工控制,也可以不开自动同步,在应用启动时手动指定实体类型:

fsql.CodeFirst.SyncStructure(typeof(User), typeof(Order));

这样更可控。模型就绪之后,就可以进入最核心的 CRUD 实战了。

4. CRUD 实战:FreeSql 单表操作的花式用法与习惯

4.1 Insert:插入数据与拿自增主键

写数据最基础的就是插入。FreeSql 的 API 设计得很连续,读起来像在造句:

var user = new User { NickName = "阿飞", Age = 28 }; var id = fsql.Insert<User>() .AppendData(user) .ExecuteIdentity();

AppendData传入实体,链式接ExecuteIdentity()会返回自增主键。这个场景很适合"插入后马上拿 ID 继续写关联数据"。如果不需要主键,只想知道影响行数,用ExecuteAffrows()。

批量插入也一样自然:

var rows = fsql.Insert<User>() .AppendData(userList) // userList 是 List<User> .ExecuteAffrows();

这里最深刻的体会是:别写循环逐条插入。每调一次 Insert 都是一次数据库往返,循环 1000 条就是 1000 次往返,批量一条 SQL 就能搞定,性能差距可能是一个数量级。我见过有人因为图省事在循环里调ExecuteAffrows(),线上巡检时一条插入接口耗时几百毫秒,改成批量后直接降到几十毫秒。

4.2 Update:整体更新还是指定字段更新

更新有两种常见姿势。第一种是整实体更新,适合表单回写场景:

var user = fsql.Select<User>().Where(a => a.Id == 1).ToOne(); user.NickName = "新名字"; var rows = fsql.Update<User>() .SetSource(user) .ExecuteAffrows();

SetSource(user)把实体所有字段更新一遍,主键除外。但有个细节要注意:如果实体字段很多,而实际上只改动了一两个字段,SetSource依然会更新所有列,这会带来不必要的网络开销和潜在的并发覆盖。我更推荐第二种方式——只更新必要的列:

var rows = fsql.Update<User>() .Set(x => x.NickName, "新昵称") .Set(x => x.Age, x => x.Age + 1) .Where(x => x.Id == 10) .ExecuteAffrows();

第二个Set的新值可以直接依赖原值,它会翻译成 SQL 里的age = age + 1,不需要先查出来再在 C# 里自增。这种表达既清晰又安全,还能少一次查询往返。

4.3 Delete:条件删除与逻辑删除的提醒

删除也很直接:

fsql.Delete<User>().Where(x => x.Id == 1).ExecuteAffrows(); fsql.Delete<User>().Where(x => x.Age < 18).ExecuteAffrows();

条件由表达式直译过去,不需要写DELETE FROM ...。要清空全表,写Where(a => true)就行,但生产环境千万慎用。另外建议大家优先考虑逻辑删除,而不是物理删除。FreeSql 内部有全局过滤器,可以在表上配置逻辑删除字段,之后SELECT会自动追加"未删除"条件,用户侧的查询代码完全不用改,数据却不会真的丢失。

4.4 Select:查询、投影与分页

单表查询:

var list = fsql.Select<User>() .Where(x => x.Age >= 18 && x.NickName.Contains("张")) .OrderBy(x => x.Id) .Skip(0).Take(20) .ToList();

Contains会翻译成LIKE '%张%',OrderBy、Skip、Take都有,语法跟 LINQ 很像,写过一点点 LINQ 的人基本零成本上手。

分页我更推荐封装好的ToPageList:

var list = fsql.Select<User>() .Where(x => x.Age >= 18) .OrderByDescending(x => x.Id) .ToPageList(1, 20, out var total);

这里注意页码从 1 开始计数,out total返回总条数。它的底层是两次查询,一次COUNT(*)、一次数据页,比自己拼 SQL 干净得多,也不容易漏掉总条数统计。

查询时还有一个值得养成的习惯:按需投影。如果查询只需要其中两个列,就不要图省事SELECT *:

var dtos = fsql.Select<User>() .Where(x => x.Age > 0) .Select(x => new UserDto { Id = x.Id, Name = x.NickName }) .ToList();

这样能减少无谓的大字段传输,SQL 性能和网络带宽都会更好。

5. 进阶用法:表达式树、导航属性、仓储模式与工作单元

5.1 表达式树查询和子查询的实际威力

FreeSql 和 EF Core 最接近的地方,就是支持把 C# 的 lambda 表达式翻译成 SQL。你写的是强类型 C#,数据库拿到的却是带占位参数的 SQL:

fsql.Select<User>() .Where(a => a.NickName.Contains("张") || a.Age >= 18) .ToList();

我最初不太敢用复杂表达式,总担心会产生奇怪的 SQL。后来开着UseMonitorCommand的 SQL 日志,亲眼看到它把Contains、Any、Select都转成对应的方言和参数,心里才踏实。FreeSql 对表达式支持的范围挺广,日常遇到的In、Not In、字符串拼接、日期比较都能翻译。最常用的是子查询写法:

fsql.Select<User>() .Where(a => fsql.Select<Order>() .Where(o => o.UserId == a.Id) .Any(o => o.Amount > 100)) .ToList();

这段会被翻译成EXISTS (SELECT ... FROM tb_order o WHERE o.user_id = a.id AND o.amount > 100)类型的子查询,业务含义就是"找出下过超百元订单的用户"。注意嵌套写法会自动关联外层实体的a.Id和外键,比自己拼接子查询安全得多。

5.2 导航属性与 IncludeMany:关联查询不再手写

导航属性是 FreeSql 提效的重点。在实体上定义好外键和Navigate之后,查询时可以一次性把关联数据带出来:

var users = fsql.Select<User>() .IncludeMany(a => a.Orders) .ToList();

访问users[i].Orders时,集合是已经加载好的,不需要再单独写一次fsql.Select<Order>().Where(o => o.UserId == ...)。实际执行时,FreeSql 会先查主表,再根据主表筛选出来的主键列表发起关联查询,从数据库往返次数上看依然很省。

多对多关系也支持,比如学生和课程的经典场景,需要中间表。实体上配置[Navigate(ManyToMany = typeof(StudentCourse))]这类信息,IncludeMany会自动把中间表的数据汇聚成右侧的列表。这个我在项目里用得相对少,用到时翻官方文档查一下示例即可。

不过要提醒一个容易误会的点:FreeSql 默认不会像 EF Core 那样自动懒加载导航属性。你需要手动Include或IncludeMany,这是我比较喜欢的设计,因为查询变慢时你至少知道是自己明确要求的加载逻辑在起作用,而不是框架偷偷帮你查了一大堆关联数据。

5.3 仓储模式与工作单元:业务层如何组织数据访问

FreeSql 的仓储模式是可选能力,但和依赖注入放一起很舒服:

var repo = fsql.GetRepository<User>(); var u = new User { NickName = "仓库用户", Age = 30 }; repo.Insert(u); u.Age = 31; repo.Update(u);

仓储对象会跟踪实体状态,Update(u)会按主键更新整个实体,而且如果实体上有Version字段,它还会自动带上乐观锁检查。仓储很适合在业务服务里把同一个实体的操作聚合起来,代码读起来清晰,也避免fsql.Insert<User>、fsql.Update<User>散落得到处都是。

事务方面我最推荐工作单元:

using var uow = fsql.CreateUnitOfWork(); var userRepo = uow.GetRepository<User>(); var orderRepo = uow.GetRepository<Order>(); userRepo.Insert(new User { NickName = "事务用户", Age = 25 }); orderRepo.Insert(new Order { UserId = 新用户Id, Amount = 199 }); uow.Commit();

CreateUnitOfWork()会开启一个事务范围内的会话,内部所有仓储操作都在同一个连接上执行;uow.Commit()统一提交,如果中途异常没有调用 Commit,Dispose时自动回滚。我在接口里同时写主表和明细表时基本都用这一套,比手动管理Ado.Transaction的代码干净得多。

6. 实战问题记录:事务边界、并发控制与多数据库切换的隐性坑

6.1 事务:谁要一起成功,谁必须独立

写事务的第一个原则是范围能小则小。工作单元虽然方便,但事务连接会被独占,占用时间越短越好。我在一个数据导入接口里吃过亏:一开始把 Excel 解析加批量 Insert 全包在一个工作单元里,结果数据量一大,事务跑了好几秒,数据库连接被长期占用,服务吞吐明显掉下来。后来把 Excel 解析移到事务外面,只有真正写库的批量操作才进事务,问题才解决。

第二个原则是明确哪些操作必须原子。比如创建订单时同时插主表和明细表,这两步必须同生共死。但"写订单 + 发通知消息"就不能放同一个事务,消息发送失败不应该回滚订单。事务本质是用资源换一致性,别什么都往里塞。

6.2 乐观锁与并发冲突处理

高并发更新场景,我基本只用乐观锁。FreeSql 支持在实体上标记Version字段实现自动版本控制,但我更常用手动控制的方式,逻辑更透明:

var old = fsql.Select<User>().Where(a => a.Id == 1).ToOne(); if (old == null) return; var rows = fsql.Update<User>() .Set(x => x.NickName, "新名字") .Set(x => x.Version, old.Version + 1) .Where(x => x.Id == old.Id && x.Version == old.Version) .ExecuteAffrows(); if (rows == 0) { // 版本冲突,说明别人先改了,需要重新读取或提示用户 }

这个写法的核心是"版本相等才允许更新"被翻译进 WHERE 条件,ExecuteAffrows()返回 0 就代表冲突。比起"先查再无条件更新",它能真正避免丢失更新。至于悲观锁,FOR UPDATE在某些数据库里支持得很好,但 FreeSql 跨数据库时尽量不要依赖它,因为不同厂商的锁语法和行为细节差异很大,很容易在切换库时踩雷。

6.3 多数据库切换时的隐性坑

FreeSql 的卖点是"换库不换代码",但我的真实体验是:大部分 CRUD 确实不用改,可边界条件很多。比如自增主键,MySql 是auto_increment,SqlServer 是IDENTITY(1,1),PostgreSQL 是SERIAL或GENERATED AS IDENTITY。你用ExecuteIdentity()取主键的代码不用变,但数据库里如果用了特殊 DDL 或默认值,就要小心兼容性。

还有一个更常见的坑:fsql.Ado.ExecuteSql直接写带数据库特有语法的 SQL。我在 MySql 里跑得好好的分页写法,换到 SqlServer 直接语法错误。后来我给自己定了一条规矩:跨库业务代码只用 FreeSql 统一 API;真要手写原生 SQL,就按 DataType 做分支:

var isMySql = fsql.Ado.DataType == DataType.MySql; var sql = isMySql ? "原生SQL-A" : "原生SQL-B"; fsql.Ado.ExecuteSql(sql);

这样核心业务代码不散落数据库方言,切换库时只需要维护少数几个分支点。

最后再分享一点自己的实战体会:框架再好,也只是把数据库访问的常见模式封装了一层,底层的事务、索引、SQL 优化这些基本功还是得过关。我实际用 FreeSql 这一年多,最大的受益是开发效率明显提升,写数据访问层的时间至少缩短一半,省下来的时间去调索引、看执行计划,反而对数据库的理解更深了。

建议从第一个 Demo 跑顺之后多做实验:改条件、加字段、打开 SQL 日志、看执行计划。等你能习惯性地从日志里读出"这条查询走了全表扫描、那条更新带了版本条件",FreeSql 就不再只是一个工具,而成为了你设计数据访问思路的一部分。

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

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

立即咨询