2026/3/4 23:08:26
网站建设
项目流程
动感技术网站建设,河南网站建设推荐,互联网有创意的公司名字大全,网站建设与维护新的体会现状你去检索下读写分离#xff0c;一大堆告诉你#xff0c;写#xff0c;更改#xff0c;删除#xff0c;走主数据库#xff0c;读走从数据库我要说的是#xff0c;这个完全错误的原因其实但从字面来说#xff0c;上面也是很有道理的#xff0c;但是问题就出现在现实…现状你去检索下读写分离一大堆告诉你写更改删除走主数据库读走从数据库我要说的是这个完全错误的原因其实但从字面来说上面也是很有道理的但是问题就出现在现实上延迟问题主从备份主数据库从数据库我们都知道是往主数据库写入从数据库自动从主数据库复制数据但是这里是有延迟的而且延迟还不低不说多吧100ms是很常见的当然你服务器牛叉可以做到1ms那也不能说他没延迟只是延迟多少的问题最大问题读写分离最大的问题不是读写分离如何写而是如何处理实际情况比如写入了更新了你读取的时候还没有读取到读取到的是旧的总结所以说如果你看到读写分离一股脑的用读只走从数据库的可以直接划走了DeepSeek的说法您举的这个例子太典型了这正好说明了读写分离不能简单粗暴地按照所有读走从库来设计。您说的这个场景的致命问题// 邀约注册 立即登录场景public class InvitationService{// 接口1邀约自动注册写主库public async TaskIActionResult 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 TaskIActionResult 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. 新用户注册后立即操作// 用户注册后立即完善资料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. 订单创建后立即查询public async TaskIActionResult 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用户我刚刚创建的订单呢}正确的解决方案方案一基于业务语义的路由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));}}方案二用户会话级别的路由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));}}方案三写操作后的读强制主库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);}}现实中的妥协// 实际项目中我们通常这样做public class PracticalApproach{// 1. 关键业务路径全部主库// - 用户认证相关// - 订单交易相关// - 支付相关// - 库存相关// 2. 非关键业务走从库// - 商品浏览// - 内容展示// - 报表统计// - 搜索建议// 3. 新用户特殊处理// - 注册后30分钟内强制主库// - 关键操作后短时间内强制主库}结论您说得对读写分离不能简单地按照所有读走从库来设计必须理解业务语义 - 哪些读操作对实时性要求高识别数据流 - 写操作后哪些读操作会立即发生接受复杂性 - 路由逻辑会变得复杂但这是必要的监控和调整 - 根据实际运行情况不断优化路由策略那种一刀切的读写分离方案在实际业务中会带来很多您提到的这种数据不一致问题。合理的读写分离是艺术不是简单的技术配置。PasteForm中的读写方案由于最近一个项目要考虑读写分离的问题之前的都比较小都是直接主库操作所以对PasteForm的做了一个改版PasteForm框架介绍PasteForm是一个基于ABP的敏捷开发框架核心思想是通过对Dto进行标注特性让管理端完全交给后端然你体验啥叫敏捷开发原理说明上面说到了读写分离在这个框架中我主要用dbContext的方式实现数据库的相关操作别问为啥不用仓储我感觉仓储的存在很奇怪或者说不够直接不够灵活!思路一和其他文章一样在读取的时候走从数据库在其他操作上走主数据库但是这个想法直接就被毙了因为这个方案完全用不了和业务需求完全冲突思路二既然思路一走不通那就换一个方式其实在实际开发中几乎的项目很多是走主库的很少走从的为啥呢这里说的多少是接口不是说访问次数哈那就换一个思路让开发者主动标记我这个Action走从库还是走主库上面说的走从库的少那么我就默认走主库这个思路我觉得是可行的而且问了AI也是肯定答复那么问题就剩下如何写和测试了!请看PasteFormDbContext的代码/// summary////// /summary[ConnectionStringName(PasteFormDbProperties.ConnectionStringName)]public class PasteFormDbContext : AbpDbContextPasteFormDbContext, IPasteFormDbContext{/* Add DbSet for each Aggregate Root here. Example:* public DbSetQuestion Questions { get; set; }*//// summary////// /summary/// param nameoptions/param/// param namecurrentUser/parampublic PasteFormDbContext(DbContextOptionsPasteFormDbContext options): base(options){}//其他代码}发现没有有一个过滤器ConnectionStringName其他没有设置链接串的地方如果你查看这个过滤器的源码你会发觉里面也没有写啥public class ConnectionStringNameAttribute : Attribute{public string Name { get; }public ConnectionStringNameAttribute(string name){Check.NotNullstring(name, name);Name name;}public static string GetConnStringNameT(){return GetConnStringName(typeof(T));}public static string GetConnStringName(Type type){ConnectionStringNameAttribute customAttribute type.GetTypeInfo().GetCustomAttributeConnectionStringNameAttribute();if (customAttribute null){return type.FullName;}return customAttribute.Name;}}也就是说执行数据库链接串写入到dbContext的不是他他只是做一个标记然后我找到了这个DefaultConnectionStringResolverpublic class DefaultConnectionStringResolver : IConnectionStringResolver, ITransientDependency{protected AbpDbConnectionOptions Options { get; }public DefaultConnectionStringResolver(IOptionsMonitorAbpDbConnectionOptions options){Options options.CurrentValue;}[Obsolete(Use ResolveAsync method.)]public virtual string Resolve(string? connectionStringName null){return ResolveInternal(connectionStringName);}public virtual Taskstring 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的初始化了你才想改他的链接字符串那么我们就换一个换成更早的更底层的中间件/// summary////// /summarypublic class ConnectionStringMiddleware{private readonly RequestDelegate _next;private readonly IConnectionStringSelector _selector;/// summary////// /summary/// param namenext/param/// param nameselector/parampublic ConnectionStringMiddleware(RequestDelegate next, IConnectionStringSelector selector){_next next;_selector selector;}/// summary////// /summary/// param namecontext/param/// returns/returnspublic async Task InvokeAsync(HttpContext context){var endpoint context.GetEndpoint();string connectionStringName PasteFormDbProperties.SqliteConnectionStringName;if (endpoint?.Metadata.GetMetadataUseReadOnlyConnectionAttribute() ! null){connectionStringName PasteFormDbProperties.SqliteReadOnlyConnectionStringName;}//else if (endpoint?.Metadata.GetMetadataUseWriteConnectionAttribute() ! null)//{// connectionStringName Default;//}//else//{// connectionStringName context.Request.Method.Equals(GET, StringComparison.OrdinalIgnoreCase)// ? ReadOnly// : Default;//}_selector.SetConnectionStringName(connectionStringName);await _next(context);}好理解吧上面的意思是如果当前的终结点没有UseReadOnlyConnectionAttribute过滤器则走默认的也就是主库有则走从库然后设置这个信息到IConnectionStringSelectorpublic interface IConnectionStringSelector{string GetConnectionStringName();void SetConnectionStringName(string name);}/// summary/// 返回当前上下文的链接串名称注意是名称不是链接字符串/// /summarypublic class ConnectionStringSelector : IConnectionStringSelector{/// summary////// /summaryprivate string _connectionStringName PasteFormDbProperties.SqliteConnectionStringName;/// summary////// /summary/// returns/returnspublic string GetConnectionStringName() _connectionStringName;/// summary////// /summary/// param namename/parampublic void SetConnectionStringName(string name) _connectionStringName name;}这样大致信息就链接起来了对原来的代码几乎没有改动那么生效的就是让刚刚改的代码生效//读写分离支持 如果不需要需要把下面三行给注释掉context.Services.AddScopedIConnectionStringSelector, ConnectionStringSelector();context.Services.Replace(ServiceDescriptor.SingletonIConnectionStringResolver, DynamicConnectionStringResolver());// app.UseMiddlewareConnectionStringMiddleware(); 在UseRouting之后上面中DynamicConnectionStringResolver的注入为啥是单例呢因为里面的代码意思就是基于链接名称获取连接字符串这个是一对一的关系不需要做特意的变更因为一个程序启动后这个对应关系是固定的关键点在于ConnectionStringSelector基于访问上下文修改当前的连接名称测试改动后我启动测试下在权限page的Action中做如下只读标记/// summary////// /summary/// param nameinput/param/// returns/returns[HttpGet][UseReadOnlyConnectionAttribute]//关键点在这标识这个接口走只读[TypeFilter(typeof(RoleAttribute), Arguments new object[] { data, view })]public async TaskPagedResultDtoRoleInfoListDto Page([FromQuery] InputQueryRoleInfo input){//具体实现代码}然后我去创建一个新数据image会发现读取列表的时候是没有这个数据的image因为测试阶段我的从数据库没有从主数据库自动同步而测试其他表的新增和读取则正常也就是role的page接口走的是从数据库的读取结语其实关键点在于IConnectionStringSelector所以非接口函数要实现的话我们可以手动修改IConnectionStringSelector的数据这样就可以实现切换主从了