资讯动态

C#+SQLServer增删改查实战:连接、事务与参数化的关键细节

发布时间:2026/10/22 13:50:17 来源:尧图企业网站定制
做管理类系统打交道最多的就是那套增删改查。无论是企业里的订单管理、用户台账还是学校里的选课记录本质上都逃不过 C# 和 SQLServer 的联合操作。很多人一开始都以为这条路很简单——写个连接字符串拖几个控件点一下按钮就完事了可实际做起来连接超时、事务没提交、SQL 注入、并发冲突问题一个接一个。这篇文章不是那种干巴巴的教程更像是我自己在几个真实项目里踩坑之后的总结合集把 C# 联合 SQLServer 做增加删除这条主线上的关键细节一次讲透。如果你正准备写一个管理系统、维护一套老代码或者正在做课程设计希望这篇内容能让你少走几段弯路。我会从整体设计思路讲起再拆解连接字符串、SqlCommand、事务这些核心细节然后给出一套可以直接改改就用的增删改查实现最后把高频踩坑点整理成排查清单。整个过程会带上参数、代码和现场经验不只告诉你“怎么做”更多是告诉你“为什么这么做”。1. 整体设计与思路拆解1.1 为什么是C#搭档SQLServerC# 和 SQLServer 是微软技术栈里最经典的组合之一。Visual Studio 里建一个项目NuGet 里拿一个 SqlClient 包就能跟 SQLServer 对话语法和调试体验都很顺。更重要的是微软对这些库的长期维护让数据访问变得非常稳定从 .NET Framework 时代到现代的 .NET Core/.NET 5核心用法基本一脉相承。很多初学者会问既然 SQLServer 自己就能写存储过程为什么还要 C# 来联合操作我的理解是SQLServer 擅长的是数据存储、约束、事务和查询优化但界面交互、业务流程判断、权限控制、数据校验这些事情并不适合全塞进数据库。C# 负责组织和调度SQLServer 负责存和取各干各擅长的活这就是“联合”的底层逻辑。那“联合”具体发生在哪几个层面连接层面C# 通过 SqlConnection 建立到 SQLServer 实例的通道这一步负责服务发现、身份验证、连接池管理。命令层面SqlCommand 把 T-SQL 文本或存储过程名称发给 SQLServer并接收返回结果。数据层面SqlDataReader、SqlDataAdapter 把查询结果集映射到 C# 对象或 DataTable 中。事务层面SqlTransaction 把多条增删改语句绑定在一个逻辑单元里要么全部成功要么全部回滚。理解了这四层你再看那些所谓“高级用法”其实都是在这些基础层面做文章比如异步、批量复制、依赖注入、ORM 封装等。1.2 增删改查不等于四段孤立代码项目标题里只写了“增加删除”但我强烈建议所有做这类系统的朋友一开始就按“增删改查”四件套来设计。因为在实际界面里你不可能做一个只能新增但不能查看的功能也不可能做了删除却不提供修改入口。四个操作天然互相依赖。在设计一个用户管理或订单管理的 Demo 时我习惯先把数据访问层单独拆出来不要让界面的按钮事件里直接写 SQL。哪怕项目很小也至少分成三层界面层负责收集用户输入、展示结果。业务/服务层做逻辑判断比如检查必填项、判断删除前是否有关联数据。数据访问层封装所有 SQL 命令和数据库连接细节。这样做的直接好处是当你想把 SQLServer 换成其他数据库或者把 WinForms 升级成 Web API 时不需要重写所有代码。另一个原因是排查问题方便出 Bug 时能很快定位是 SQL 写错了还是界面绑定写错了。1.3 参数化SQL与存储过程怎么选在实际项目中C# 操作 SQLServer 通常有两种风格一种是直接执行参数化 T-SQL 文本另一种是调用存储过程。两者不是谁取代谁的关系而是适用场景不同。对比项参数化SQL存储过程部署复杂度随应用发布数据库改动自由需要额外脚本部署到数据库调试方便程度在 C# 代码里直接可见便于排查需要在 SSMS 里单独调试但可重复执行性能缓存依赖参数化后的执行计划复用常规情况下执行计划更容易复用权限控制应用账号通常需要表级权限可只授予过程执行权限更细粒度适合场景快速迭代、小项目、原型复杂报表、大批量操作、DBA 强管控环境我的建议是如果你正在做一个中小型管理系统参数化 SQL 就够了如果你所在团队有专门的 DBA或者你希望把复杂查询逻辑沉淀在数据库里那就优先用存储过程。两者都要求参数化这一点没有例外。2. 核心细节解析与实操要点2.1 连接字符串的多个坑连接字符串看着简单其实是项目里最容易出问题的地方之一。我见过很多人折腾半天连不上库最后发现是字符串里的一个单词写错了。先看一个最常见的写法string connStr Server.;DatabaseDemoDB;User Idsa;Password123456;;这个写法里“Server.” 表示本机默认实例也可以写成Serverlocalhost或Server127.0.0.1。如果你的 SQLServer 是命名实例比如装的时候写了实例名SQLEXPRESS那就要写成Server.\SQLEXPRESS否则怎么都连不上。另一个容易混淆的点是Database和Initial Catalog。其实两者是同一个意思完全等价可有些人会在一个字符串里同时写两个还写不一样的值结果一脸懵。建议团队统一一个写法别混用。还需要注意登录方式。开发机上用 Windows 身份验证最省事Server.;DatabaseDemoDB;Integrated SecurityTrue;但部署到服务器时Windows 身份验证往往不适用需要用 SQL 账号。这时就要写User Id和Password并且千万别在图省事的时候把密码硬编码到前端项目里至少应该放到配置文件并加密。连接字符串里还有一个隐藏特性连接池。默认情况下 ADO.NET 会启用连接池相同连接字符串的 SqlConnection 会被复用。这本来是好事但如果你在代码里没有正确关闭连接池里的连接会一直被占用最终出现“连接超时”或“会话已满”的错误。2.2 命令对象与参数化写法连接字符串解决“怎么连”的问题SqlCommand 解决“怎么执行”的问题。一个典型的增删改查操作套路都差不多创建连接、创建命令、设置参数、执行、处理结果、关闭释放。using (var conn new SqlConnection(connStr)) { conn.Open(); using (var cmd new SqlCommand(INSERT INTO dbo.Users (Name, Age) VALUES (Name, Age);, conn)) { cmd.Parameters.AddWithValue(Name, name); cmd.Parameters.AddWithValue(Age, age); int rows cmd.ExecuteNonQuery(); } }这个例子里有三个关键点第一using块不能省。SqlConnection 和 SqlCommand 都实现了 IDisposable用using可以保证即使发生异常也会释放资源。有些人只Open不Close或者只在正常路径里Close结果就是连接池被耗尽。第二参数名必须以开头。SQLServer 的变量名和参数名都用C# 这边的参数名要和 SQL 文本里的占位符完全一致。不一致时不一定报错但很可能取不到值。第三AddWithValue虽然好用却有一个隐藏性能问题它会默认按 C# 类型的推断去映射 SQLServer 类型。比如 C# 的int没问题但字符串映射的NVarChar长度往往和表定义不一致导致 SQLServer 无法复用查询计划。更稳妥的做法是显式指定 SqlDbType 和长度cmd.Parameters.Add(Name, SqlDbType.NVarChar, 50).Value name; cmd.Parameters.Add(Age, SqlDbType.Int).Value age;2.3 事务不是可选项如果只做单条插入或单条删除不显式开事务问题不大SQLServer 默认会在语句级别保证原子性。但一旦出现“先删主表再删子表”或“先插入主记录再插入多条明细”这种跨语句操作事务就不是可选项了。我接手过一个退货功能原先的逻辑是先更新库存表再插入退货单。结果在某次运行中插入退货单失败库存却已经更新了导致账面不一致。这种问题靠单条语句无法解决必须由事务保证整体原子性。在 C# 里使用事务的标准姿势是这样using (var conn new SqlConnection(connStr)) { conn.Open(); using (var tx conn.BeginTransaction()) { try { using (var cmd new SqlCommand(DELETE FROM dbo.OrderItems WHERE OrderId OrderId;, conn, tx)) { cmd.Parameters.AddWithValue(OrderId, orderId); cmd.ExecuteNonQuery(); } using (var cmd new SqlCommand(DELETE FROM dbo.Orders WHERE OrderId OrderId;, conn, tx)) { cmd.Parameters.AddWithValue(OrderId, orderId); cmd.ExecuteNonQuery(); } tx.Commit(); } catch { tx.Rollback(); throw; } } }注意使用事务时SqlCommand 必须指定同一个事务对象也就是构造函数里的第三个参数tx。如果漏掉这个参数命令虽然也能执行但不会参与到当前事务中一旦后续出错回滚那条命令的修改就残留下来了。这是新手最容易踩的坑之一。还有一点Rollback后我没有吞掉异常而是重新throw这样上层界面才能感知到操作失败进而给出提示。很多人习惯在 catch 里只打个日志返回 false导致用户那边永远只看到“操作失败”但不知道为什么后期排查非常痛苦。3. 实操过程与核心环节实现3.1 先准备一张表和测试数据为了把整个流程走通我建议用一个“用户信息表”作为例子。表结构不要太复杂但一定要带上自增主键和几个常见字段。CREATE TABLE dbo.Users ( Id INT IDENTITY(1,1) PRIMARY KEY, UserName NVARCHAR(50) NOT NULL, Phone NVARCHAR(20) NULL, Age INT NULL, CreatedTime DATETIME NOT NULL DEFAULT GETDATE() );这张表有四个值得留意的设计点Id用IDENTITY(1,1)表示从 1 开始每次自增 1。新增时不需要手动赋值。UserName不允许为空这是基本约束。Phone和Age允许为空模拟真实场景里的可选项。CreatedTime用默认值减少代码里手动赋值的工作。插入几条测试数据方便后面做查询和删除验证INSERT INTO dbo.Users (UserName, Phone, Age) VALUES (张三, 13800000001, 28); INSERT INTO dbo.Users (UserName, Phone, Age) VALUES (李四, 13800000002, 32); INSERT INTO dbo.Users (UserName, Phone, Age) VALUES (王五, NULL, NULL);3.2 数据访问层的完整代码数据访问层我一般会建一个独立类比如叫UserRepository。每个方法只负责一种数据库操作不掺杂界面逻辑。先看查询列表和按主键查询public ListUser GetAll() { var list new ListUser(); string sql SELECT Id, UserName, Phone, Age, CreatedTime FROM dbo.Users;; using (var conn new SqlConnection(_connStr)) using (var cmd new SqlCommand(sql, conn)) { conn.Open(); using (var reader cmd.ExecuteReader()) { while (reader.Read()) { list.Add(new User { Id reader.GetInt32(0), UserName reader.GetString(1), Phone reader.IsDBNull(2) ? null : reader.GetString(2), Age reader.IsDBNull(3) ? null : reader.GetInt32(3), CreatedTime reader.GetDateTime(4) }); } } } return list; } public User GetById(int id) { string sql SELECT Id, UserName, Phone, Age, CreatedTime FROM dbo.Users WHERE Id Id;; using (var conn new SqlConnection(_connStr)) using (var cmd new SqlCommand(sql, conn)) { cmd.Parameters.Add(Id, SqlDbType.Int).Value id; conn.Open(); using (var reader cmd.ExecuteReader()) { if (reader.Read()) { return new User { Id reader.GetInt32(0), UserName reader.GetString(1), Phone reader.IsDBNull(2) ? null : reader.GetString(2), Age reader.IsDBNull(3) ? null : reader.GetInt32(3), CreatedTime reader.GetDateTime(4) }; } } } return null; }这里要解释两个处理细节。第一reader.IsDBNull判断不能省。数据库里Phone是允许为空的直接用reader.GetString(2)会在遇到 NULL 时抛异常。每次取可空字段之前都做一次判断虽然代码啰嗦一点但最安全。第二using可以连写。SqlConnection和SqlCommand都实现了 IDisposable连续写多个using是合法的执行顺序相当于嵌套释放顺序则是先释放后声明的对象。这种写法比一堆try/finally清爽得多。再看新增、修改和删除public int Insert(User user) { string sql INSERT INTO dbo.Users (UserName, Phone, Age) VALUES (UserName, Phone, Age); SELECT CAST(SCOPE_IDENTITY() AS INT);; using (var conn new SqlConnection(_connStr)) using (var cmd new SqlCommand(sql, conn)) { cmd.Parameters.Add(UserName, SqlDbType.NVarChar, 50).Value user.UserName; cmd.Parameters.Add(Phone, SqlDbType.NVarChar, 20).Value (object)user.Phone ?? DBNull.Value; cmd.Parameters.Add(Age, SqlDbType.Int).Value (object)user.Age ?? DBNull.Value; conn.Open(); return (int)cmd.ExecuteScalar(); } } public int Update(User user) { string sql UPDATE dbo.Users SET UserName UserName, Phone Phone, Age Age WHERE Id Id;; using (var conn new SqlConnection(_connStr)) using (var cmd new SqlCommand(sql, conn)) { cmd.Parameters.Add(Id, SqlDbType.Int).Value user.Id; cmd.Parameters.Add(UserName, SqlDbType.NVarChar, 50).Value user.UserName; cmd.Parameters.Add(Phone, SqlDbType.NVarChar, 20).Value (object)user.Phone ?? DBNull.Value; cmd.Parameters.Add(Age, SqlDbType.Int).Value (object)user.Age ?? DBNull.Value; conn.Open(); return cmd.ExecuteNonQuery(); } } public int Delete(int id) { string sql DELETE FROM dbo.Users WHERE Id Id;; using (var conn new SqlConnection(_connStr)) using (var cmd new SqlCommand(sql, conn)) { cmd.Parameters.Add(Id, SqlDbType.Int).Value id; conn.Open(); return cmd.ExecuteNonQuery(); } }新增方法有一个值得学习的细节我用SCOPE_IDENTITY()拿回了刚插入的自增主键。为什么不用IDENTITY因为IDENTITY会返回当前会话最后生成的那个标识值如果在插入过程中有触发器又插入了别的表你拿到的可能就不是目标表的 Id。SCOPE_IDENTITY()把范围限定在当前语句所属的范围内更可靠。参数赋值那里用(object)user.Phone ?? DBNull.Value也是个常见技巧。C# 的字符串和可空类型不能直接赋给SqlParameter.Value的 object 属性需要先装箱再让空值变成DBNull.Value。否则值为 null 时ADO.NET 可能会把它当没有值处理导致该字段被写成 NULL 或者抛异常。删除和修改方法都返回影响行数。这个返回值的用处很大如果删除时影响行数为 0说明目标记录不存在界面可以提示“数据已被删除”而不是笼统地提示成功。3.3 带事务的主子表删除很多业务模型都不是单表单记录比如订单和订单明细、部门和科室这种父子关系在做删除时要特别小心。直接在数据库里加外键约束是一种办法但外键会限制删除顺序而且部分历史遗留数据库并没有设计外键。我常用的做法是在事务里先删子表再删主表。以下是一个简单演示假设我们有Orders和OrderItems两张表public void DeleteOrderWithItems(int orderId) { using (var conn new SqlConnection(_connStr)) { conn.Open(); using (var tx conn.BeginTransaction()) { try { string deleteItemsSql DELETE FROM dbo.OrderItems WHERE OrderId OrderId;; using (var cmd new SqlCommand(deleteItemsSql, conn, tx)) { cmd.Parameters.Add(OrderId, SqlDbType.Int).Value orderId; cmd.ExecuteNonQuery(); } string deleteOrderSql DELETE FROM dbo.Orders WHERE Id Id;; using (var cmd new SqlCommand(deleteOrderSql, conn, tx)) { cmd.Parameters.Add(Id, SqlDbType.Int).Value orderId; int rows cmd.ExecuteNonQuery(); if (rows 0) { throw new Exception(订单不存在可能已被删除。); } } tx.Commit(); } catch { tx.Rollback(); throw; } } } }这段代码展示了我在真实项目里经常使用的删除策略先删子表避免外键冲突再删主表并用影响行数判断记录是否存在如果子表删除成功但主表不存在会主动抛出异常进入回滚避免留下“子表删了但主表还在”的脏数据。事务的粒度也要把握好。有些人习惯把整个界面操作都放在一个超长事务里比如先查询再更新再查询这个做法会让事务持有锁的时间过长并发能力大幅下降。事务只包裹必要的写操作就好查询尽量放在外面。4. 常见问题与排查技巧实录4.1 连接类问题排查清单数据库连接问题占据了我日常排错的一半以上。很多问题不是代码逻辑问题而是环境或配置问题。现象可能原因排查建议无法连接到数据库错误码 40SQLServer 服务未启动打开服务管理器确认 SQL Server 服务在运行连接超时连接字符串实例名不对、端口不通用 telnet 测试 1433 端口确认实例名登录失败用户登录失败账号密码错误或账号未启用在 SSMS 里用同样账号测试登录登录失败默认数据库不存在账号映射的默认库被删除修改账号默认数据库或使用 Initial Catalog已成功连接但操作卡住连接池耗尽或有未关闭连接检查代码中是否漏掉 Dispose/Close我特别想强调一下“已成功与服务器建立连接但在登录过程中发生错误”这类问题。这通常不是网络不通而是 SQLServer 身份验证模式没开对。SQLServer 有两种验证模式Windows 身份验证和混合模式。如果你用 SQL 账号登录失败先检查 SQLServer 是否允许“SQL Server 身份验证”这是服务属性里的一个开关很多初学者会在这里卡很久。4.2 参数化SQL的细节参数化是防 SQL 注入的第一道防线但参数化不是万能药写法不对照样有隐患。错误的做法是拼接字符串string sql DELETE FROM dbo.Users WHERE Id id;如果id是用户输入一旦有人传入1; DROP TABLE Users;--语句就会变成灾难。正确做法是永远用占位符然后通过参数集合赋值。但参数化还有一个容易忽略的细节参数顺序和名称。SQLServer 的 SqlParameter 集合里参数的顺序在大多数情况下无所谓因为数据库是按名称匹配的。可存储过程的参数如果定义了默认值C# 这边没有传对应参数就不会触发默认值这一点需要提前确认。另外不要在 SQL 文本里直接写LIKE %Keyword%。这样写看起来是参数化了实际却不会生效因为Keyword在引号里只是字符串内容。正确写法是string sql SELECT * FROM dbo.Users WHERE UserName LIKE Keyword;; cmd.Parameters.Add(Keyword, SqlDbType.NVarChar, 50).Value % keyword %;4.3 并发与事务的常见误区并发问题在桌面系统里不常见但只要系统一上 Web多个用户同时操作同一张表时立刻暴露。典型场景两个用户同时删除同一条记录。第一个用户删除成功第二个用户删除时影响行数为 0。如果代码不做判断界面可能直接显示“成功”但用户刷新后数据还在就会产生困惑。所以删除操作一定要读取ExecuteNonQuery的返回值。另一个典型场景是更新丢失。用户 A 和用户 B 同时读取了同一条订单A 修改金额为 100B 修改金额为 200后提交的人会覆盖先提交的人。解决思路有两种一是加锁在更新时使用SELECT ... FOR UPDATE或应用层锁二是使用乐观并发在表里加一个版本号或时间戳字段更新时检查版本号是否匹配。UPDATE dbo.Orders SET Amount Amount, Version Version 1 WHERE Id Id AND Version Version;如果影响行数为 0说明版本号不匹配界面可以提示“数据已被他人修改请刷新后重试”。这种方案实现简单对数据库压力小是目前比较推荐的轻量级方案。事务隔离级别也容易被忽略。默认情况下SQLServer 读操作不会阻塞其他事务但如果你的业务要求“读取到的数据不能被其他事务修改”就需要考虑可重复读或快照隔离。大多数增删改查场景用默认的 Read Committed 就够了千万不要为了图安心随意调成 Serializable锁竞争会明显增加系统反而变慢。4.4 几个容易忽略的开发习惯最后分享几个我吃了不少亏才养成的习惯。第一所有数据库访问方法都尽量带上using或显式释放。以前我写过一段代码正常操作没问题但一到批量导入时就频繁报“连接池超时”查了半天才发现是异常路径里面连接没关闭。第二删除和修改之前先确认条件。尤其是在写 UPDATE 时我见过太多因为少写WHERE导致全表更新的案例。哪怕只是开发环境这种操作也会让人后背发凉。养成先写 SELECT 查一遍再改成 UPDATE 的习惯成本很低但非常有效。第三新增数据后用SCOPE_IDENTITY()返回主键而不是重新查询一遍。重新查询多一次往返不说如果数据在同一时刻被其他事务修改拿到的可能就不是新插入的那条。第四SQL 语句统一关键词大写表名前加架构名dbo.。这不是强迫症而是当系统里出现多个 schema 时不写 schema 名会带来额外的解析开销偶尔还会解析到错误的表。第五尽量不在数据库里存储大文本和二进制文件。SQLServer 虽然支持VARBINARY(MAX)和文件流但绝大多数增删改查系统把附件路径存在数据库里文件放在磁盘或对象存储中查询和备份压力都会小很多。如果非要在“增加删除”这条主线里提炼一句经验那就是新增时要把主键拿回来删除时要把影响行数拿回来而连接和事务一定要管好生命周期。这几个习惯看着不起眼等系统上了规模用户一多它们就是你少熬夜的关键。最后再分享一个小技巧写完数据访问层后先别急着接界面写一个简单的控制台测试把每个方法都调一遍确认新增后能查到、删除后影响行数正确、修改后字段确实变了。这一步只花十几分钟但能省下后面联调时大量的排查时间。希望这篇内容能帮你在 C# 联合 SQLServer 的增删改查路上走得更稳。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑