☰
SqlSugar操作MySQL的五个隐藏坑:连接串、类型映射到事务的实战避坑指南
2026/10/3 9:51:28 网站建设 项目流程

上个月陪朋友排查一个C#服务的线上事故,症状很有意思:商品评价里的emoji全变成问号、订单对账少了几分钱、凌晨的批量导入任务直接超时。我第一反应不是看业务代码,而是让他把SqlSugar连MySQL的连接串发过来。果然是老熟人——CharSet没设置,服务端字符集扛不住emoji;实体里的金额字段没标精度,数据库一收就四舍五入,对账自然对不上。这口锅SqlSugar不背,真正的坑是大家把注意力全放在CRUD好不好写上,却忽略了底层连接、类型映射和生成SQL的真实行为。

如果你正在用C#写MySQL,或者正准备从EF/Dapper切到SqlSugar,这篇文章值得花五分钟过一遍。下面这五个细节,每一个我都给出完整的现象、原因和能直接落地的代码。不是抄官方文档,是踩坑踩出来的。

1. 连接串上那些"不报错但结果不对"的参数:编码、时区和驱动

1.1 CharSet=utf8mb4 不是可选项,是必选项

SqlSugar连接MySQL时,最容易被忽略的就是连接串里的CharSet参数。很多人写完Server=localhost;Database=shop;Uid=root;Pwd=123456;就以为完事了,结果往表里写emoji或者生僻字时,要么直接报错,要么变成一串问号。

问题出在MySQL的字符集层级上。MySQL的utf8实际是utf8mb3,最多只能存3字节字符,而emoji是4字节。如果客户端连接字符集没指定,驱动很可能用了latin1或者老版本的utf8mb3,参数传进去就已经折了,跟表字段是不是utf8mb4没有关系。SqlSugar生成SQL时用参数化查询,参数的编码由连接串决定,所以就算你把数据库和表都改成utf8mb4,连接串不写CharSet=utf8mb4照样乱码。

推荐写法:

Server=localhost;Port=3306;Database=shop;Uid=root;Pwd=123456;CharSet=utf8mb4;SslMode=None;AllowPublicKeyRetrieval=true;

SslMode=None一般用于内网开发环境,生产环境建议用Preferred或Required。AllowPublicKeyRetrieval=true是针对MySQL 8默认的caching_sha2_password认证方式,如果你既不用SSL又不开这个参数,连接时会出现公钥检索失败。

还有一点容易被忽略:utf8mb4下varchar(255)的索引长度。一个utf8mb4字符最多4字节,255个字符就是1020字节,如果赶上旧版本MySQL或特定的row_format,建索引会报Specified key was too long。所以别把所有字符串都设计成255,需要建索引的列要么缩短长度,要么用前缀索引。

1.2 时区与DateTime.Kind:为什么部署到服务器后时间差8小时

这个问题更隐蔽,因为本地开发绝大多数时候根本看不出来。你本机连的MySQL和你代码跑在同一时区,驱动一读,时间显示正常。但服务部署到云上、数据库单独一台机器、或者两个机房跨时区之后,读出来的时间就可能整体偏移8小时,甚至DateTime的Kind变成Utc,前端再一格式化,全串了。

原因是MySQL连接的会话时区和.NET端DateTime的Kind没有对齐。MySQL驱动读取日期时会按照连接会话的时区做转换,而C#这边拿到的DateTime可能是Local、Utc或Unspecified;之后你要是再调用ToUniversalTime()或者ToLocalTime(),就会发生二次转换。

我的建议是定一个规范:要么应用和数据库都统一用UTC,连接串里配置DateTimeKind=Utc;要么全都用北京时间,业务代码里禁止再到处ToLocalTime()。不管哪种,先在测试环境验证一次,别等上线后再靠猜。MySQL这边可以执行SELECT @@global.time_zone, @@session.time_zone;确认一下会话时区。

1.3 驱动选型:MySqlConnector和MySql.Data的参数不完全通用

SqlSugar的MySQL支持主要围绕两套驱动:一套是老牌的MySql.Data(Oracle官方Connector/NET),另一套是MySqlConnector。很多教程给的是老驱动的连接串,你项目里引的却是新驱动,某些参数就会出现"写了没效果"或"直接不识别"的情况。

这两个驱动在连接串上至少有三个地方不一样:

配置项MySql.DataMySqlConnector备注
字符集CharSetCharSet两者都支持
公钥检索AllowPublicKeyRetrievalAllowPublicKeyRetrievalMySQL 8认证需要
零日期AllowZeroDateTimeAllowZeroDateTime零值日期处理
布尔映射TreatTinyAsBoolean默认行为有差别tinyint(1)是否读成bool
时区依赖连接时区DateTimeKind参数转换策略不同

踩过一次很典型的坑:从老的MySql.Data升级到MySqlConnector后,原来运行正常的代码突然在读取某些字段时报"无法从SByte转换为Boolean"。原因就是两个驱动对TINYINT(1)列的默认映射策略不一样。这种问题不看你代码,先确认驱动,再统一连接串参数。

2. 实体映射与类型精度:这几个坑不报错,但会让数据悄悄变错

2.1 decimal不标精度,金额被静默四舍五入

SqlSugar里你写一个public decimal Price { get; set; },看起来很安全。但如果数据库里的列是decimal(10,4),而SqlSugar CodeFirst按默认策略建表,很可能建成decimal(18,2)。你插入时带的是三位小数、四位小数,MySQL在严格模式下直接抛Out of range,非严格模式下就静默四舍五入——对账差出来的几分钱就是这么来的。

金额计算还有一个更基础的坑:别用double。double是二进制浮点,0.1加0.2等于0.30000000000000004,存到数据库再去算总金额,误差会累积。C#里的标准做法是decimal,同时在实体上用SugarColumn把精度写死:

[SugarColumn(ColumnDataType = "decimal(18,4)", DecimalDigits = 4)] public decimal Price { get; set; }

DecimalDigits表示小数位数,Length表示总位数。建议根据业务定一个统一标准:金额用decimal(18,4),汇率、积分这类可能需要更高精度,就自己评估。用DbFirst从已有库生成实体时,别手贱把生成的ColumnDataType删掉,那是最安全的映射来源。

2.2 bool和tinyint(1)的映射:状态的"2"怎么就成了异常

很多老表喜欢用tinyint(1)表示布尔值,比如is_deleted,0表示未删,1表示已删。C#实体你自然会写成public bool IsDeleted { get; set; }。问题来了:如果某一行数据因为历史原因写入了2或者3,驱动读取时会发现TINYINT的值无法转成bool,直接抛异常。

另一种情况是连接串的布尔映射参数没配对。同样的表,A驱动默认把tinyint(1)读成bool,B驱动默认读成byte或short;你的实体属性是bool还是byte就可能出现类型不匹配。看起来是"同样的代码换环境就崩",本质是驱动行为差异。

我的建议很简单:

  • 如果语义确实是布尔(只有0/1),实体用bool,列保持tinyint(1),然后把连接串的布尔映射参数统一。
  • 如果语义是状态(0、1、2、3),别硬用bool,改成byte或int。

别小看这个,我在生产环境见过一次因为is_deleted被中间层误写成2,导致整张表查不出来的事故。布尔就是布尔,状态就是状态,混着用迟早出事。

2.3 DateTime零值:MySQL的0000-00-00怎么处理

MySQL有一类数据是SQL Server那边不太常见的:日期字段的默认值是0000-00-00 00:00:00,尤其老库迁移过来经常遇到。C#的DateTime最小只能表示0001-01-01,所以驱动读零日期时,默认行为是抛异常。

解决思路有两个方向:

  • 治本:写个SQL把零日期统一改成NULL或者有效日期,同时建表时给日期字段加上DEFAULT CURRENT_TIMESTAMP,从源头杜绝零值。
  • 治标:连接串里加AllowZeroDateTime=true,让驱动把零值读成DateTime.MinValue。两个主流MySQL驱动基本都支持这个参数。

实体那边建议用DateTime?接收可空日期,这样NULL不会出问题。但注意,AllowZeroDateTime处理的是"零值",不是NULL,两者别混为一谈。

2.4 string默认长度与索引的字符集限制

SqlSugar实体里的string属性如果没有指定长度,CodeFirst建表时一般按varchar(255)处理。255在大多数场景够用,但有两种情况会翻车:

一种是内容超长。比如备注字段塞了500字,严格模式下MySQL直接报Data too long for column。解决方式是在实体上标注长度或指定类型:

[SugarColumn(ColumnDataType = "text")] public string Remark { get; set; } [SugarColumn(Length = 500, ColumnDataType = "varchar(500)")] public string Description { get; set; }

另一种是索引长度。需要加索引的字符串列,如果长度太大,加上utf8mb4的4字节存储,很容易撞上InnoDB的索引长度上限。我习惯的做法是:需要排序、去重、查询条件的字符串列,长度控制在191以内;大文本不建索引,真要搜索就走LIKE或全文检索方案。

3. 批量插入与分页:SqlSugar写着简单,底层SQL要先想清楚

3.1 Insertable一次塞太多,MySQL直接拒收

SqlSugar的Insertable(list).ExecuteCommand()用起来非常爽,但很多人不知道它在MySQL下生成的是一条多值INSERT语句:

INSERT INTO `order_item` (...) VALUES (...), (...), (...), ...

这句话本身没问题,但SQL文本太大就会撞上MySQL的max_allowed_packet限制。默认配置下这个值可能只有4MB到64MB,你要是把5万条数据一次性丢进去,SQL直接超过上限,MySQL直接拒绝,报Packets larger than max_allowed_packet are not allowed。

不要试图通过调大max_allowed_packet来解决,大包还会带来锁长时间不释放、binlog暴涨、主从延迟等一系列问题。正确做法是先分批,再插入:

var list = LoadOrders(); // 假设有几万条 foreach (var batch in list.Chunk(1000)) { await db.Insertable(batch.ToList()).ExecuteCommandAsync(); }

.Chunk(1000)是.NET 6的LINQ方法,用老版本的话就自己写个Skip/Take循环。批次大小建议500到1000之间,结合单条数据大小来调。如果追求极致导入性能,可以研究MySqlBulkCopy,但它的限制更多,比如列顺序必须和表一致,一般批量导入再考虑。

3.2 ReturnIdentity批量场景别依赖,连续编号是另一个故事

ExecuteReturnIdentity()返回自增ID,这条在单条插入时很好用。但批量插入时,这个方法返回的是哪条ID,不同驱动、不同版本行为可能不一样。有人以为会返回最后一条,有人以为会返回集合里所有ID,都是误解。

如果你批量插入后确实需要拿到每一行的ID,我的建议是:

  • 插入完成后,用业务上的唯一键(订单号、批次号、业务编号)去反查ID。
  • 如果业务字段也没有唯一键,就改代码生成一套业务编号,别依赖自增列做业务关联。
  • 如果一定要每条都拿ID,老老实实逐条插入,但要做好性能预期。

还有一个和"连续编号"相关的认知问题:MySQL InnoDB的自增ID只要发生过回滚或者删除,就不会再复用,所以ID中间有空洞是正常现象。如果业务要求编号严格连续,自增列不能作为编号来源,应该单独做一张编号表,在事务里用SELECT ... FOR UPDATE取号。

// 别指望批量插入后ID是连续的 var inserted = await db.Insertable(list).ExecuteReturnIdentityAsync(); Console.WriteLine(inserted); // 可能只是第一条或某一条

3.3 分页查询必须带唯一排序,LIMIT偏移还藏着性能隐患

SqlSugar的分页API很好用,但有个隐含前提:你的查询必须带一个稳定的排序,否则MySQL的LIMIT偏移翻页时会乱。举个例子,你不写OrderBy,直接ToPageListAsync(1, 20, ref total),MySQL没有物理顺序保证,可能第一页和第二页的数据交叉重复,也可能漏数据。

更隐蔽的是排序字段不唯一。比如按CreateTime排序,恰好同一毫秒插入了多条,分页结果就是不稳定的。稳妥做法是唯一键兜底:

var page = await db.Queryable<Order>() .Where(o => o.Status == 1) .OrderBy(o => o.CreateTime) .OrderBy(o => o.Id) // 唯一键收尾 .ToPageListAsync(pageIndex, pageSize, ref total);

再往深一层,LIMIT 1000000, 20这种大偏移分页,无论用不用SqlSugar都会很慢,因为它要扫描并丢弃前100万行。业务上做后台列表还好,做C端列表就建议改用"键集分页":

var nextPage = await db.Queryable<Order>() .Where(o => o.Id > lastId) // 上一页最大ID .OrderBy(o => o.Id) .Take(20) .ToListAsync();

这种方式只适合按ID排序或者按唯一顺序字段翻页,但性能是实打实的提升。

4. CodeFirst与表结构:保留字、大小写和"只建不补"的半自动迁移

4.1 列名和表名撞上MySQL保留字:建表都很痛苦

SqlSugar的实体属性如果叫Order、Level、Key、Group、Desc,建表时不一定会自动用反引号包起来。MySQL里这些词是保留字,直接出现在SQL里就是语法错误。

我的建议是从源头规避:所有映射到数据库的列名、表名都统一走蛇形命名,并且避开保留字。

[SugarTable("user_order")] public class OrderEntity { [SugarColumn(ColumnName = "order_no")] public string OrderNo { get; set; } [SugarColumn(ColumnName = "level_no")] public int LevelNo { get; set; } [SugarColumn(ColumnName = "remark")] public string Remark { get; set; } }

如果你确实需要映射到一个叫order的字段,可以通过[SugarColumn(ColumnName = "order")]这种方式显式声明,但我更建议直接改列名,而不是每次写SQL都跟保留字较劲。

4.2 Linux和Windows下大小写敏感:开发没事,上线翻车

Windows上的MySQL默认对表名大小写不敏感,Linux上则敏感,具体由lower_case_table_names控制。这就导致一个经典场景:本地开发用Product表,跑得好好的;部署到Linux服务器,报Table 'shop.Product' doesn't exist,一看线上表名是product。

解决方式很简单:所有表名、列名统一小写,实体上通过[SugarTable("product")]和[SugarColumn(ColumnName = "product_name")]显式映射。

另外,lower_case_table_names只能在MySQL初始化时设置,改配置还需要重启实例,而且已有的表不会自动改大小写。所以这个事最好在项目一开始就定好规范。

4.3 CodeFirst不是Migration:自动建表能加表,不能改列

SqlSugar的CodeFirst能力在很多项目里被当成"数据库迁移工具"用,这是个误区。

db.CodeFirst.InitTables(typeof(Product))能做到的是:如果Product表不存在,就帮你建表;如果实体里新增了一个列,某些版本会做增量补列。但它不会帮你改已有列的类型和精度,更不会帮你删列、同步索引。你改了实体里的decimal(18,4)为decimal(20,6),然后重新跑项目,数据库表结构九成不会跟着变。

我的习惯是:

  • 开发阶段,可以用CodeFirst快速建表,省去手写SQL。
  • 生产环境,表结构变更一律走手工SQL脚本,走发布流程。
  • 启动时加一个自检:用db.DbMaintenance.GetColumnInfosByTableName("product")把实际列信息拉出来,和实体属性比对,不一致就报警,而不是靠运行时报错来发现。

这样既保留了CodeFirst的便利,又不会被它的"半自动"坑一把。

5. AOP日志和事务:排查效率提升一半,事务别在lambda里吞异常

5.1 让SqlSugar把SQL全部打印出来,比看错误堆栈快得多

SqlSugar有个特别好用的AOP,可以拦截所有SQL执行:

db.Aop.OnLogExecuting = (sql, pars) => { Console.WriteLine($"{DateTime.Now:HH:mm:ss.fff} {sql}"); Console.WriteLine(string.Join(", ", pars.Select(p => $"{p.ParameterName}={p.Value}"))); };

这个开关一开,SqlSugar到底执行了什么SQL、传了什么参数,一目了然。排查乱码、类型转换、性能问题时,这是最快的路径。

有几个使用细节:

  • 打印出来的ParameterName可能是@xxx,不要纠结这个,实际走MySQL驱动时会转成?参数,不影响调试。
  • 生产环境别全量打印,应该加一个配置开关,只在调试时打开;或者做一个慢SQL过滤,只输出超过指定阈值的SQL。
  • OnLogExecuting回调里不要再去执行数据库操作,否则会递归,把自己绕进去。

5.2 UseTran的隐藏规则:同一个db、别嵌套、更别吞异常

SqlSugar的事务API写起来很舒服:

var result = await db.Ado.UseTranAsync(async () => { await db.Insertable(order).ExecuteCommandAsync(); await db.Updateable(orderItem).ExecuteCommandAsync(); }); if (!result.IsSuccess) { Console.WriteLine(result.ErrorMessage); }

但我见过不少同学在这上面踩坑,主要三个:

第一,事务里混用了多个数据库实例。UseTran管理的是当前db实例上的连接和事务。你在lambda里又创建了一个新的SqlSugarClient,或者调用了另一个Service里自己new的连接,它们不会共享当前事务。结果就是一部分数据在事务里,一部分在外面,一旦报错回滚,数据就不一致了。

第二,嵌套事务。UseTran里面再包一层UseTran,SQL Server可能有SavePoint,MySQL上的行为则要看驱动和版本,很多时候内层异常会导致外层状态混乱。我的建议是事务最多一层,不要嵌套。

第三,也是我最想提醒的:lambda里如果把异常try-catch吞掉了,SqlSugar以为这段代码正常跑完了,会执行提交。这不是SqlSugar的bug,它判断是否回滚的依据就是lambda有没有抛异常。你吞了异常,它就会提交,数据该回滚时没回滚。

正确的做法是要么不catch,要么catch之后重新throw,要么在catch里手动db.Ado.RollbackTran()。我在项目里见过几次"明明写事务了,还是出现了半截数据",最后查下来全是吞异常导致的。

最后说一个我自己的习惯:事务块里不要放超大batch的插入,也不要放耗时的外部接口调用。MySQL的事务在提交前会持有行锁,事务拖得越久,锁冲突和主从延迟的风险越大。SqlSugar只负责帮你管理事务生命周期,这些"度"还是得自己在业务层把握。

写到这里正好想起来,我每次新项目上线前都会过一遍清单:连接串的字符集和时区、实体的decimal精度、所有类布尔字段的取值枚举、批量插入的分批大小、分页SQL的唯一排序、表名/列名的大小写规范、以及生产环境的SQL日志开关。这套动作做下来,线上因为"SqlSugar操作MySQL"翻车的概率会低很多。你踩过哪些坑,也欢迎交流。

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

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

立即咨询