SqlSugar更新语法全解:从单实体到批量更新与AOP审计 我先把话说在前面SqlSugar的Update相关语法是很多人从LINQ转过来之后第一个被绕晕的地方。上一秒还在用db.Updateable(entity).ExecuteCommand()下一秒发现不该改的列也被改了想按条件更新某个字段又不知道SetColumns和Where到底怎么组合等到要批量更新几千条数据又纠结要不要foreach。这篇文章就是把SqlSugar的更新语法按场景拆开从最简单的单实体更新到条件表达式更新、批量更新、子查询更新、AOP审计一次讲透。适合刚接触SqlSugar的.NET开发者也适合已经用了一段时间、想系统查漏补缺的朋友。1. 为什么SqlSugar要把Update拆成这么多API1.1 一条UPDATE语句背后的选择题先回到SQL本身。任何一条更新语句本质上就三件事SET哪些列、赋什么值、WHERE哪一行。UPDATE Student SET Name N张三 WHERE Id 1这段SQL看起来简单但在ORM里做起来麻烦得多。因为ORM面对的是对象而不是一条写好的SQL。一个实体类可能有三四十个属性你调用Update的时候框架必须回答几个问题哪个列是主键能不能当WHERE条件这次的更新是全部列都赋值还是只更新其中几个实体里某些字段是null要不要把数据库里的值覆盖成NULL如果实体没有主键靠什么定位要改的行能不能像写SQL那样直接在SET里做自增、拼接字符串、取子查询结果同一个Update需求因为这几个问题的答案不同写法就完全不同。SqlSugar没有把这些细节藏在“智能默认”身后而是提供了一组API让你自己决定每一步。理解了这一点再看Updateable、UpdateColumns、IgnoreColumns、WhereColumns、SetColumns这些方法就不会觉得是语法堆砌了。1.2 更新语法的四种基本范式我把SqlSugar的Update用法归纳成四种范式后面所有技巧基本都跑不出这四类范式代表性API适用场景实体驱动Updateable(entity)手头有一个完整或部分实体对象按主键更新列控制驱动UpdateColumns/IgnoreColumns只想更新个别列或想保护某些列不被覆盖条件表达式驱动UpdateableT()SetColumnsWhere不依赖实体对象直接用Lambda描述“SET什么、WHERE什么”批量驱动Updateable(ListT)一次性更新一批实体减少数据库往返实际开发中条件表达式驱动用得最多也最安全。因为它的写法最接近SQL语义可读性强而且不会因为实体里多了个莫名其妙的字段就误更新。后面每一节都会围绕这四种范式展开。2. 最常用的一套按主键更新实体以及列级控制2.1 单实体更新的默认行为与最小写法先看最常见的一段代码var student new Student { Id 1, Name 张三, Age 20 }; var rows db.Updateable(student).ExecuteCommand();这段代码生成的SQL大致是这样UPDATE Student SET Name N张三, Age 20, Sex NULL, CreateTime NULL, IsDeleted NULL WHERE Id 1注意看默认行为实体里所有的非主键列都会出现在SET里包括值为null的属性。也就是说如果Sex字段在实体里没赋值那么更新之后数据库里的Sex就变成了NULL。这个默认行为在日常开发中是个大坑。很多人的实体是从前端接过来的DTO前端只传了Name和Age其余字段都是默认值。这时候直接Updateable(entity).ExecuteCommand()等于把这一行其他字段全部清空。我一开始用的时候也踩过这个坑后来才养成习惯要么在实体上提前把所有字段塞满要么用下一节讲的列控制方法。还有一点要提醒ExecuteCommand()返回的是受影响行数。你可以用这个返回值判断更新是否真的命中了一行if (rows 0) { // 说明没有找到 Id 1 的数据或者数据本身没变化 }2.2 UpdateColumns只动指定列IgnoreColumns锁住不碰的列当你只需要更新一个实体的某几个字段时用UpdateColumnsdb.Updateable(student) .UpdateColumns(it new { it.Name, it.Age }) .ExecuteCommand();生成的SQL就只有两列UPDATE Student SET Name N张三, Age 20 WHERE Id 1注意UpdateColumns里的Lambda返回的是一个匿名对象这个匿名对象的属性名就是你要更新的列名。如果你不喜欢表达式写法也可以传字符串数组db.Updateable(student) .UpdateColumns(Name, Age) .ExecuteCommand();字符串写法适合动态拼列名比如权限系统里根据用户可编辑字段动态生成更新列表。与UpdateColumns互补的是IgnoreColumns意思是“除了这些列其他都更新”db.Updateable(student) .IgnoreColumns(it new { it.CreateTime, it.IsDeleted }) .ExecuteCommand();这个写法适合更新一个“几乎完整”的实体但想保护CreateTime、IsDeleted这种不该被业务操作覆盖的字段。两个方法的选择标准很简单更新字段少 - 用UpdateColumns明确列出要动的列。更新字段多、只有一两个字段需要保护 - 用IgnoreColumns写起来更省。2.3 没有主键的表怎么安全更新WhereColumns与Where兜底实体有没有主键SqlSugar的更新行为差别很大。有主键时默认就用主键当WHERE条件这个没什么好说的。麻烦的是某些表没有主键比如历史流水表、日志表、或者早期设计不规范的表。这时候直接用Updateable(entity).ExecuteCommand()SqlSugar很可能不知道拿哪一列去定位记录。按我的经验最关键的是绝不能指望它“聪明地猜”必须显式指定条件列// 假设业务唯一键是 Code表上没有主键 db.Updateable(new Student { Code S001, Name 新名字 }) .WhereColumns(it it.Code) .ExecuteCommand();WhereColumns的作用是告诉SqlSugar用Code列作为定位条件而不是主键。生成的SQL是UPDATE Student SET Name N新名字 WHERE Code NS001类似的如果你连WhereColumns都不想用还可以直接用Wheredb.Updateable(new Student { Name 新名字 }) .Where(it it.Code S001) .ExecuteCommand();这两种写法都能在没有主键的情况下精确定位数据。但这里我要特别强调在没有主键、也不指定任何条件的情况下某些数据库上SqlSugar会直接更新整表。这属于典型的生产事故级别操作千万不能靠侥幸。凡是操作没有主键的表更新前必须先确认条件列或者先在测试库里跑一遍生成的SQL看有没有漏掉WHERE。3. 条件更新与表达式更新SetColumns才是主力语法3.1 基本条件更新怎么组合实体驱动式更新有一个天然的局限你得先有一个实体对象。但很多时候你只是想把某个字段更新成指定值比如“把所有状态为0的订单改成已取消”。这时候用UpdateableT()加SetColumns加Where才是正解db.UpdateableOrder() .SetColumns(it it.Status 2) .Where(it it.Status 0) .ExecuteCommand();这段代码的意思是把Status等于0的行的Status设置为2。看清这里的赋值语义SetColumns(it it.Status 2)里的不是判断相等而是“将Status赋值为2”。第一眼容易懵用习惯之后反而觉得顺因为一眼就能看出“要改哪列、改成什么值”。多个条件可以直接用或||组合db.UpdateableOrder() .SetColumns(it new Order { Status 2, UpdateTime DateTime.Now }) .Where(it it.Status 0 it.CreateTime DateTime.Now.AddDays(-30)) .ExecuteCommand();如果你更喜欢画线式的写法也可以把SetColumns换成传入对象db.UpdateableOrder() .SetColumns(it it.Status 2) .Where(it it.Id 1) .ExecuteCommand();然后注意一个非常重要的坑不建议连续多次调用.Where()来叠加条件。我印象中某些版本里多次调.Where()不是把条件累加而是后面的覆盖前面的。动态条件多的时候最好用表达式拼接或者让调用方传一个合并好的表达式树。你可以在本地用LINQPad之类工具打印生成的SQL验证一下反正我每次看到有人写.Where(a).Where(b)都会多留个心眼。3.2 让数据库算而不是让程序算自增、日期、字符串拼接SetColumns真正强大的地方在于它不只是支持“赋一个固定值”还能写表达式把计算交给数据库执行。最典型的是自增场景// 浏览数加1 db.UpdateableArticle() .SetColumns(it it.ViewCount it.ViewCount 1) .Where(it it.Id 10) .ExecuteCommand();这条生成的SQL是UPDATE Article SET ViewCount ViewCount 1 WHERE Id 10注意自增操作在并发下如果用“先查出来再1再更新”的写法很容易丢更新。而直接在数据库里执行ViewCount 1是原子性的这才是自增的正确姿势。类似的用法还有// 更新时间 db.UpdateableUser() .SetColumns(it it.LastLoginTime DateTime.Now) .Where(it it.Id userId) .ExecuteCommand(); // 字符串拼接给所有VIP用户的名字加后缀 db.UpdateableUser() .SetColumns(it it.Name it.Name VIP) .Where(it it.IsVip true) .ExecuteCommand();用这一组写法你就可以少写很多“先Select再Update”的冗余代码。记住一个原则能够在数据库里一步完成的赋值不要拉回内存做一遍再写回去。3.3 关联子查询更新一个SqlFunc.Subqueryable解决的问题还有一个高频需求我单独拿出来说把A表某个字段更新成B表查出来的值。比如学生表里存了TeacherName冗余字段老师改名了学生表的冗余字段也得跟着改。SqlSugar里可以用SqlFunc.Subqueryable做子查询赋值db.UpdateableStudent() .Where(it it.TeacherId 0) .SetColumns(it it.TeacherName SqlFunc.SubqueryableTeacher() .Where(t t.Id it.TeacherId) .Select(t t.Name)) .ExecuteCommand();生成的SQL大致是UPDATE Student SET TeacherName (SELECT TOP 1 Name FROM Teacher WHERE Id Student.TeacherId) WHERE TeacherId 0这个写法的好处是不用先查出所有学生再逐条更新一条SQL就把关联更新做完了。如果你觉得子查询可读性差也可以回退到原生SQLdb.Ado.ExecuteCommand( UPDATE s SET s.TeacherName t.Name FROM Student s INNER JOIN Teacher t ON t.Id s.TeacherId WHERE s.TeacherId 0 );原生SQL在复杂关联更新时更直观但跨数据库移植性差。SqlSugar支持多数据库只要一个写法能在不同库上翻译出正确SQL我就优先用ORM。子查询更新就是这种场景。4. 批量更新从foreach到一条命令性能差的根源与解法4.1 批量更新的正确姿势与参数分片批量更新最常见的错误姿势是foreach逐条更新foreach (var item in list) { db.Updateable(item).ExecuteCommand(); }这么写功能上没问题但性能非常差。每一条UPDATE都要经历一次网络往返、SQL解析、事务提交1000条数据就要打1000次数据库。实测下来这种写法在数据量过千之后会明显变慢。正确做法是直接传Listvar studentList new ListStudent { new Student { Id 1, Name 张三, Age 20 }, new Student { Id 2, Name 李四, Age 21 } }; var rows db.Updateable(studentList).ExecuteCommand();SqlSugar底层会把List里所有实体的UPDATE语句合并成一个批处理一次性发给数据库执行减少大量往返。在我自己的测试环境里1000条数据逐条更新可能要十几秒用批量更新后基本一两秒内完成。当然不同数据库、不同网络环境数据会有差异但数量级差距是真实的。如果数据量特别大比如上万条我建议手动分片每500条一批配合事务保证原子性db.Ado.BeginTran(); try { var pageSize 500; for (var i 0; i list.Count; i pageSize) { var batch list.Skip(i).Take(pageSize).ToList(); db.Updateable(batch).ExecuteCommand(); } db.Ado.CommitTran(); } catch { db.Ado.RollbackTran(); throw; }分片的原因主要有两个一是某些数据库对单条SQL的参数数量有限制比如SQL Server默认是2100个参数左右二是单次批处理太大数据库端的重放日志也会膨胀。分片之后参数数量可控失败回滚也清晰。4.2 批量更新最容易忽略的字段覆盖问题Updateable(ListT)和单实体更新一样默认也是把每行对象的所有列都更新一遍。批量更新场景里最危险的画面是你先查出一些数据在内存里改了其中两个字段然后把这个List交给Updateable执行。看起来只改了两列实际上每行所有列都被重新SET了一遍包括你没动过的字段。解决方案是给批量更新指定列db.Updateable(studentList) .UpdateColumns(it new { it.Name, it.Age }) .ExecuteCommand();或者配合IgnoreColumnsdb.Updateable(studentList) .IgnoreColumns(it new { it.CreateTime, it.UpdateTime }) .ExecuteCommand();这里我再说一个判断经验如果你批量更新的数据原封不动来自上一次查询结果那么全列更新问题不大如果你的数据来自接口入参或者只组装了部分字段那必须用列控制。千万不要写“这次侥幸没出事”的代码。4.3 批量更新失败怎么处理批量更新不是“执行完就完事”的。注意ExecuteCommand()返回的是受影响的总行数你可以用它验证批量更新是否完整var expected studentList.Count; var affected db.Updateable(studentList).ExecuteCommand(); if (affected ! expected) { // 说明有一部分行没有被更新需要进一步排查 }有的数据库对UPDATE语句的“受影响行数”统计方式不太一样但大部分场景下这个数字可以当做一个粗粒度的校验指标。如果批量更新中途抛异常数据库通常不会自动回滚所有行所以我在批量更新大数据量时习惯显式开启事务像上面那段分片代码一样。注意事务范围内不要做太多无关操作尽量只把更新逻辑包进去缩短事务持有时间。5. 更新过程中的数据保护与审计AOP、返回值和版本控制5.1 用AOP统一处理审计字段和SQL日志SqlSugar的AOP功能在更新场景里特别好用。OnUpdateExecuting会在每次更新执行前触发你可以在这里拿到即将执行的SQL和参数做统一处理db.Aop.OnUpdateExecuting (sql, pars) { // 打印更新SQL排查问题时非常有用 Console.WriteLine(sql); }; db.Aop.OnUpdateExecuted (sql, pars) { // 更新执行成功后可以记录审计日志 };我实际项目里经常用这个钩子统一处理审计字段。比如要求所有更新操作都必须把UpdateTime设为当前时间、UpdateUserId设为当前登录人。如果在每个业务方法里手动赋值很容易漏。统一在AOP里根据参数名修改就能保证一致性db.Aop.OnUpdateExecuting (sql, pars) { foreach (var p in pars) { if (p.ParameterName UpdateTime) { p.Value DateTime.Now; } } };注意参数名的格式可能跟列名不完全一致用之前最好先打印一遍SQL和参数名确认。AOP是一个很容易被人忽略但非常值得投入的功能更新逻辑越分散它越能帮你兜底。5.2 ExecuteCommand返回值、ExecuteReturnEntity的场景更新之后要不要拿返回值分几种情况。普通更新只需ExecuteCommand()拿一个int受影响行数就够了。前面已经说过用这个数字判断更新是否命中。有些场景更新后需要拿到完整实体。比如数据库里有触发器、计算列、默认值你更新完想拿回数据库里的最新值就可以用Updateable(entity).ExecuteReturnEntity()var updatedStudent db.Updateable(student) .UpdateColumns(it new { it.Name }) .ExecuteReturnEntity();这个方法更新完成后会返回这一行的最新实体包含数据库计算出来的字段。不过要注意它内部会多一次查询性能上比ExecuteCommand()稍重非必要不滥用。异步场景对应的是ExecuteCommandAsync()var rows await db.Updateable(student) .UpdateColumns(it new { it.Name }) .ExecuteCommandAsync();5.3 版本控制乐观锁让并发更新不丢数据并发更新的经典问题两个人同时读取同一条数据A把状态改成1B把状态改成2最后谁后提交谁覆盖谁丢失更新。SqlSugar提供了一套版本控制机制本质是乐观锁。使用前先在实体里加一个版本号字段public class Student { [SqlSugar.SugarColumn(IsPrimaryKey true)] public int Id { get; set; } public string Name { get; set; } public int Version { get; set; } }更新时调用VersionCodevar rows db.Updateable(student) .VersionCode(it it.Version) .ExecuteCommand(); if (rows 0) { // 版本号已经不匹配说明数据被其他人改过了 }SqlSugar会在生成的SQL里带上版本号条件类似UPDATE Student SET Name N张三, Version Version 1 WHERE Id 1 AND Version 0如果执行时数据库里的Version已经不是0说明这行数据已经被其他人更新过本次更新影响行数为0。你在业务里拿到这个0就可以提示用户“数据已变化请刷新后重试”。这套机制建议在所有需要编辑保存的场景里用起来成本低防丢更新效果很直接。6. 更新语法速查表与避坑备忘6.1 常见更新场景的写法速查需求推荐写法关键点按主键更新实体所有非主键列Updateable(entity).ExecuteCommand()空值也会覆盖到数据库只更新指定列Updateable(entity).UpdateColumns(x new { x.Name }).ExecuteCommand()适合部分字段更新忽略指定列Updateable(entity).IgnoreColumns(x new { x.CreateTime }).ExecuteCommand()适合保护敏感字段按业务条件更新UpdateableT().SetColumns(x x.Status 2).Where(x x.Id 1).ExecuteCommand()不依赖完整实体字段自增SetColumns(x x.ViewCount x.ViewCount 1)原子操作并发安全批量更新Updateable(list).UpdateColumns(...).ExecuteCommand()避免foreach逐条更新更新值来自子查询SetColumns(x x.Name SqlFunc.SubqueryableT()...)可做关联表同步更新后返回完整实体Updateable(entity).ExecuteReturnEntity()多一次查询异步更新ExecuteCommandAsync()配合async/await乐观锁更新Updateable(entity).VersionCode(x x.Version).ExecuteCommand()防并发丢更新这张表基本覆盖了日常工作里九成以上的更新需求。剩下的个性化场景基本都是这些API的组合。6.2 我踩过的三个Update坑第一个坑就是实体里的null字段把数据库值覆盖成NULL。那次是给一套老系统加批量修改功能前端只传了主键和两个要改的字段我用Updateable(entity)直接执行结果一条更新把所有客户资料的备注、联系电话全清空了。当时吓得赶紧回滚后来统一改成UpdateColumns明确指定列再也没出过事。第二个坑是没有主键的表直接调用Updateable(entity)。代码没报错执行完才发现整表都被改了一遍。从此我给自己定了一条死规矩凡是实体上没标主键的更新之前必须显式指定WhereColumns或Where不放行默认更新。第三个坑是动态条件拼接。多个来源的条件需要同时生效时向Where里传入动态组合的表达式树不要靠多次调用Where。我有一次就因为这个写出来的SQL条件只剩了最后一个排查了很久才定位到是链式调用覆盖的问题。6.3 关于更新语法的使用建议最后说点个人体会。SqlSugar的更新API看着多实际记住一条主线就行默认实体更新是“全列覆盖”不想全列覆盖就用UpdateColumns或IgnoreColumns控制更精细的更新直接走UpdateableT().SetColumns(...).Where(...)表达式路线。我现在的项目里超过八成的更新都是SetColumns Where写的实体驱动的更新只保留在“整行保存”的场景。如果你还在困惑某条更新为什么没生效先别急着怀疑框架。把db.Aop.OnUpdateExecuting里的SQL打印出来看一遍八成问题出在条件没拼对或者有字段被意外置空。ORM只是把你的意图翻译成SQL翻译出来的SQL好不好、该不该执行最终还是得开发者自己心里有数。