很多朋友第一次接触C#联合SQLServer,都是从一个"增加删除"的小功能开始的。这个组合几乎是Windows桌面应用、上位机系统、中小型管理软件的标配:前台用C#快速搭界面、处理业务逻辑,后台用SQLServer存数据、保证数据安全。但真到自己动手写的时候,问题往往出在那些"看起来很简单"的地方——连接串配置、参数化查询、事务边界、外键关系,任何一个细节没处理好,程序不是报错就是数据悄悄出问题。
这篇文章我就围绕C#联合SQLServer做增删操作这件事,把从环境准备、连接数据库、写增删SQL,到批量处理、事务控制、常见踩坑的完整链路梳理一遍。适合刚入门C#、正在做课程设计、或者要接手WinForm/WPF上位机项目的开发者参考,内容兼顾基础概念和实操细节,你能直接照着写。
1. 为什么是C#联合SQLServer:选型逻辑与适用场景
1.1 这套组合解决什么问题
C#和SQLServer都是微软家的产品,配套使用有天然的优势。C#负责三件事:界面展示、业务逻辑、把用户的请求翻译成数据库能听懂的SQL指令;SQLServer负责另外三件事:数据存储、数据安全、数据查询与统计。分工很清楚,界面和数据分离,改界面不影响数据,换数据库不影响界面逻辑。
具体到增加删除这种操作,C#侧要做的是"取数→校验→拼参数→执行→回显结果",SQLServer侧要做的是"接收指令→事务处理→写盘→返回影响行数"。两个环节的数据交互核心就一个对象——SqlCommand,以及它携带的Parameters集合。
我见过不少初学项目,数据库用的是Access或者SQLite,文件型数据库的好处是零配置,但一旦程序运行在多个客户端、同时写入并发高的时候,文件锁、损坏、权限问题就都来了。SQLServer的优势恰恰在"正经的数据库服务"这件事上:并发控制、事务、备份恢复、权限体系都很成熟。对于需要长期稳定运行、多人同时操作、数据不能丢的场景,C#联合SQLServer是比文件型数据库稳妥得多的选择。
1.2 什么时候该用,什么时候该换
不是所有项目都适合上SQLServer。我个人的判断标准是看数据量和并发量,外加一个"有没有IT维护条件":
- 单机单用户、数据量万级以内,用SQLite或Access就够了,没必要装一个数据库服务。
- 2到50个客户端、每天新增几千条数据、需要做查询统计报表,SQLServer非常合适。
- 大规模互联网级应用、需要分布式扩展,通常不会再直接用SQLServer,而是考虑更复杂的架构。
另外还有一种常见场景值得单独说:C#上位机读取设备数据,比如从PLC、仪器仪表采集到的数值需要落库。很多上位机工程师习惯用本地文件或者Access存历史数据,一旦数据量涨到几百万条,查询和写入都会明显卡顿。这时候把历史数据存进SQLServer,用定期清理策略配合增加删除操作,整个系统的稳定性和查询速度都会上一个台阶。
2. 连接数据库的前置功课:环境准备与连接串
2.1 最小环境要求与版本选择
要让C#程序连上SQLServer,最少需要三样东西:
- SQLServer数据库服务(可以是Express版、标准版、开发者版)
- .NET Framework或.NET 6/8运行时
System.Data.SqlClient或Microsoft.Data.SqlClient程序集
关于版本选择,我多说两句。新项目建议直接用SQL Server 2019或2022的Developer版(开发环境免费),也可以用Express版,功能上增删查改完全够用。如果机器上已经装了旧版,2012以上问题都不大,语法层面增删操作基本没变化。
很多朋友卡在安装这一步。热搜里有个问题很典型——"SQLServer 2016 2017 2019安装失败:无法找到数据库引擎启动句柄"。我遇到过几次,最常的原因是安装时选了"仅安装"而没有实际初始化数据库引擎,或者现有的SQLServer服务因为账户权限问题启动不了。解决办法通常是:用管理员权限运行安装程序,安装时选择"添加功能到现有实例",确保SQL Server服务账户用的是NT Service\MSSQLSERVER这类内置账户,不要手动填一个没有登录权限的本地用户。装完之后打开"SQL Server配置管理器",确认"SQL Server服务"里的实例处于"正在运行"状态,这一步能排除掉一大半"连不上数据库"的假象。
2.2 连接字符串的写法与含义
C#连接SQLServer的方式是创建SqlConnection对象,传一个连接字符串。连接字符串是最容易出错、但报错信息往往又不太友善的地方。先给一个最常用的写法:
Data Source=localhost;Initial Catalog=TestDB;User ID=sa;Password=123456;TrustServerCertificate=True;各字段含义:
Data Source:服务器地址。本机可以写localhost或.或127.0.0.1;如果是命名实例,要写成localhost\SQLEXPRESS。Initial Catalog:数据库名,就是你要操作的库。User ID和Password:登录名和密码。TrustServerCertificate:连接加密证书是否信任。新版SQLServer默认强制加密,不加这个有时候会报证书链错误。
还有一个常用套路是Windows身份验证,不用在连接串里暴露密码:
Data Source=localhost;Initial Catalog=TestDB;Integrated Security=True;TrustServerCertificate=True;这种适合开发机自用。给客户部署的正式环境,我更推荐SQLServer账号验证,因为服务装在哪台机器、用哪个Windows账户启动,不是每次都可控,账号验证跨机器、跨环境都稳定。
2.3 数据库与表的基础设计
连接串只是敲门砖,真正干活的是库和表的结构。写增删操作之前,表设计直接影响代码的复杂度和安全性。这里给一个建议的"最简可用"设计规范,以一张设备点位数据表为例:
CREATE TABLE DeviceData ( Id INT IDENTITY(1,1) PRIMARY KEY, DeviceCode NVARCHAR(50) NOT NULL, ReadValue DECIMAL(18,4) NOT NULL, ReadTime DATETIME2(3) NOT NULL, IsDeleted BIT NOT NULL DEFAULT 0 );几个关键点:
- 用自增
Id当主键,业务上不要依赖"设备编号+时间"这种联合唯一键做删除定位,因为业务数据重复的可能性很高。 ReadValue用DECIMAL(18,4)而不是FLOAT,避免浮点数精度问题。- 预留一个
IsDeleted软删除标记,后面讲删除操作时再细说为什么。 - 时间字段用
DATETIME2(3),精度到毫秒,比老式DATETIME能存的范围更大、精度更高。
有了这张表,后面的增删例子就都有了落点。
2.4 打开连接的规范姿势
连接对象是稀缺资源,开启和关闭必须配对。下面这个using写法是C#里最标准的:
using (SqlConnection conn = new SqlConnection(connectionString)) { conn.Open(); // 在这里执行增删操作 }SqlConnection实现了IDisposable,using块结束后会自动关闭连接、释放资源。千万别写成"打开连接 → 操作 → 忘记关",连接池会很快被占满,程序后期会莫名其妙地变慢甚至超时。这些坑后面专门有一节展开讲。
3. 增加操作:从INSERT一条到批量写入
3.1 基础INSERT的完整写法
最简单的增加操作,就是构造一个SqlCommand,把SQL文本和连接对象绑在一起,然后执行。拿上面那张表举例,插入一条设备读数:
using (SqlConnection conn = new SqlConnection(connectionString)) { conn.Open(); using (SqlCommand cmd = new SqlCommand()) { cmd.Connection = conn; cmd.CommandText = @" INSERT INTO DeviceData (DeviceCode, ReadValue, ReadTime) VALUES (@DeviceCode, @ReadValue, @ReadTime);"; cmd.Parameters.AddWithValue("@DeviceCode", deviceCode); cmd.Parameters.AddWithValue("@ReadValue", readValue); cmd.Parameters.AddWithValue("@ReadTime", DateTime.Now); int rows = cmd.ExecuteNonQuery(); } }ExecuteNonQuery()是增加删除这种"不需要返回结果集"的SQL专用的执行方法,返回受影响的函数——插入一条数据正常返回1。拿这个返回值做判断,就能知道"到底插进去没有",而不是默认一定成功。
3.2 为什么必须用参数化查询
很多初学资料会写这样的代码:
string sql = "INSERT INTO DeviceData (DeviceCode, ReadValue, ReadTime) VALUES ('" + deviceCode + "', " + readValue + ", '" + DateTime.Now + "')";这是我在实际项目里最想拦下来的写法。两个致命问题:
- SQL注入漏洞:
deviceCode如果来自外部输入,里面放了'); DROP TABLE DeviceData;--这样的内容,后果非常严重。 - 数据类型转换和格式问题:日期在不同系统语言环境下ToString的格式不同,数字拼进去也可能遇到小数点符号问题。
参数化查询就是让SQLServer把@DeviceCode当参数处理,而不是把字符串拼进SQL语句再解析。值是什么就是什么,不参与语法解析,注入无从谈起。
AddWithValue使用上有个小坑:它自动推断类型,对NVARCHAR、DECIMAL这类类型偶尔会造成隐式转换,影响索引命中。要求严谨的写法是:
cmd.Parameters.Add("@DeviceCode", SqlDbType.NVarChar, 50).Value = deviceCode; cmd.Parameters.Add("@ReadValue", SqlDbType.Decimal).Value = readValue;显式指定类型和长度,好处是让SQLServer拿到参数时就知道该怎么处理,能走索引的走索引,能避免转换的避免转换。
3.3 插入后拿回自增ID
业务里经常需要"刚插入的这条数据是什么Id"。三连操作——插入主表、拿Id、往从表插入关联数据——很常见。拿自增Id有两种方式:
第一种,用SCOPE_IDENTITY():
cmd.CommandText = @" INSERT INTO DeviceData (DeviceCode, ReadValue, ReadTime) VALUES (@DeviceCode, @ReadValue, @ReadTime); SELECT SCOPE_IDENTITY();"; object result = cmd.ExecuteScalar(); int newId = Convert.ToInt32(result);SCOPE_IDENTITY()返回当前会话当前作用域内最后插入的自增值,比@@IDENTITY安全,因为@@IDENTITY可能被触发器里插入其他表的数据覆盖掉。这里用了ExecuteScalar(),它和ExecuteNonQuery()的区别是:执行SQL并返回结果集第一行第一列的值,正好用来拿这个Id。
第二种更现代,用OUTPUT子句:
INSERT INTO DeviceData (DeviceCode, ReadValue, ReadTime) OUTPUT inserted.Id VALUES (@DeviceCode, @ReadValue, @ReadTime);配合ExecuteScalar()同样能拿到新Id。OUTPUT更灵活,如果插入多条还能返回整个结果集。
3.4 批量增加的常见做法
业务里有"一次写入几百上千条"的需求时,循环单条INSERT也能跑,但在性能和事务控制上都不理想。三种常见方案,按推荐程度排序:
循环单条+同一个SqlCommand是最容易想到的写法。如果实在要用,注意把SqlCommand放进循环外面,只改参数值,避免反复构造对象:
using (SqlConnection conn = new SqlConnection(connectionString)) using (SqlCommand cmd = new SqlCommand()) { conn.Open(); cmd.Connection = conn; cmd.CommandText = "INSERT INTO DeviceData (DeviceCode, ReadValue, ReadTime) VALUES (@DeviceCode, @ReadValue, @ReadTime);"; cmd.Parameters.Add("@DeviceCode", SqlDbType.NVarChar, 50); cmd.Parameters.Add("@ReadValue", SqlDbType.Decimal); cmd.Parameters.Add("@ReadTime", SqlDbType.DateTime2); foreach (var item in items) { cmd.Parameters["@DeviceCode"].Value = item.DeviceCode; cmd.Parameters["@ReadValue"].Value = item.ReadValue; cmd.Parameters["@ReadTime"].Value = item.ReadTime; cmd.ExecuteNonQuery(); } }**表值参数(TVP)**是SQLServer提供的专业批量方案。先定义表类型,然后C#这边构造DataTable传入。这种方式能一次传几百上千条,SQLServer内部按表整体处理,性能远好于循环,而且事务天然一致。
SqlBulkCopy是数据量最大时的首选。它直接调用SQLServer的批量复制接口,适合导入几万、几十万条数据的场景。用法不复杂,但要注意目标表结构的列匹配。
我的建议很直接:日常批量写入低于500条,用循环+同一个SqlCommand没问题;500到5000条用表值参数;超过5000条用SqlBulkCopy。
3.5 实战:多表联插时的增加操作
增加操作往往不只一张表,比如"新增一条工单记录,同时往工单明细表插入对应的几条明细"。这种跨表写入有一个铁律:要么全部成功,要么全部不生效。
把多条SQL放进同一个事务里的标准写法:
using (SqlConnection conn = new SqlConnection(connectionString)) { conn.Open(); SqlTransaction tx = conn.BeginTransaction(); try { using (SqlCommand cmd = new SqlCommand()) { cmd.Connection = conn; cmd.Transaction = tx; cmd.CommandText = "INSERT INTO WorkOrder (...) VALUES (...); SELECT SCOPE_IDENTITY();"; int workOrderId = Convert.ToInt32(cmd.ExecuteScalar()); cmd.CommandText = "INSERT INTO WorkOrderDetail (WorkOrderId, ItemCode, Qty) VALUES (@WorkOrderId, @ItemCode, @Qty);"; cmd.Parameters.Clear(); cmd.Parameters.Add("@WorkOrderId", SqlDbType.Int).Value = workOrderId; // 循环添加明细 foreach (var detail in details) { cmd.Parameters["@ItemCode"].Value = detail.ItemCode; cmd.Parameters["@Qty"].Value = detail.Qty; cmd.ExecuteNonQuery(); } } tx.Commit(); } catch { tx.Rollback(); throw; } }这里有个容易漏掉的点:SqlCommand绑定事务后,参数集合重用时记得cmd.Parameters.Clear(),否则上一个SQL的参数残留会让当前语句报"参数未提供"或"参数个数不匹配"。
4. 删除操作:别只想着DELETE
4.1 DELETE的基础写法与影响行数判断
删除的SQL本身不复杂:
DELETE FROM DeviceData WHERE Id = @Id;C#侧执行和INSERT基本一致,差别在语义上:删除是不可恢复操作,所以判断影响行数这一步更重要。影响行数是0,说明Id对应的数据不存在;是1,说明删除成功。
做一个"按设备编码删除所有历史数据"的操作:
using (SqlConnection conn = new SqlConnection(connectionString)) { conn.Open(); using (SqlCommand cmd = new SqlCommand()) { cmd.Connection = conn; cmd.CommandText = "DELETE FROM DeviceData WHERE DeviceCode = @DeviceCode;"; cmd.Parameters.AddWithValue("@DeviceCode", deviceCode); int rows = cmd.ExecuteNonQuery(); if (rows == 0) { // 没有匹配的数据,提示用户检查输入 } } }不要写出不带WHERE的DELETE FROM DeviceData,除非你真的打算清空整张表。清空表用TRUNCATE TABLE更快,但TRUNCATE不能有外键引用、不记录逐行日志、且不能按条件过滤——这一点后面会提到。
4.2 软删除和硬删除怎么选
删除操作里我特别想多聊一个设计层面的问题:数据到底删还是不删。
硬删除就是DELETE FROM,物理删掉,查不到了。适用于:临时数据、可再生的缓存数据、确定无用的垃圾数据。
软删除是逻辑上"藏起来",实际不删行,靠标记字段过滤。上面建表时故意留了IsDeleted,就是干这个的:
UPDATE DeviceData SET IsDeleted = 1 WHERE Id = @Id;查询时统一带条件WHERE IsDeleted = 0。好处是:
- 数据可恢复,误删了把
IsDeleted改回来就行。 - 保留历史痕迹,比如设备读数、操作日志这类有追溯价值的数据,审计时需要。
- 删除操作变成更新操作,不会触发外键约束的连锁麻烦。
代价是查询语句必须记得过滤,漏一处就会出现"已删除数据又出现了"的bug。我在上位机项目里处理设备历史数据,几乎一律用软删除,因为设备检修、质量追溯这类场景,原始记录是不能丢的。真正会硬删的,是一些临时表数据。
4.3 多条删除必须包事务
删除多条数据的危险系数比插入还要高,因为一旦删错,数据找不回来。比如"删除某个客户及其所有订单、订单明细",三条DELETE如果不放在一个事务里,很可能出现"客户删了、订单删了一半、明细还留着"的超级脏数据。
事务写法与前面插入的多表联插完全一样。我这里单独强调一个策略:删除顺序是有讲究的。先删子表,再删父表,不然外键约束会直接报错。用上面的客户订单例子,先删订单明细,再删订单,最后删客户。
另外,一个常见误操作是删了大量数据后才发现"应该留个备份"。一个稳妥习惯:执行任何批量删除前,先把受影响的数据导出留档:
SELECT * INTO Backup_DeviceData_20250101 FROM DeviceData WHERE DeviceCode = @DeviceCode;这条语句会在删除前把要删的数据复制到一张备份表里,不占用应用层内存,速度也快,算是一道安全保险。
4.4 外键约束下的删除问题
外键关系下删除报错,是C#联合SQLServer增删操作里高频出现的运行时问题。错误信息通常是"DELETE语句与REFERENCE约束冲突"。
解决思路有四个方向:
- 调整删除顺序,先删子表。
- 删除时把子表关联数据同时处理,事务保证原子性。
- 外键设置
ON DELETE CASCADE,父表删了子表自动删。适合"父子同生命周期"的数据,但级联删除容易让开发人员"习惯性忽略子表存在",误删时影响面很大,慎重。 - 用软删除避开物理外键约束。
我实际项目里更倾向于第一种和第四种:能软删就软删,必须硬删就显式规划好顺序。级联删除看着省事,但一旦数据关系复杂,出问题时排查成本高得多。
还有一个SQLServer特有的细节:TRUNCATE TABLE不能在有外键引用的表上执行,哪怕子表是空的。要清空父表,必须先用DELETE清子表(或禁用外键约束),这个特性容易踩。
5. 踩坑实录:C#联合SQLServer增删操作里翻过的车
5.1 安装和连接的怪问题
先说说那个热搜挂了很多次的"SQLServer安装失败——无法找到数据库引擎启动句柄"。这个错误的本质是:安装程序执行到初始化数据库引擎这一步时,无法用当前配置启动SQL Server服务。常见原因和对应处理:
- 实例名冲突:老实例卸载不干净,换个实例名(比如
SQLEXPRESS01)重装。 - 服务启动权限不足:安装时必须用管理员身份运行,且SQL Server服务的启动账户不要选"Network Service",改用"Local System"。
- 防火墙阻止:数据库引擎默认监听1433端口,可以先在配置管理器里确认"TCP/IP"协议已启用,再把1433端口加入防火墙入站规则。
- 安装完成但连不上:先开"SQL Server配置管理器",确认实例状态是"正在运行",再看"SQL Server日志"里有没有明确的错误码。
另一个连接相关的高频问题,是"已成功与服务器建立连接,但在登录前握手时发生错误"。这一般是新版SQLServer强制加密连接导致的,连接串加一行TrustServerCertificate=True;通常能解决。
5.2 字符串拼接SQL的惨痛教训
我接手过一个设备管理系统,老代码里全是字符串拼接。某天一台设备在录入点位名称时输入了一个斜杠和单引号,结果整条INSERT执行失败,程序直接崩了。更吓人的是审计日志里能看到有人尝试往用户名里填' OR 1=1 --。
虽然最终没造成数据丢失,但那次之后我把整个系统的数据访问层都改成了参数化查询。多说一句,参数化查询不是SQLServer特有的,ADO.NET里所有数据库provider都支持Parameters,这算C#阵营的通用最佳实践。
5.3 数字与字符串的隐式转换坑
另一个容易被忽略的问题是类型不匹配。SQLServer在特定情况下会把字符串和数字做隐式转换,转换规则有时出乎意料。典型例子:把ReadValue字段定义为VARCHAR,查询条件却传数字,或者反过来,字段是数字类型,参数给的是字符串。
热搜词里有"sqlserver 字符串转数字",说明这个需求很普遍。字符串转数字最稳妥的方式不是靠隐式转换,而是显式转换:
-- 推荐:明确的转换 SELECT * FROM DeviceData WHERE ReadValue = CAST(@ReadValue AS DECIMAL(18,4)); -- 或者直接参数就传decimal类型C#侧更干净的做法是:参数类型严格对齐列类型。int就SqlDbType.Int,decimal就SqlDbType.Decimal,DateTime就SqlDbType.DateTime2。类型对齐了,SQLServer就不会乱猜,查询也能正确命中索引。
5.4 连接与命令对象的资源释放
"程序跑几天越来越慢,最后卡死",这种线上问题我排查过不止一次。用Process Explorer一看,应用程序的句柄数和线程数持续上涨,几乎全是数据库连接句柄。原因几乎无一例外:SqlConnection没有关闭。
C#的using语法就是为这种场景设计的。有两点容易忽略:
SqlCommand和SqlDataReader也要释放,reader不释放会锁住连接,连接池里的连接被占用且恢复不了。SqlDataAdapter(配合DataTable用)内部会管理连接,但你也别显式Open连接后又不管,用完了让DataAdapter自己处理反而更安全。
一个简单好记的规范:谁创建的IDisposable对象,谁负责using;嵌套对象跟着外层走。
5.5 并发删除时的阻塞与死锁
两台客户端同时对同一批数据做删除,可能有一个会话被阻塞,严重的会死锁。SQLServer默认的隔离级别是READ COMMITTED,删除操作要拿行级排他锁,两个会话互相持有对方需要的锁资源时就可能死锁。
应对思路分三层:
- 应用层:让删除操作尽量短小,一个事务不要塞太多条SQL。
- 代码层:多表操作时,所有会话按相同的表顺序访问,可以显著减少死锁。
- 数据库层:把事务隔离级别适当提高,比如使用
READ COMMITTED SNAPSHOT(RCSI),让读操作不阻塞写操作。
RCSI设置比较简单,一条命令的事:
ALTER DATABASE TestDB SET READ_COMMITTED_SNAPSHOT ON;但要注意:开启后所有读操作的行为会发生变化,需要测试确认应用逻辑不受影响。对增删操作来说,RCSI最大的收益是"读不挡写",这个在上位机数据录入场景里非常实用——一边插入采集数据,一边有报表查询在跑,互相不卡。
6. 代码组织与后续扩展建议
6.1 增删逻辑封装成什么样
增删操作不要散落在按钮事件里,否则项目大了之后改动一个字段,要全局搜索十几个地方。推荐做一个数据访问层的基础封装。比如定义一个通用的ExecuteNonQuery方法:
public static int ExecuteNonQuery(string sql, SqlParameter[] parameters) { using (SqlConnection conn = new SqlConnection(connectionString)) using (SqlCommand cmd = new SqlCommand(sql, conn)) { if (parameters != null) { cmd.Parameters.AddRange(parameters); } conn.Open(); return cmd.ExecuteNonQuery(); } }业务层调用时只操心SQL和参数:
SqlParameter[] p = { new SqlParameter("@DeviceCode", SqlDbType.NVarChar, 50) { Value = code }, new SqlParameter("@ReadValue", SqlDbType.Decimal) { Value = value } }; int rows = DbHelper.ExecuteNonQuery( "INSERT INTO DeviceData (DeviceCode, ReadValue, ReadTime) VALUES (@DeviceCode, @ReadValue, GETDATE())", p);这种封装把连接管理、参数管理统一收口,避免每个页面重复写SqlConnection样板代码。再进阶就是引入ORM(EF Core、SqlSugar等),但增删这种简单操作,手写参数化SQL的灵活度和可控性反而更好。
6.2 与界面绑定时别做的蠢事
WinForm/WPF里常见操作是:DataGridView显示数据,用户点删除按钮删掉选中行。我在这里踩过一个挺典型的坑:直接在UI线程里用DataSet或DataTable执行DeleteCommand,结果界面假死。
原因是数据量大的时候,重新查询和刷新列表是耗时操作,UI线程被阻塞。正确做法是把耗时操作放到异步方法里,状态栏和进度条也要同步更新。这正好对应热搜词里"c# winform如何更新状态栏与进度条"的需求。简单思路是:
async void事件处理函数里await Task.Run()执行数据库操作。- 操作前后通过
Invoke或IProgress<T>更新状态栏文字和进度条。 - 删除成功后重新加载列表,加载也走异步。
注意一点:SqlConnection不是线程安全的,但每个操作独立创建连接,用await Task.Run包裹的是整个"打开连接→执行→关闭"流程,而不是复用连接对象,这样就不会有线程安全问题。
6.3 从增删到改查:把路子走顺
写完整套增加和删除,后面补修改和查询基本是水到渠成的事。修改操作核心是UPDATE,查询操作核心是SELECT和DataAdapter/DataReader的选择。我分享几个衔接思路:
- 增加和删除共用的参数化、事务、连接释放规范,一字不改地用到修改和查询上。
- 查询结果绑定到界面时,优先用
DataReader流式读取,还是DataTable一次性加载,取决于数据量和是否需要离线编辑。几千行内用DataTable方便,几万行以上用DataReader更省内存。 - 分页查询可以用
OFFSET ... FETCH NEXT语法,但要注意排序字段必须有唯一性,否则分页结果可能不稳定。这也对应热搜词里"sqlserver offset后再top 20查到的是什么"那个疑问——没有稳定排序的情况下,翻页重复和漏行都可能出现。
最后分享一个实际操作中的体会:C#联合SQLServer做增删功能,难点从来不在SQL语句本身,而在于你有没有把"连接生命周期管理、参数化、事务边界、资源释放"这几条规矩刻进肌肉记忆里。把这些基础打牢,后面接任何数据库、做任何复杂业务,都是在这个底盘上加砖。遇到增删查改的报错,先把排查清单过一遍:连接串能不能通、参数类型对不对、事务是不是该开没开、有没有外键约束漏看了——八成问题都能定位。