EF Core本身不支持读写分离,真正可用的方案是在DbContext实例化时就确定读写路由;推荐注册WriteDbContext和ReadDbContext两个独立类型,分别绑定主从库连接字符串,并拆分仓储接口以确保操作隔离。

EF Core 本身不支持读写分离,硬切连接字符串或全局拦截 SQL 是常见但危险的做法——容易破坏事务一致性、引发连接复用异常、掩盖主从延迟问题。真正可用的方案,是让读写路由在 DbContext 实例化那一刻就确定下来,而不是运行时动态改 Connection.ConnectionString。
为什么不能在 DbContext 创建后切换 ConnectionString
EF Core 的 DbContext 在首次访问 Database.GetDbConnection() 或执行命令时会缓存连接对象;后续再改 ConnectionString 不会自动关闭旧连接、也不会触发新连接池复用逻辑。典型表现是:
- 第一次查询走从库,第二次却仍用主库连接(或反之),日志里看到连接池 ID 没变
-
SaveChanges()报错Invalid operation on a closed connection,因为手动Close()后没正确Open() - 在异步上下文(如 ASP.NET Core Scoped 生命周期)中,
AsyncLocal标记失效,导致读操作误走主库
根本原因是:EF Core 设计上不认为 DbContext 应该“换库”,它假设一个实例对应一个稳定的数据源。
注册两个独立的 DbContext 类型(推荐)
最可控、最易调试的方式:定义 WriteDbContext 和 ReadDbContext 两个类,都继承自同一基类(如 AppDbContextBase),共享 OnModelCreating 配置,但各自绑定固定连接字符串。
关键点:
- 在
Program.cs中分别注册,生命周期统一为Scoped:builder.Services.AddScoped(sp => new WriteDbContext(builder.Configuration.GetConnectionString("MasterDb"))) builder.Services.AddScoped(sp => new ReadDbContext(builder.Configuration.GetConnectionString("SlaveDb"))) - 仓储接口必须拆开:
IUserWriteRepository依赖WriteDbContext,IUserReadRepository依赖ReadDbContext,避免开发人员误用从库做Add() - 不要共用同一个
DbSet实例——两个上下文里的Users属性只是同名,底层连接完全隔离
用 IDbContextFactory 动态选库(适合多从库或灰度场景)
当需要根据用户地域、租户 ID 或负载情况选择不同从库时,IDbContextFactory 比硬编码两个类型更灵活。
实操要点:
- 工厂实现里不做 SQL 解析,而是基于调用上下文判断:
— 若当前方法标记了[Write]特性 → 返回主库配置的上下文
— 若请求头含X-Force-Master: true→ 强制返回主库
— 否则随机选一个从库连接字符串构造上下文 - 必须禁用
DbContext的默认 DI 注册,否则工厂和容器注入会冲突 - 连接字符串要带明确超时与重试策略:
SlaveDb可设Command Timeout=30,MasterDb设Command Timeout=60并启用EnableRetryOnFailure()
哪些查询必须走主库(最容易被忽略的点)
不是所有 SELECT 都能扔给从库。以下情况必须强制使用 WriteDbContext:
- 刚执行完
SaveChanges()就查自己刚插入/更新的数据(例如注册后立即查用户 Profile) - 涉及
FOR UPDATE、SELECT ... FROM ... WITH (UPDLOCK)等锁提示的查询 - 跨多个
DbSet的 JOIN 查询,且其中至少一个表刚被修改过(即使没提交) - 任何在
TransactionScope或显式BeginTransaction()内的查询
这类逻辑无法靠拦截器自动识别,必须由业务层显式声明——比如加一个 WithConsistency(ConsistencyLevel.Strong) 扩展方法,内部路由到主库上下文。











