真正可行的主从路由方案是封装 IDbConnection 代理层,通过 AsyncLocal 标记上下文并确保同一请求共享物理连接;事务必须在开启前确定主库连接,EF Core 的 SaveChangesAsync 默认走主库,Dapper/MyBatis-Net 需统一收口连接创建。

怎么在 C# 里让读操作走从库、写操作走主库
靠手动判断 SQL 类型或方法名来路由不现实,也容易出错。真正可行的方式是封装一个 IDbConnection 的代理层,在连接创建时根据当前上下文(比如方法调用栈、AsyncLocal 标记)决定连哪个数据源。
常见错误是直接在业务层用两个不同 DbContext 实例分别连主从库,结果事务一跨就崩——因为 SqlTransaction 不支持跨连接,更别说跨实例了。所以必须保证:同一请求内所有数据库操作共享同一个物理连接实例,且该连接的指向(主 or 从)在入口处就确定好。
- 读写分离的前提是主从同步延迟可控,否则
INSERT后立刻SELECT可能查不到刚写入的数据 - 不是所有查询都适合走从库:
SELECT ... FOR UPDATE、WITH (UPDLOCK)这类带锁读必须走主库 - EF Core 6+ 提供了
DbContext.Database.GetDbConnection(),但不能直接换掉底层DbConnection对象,得重写DbConnection子类并接管OpenAsync
EF Core 中如何动态切换主从连接字符串
别碰 DbContextOptionsBuilder.UseSqlServer() 的硬编码字符串,它在 Startup 或 Program.cs 里就固定了。应该把连接字符串工厂抽出来,让每次创建 DbContext 实例前先判断当前是否需要读库。
典型做法是用 IServiceProvider 注册一个 IConnectionStringResolver,内部通过 AsyncLocal 记录当前是否处于“只读上下文”中。这个标记通常由中间件、AOP 拦截器或仓储方法命名约定触发(例如方法名含 Get、List、Find)。
- 避免用线程静态变量(
ThreadStatic),ASP.NET Core 是异步模型,AsyncLocal才能穿透 await - 连接字符串不能拼接用户名密码,要用
SqlConnectionStringBuilder安全合并 - 如果用了连接池,主从库的连接字符串必须完全不同(哪怕只是
Data Source不同),否则会复用同一个池子导致路由失效
为什么事务里不能自动切到主库
EF Core 的 BeginTransaction() 默认绑定当前 DbConnection,而连接一旦打开,就不能再“切换”目标服务器。也就是说,如果你在事务中执行了第一个命令才决定要读主库,已经晚了。
正确做法是在事务开启前就明确连接归属。比如封装一个 UnitOfWork.BeginWriteTransaction() 和 UnitOfWork.BeginReadTransaction(),后者其实只是占位(因为从库不支持写事务),前者才真正调用底层 BeginTransaction() 并强制使用主库连接。
- 任何显式调用
context.Database.BeginTransaction()的地方,都意味着你已进入写上下文,后续所有操作必须走主库 - EF Core 的
SaveChangesAsync()内部会自动开事务,所以只要调用了它,当前DbContext就默认绑定主库 - 不要试图在
SaveChangesAsync()后立刻用另一个DbContext去从库查新数据——这是典型的“幻读”,得靠应用层加缓存或等待同步延迟窗口
MyBatis-Net 或 Dapper 场景下怎么处理
这类轻量 ORM 没有 EF 那套生命周期管理,反而更容易控制连接来源。关键是把 IDbConnection 的创建逻辑收口,而不是把连接字符串塞进每个 new SqlConnection(...) 里。
推荐做法是提供一个 DbConnectionFactory,对外暴露 CreateReadConnection() 和 CreateWriteConnection(),并在内部维护两个独立的连接池配置。Dapper 的扩展方法如 QueryAsync 接收的就是 IDbConnection,你传哪个,它就用哪个。
- MyBatis-Net 的
SqlSessionFactory支持自定义IDataSource,可以在这里做路由判断 - Dapper 不管你传进来的是什么连接,但它不会帮你管理事务跨连接的问题,这点比 EF 更裸,也更需要你小心
- 注意
SqlConnection是非线程安全的,别把它当成单例扔进全局容器里共享
最麻烦的不是怎么切库,而是怎么让开发者不用记住“这个方法该走主还是从”。靠文档和约定永远会漏,必须靠编译期检查或运行时拦截兜底。比如给仓储接口加特性标记 [ReadOnly],再配合源生成器或 Castle DynamicProxy 在调用前校验连接类型——这种细节,上线后第一周就会暴露出来。











