我来说说读写分离,就是数据库读写分离在ORM中是如何实现的,附上源码

现状

你去检索下读写分离,一大堆告诉你,写,更改,删除,走主数据库,读走从数据库

我要说的是,这个完全错误的

原因

其实但从字面来说,上面也是很有道理的,但是问题就出现在现实上

延迟问题

主从备份,主数据库,从数据库,我们都知道,是往主数据库写入,从数据库自动从主数据库复制数据,但是,这里是有延迟的,而且延迟还不低,不说多吧,100ms是很常见的!当然你服务器牛叉,可以做到1ms,那也不能说他没延迟,只是延迟多少的问题!

最大问题

读写分离最大的问题,不是读写分离如何写,而是如何处理实际情况,比如写入了,更新了,你读取的时候还没有读取到,读取到的是旧的!!!

总结

所以说,如果你看到读写分离,一股脑的用读只走从数据库的,可以直接划走了!

DeepSeek的说法

您举的这个例子太典型了!这正好说明了读写分离不能简单粗暴地按照"所有读走从库"来设计

您说的这个场景的致命问题

csharp 复制代码
// 邀约注册 + 立即登录场景
public class InvitationService
{
    // 接口1:邀约自动注册(写主库)
    public async Task<IActionResult> AutoRegisterFromInvitation(string invitationCode)
    {
        // 验证邀约码,创建用户(写主库)
        var user = new User { 
            Username = GenerateUsername(), 
            Password = HashPassword("temp123"),
            InvitationCode = invitationCode
        };
        
        _dbContext.Users.Add(user);
        await _dbContext.SaveChangesAsync(); // 写入主库
        
        // 返回成功,前端自动跳转到登录页
        return Ok(new { success = true, userId = user.Id });
    }

    // 接口2:用户立即登录(如果强制走从库就完蛋了!)
    public async Task<IActionResult> Login(string username, string password)
    {
        // 问题:这里如果强制走从库查询
        var user = await _readOnlyDbContext.Users
            .FirstOrDefaultAsync(u => u.Username == username);
            
        if (user == null)
        {
            // 用户看到:"刚注册成功,现在说用户不存在?"
            return BadRequest("用户不存在!");
        }
        
        // 验证密码...
        return Ok(new { token = GenerateToken(user) });
    }
}

现实中的连锁问题

1. 新用户注册后立即操作

csharp 复制代码
// 用户注册后立即完善资料
public async Task CompleteProfile(int userId, UserProfile profile)
{
    // 如果这里走从库查询用户
    var user = await _readOnlyDbContext.Users.FindAsync(userId);
    // 可能为null!用户刚注册,从库还没同步
    
    user.Profile = profile;
    await _dbContext.SaveChangesAsync(); // 又写主库
    // 如果user为null,这里直接异常!
}

2. 订单创建后立即查询

csharp 复制代码
public async Task<IActionResult> CreateOrder(Order order)
{
    // 创建订单(主库)
    _dbContext.Orders.Add(order);
    await _dbContext.SaveChangesAsync();
    
    // 立即返回订单详情给前端
    // 如果前端立即调用查询订单接口(走从库)
    // 可能查不到刚创建的订单!
    return Ok(new { orderId = order.Id });
}

// 前端代码
const createOrder = async () => {
    await api.post('/orders', orderData); // 创建成功
    const result = await api.get(`/orders/${orderId}`); // 立即查询
    // 可能返回404!用户:"我刚刚创建的订单呢?"
}

正确的解决方案

方案一:基于业务语义的路由

csharp 复制代码
public class SmartConnectionRouter
{
    public bool ShouldUseReadOnlyDb(HttpContext context)
    {
        var endpoint = context.GetEndpoint();
        
        // 这些场景必须走主库(即使它们是读操作!)
        var criticalReadOperations = new[]
        {
            "/auth/login",           // 登录
            "/users/{id}/profile",   // 用户详情
            "/orders/{id}",          // 订单详情
            "/payments/status"       // 支付状态
        };
        
        // 这些场景可以走从库(容忍延迟)
        var tolerantReadOperations = new[]
        {
            "/products/list",        // 商品列表
            "/articles",             // 文章列表
            "/statistics/report"     // 统计报表
        };
        
        var path = context.Request.Path.ToString();
        return tolerantReadOperations.Any(p => path.Contains(p)) && 
               !criticalReadOperations.Any(p => path.Contains(p));
    }
}

方案二:用户会话级别的路由

csharp 复制代码
public class UserAwareConnectionSelector
{
    public string GetConnectionString(HttpContext context)
    {
        // 新注册用户:短时间内强制主库
        if (context.User.IsNewlyRegistered())
        {
            return "Default"; // 主库
        }
        
        // 关键操作:强制主库
        if (IsCriticalOperation(context))
        {
            return "Default"; // 主库
        }
        
        // 其他情况:可以从库
        return "ReadOnly"; // 从库
    }
    
    private bool IsCriticalOperation(HttpContext context)
    {
        var criticalPaths = new[]
        {
            "/auth/", "/users/", "/orders/", "/payments/"
        };
        
        var path = context.Request.Path.ToString();
        return criticalPaths.Any(p => path.StartsWith(p));
    }
}

方案三:写操作后的读强制主库

csharp 复制代码
public class WriteAwareConnectionMiddleware
{
    public async Task InvokeAsync(HttpContext context, IConnectionStringSelector selector)
    {
        // 如果是写操作,设置标记
        if (context.Request.Method != "GET")
        {
            context.Items["ForceMasterDb"] = true;
            selector.SetConnectionStringName("Default");
        }
        else
        {
            // 读操作:检查是否需要强制主库
            var forceMaster = context.Items["ForceMasterDb"] as bool? ?? false;
            if (forceMaster || IsCriticalReadOperation(context))
            {
                selector.SetConnectionStringName("Default");
            }
            else
            {
                selector.SetConnectionStringName("ReadOnly");
            }
        }
        
        await _next(context);
    }
}

现实中的妥协

csharp 复制代码
// 实际项目中,我们通常这样做:
public class PracticalApproach
{
    // 1. 关键业务路径全部主库
    // - 用户认证相关
    // - 订单交易相关  
    // - 支付相关
    // - 库存相关
    
    // 2. 非关键业务走从库
    // - 商品浏览
    // - 内容展示
    // - 报表统计
    // - 搜索建议
    
    // 3. 新用户特殊处理
    // - 注册后30分钟内强制主库
    // - 关键操作后短时间内强制主库
}

结论

您说得对!读写分离不能简单地按照"所有读走从库"来设计,必须:

  1. 理解业务语义 - 哪些读操作对实时性要求高
  2. 识别数据流 - 写操作后哪些读操作会立即发生
  3. 接受复杂性 - 路由逻辑会变得复杂,但这是必要的
  4. 监控和调整 - 根据实际运行情况不断优化路由策略

那种"一刀切"的读写分离方案,在实际业务中会带来很多您提到的这种数据不一致问题。合理的读写分离是艺术,不是简单的技术配置

PasteForm中的读写方案

由于最近一个项目要考虑读写分离的问题,之前的都比较小,都是直接主库操作,所以对PasteForm的做了一个改版

PasteForm框架介绍

PasteForm是一个基于ABP的敏捷开发框架,核心思想是通过对Dto进行标注特性,让管理端完全交给后端,然你体验啥叫敏捷开发!!!

原理说明

上面说到了读写分离,在这个框架中,我主要用dbContext的方式实现数据库的相关操作,别问为啥不用仓储,我感觉仓储的存在很奇怪,或者说不够直接,不够灵活!

思路一

和其他文章一样,在读取的时候走从数据库,在其他操作上走主数据库,但是这个想法直接就被毙了,因为这个方案完全用不了,和业务需求完全冲突!

思路二

既然思路一走不通,那就换一个方式

其实在实际开发中,几乎的项目很多是走主库的,很少走从的,为啥呢?这里说的多少是接口,不是说访问次数哈!

那就换一个思路,

让开发者主动标记,我这个Action走从库还是走主库,上面说的走从库的少,那么我就默认走主库

这个思路我觉得是可行的,而且问了AI,也是肯定答复,那么问题就剩下如何写和测试了!

请看PasteFormDbContext的代码

csharp 复制代码
    /// <summary>
    /// 
    /// </summary>
    [ConnectionStringName(PasteFormDbProperties.ConnectionStringName)]
    public class PasteFormDbContext : AbpDbContext<PasteFormDbContext>, IPasteFormDbContext
    {
        /* Add DbSet for each Aggregate Root here. Example:
         * public DbSet<Question> Questions { get; set; }
         */

        /// <summary>
        /// 
        /// </summary>
        /// <param name="options"></param>
        /// <param name="currentUser"></param>
        public PasteFormDbContext(DbContextOptions<PasteFormDbContext> options)
            : base(options)
        {

        }
		//其他代码
}

发现没有,有一个过滤器

复制代码
ConnectionStringName

其他没有设置链接串的地方,

如果你查看这个过滤器的源码,你会发觉里面也没有写啥

csharp 复制代码
public class ConnectionStringNameAttribute : Attribute
{
    public string Name { get; }

    public ConnectionStringNameAttribute(string name)
    {
        Check.NotNull<string>(name, "name");
        Name = name;
    }

    public static string GetConnStringName<T>()
    {
        return GetConnStringName(typeof(T));
    }

    public static string GetConnStringName(Type type)
    {
        ConnectionStringNameAttribute customAttribute = type.GetTypeInfo().GetCustomAttribute<ConnectionStringNameAttribute>();
        if (customAttribute == null)
        {
            return type.FullName;
        }

        return customAttribute.Name;
    }
}

也就是说,执行数据库链接串写入到dbContext的不是他,他只是做一个标记

然后我找到了这个DefaultConnectionStringResolver

csharp 复制代码
public class DefaultConnectionStringResolver : IConnectionStringResolver, ITransientDependency
{
    protected AbpDbConnectionOptions Options { get; }

    public DefaultConnectionStringResolver(IOptionsMonitor<AbpDbConnectionOptions> options)
    {
        Options = options.CurrentValue;
    }

    [Obsolete("Use ResolveAsync method.")]
    public virtual string Resolve(string? connectionStringName = null)
    {
        return ResolveInternal(connectionStringName);
    }

    public virtual Task<string> ResolveAsync(string? connectionStringName = null)
    {
        return Task.FromResult(ResolveInternal(connectionStringName));
    }

    private string? ResolveInternal(string? connectionStringName)
    {
        if (connectionStringName == null)
        {
            return Options.ConnectionStrings.Default;
        }

        string connectionStringOrNull = Options.GetConnectionStringOrNull(connectionStringName);
        if (!connectionStringOrNull.IsNullOrEmpty())
        {
            return connectionStringOrNull;
        }

        return null;
    }
}

我们来看看这个AI的解释

复制代码
DefaultConnectionStringResolver 是ABP框架数据访问层的一个基础且关键的组件,它优雅地处理了连接字符串的管理问题,为应用程序特别是多租户应用程序提供了强大的灵活性。

上面的代码意思是什么呢?

在ABP中,链接串还有一个东西叫名称,上面的意思就是基于传入的名称,返回给调用方链接具体字符串!

注意看他注入的生命周期,是瞬时的,那么我们不就可以改变这个,让读取的时候,基于上下文返回字符串,而不是从传入的名称!

综上

从上面信息,那么问题就变成了,我如何基于上下文,给dbContext喂不一样的连接字符串,或者说基于上下文给不一样的dbContext

问题又来了,

如果你看一个Action,你会发现,在Action的过滤器执行前,Controller的构造函数已经执行了

也就是生命周期的顺序不对,都已经执行dbContext的初始化了,你才想改他的链接字符串

那么我们就换一个,换成更早的,更底层的中间件

csharp 复制代码
    /// <summary>
    /// 
    /// </summary>
    public class ConnectionStringMiddleware
    {
        private readonly RequestDelegate _next;
        private readonly IConnectionStringSelector _selector;

        /// <summary>
        /// 
        /// </summary>
        /// <param name="next"></param>
        /// <param name="selector"></param>
        public ConnectionStringMiddleware(RequestDelegate next, IConnectionStringSelector selector)
        {
            _next = next;
            _selector = selector;
        }

        /// <summary>
        /// 
        /// </summary>
        /// <param name="context"></param>
        /// <returns></returns>
        public async Task InvokeAsync(HttpContext context)
        {
            var endpoint = context.GetEndpoint();

            string connectionStringName = PasteFormDbProperties.SqliteConnectionStringName;
            if (endpoint?.Metadata.GetMetadata<UseReadOnlyConnectionAttribute>() != null)
            {
                connectionStringName = PasteFormDbProperties.SqliteReadOnlyConnectionStringName;
            }
            //else if (endpoint?.Metadata.GetMetadata<UseWriteConnectionAttribute>() != null)
            //{
            //    connectionStringName = "Default";
            //}
            //else
            //{
            //    connectionStringName = context.Request.Method.Equals("GET", StringComparison.OrdinalIgnoreCase)
            //        ? "ReadOnly"
            //        : "Default";
            //}

            _selector.SetConnectionStringName(connectionStringName);
            await _next(context);
        }

好理解吧,上面的意思是,如果当前的终结点没有UseReadOnlyConnectionAttribute过滤器,则走默认的,也就是主库,有则走从库,然后设置这个信息到IConnectionStringSelector

csharp 复制代码
    public interface IConnectionStringSelector
    {
        string GetConnectionStringName();
        void SetConnectionStringName(string name);
    }

    /// <summary>
    /// 返回当前上下文的链接串名称,注意是名称,不是链接字符串
    /// </summary>
    public class ConnectionStringSelector : IConnectionStringSelector
    {
        /// <summary>
        /// 
        /// </summary>
        private string _connectionStringName = PasteFormDbProperties.SqliteConnectionStringName;

        /// <summary>
        /// 
        /// </summary>
        /// <returns></returns>
        public string GetConnectionStringName() => _connectionStringName;

        /// <summary>
        /// 
        /// </summary>
        /// <param name="name"></param>
        public void SetConnectionStringName(string name) => _connectionStringName = name;
    }

这样大致信息就链接起来了

对原来的代码几乎没有改动,

那么生效的就是让刚刚改的代码生效

csharp 复制代码
                //读写分离支持 如果不需要,需要把下面三行给注释掉
                context.Services.AddScoped<IConnectionStringSelector, ConnectionStringSelector>();
                context.Services.Replace(ServiceDescriptor.Singleton<IConnectionStringResolver, DynamicConnectionStringResolver>());
                //  app.UseMiddleware<ConnectionStringMiddleware>(); 在UseRouting之后

上面中DynamicConnectionStringResolver的注入为啥是单例呢

因为里面的代码意思就是基于链接名称获取连接字符串,这个是一对一的关系,不需要做特意的变更,因为一个程序启动后,这个对应关系是固定的!

关键点在于ConnectionStringSelector

基于访问上下文,修改当前的连接名称!!!

测试

改动后,我启动测试下

在权限page的Action中做如下只读标记

csharp 复制代码
        /// <summary>
        /// 
        /// </summary>
        /// <param name="input"></param>
        /// <returns></returns>
        [HttpGet]
        [UseReadOnlyConnectionAttribute]//关键点在这,标识这个接口走只读
        [TypeFilter(typeof(RoleAttribute), Arguments = new object[] { "data", "view" })]
        public async Task<PagedResultDto<RoleInfoListDto>> Page([FromQuery] InputQueryRoleInfo input)
        {
		//具体实现代码
		}

然后我去创建一个新数据

会发现读取列表的时候,是没有这个数据的

因为测试阶段,我的从数据库没有从主数据库自动同步

而测试其他表的新增和读取,则正常!

也就是role的page接口,走的是从数据库的读取!

结语

其实关键点在于IConnectionStringSelector

所以,非接口函数要实现的话,我们可以手动修改IConnectionStringSelector的数据,这样就可以实现切换主从了!

实际中,我感觉我上面还是有很多不足的,比如如果我是支持用户自己选择数据库的,那么就应该改成IConnectionStringSelector

只配置用主还是从

而下一个地方,则基于实际配置,自动拆分,比如拆分成sqlite的主库,还是sqlite得从库!