
简介这份C#增删改查源代码面向ASP.NET初学者与需要快速搭建数据访问层的开发者聚焦数据库操作这一核心技能。资源以doc文档形式提供压缩包内共1个文件约203KB内容围绕OleDbConnection与OleDbCommand展开给出连接Access数据库、执行INSERT、DELETE、UPDATE、SELECT四类语句的完整示例并演示OleDbParameter参数化查询的写法帮助读者理解连接字符串配置、命令对象创建与参数绑定的基本流程。文档还整理了数据库操作固定模板从创建连接对象、打开数据库到执行SQL语句形成可复用套路并附有SqlConnection、SqlDataAdapter与DataSet填充DataGridView的补充示例便于对比不同数据访问方式。目前已有819人学习适合作为课程实验、小型管理系统开发或面试复习的参考素材能帮助读者快速掌握增删改查的编码结构与排错思路。1. 从一段“能跑但不敢改”的代码说起很多人第一次写 C# 增删改查代码都是从复制一段能跑的示例开始的。窗体上几个按钮点一下就把数据写进数据库看起来没问题。但真到了要加字段、换数据库、或者并发一上来这段代码就成了黑匣子——不敢动一动就崩。问题不在语法而在结构连接没释放、SQL 拼接、异常吞掉、事务缺失这些坑在演示阶段不暴露上线后集中爆发。这篇笔记要解决的就是这件事把 C# 增删改查代码从“能跑”推到“敢改、敢扩、敢上生产”。适合两类人——刚学完 ADO.NET 基础、想写出规范 CRUD 的新手以及写了几年但一直用拼接字符串、想系统梳理参数化与分层写法的熟手。下面按“先定架构、再写代码、最后排坑”的顺序展开每一步都能直接抄进项目里。2. 增删改查的四种写法从 ADO.NET 到 Dapper 怎么选在动手写代码之前先要决定用哪套数据访问方式。C# 里做增删改查常见有四条路原生 ADO.NET、Dapper、Entity Framework Core、以及手写仓储层封装。选错了不是不能跑而是后期维护成本差好几倍。2.1 四种方案的适用边界原生 ADO.NET 最底层SqlConnection、SqlCommand、SqlDataReader全手动控制。优点是零依赖、性能可控、SQL 完全透明缺点是样板代码多一个简单的查询要写七八行参数和连接释放全靠自觉。适合对性能极度敏感、或者需要精细控制 SQL 的场景。Dapper 是轻量 ORM本质是 ADO.NET 的扩展方法。它把ExecuteReader到对象映射的过程自动化了但 SQL 还是你自己写。查询一行conn.QueryFirstOrDefaultUser(select * from Users where IdId, new { Id id })就够了。适合中小项目、SQL 需要手写优化的场景。Entity Framework Core 是全功能 ORM用 LINQ 表达查询迁移、关系映射、变更追踪都内置。开发效率最高但生成的 SQL 有时不够直观复杂查询容易写出性能问题。适合领域模型复杂、团队更看重开发速度的项目。手写仓储层不是独立技术而是在上述任一方案外面包一层接口把数据访问和业务逻辑隔开。常见做法是定义IUserRepository内部用 Dapper 或 EF 实现。好处是换数据库时业务代码不动测试时可以替换成内存实现。方案代码量SQL 控制力学习成本适合场景ADO.NET多完全低性能敏感、SQL 复杂Dapper少完全低中小项目、手写 SQLEF Core最少弱中领域复杂、快速开发仓储层中取决于底层中需要解耦和可测试我一般会这样选新项目如果表不超过二十张、查询不复杂直接 Dapper 加仓储接口如果关系多、需要迁移管理上 EF Core只有在批量导入或报表这类场景才退回原生 ADO.NET。2.2 用 Dapper 搭一个最小可跑的 CRUD下面这段代码是一个完整的用户表增删改查用 Dapper 实现。先建表再写四个方法最后在控制台里调用验证。-- 建表语句SQL Server 语法 CREATE TABLE Users ( Id INT IDENTITY(1,1) PRIMARY KEY, Name NVARCHAR(50) NOT NULL, Email NVARCHAR(100) NOT NULL, CreatedAt DATETIME2 NOT NULL DEFAULT GETDATE() );// 引入 Dapper 和 SqlClient using Dapper; using Microsoft.Data.SqlClient; public class UserRepository { private readonly string _connStr; public UserRepository(string connStr) { _connStr connStr; } // 增返回自增主键 public int Add(User user) { using var conn new SqlConnection(_connStr); string sql INSERT INTO Users (Name, Email, CreatedAt) VALUES (Name, Email, CreatedAt); SELECT CAST(SCOPE_IDENTITY() as int);; return conn.ExecuteScalarint(sql, user); } // 删按主键删除返回影响行数 public int Delete(int id) { using var conn new SqlConnection(_connStr); string sql DELETE FROM Users WHERE Id Id; return conn.Execute(sql, new { Id id }); } // 改按主键更新返回影响行数 public int Update(User user) { using var conn new SqlConnection(_connStr); string sql UPDATE Users SET Name Name, Email Email WHERE Id Id; return conn.Execute(sql, user); } // 查按主键查询单条 public User GetById(int id) { using var conn new SqlConnection(_connStr); string sql SELECT * FROM Users WHERE Id Id; return conn.QueryFirstOrDefaultUser(sql, new { Id id }); } } public class User { public int Id { get; set; } public string Name { get; set; } public string Email { get; set; } public DateTime CreatedAt { get; set; } }逻辑说明using var conn确保连接在方法结束时释放这是 ADO.NET 连接池能正常工作的前提。ExecuteScalarint用于插入后取回自增主键SCOPE_IDENTITY()只返回当前作用域的最后插入值比IDENTITY安全。Execute返回受影响行数调用方可以据此判断操作是否命中记录。QueryFirstOrDefault在无结果时返回 null不会抛异常。参数说明Name、Email这些是参数占位符Dapper 会自动把匿名对象或实体类的属性映射进去。注意CreatedAt在插入时由代码传入如果想让数据库默认值生效可以从 INSERT 语句里去掉这一列。连接字符串建议放在配置文件里不要硬编码。2.3 参数化查询为什么必须默认开启上面代码里所有用户输入都走参数这不是风格问题是安全底线。字符串拼接的写法select * from Users where Name name 一旦遇到单引号就会语法错误遇到恶意输入就是注入漏洞。参数化查询把 SQL 结构和数据分开传给数据库数据永远被当作值处理不参与语法解析。Dapper 和 ADO.NET 的参数化在底层是一回事都是SqlParameter。EF Core 的 LINQ 查询最终也会生成参数化 SQL。唯一需要警惕的是在 Dapper 里用string.Format或插值拼接表名、列名——这些是标识符不能参数化只能用白名单校验。常见做法是把可排序字段定义成枚举映射到固定列名绝不直接拼用户输入。3. 把 CRUD 封装成可复用仓储接口、事务与异步写完四个方法只是起点。真实项目里增删改查不会孤立存在——插入订单要同时写订单明细删除用户要级联清理关联数据这些都需要事务。另外Web 场景下同步方法会阻塞线程异步版本是标配。3.1 定义仓储接口与实现分离先定接口再写实现。这样业务层只依赖接口单元测试时可以换成内存实现不需要真实数据库。public interface IUserRepository { Taskint AddAsync(User user); Taskint DeleteAsync(int id); Taskint UpdateAsync(User user); TaskUser GetByIdAsync(int id); TaskIEnumerableUser GetAllAsync(); }实现类把上一节的同步方法改成异步版本用ExecuteAsync、QueryFirstOrDefaultAsync等。Dapper 的异步方法底层是DbCommand.ExecuteReaderAsync在 ASP.NET Core 里能真正释放线程不是假异步。public class DapperUserRepository : IUserRepository { private readonly string _connStr; public DapperUserRepository(string connStr) _connStr connStr; public async Taskint AddAsync(User user) { using var conn new SqlConnection(_connStr); string sql INSERT INTO Users (Name, Email, CreatedAt) VALUES (Name, Email, CreatedAt); SELECT CAST(SCOPE_IDENTITY() as int);; return await conn.ExecuteScalarAsyncint(sql, user); } public async TaskIEnumerableUser GetAllAsync() { using var conn new SqlConnection(_connStr); return await conn.QueryAsyncUser(SELECT * FROM Users); } // 其余方法同理省略重复代码 }参数说明连接字符串通过构造函数注入配合 DI 容器注册为单例或作用域实例。注意SqlConnection本身不是线程安全的每次方法调用新建一个靠连接池复用底层物理连接这是官方推荐用法。3.2 事务多表操作不能少的一步单表增删改查用不上事务但一旦涉及两张以上表的写操作就必须显式开启。下面是一个插入用户并同时写日志的例子。public async Taskint AddWithLogAsync(User user, string logContent) { using var conn new SqlConnection(_connStr); await conn.OpenAsync(); using var tran conn.BeginTransaction(); try { string sql1 INSERT INTO Users (Name, Email, CreatedAt) VALUES (Name, Email, CreatedAt); SELECT CAST(SCOPE_IDENTITY() as int);; int userId await conn.ExecuteScalarAsyncint(sql1, user, tran); string sql2 INSERT INTO OperationLogs (UserId, Content, CreatedAt) VALUES (UserId, Content, GETDATE()); await conn.ExecuteAsync(sql2, new { UserId userId, Content logContent }, tran); tran.Commit(); return userId; } catch { tran.Rollback(); throw; } }逻辑说明BeginTransaction之后所有命令必须显式传入tran参数否则不在同一事务里。Commit成功才落盘catch里回滚并重新抛出异常让上层知道失败原因。注意throw不要写成throw ex后者会丢失原始堆栈。参数说明conn.OpenAsync()在事务场景下必须显式调用因为BeginTransaction要求连接已打开。Dapper 的ExecuteAsync第三个参数就是事务对象传 null 表示不参与事务。3.3 批量操作与性能边界逐条插入一千条数据每次开连接、发 SQL、等返回耗时可能是批量插入的几十倍。Dapper 支持ExecuteAsync传入IEnumerableT做批量但底层仍是逐条执行只是复用同一连接。真正的大批量场景要用SqlBulkCopy。public async Task BulkInsertAsync(IEnumerableUser users) { using var conn new SqlConnection(_connStr); await conn.OpenAsync(); using var bulk new SqlBulkCopy(conn) { DestinationTableName Users, BatchSize 1000 }; // 需要把 ListUser 转成 DataTable列名与目标表一致 var table ToDataTable(users); await bulk.WriteToServerAsync(table); }参数说明BatchSize控制每批提交行数太大占内存太小网络往返多一千到五千是常见区间。SqlBulkCopy不触发触发器、不检查约束除非指定用之前确认目标表状态。数据量在几百条以内Dapper 批量就够了上万条再考虑SqlBulkCopy。4. 增删改查代码的避坑清单连接、并发与异常这一章集中说踩过的坑。每一条都是现象、原因、解决三段式照着排查能省不少时间。4.1 连接未释放导致连接池耗尽现象程序跑一段时间后报“超时时间已到但是尚未从池中获取连接”。原因SqlConnection没有用using包裹或者DataReader没关闭连接一直占着不还。解决所有SqlConnection、SqlCommand、SqlDataReader都用using声明Dapper 内部已经帮你处理了命令和读取器但连接必须自己管。另外检查是否有conn.Open()后忘记Close的分支。4.2 并发更新覆盖后写覆盖先写现象两个用户同时编辑同一条记录后保存的把先保存的改动冲掉了。原因更新语句只按主键匹配没有版本校验。解决加一个RowVersion列SQL Server 用rowversion类型更新时带上版本条件。UPDATE Users SET Name Name, Email Email WHERE Id Id AND RowVersion RowVersion;如果返回影响行数为 0说明记录已被别人改过业务层提示用户刷新重试。这是乐观并发的标准做法比锁表轻量。4.3 异常被吞掉排查时没有线索现象操作失败但界面没提示日志里也找不到原因。原因catch块里写了空语句或者只catch (Exception)不记录。解决至少记录异常类型、消息和堆栈推荐用结构化日志组件。不要用catch包住所有代码再什么都不做那等于把问题藏起来。对于可预期的异常如主键冲突捕获后转成业务提示对于不可预期的记录后向上抛。4.4 参数化查询里踩到类型不匹配现象查询条件传了字符串数据库列是整数结果报转换错误或者查不到数据。原因Dapper 按属性类型推断参数类型如果匿名对象里写new { Id 123 }传下去就是字符串。解决确保匿名对象属性类型和数据库列类型一致。日期类型注意DateTime和DateTime2的区别前者精度有限可能丢毫秒。字符串长度超过列定义时SQL Server 会报截断错误提前校验输入长度。4.5 异步方法里混用同步调用现象ASP.NET Core 里用了async方法但内部调的是conn.Execute而不是ExecuteAsync高并发下线程池被占满。原因同步方法在异步上下文里阻塞线程。解决从连接打开到命令执行全部用异步版本OpenAsync、ExecuteAsync、QueryAsync配套使用。唯一例外是控制台程序入口可以用.GetAwaiter().GetResult()但 Web 里不要这么写。5. 用集成测试验证 CRUD从建库到断言代码写完不算完得证明它真的能跑。这一章给一套可复现的验证方法用 xUnit 加一个临时数据库把增删改查和事务回滚都测一遍。5.1 测试环境准备与建表脚本测试不要连生产库也不要用内存数据库替代——SQL 方言差异会掩盖问题。常见做法是连一个本地测试库每个测试类跑之前建表、跑之后删表。下面用IDisposable管理生命周期。public class UserRepositoryTests : IDisposable { private readonly string _connStr Serverlocalhost;DatabaseTestDb;Trusted_ConnectionTrue;TrustServerCertificateTrue; private readonly DapperUserRepository _repo; public UserRepositoryTests() { _repo new DapperUserRepository(_connStr); using var conn new SqlConnection(_connStr); conn.Execute(IF OBJECT_ID(Users) IS NULL CREATE TABLE Users ( Id INT IDENTITY(1,1) PRIMARY KEY, Name NVARCHAR(50) NOT NULL, Email NVARCHAR(100) NOT NULL, CreatedAt DATETIME2 NOT NULL DEFAULT GETDATE() )); } public void Dispose() { using var conn new SqlConnection(_connStr); conn.Execute(DELETE FROM Users); } }参数说明连接字符串指向本地测试库TrustServerCertificateTrue避免开发环境证书报错。构造函数里建表用IF OBJECT_ID判断避免重复创建。Dispose清空数据而不是删表下次测试还能复用表结构。5.2 四个核心断言与事务回滚验证测试方法覆盖增、查、改、删再加一个事务回滚的用例。[Fact] public async Task Add_Then_GetById_ReturnsSameUser() { var user new User { Name A同学, Email atest.com, CreatedAt DateTime.UtcNow }; int id await _repo.AddAsync(user); var loaded await _repo.GetByIdAsync(id); Assert.NotNull(loaded); Assert.Equal(A同学, loaded.Name); } [Fact] public async Task Update_ChangesName() { var user new User { Name 旧名, Email xtest.com, CreatedAt DateTime.UtcNow }; int id await _repo.AddAsync(user); user.Id id; user.Name 新名; int rows await _repo.UpdateAsync(user); Assert.Equal(1, rows); var loaded await _repo.GetByIdAsync(id); Assert.Equal(新名, loaded.Name); } [Fact] public async Task Delete_RemovesRecord() { var user new User { Name 待删, Email dtest.com, CreatedAt DateTime.UtcNow }; int id await _repo.AddAsync(user); int rows await _repo.DeleteAsync(id); Assert.Equal(1, rows); Assert.Null(await _repo.GetByIdAsync(id)); }逻辑说明每个测试独立插入自己的数据不依赖执行顺序。Assert.Equal(1, rows)验证更新和删除确实命中了记录如果返回 0 说明主键不对或记录已不存在。事务回滚的测试可以故意在第二个插入时传非法值断言第一个插入也被回滚。5.3 验证并发更新是否被拦截乐观并发的测试要模拟两个版本。先查出记录拿到版本号再用旧版本号更新断言影响行数为 0。[Fact] public async Task Update_WithStaleVersion_ReturnsZero() { var user new User { Name 并发, Email ctest.com, CreatedAt DateTime.UtcNow }; int id await _repo.AddAsync(user); // 第一次更新版本前进 user.Id id; user.Name 第一次; await _repo.UpdateAsync(user); // 用旧对象再更新模拟并发覆盖 user.Name 第二次; int rows await _repo.UpdateAsync(user); // 如果实现里带了版本校验这里应为 0 Assert.Equal(0, rows); }这个测试的前提是UpdateAsync的 SQL 里带了RowVersion条件。如果没带断言会失败正好提醒你补上并发控制。测试不是走过场它是把“我以为”变成“我验证过”的手段。5.4 一个容易忽略的收尾习惯跑完测试记得清理测试库里的残留数据尤其是自增主键不会重置多次运行后 Id 越来越大不影响正确性但看着乱。我一般会在Dispose里DELETE而不是TRUNCATE因为TRUNCATE要求没有外键引用测试表多了容易冲突。另外测试连接字符串不要提交到代码仓库放在本地环境变量或用户机密里。这套流程跑通一次后面加字段、换表、改 SQL 都能快速回归比手动点界面可靠得多。希望帮到你。本文还有配套的精品资源点击获取