
做上位机和业务系统的时候往数据库里塞数据几乎是每天都要干的事。我之前一直用SqlServer和SQLite后来一个数据采集项目要换到PostgreSQL顺手把数据访问层切到了SqlSugar。这篇文章把C#通过SqlSugar插入数据到PostgreSQL的完整流程整理出来包括包引用、连接配置、实体映射、批量插入以及我在实际项目里踩过的几个坑。如果你正在做上位机数据采集、工业现场数据入库或者打算把老系统从SqlServer迁到PostgreSQL这篇应该能帮你少走几条弯路。1. 为什么是SqlSugarORM选型背后的实际问题1.1 原生Npgsql的痛点很多C#开发者第一次接触PostgreSQL时第一反应是装Npgsql驱动然后老老实实写ADO代码。比如要往一张设备数据表里插入一条记录通常是这样using var conn new NpgsqlConnection(connString); await conn.OpenAsync(); var sql INSERT INTO device_data(device_id, metric, value, collect_time) VALUES(device_id, metric, value, collect_time) RETURNING id;; using var cmd new NpgsqlCommand(sql, conn); cmd.Parameters.AddWithValue(device_id, deviceId); cmd.Parameters.AddWithValue(metric, metric); cmd.Parameters.AddWithValue(value, value); cmd.Parameters.AddWithValue(collect_time, DateTime.Now); var id (long)await cmd.ExecuteScalarAsync();单看这一段没问题问题出在表一多、字段一变代码就开始膨胀。设备采集的点位可能有几十个字段手写参数映射最大的痛点是字段与参数顺序全靠人肉维护一旦表结构调整插入方法和实体类、DTO全都要跟着改漏改一个参数就是运行时才爆出来的硬错。而且上位机场景往往不只是接一种数据库。甲方今天说用PostgreSQL明天可能要求兼容SQLite做本地缓存。用原生ADO的话每一种数据库都要写一套SQL方言和参数化方式数据访问层直接变成意大利面条。1.2 SqlSugar的定位介于EF Core和Dapper之间SqlSugar是国产.NET ORMNuGet包名叫SqlSugarCore对应.NET Core/.NET 5环境老.NET Framework项目则用SqlSugar包。它和另外两个主流方案的区别很明显EF Core功能全有完整的变更追踪、导航属性、LINQ Provider但重对小项目来说杀鸡用牛刀而且从SqlServer迁移到PostgreSQL时有些查询表达式两边行为不完全一致排查成本高。Dapper轻量性能好但本质是半自动SQL要自己写查询结果到实体要自己映射插入一条数据该写的参数一个都少不了。SqlSugar介于两者之间API风格贴近ADO用起来直接同时把参数构建、主键识别、批量拼接这些脏活包掉了。我选择SqlSugar还有一个很现实的原因它支持多数据库切换。DbType一改连接字符串一换大部分Insert、Query代码可以原样复用。对于要同时对接PostgreSQL和SQLite的上位机项目这个特性省下的工作量不是一点半点。1.3 我的选型标准做了几年数据采集和上位机我对数据访问层的需求其实很朴素能快速把业务对象写进数据库不想手写参数。批量插入性能要过得去。多数据库切换成本低。出了问题能方便地看到SQL日志。SqlSugar正好都满足。这里多说一句如果项目已经是EF Core深度使用大量依赖导航属性、复杂LINQ查询就不建议半路换掉但如果你是像我这样主要就是插数据、查数据、分页展示SqlSugar会更顺手。2. 环境准备NuGet包、Npgsql驱动与连接字符串2.1 安装什么包先创建一个.NET 8控制台项目作为示例dotnet new console -n DemoPostgres cd DemoPostgres dotnet add package SqlSugarCoreSqlSugarCore会自动把Npgsql作为依赖带进来不需要再单独装。如果你用的是老项目比如.NET Framework 4.7.2的WinForm就装SqlSugar包再加Npgsql。版本上稍微注意一下.NET 8以上用SqlSugarCore最新版即可.NET Core 3.1 / .NET 5这种老版本选5.x的SqlSugarCore更稳。装包的时候别顺手装成SqlSugarCore的预览版我吃过这个亏预览版API偶尔会有变动不稳定。2.2 连接字符串的格式坑很多人在这一步卡住因为SqlSugar连接PostgreSQL用的是Npgsql的连接字符串格式和SqlServer完全两个世界Host127.0.0.1;Port5432;Databasetestdb;Usernamepostgres;Password123456;Poolingtrue;Minimum Pool Size1;Maximum Pool Size100;Timeout15;如果你按SqlServer的习惯写成Server127.0.0.1;Databasetestdb;User IDpostgres;Password...Npgsql会直接报Keyword not supported: server。我第一次从SqlServer切过来就被这个错误提醒过后来干脆把连接字符串放到配置文件里统一管理{ ConnectionStrings: { Postgres: Host127.0.0.1;Port5432;Databasetestdb;Usernamepostgres;Password123456;Poolingtrue;Maximum Pool Size100;Timeout15; } }读取方式可以用原生的IConfiguration也可以像我在小工具里那样直接写个静态类读环境变量。重点是把连接字符串和代码分开不然换环境时到处改字符串迟早会漏。2.3 用SqlSugarScope管理数据库实例SqlSugar的官方示例经常直接new SqlSugarClient但我在实际项目里更推荐用SqlSugarScope它对线程安全做了处理并且可以复用连接池资源。封装成静态单例是这样的public static class Db { public static SqlSugarScope Instance new SqlSugarScope(new ConnectionConfig() { DbType DbType.PostgreSQL, ConnectionString Host127.0.0.1;Port5432;Databasetestdb;Usernamepostgres;Password123456;, IsAutoCloseConnection true, InitKeyType InitKeyType.Attribute }); }两个配置项必须讲清楚IsAutoCloseConnection true每次数据库操作结束后自动关闭连接避免连接泄漏。我自己见过太多人new了连接不关导致连接池被占满的例子SqlSugar这个开关能兜底。InitKeyType.Attribute告诉SqlSugar主键、自增列等信息以实体类的特性为准而不是每次去数据库读schema。这样既快又可控尤其是数据库和实体偶尔命名不一致时特性方式更稳。为什么不每次new一个SqlSugarClient因为数据库连接是昂贵资源频繁创建销毁会拖慢吞吐。用SqlSugarScope单例配合底层连接池才是批量写入的正确打开方式。3. 从表结构到C#实体类字段类型映射全拆解3.1 先有一张表在pgAdmin或者psql里执行下面的建表语句假设是设备采集数据表CREATE TABLE IF NOT EXISTS public.device_data ( id BIGSERIAL PRIMARY KEY, device_id VARCHAR(64) NOT NULL, metric VARCHAR(32) NOT NULL, value NUMERIC(12,4), collect_time TIMESTAMPTZ NOT NULL DEFAULT now(), remark TEXT, is_valid BOOLEAN DEFAULT TRUE ); COMMENT ON COLUMN public.device_data.metric IS 点位名称; CREATE INDEX idx_device_data_device_id ON public.device_data(device_id);这里特意用了BIGSERIAL自增主键和TIMESTAMPTZ时间类型后面第5部分会详细讲为什么这两个字段最容易踩坑。3.2 实体类与特性映射对应的C#实体类应该这样写[SugarTable(device_data)] public class DeviceData { [SugarColumn(IsPrimaryKey true, IsIdentity true, ColumnName id)] public long Id { get; set; } [SugarColumn(ColumnName device_id)] public string DeviceId { get; set; } [SugarColumn(ColumnName metric)] public string Metric { get; set; } [SugarColumn(ColumnName value, DecimalDigits 4)] public decimal Value { get; set; } [SugarColumn(ColumnName collect_time)] public DateTime CollectTime { get; set; } [SugarColumn(ColumnName remark, IsNullable true)] public string Remark { get; set; } [SugarColumn(ColumnName is_valid)] public bool IsValid { get; set; } }几个特性的含义SugarTable(device_data)指定表名。不要偷懒省略让SqlSugar直接用类名DeviceData去匹配表否则会触发第5章讲的大小写坑。IsPrimaryKey true标识主键批量更新、删除、按主键查询都会用到它。IsIdentity true标识自增列。PostgreSQL的BIGSERIAL列要正确返回自增ID这一项必须写。ColumnName显式指定列名让C#属性DeviceId和数据库列device_id对应起来。DecimalDigits 4配合NUMERIC(12,4)告诉SqlSugar保留4位小数。IsNullable true允许该列插入NULL。3.3 常用类型映射对照我用下来的PostgreSQL和C#类型对照大致如下PostgreSQL类型C#类型说明bigserial / bigintlong / long?自增主键最常用serial / integerint / int?小范围自增或普通整数numeric(p,s)decimal金额、传感器值都用这个别用doubledouble precisiondouble精度要求不高的浮点varchar(n) / textstring长度在应用层校验即可booleanbool / bool?注意数据库默认值timestamp / timestamptzDateTime / DateTimeOffset推荐DateTimeOffset处理时区dateDateTime只取日期部分jsonbstring可以在SqlSugar中直接存JSON字符串uuidGuid主键用uuid时注意顺序byteabyte[]二进制比如图片快照3.4 大小写与下划线命名一个容易被忽略的全局问题PostgreSQL有一条特殊规则不加双引号的标识符会自动折叠成小写。也就是说DeviceData会被当成devicedata而不是DeviceData。所以当你的表叫device_data而实体类叫DeviceData时SqlSugar生成的SQL里如果用类名直接当表名数据库就会告诉你relation DeviceData does not exist。解决办法有两个每个实体类显式写[SugarTable(device_data)]每个属性写[SugarColumn(ColumnName device_id)]。这是我推荐的做法因为表可能来自不同时期命名方式不一定统一。通过全局配置开启自动转小写或下划线映射但遇到不规则的列名时反而更难排查。显式映射虽然代码多几行但可读性和可维护性最好。项目里一旦统一了命名规范复制粘贴也很快。3.5 补充自动建表与DbFirst如果你的表还没建可以让SqlSugar按实体类自动建表db.CodeFirst.InitTablesDeviceData();它会把实体类转换成对应的PostgreSQL建表语句主键、自增、长度都会按特性生成。这个方案适合快速原型正式项目我建议还是先让DBA把表结构设计好再配实体类因为实际项目中索引、约束、分区这些SqlSugar自动建表还处理不了。反过来如果表已经存在想快速生成实体类可以用SqlSugar的DbFirst功能从数据库生成代码。生成之后记得检查一下自增和列名特性是否正确不要直接拿生成结果当最终代码。4. 插入数据的核心操作单条、批量与异步写入4.1 单条插入与返回自增主键最简单的插入是这样var db Db.Instance; var device new DeviceData { DeviceId DEV-001, Metric temperature, Value 36.8m, CollectTime DateTime.Now, Remark 首次写入, IsValid true }; var rows db.Insertable(device).ExecuteCommand(); Console.WriteLine($受影响行数: {rows});ExecuteCommand()返回受影响的行数通常插入成功就是1。如果你需要拿到数据库自增生成的ID就用var newId db.Insertable(device).ExecuteReturnBigIdentity();有两点要提醒ExecuteReturnIdentity()和ExecuteReturnBigIdentity()的区别在于返回类型。ExecuteReturnIdentity()返回intExecuteReturnBigIdentity()返回long。BIGSERIAL列生成的ID可能超过int范围我一般直接用ExecuteReturnBigIdentity()省得溢出。这个方法能生效的前提是实体里已经写了IsIdentity true。如果没写SqlSugar不知道这是自增列返回结果就不对。具体排查看第5章。4.2 批量插入攒一批再写采集项目的典型场景是数据攒了一批然后落库。SqlSugar的批量插入写法比循环单插简单得多var list new ListDeviceData(); for (int i 0; i 1000; i) { list.Add(new DeviceData { DeviceId DEV-001, Metric temperature, Value 20 i * 0.1m, CollectTime DateTime.Now.AddSeconds(i) }); } var rows db.Insertable(list).ExecuteCommand();批量插入的性能比单条循环高出非常多原因在于少了大量网络往返。但这里藏着一个PostgreSQL特有的坑默认情况下Npgsql的单条SQL参数数量有上限超过约65535个参数就会报错。如果一张表30列一次插3000条参数数就是90000个直接顶到上限。解决办法是分块var chunkSize 500; foreach (var chunk in list.Chunk(chunkSize)) { db.Insertable(chunk.ToList()).ExecuteCommand(); }分块大小我一般取500到1000。太小批量效果出不来太大容易撞参数上限还会让单条SQL太大PostgreSQL解析和写入WAL的压力都增加。4.3 异步插入别卡住UI线程上位机程序最忌讳界面卡死。UI线程里直接同步插入数据量大时会假死几秒用户体验很差。SqlSugar提供了完整的异步接口await db.Insertable(device).ExecuteCommandAsync(); await db.Insertable(list).ExecuteCommandAsync();在WinForm里用async/await有个老生常谈的注意点不要在UI线程里用.Result或.Wait()阻塞地去等异步方法那样反而更容易死锁。直接用await配合ConfigureAwait(false)UI线程就不会被数据库操作拖住。4.4 插入部分列、忽略列和数据库默认值有时候导入一批数据只要写其中几列剩下的让数据库填默认值。比如device_data表的is_valid有默认值TRUEremark允许为空导入时只想写设备、点位、数值和时间db.Insertable(device) .InsertColumns(it new { it.DeviceId, it.Metric, it.Value, it.CollectTime }) .ExecuteCommand();反过来如果实体里某些属性值不想入库比如内部计算字段可以用IgnoreColumnsdb.Insertable(device) .IgnoreColumns(it new { it.Remark }) .ExecuteCommand();这个能力在重构老代码时尤其有用旧表多出一列新代码不想管它直接忽略不用改实体类。4.5 事务批量入库的安全带批量写数据最怕写到一半挂了前面成功的部分也留着数据有的有、有的无后续很难对账。SqlSugar的事务写法很直白try { db.Ado.BeginTran(); db.Insertable(list1).ExecuteCommand(); db.Insertable(list2).ExecuteCommand(); db.Ado.CommitTran(); } catch { db.Ado.RollbackTran(); throw; }事务要注意别开太大。一个事务里塞几十万条数据锁和WAL日志的膨胀对性能影响很大。我一般把单事务控制在几千条既保证原子性又不会把数据库拖垮。5. 自增主键、时间字段与中文编码三个高频坑的排查过程5.1 自增主键不返回或者插入报主键冲突现象插入成功但ExecuteReturnBigIdentity()返回0另一种情况是明明没给Id赋值却报null value in column id violates not-null constraint。原因SqlSugar识别自增列完全靠实体类的IsIdentity特性。如果你只写了IsPrimaryKey true没写IsIdentity trueSqlSugar会认为主键应该由业务代码自己提供插入时就不会在SQL里带上RETURNING id自然返回不了自增ID。排查链路我建议这样走检查实体类Id属性上的特性是否写全。打开Aop日志看实际生成的SQL见5.5节。用SQL确认表结构SELECT column_name, is_identity, column_default FROM information_schema.columns WHERE table_name device_data AND column_name id;如果是BIGSERIALcolumn_default应该是nextval(device_data_id_seq::regclass)并且is_identity可能显示为NO因为SERIAL本质上还是靠默认值实现的自增。SqlSugar对这种列的处理就是靠IsIdentity特性去生成RETURNING id。如果项目不想用数据库自增想用分布式雪花ID实体可以这样[SugarColumn(IsPrimaryKey true)] public long Id { get; set; }插入时先通过SqlSugar生成雪花ID再写入var snowId db.Insertable(device).ExecuteReturnSnowflakeId();这里需要注意雪花ID是应用层生成的和数据库自增是两条路线不要混用。5.2 时间字段timestamp with time zone vs without time zone如果你建表用的是TIMESTAMPTZ网上很多教程却用DateTime去映射有时候会报column collect_time is of type timestamp with time zone but expression is of type timestamp without time zone原因SqlSugar会根据C#属性类型推断Npgsql参数类型C#的DateTime默认映射成timestamp without time zone而列是timestamp with time zone两边对不上。解决方式有两种实体属性改用DateTimeOffset数据库列保持TIMESTAMPTZ。DateTimeOffset本身带时区信息映射起来语义最清晰。如果你还是想用DateTime就在连接字符串里指定时区例如Host127.0.0.1;Port5432;Databasetestdb;Usernamepostgres;Password123456;TimezoneAsia/Shanghai;我个人习惯是数据库统一存UTC存储和传输全部用UTC时间只有UI展示时转成北京时间。这样部署到任何时区的服务器都不会乱而且排序、比较都不受时区影响。如果历史表里已经混了两种类型就先用ALTER TABLE ... ALTER COLUMN ... TYPE timestamptz USING ...统一再跑程序。5.3 中文乱码和编码问题现象插入的中文变成问号或者直接报invalid byte sequence for encoding UTF8。原因PostgreSQL数据库的字符集如果不是UTF8或者客户端连接用的编码和库不一致中文就会出问题。排查顺序先查数据库编码SHOW server_encoding;结果应该是UTF8。如果建库时错选了SQL_ASCII中文字符存进去就是乱码而且这问题在库层面解决更彻底。确认Npgsql连接是否走了UTF8。Npgsql默认就是UTF8除非有人手动改了Encoding参数。如果数据库和连接都对问题往往出在应用层比如从文件读取数据时用了GBK字符串到内存就已经乱码再交给ORM也没有用。这时候要在读取文件的地方指定正确的编码而不是在数据库连接上找原因。5.4 表名和列名大小写SqlServer迁移用户最痛的一课现象插入时报relation DeviceData does not exist。原因前面3.4节讲过PostgreSQL会把未加引号的标识符折叠成小写。SqlSugar默认生成SQL时不加双引号C#类名DeviceData传过去就变成devicedata。如果你的表名是device_data两边当然对不上。排查链路打开Aop日志看SqlSugar生成的表名是什么。到数据库里查一下实际表名SELECT tablename FROM pg_tables WHERE schemaname public;解决办法是实体类上显式加[SugarTable(device_data)]属性上显式加ColumnName。这个坑几乎每个从SqlServer迁过来的人都会踩一次因为SqlServer默认保留大小写。PostgreSQL里如果真建了带大写字母的表名查询时都得加双引号非常难受。新项目建表时统一用小写加下划线是最省心的方案。5.5 用Aop日志快速定位一切插入问题SqlSugar有个特别好用的排查工具Aop日志事件db.Aop.OnLogExecuting (sql, pars) { Console.WriteLine(sql); foreach (var p in pars) { Console.WriteLine($ {p.ParameterName}: {p.Value}); } };插入报错的时候把这段日志打开SqlSugar生成的SQL会原样打出来。把这句SQL复制到pgAdmin里手动执行通常一眼就能看出是列名写错了、类型对不上还是语法问题比盯着一大串异常堆栈猜效率高多了。我在实际项目里遇到诡异问题时第一步永远是开Aop日志看生成的SQL第二步是拿SQL去数据库客户端手跑。90%的问题到这一步就已经定位了。6. 实测数据与优化建议6.1 一次粗糙但不骗人的性能对比我在本机PostgreSQL 16上做过一次简单测试插入1000条DeviceData结果大概是这样的方式1000条耗时说明单条循环Insertable约1.8秒每条都等数据库往返批量Insertable(list)约120毫秒一次生成多条VALUES分批500条事务约150毫秒和批量接近胜在更稳不同机器、不同网络延迟下数字会变但趋势很稳定单条插入慢在每次都要等往返批量插入才是采集场景的正道。以后看到有人写循环单条插入去接采集数据我建议赶紧改成批量。6.2 几个实测后觉得管用的优化方向连接字符串打开Poolingtrue默认一般就是true注意别为了调线程数量把连接池关掉。批次大小控制在500到1000条太小效果不明显太大会撞Npgsql参数上限。插入时只带必要列列越少参数越少性能和稳定性都更好。事务规模不要贪大几千条一个事务最舒服。如果数据量实在太大几十万条可以考虑用COPY命令PostgreSQL的COPY在批量导入上比逐条INSERT快一个数量级。SqlSugar没有直接封装COPY但结合NpgsqlBinaryImporter可以做这个属于进阶玩法后面有空我再单独写一篇。6.3 线程安全与连接复用的实践上位机里的采集线程、UI线程经常会同时访问数据库。SqlSugarScope是线程安全的多个线程共用同一个实例没有问题。但要记住原生NpgsqlConnection对象不能直接跨线程使用如果你在某处手动new了连接用完之后要确保同一个线程关闭它。我的习惯是全局只注入一个SqlSugarScope所有查询、插入都走它不自己管理连接除非有特殊需求比如需要长时间持有事务才手动开启独立连接。这样既简单又不容易踩连接资源泄漏的坑。最后说点个人体会。数据访问层选型固然重要但真正让项目顺不顺利的往往是自增主键、时间时区、标识符大小写、字符编码这些边边角角的问题。SqlSugar整体上是个很顺手的工具社区活跃文档也全遇到问题打开Aop日志看SQL基本都能定位到根因。希望这篇记录能帮你少走几步弯路尤其是从SqlServer迁移过来的朋友大小写那条坑真的值得先记住。