我來說說讀寫分離,就是數(shù)據(jù)庫讀寫分離在ORM中是如何實(shí)現(xiàn)的,附上源碼
現(xiàn)狀
你去檢索下讀寫分離,一大堆告訴你,寫,更改,刪除,走主數(shù)據(jù)庫,讀走從數(shù)據(jù)庫
我要說的是,這個(gè)完全錯(cuò)誤的
原因
其實(shí)但從字面來說,上面也是很有道理的,但是問題就出現(xiàn)在現(xiàn)實(shí)上
延遲問題
主從備份,主數(shù)據(jù)庫,從數(shù)據(jù)庫,我們都知道,是往主數(shù)據(jù)庫寫入,從數(shù)據(jù)庫自動(dòng)從主數(shù)據(jù)庫復(fù)制數(shù)據(jù),但是,這里是有延遲的,而且延遲還不低,不說多吧,100ms是很常見的!當(dāng)然你服務(wù)器牛叉,可以做到1ms,那也不能說他沒延遲,只是延遲多少的問題!
最大問題
讀寫分離最大的問題,不是讀寫分離如何寫,而是如何處理實(shí)際情況,比如寫入了,更新了,你讀取的時(shí)候還沒有讀取到,讀取到的是舊的?。。?/p>
總結(jié)
所以說,如果你看到讀寫分離,一股腦的用讀只走從數(shù)據(jù)庫的,可以直接劃走了!
DeepSeek的說法
您舉的這個(gè)例子太典型了!這正好說明了讀寫分離不能簡(jiǎn)單粗暴地按照"所有讀走從庫"來設(shè)計(jì)。
您說的這個(gè)場(chǎng)景的致命問題
// 邀約注冊(cè) + 立即登錄場(chǎng)景
public class InvitationService
{
// 接口1:邀約自動(dòng)注冊(cè)(寫主庫)
public async Task<IActionResult> AutoRegisterFromInvitation(string invitationCode)
{
// 驗(yàn)證邀約碼,創(chuàng)建用戶(寫主庫)
var user = new User {
Username = GenerateUsername(),
Password = HashPassword("temp123"),
InvitationCode = invitationCode
};
_dbContext.Users.Add(user);
await _dbContext.SaveChangesAsync(); // 寫入主庫
// 返回成功,前端自動(dòng)跳轉(zhuǎn)到登錄頁
return Ok(new { success = true, userId = user.Id });
}
// 接口2:用戶立即登錄(如果強(qiáng)制走從庫就完蛋了?。? public async Task<IActionResult> Login(string username, string password)
{
// 問題:這里如果強(qiáng)制走從庫查詢
var user = await _readOnlyDbContext.Users
.FirstOrDefaultAsync(u => u.Username == username);
if (user == null)
{
// 用戶看到:"剛注冊(cè)成功,現(xiàn)在說用戶不存在?"
return BadRequest("用戶不存在!");
}
// 驗(yàn)證密碼...
return Ok(new { token = GenerateToken(user) });
}
}
現(xiàn)實(shí)中的連鎖問題
1. 新用戶注冊(cè)后立即操作
// 用戶注冊(cè)后立即完善資料
public async Task CompleteProfile(int userId, UserProfile profile)
{
// 如果這里走從庫查詢用戶
var user = await _readOnlyDbContext.Users.FindAsync(userId);
// 可能為null!用戶剛注冊(cè),從庫還沒同步
user.Profile = profile;
await _dbContext.SaveChangesAsync(); // 又寫主庫
// 如果user為null,這里直接異常!
}
2. 訂單創(chuàng)建后立即查詢
public async Task<IActionResult> CreateOrder(Order order)
{
// 創(chuàng)建訂單(主庫)
_dbContext.Orders.Add(order);
await _dbContext.SaveChangesAsync();
// 立即返回訂單詳情給前端
// 如果前端立即調(diào)用查詢訂單接口(走從庫)
// 可能查不到剛創(chuàng)建的訂單!
return Ok(new { orderId = order.Id });
}
// 前端代碼
const createOrder = async () => {
await api.post('/orders', orderData); // 創(chuàng)建成功
const result = await api.get(`/orders/${orderId}`); // 立即查詢
// 可能返回404!用戶:"我剛剛創(chuàng)建的訂單呢?"
}
正確的解決方案
方案一:基于業(yè)務(wù)語義的路由
public class SmartConnectionRouter
{
public bool ShouldUseReadOnlyDb(HttpContext context)
{
var endpoint = context.GetEndpoint();
// 這些場(chǎng)景必須走主庫(即使它們是讀操作?。? var criticalReadOperations = new[]
{
"/auth/login", // 登錄
"/users/{id}/profile", // 用戶詳情
"/orders/{id}", // 訂單詳情
"/payments/status" // 支付狀態(tài)
};
// 這些場(chǎng)景可以走從庫(容忍延遲)
var tolerantReadOperations = new[]
{
"/products/list", // 商品列表
"/articles", // 文章列表
"/statistics/report" // 統(tǒng)計(jì)報(bào)表
};
var path = context.Request.Path.ToString();
return tolerantReadOperations.Any(p => path.Contains(p)) &&
!criticalReadOperations.Any(p => path.Contains(p));
}
}
方案二:用戶會(huì)話級(jí)別的路由
public class UserAwareConnectionSelector
{
public string GetConnectionString(HttpContext context)
{
// 新注冊(cè)用戶:短時(shí)間內(nèi)強(qiáng)制主庫
if (context.User.IsNewlyRegistered())
{
return "Default"; // 主庫
}
// 關(guān)鍵操作:強(qiáng)制主庫
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));
}
}
方案三:寫操作后的讀強(qiáng)制主庫
public class WriteAwareConnectionMiddleware
{
public async Task InvokeAsync(HttpContext context, IConnectionStringSelector selector)
{
// 如果是寫操作,設(shè)置標(biāo)記
if (context.Request.Method != "GET")
{
context.Items["ForceMasterDb"] = true;
selector.SetConnectionStringName("Default");
}
else
{
// 讀操作:檢查是否需要強(qiáng)制主庫
var forceMaster = context.Items["ForceMasterDb"] as bool? ?? false;
if (forceMaster || IsCriticalReadOperation(context))
{
selector.SetConnectionStringName("Default");
}
else
{
selector.SetConnectionStringName("ReadOnly");
}
}
await _next(context);
}
}
現(xiàn)實(shí)中的妥協(xié)
// 實(shí)際項(xiàng)目中,我們通常這樣做:
public class PracticalApproach
{
// 1. 關(guān)鍵業(yè)務(wù)路徑全部主庫
// - 用戶認(rèn)證相關(guān)
// - 訂單交易相關(guān)
// - 支付相關(guān)
// - 庫存相關(guān)
// 2. 非關(guān)鍵業(yè)務(wù)走從庫
// - 商品瀏覽
// - 內(nèi)容展示
// - 報(bào)表統(tǒng)計(jì)
// - 搜索建議
// 3. 新用戶特殊處理
// - 注冊(cè)后30分鐘內(nèi)強(qiáng)制主庫
// - 關(guān)鍵操作后短時(shí)間內(nèi)強(qiáng)制主庫
}
結(jié)論
您說得對(duì)!讀寫分離不能簡(jiǎn)單地按照"所有讀走從庫"來設(shè)計(jì),必須:
- 理解業(yè)務(wù)語義 - 哪些讀操作對(duì)實(shí)時(shí)性要求高
- 識(shí)別數(shù)據(jù)流 - 寫操作后哪些讀操作會(huì)立即發(fā)生
- 接受復(fù)雜性 - 路由邏輯會(huì)變得復(fù)雜,但這是必要的
- 監(jiān)控和調(diào)整 - 根據(jù)實(shí)際運(yùn)行情況不斷優(yōu)化路由策略
那種"一刀切"的讀寫分離方案,在實(shí)際業(yè)務(wù)中會(huì)帶來很多您提到的這種數(shù)據(jù)不一致問題。合理的讀寫分離是藝術(shù),不是簡(jiǎn)單的技術(shù)配置。
PasteForm中的讀寫方案
由于最近一個(gè)項(xiàng)目要考慮讀寫分離的問題,之前的都比較小,都是直接主庫操作,所以對(duì)PasteForm的做了一個(gè)改版
PasteForm框架介紹
PasteForm是一個(gè)基于ABP的敏捷開發(fā)框架,核心思想是通過對(duì)Dto進(jìn)行標(biāo)注特性,讓管理端完全交給后端,然你體驗(yàn)啥叫敏捷開發(fā)?。。?/p>
原理說明
上面說到了讀寫分離,在這個(gè)框架中,我主要用dbContext的方式實(shí)現(xiàn)數(shù)據(jù)庫的相關(guān)操作,別問為啥不用倉儲(chǔ),我感覺倉儲(chǔ)的存在很奇怪,或者說不夠直接,不夠靈活!
思路一
和其他文章一樣,在讀取的時(shí)候走從數(shù)據(jù)庫,在其他操作上走主數(shù)據(jù)庫,但是這個(gè)想法直接就被斃了,因?yàn)檫@個(gè)方案完全用不了,和業(yè)務(wù)需求完全沖突!
思路二
既然思路一走不通,那就換一個(gè)方式
其實(shí)在實(shí)際開發(fā)中,幾乎的項(xiàng)目很多是走主庫的,很少走從的,為啥呢?這里說的多少是接口,不是說訪問次數(shù)哈!
那就換一個(gè)思路,
讓開發(fā)者主動(dòng)標(biāo)記,我這個(gè)Action走從庫還是走主庫,上面說的走從庫的少,那么我就默認(rèn)走主庫
這個(gè)思路我覺得是可行的,而且問了AI,也是肯定答復(fù),那么問題就剩下如何寫和測(cè)試了!
請(qǐng)看PasteFormDbContext的代碼
/// <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)
{
}
//其他代碼
}
發(fā)現(xiàn)沒有,有一個(gè)過濾器
ConnectionStringName
其他沒有設(shè)置鏈接串的地方,
如果你查看這個(gè)過濾器的源碼,你會(huì)發(fā)覺里面也沒有寫啥
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;
}
}
也就是說,執(zhí)行數(shù)據(jù)庫鏈接串寫入到dbContext的不是他,他只是做一個(gè)標(biāo)記
然后我找到了這個(gè)DefaultConnectionStringResolver
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;
}
}
我們來看看這個(gè)AI的解釋
DefaultConnectionStringResolver 是ABP框架數(shù)據(jù)訪問層的一個(gè)基礎(chǔ)且關(guān)鍵的組件,它優(yōu)雅地處理了連接字符串的管理問題,為應(yīng)用程序特別是多租戶應(yīng)用程序提供了強(qiáng)大的靈活性。
上面的代碼意思是什么呢?
在ABP中,鏈接串還有一個(gè)東西叫名稱,上面的意思就是基于傳入的名稱,返回給調(diào)用方鏈接具體字符串!
注意看他注入的生命周期,是瞬時(shí)的,那么我們不就可以改變這個(gè),讓讀取的時(shí)候,基于上下文返回字符串,而不是從傳入的名稱!
綜上
從上面信息,那么問題就變成了,我如何基于上下文,給dbContext喂不一樣的連接字符串,或者說基于上下文給不一樣的dbContext
問題又來了,
如果你看一個(gè)Action,你會(huì)發(fā)現(xiàn),在Action的過濾器執(zhí)行前,Controller的構(gòu)造函數(shù)已經(jīng)執(zhí)行了
也就是生命周期的順序不對(duì),都已經(jīng)執(zhí)行dbContext的初始化了,你才想改他的鏈接字符串
那么我們就換一個(gè),換成更早的,更底層的中間件
/// <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);
}
好理解吧,上面的意思是,如果當(dāng)前的終結(jié)點(diǎn)沒有UseReadOnlyConnectionAttribute過濾器,則走默認(rèn)的,也就是主庫,有則走從庫,然后設(shè)置這個(gè)信息到IConnectionStringSelector
public interface IConnectionStringSelector
{
string GetConnectionStringName();
void SetConnectionStringName(string name);
}
/// <summary>
/// 返回當(dāng)前上下文的鏈接串名稱,注意是名稱,不是鏈接字符串
/// </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;
}
這樣大致信息就鏈接起來了
對(duì)原來的代碼幾乎沒有改動(dòng),
那么生效的就是讓剛剛改的代碼生效
//讀寫分離支持 如果不需要,需要把下面三行給注釋掉
context.Services.AddScoped<IConnectionStringSelector, ConnectionStringSelector>();
context.Services.Replace(ServiceDescriptor.Singleton<IConnectionStringResolver, DynamicConnectionStringResolver>());
// app.UseMiddleware<ConnectionStringMiddleware>(); 在UseRouting之后
上面中DynamicConnectionStringResolver的注入為啥是單例呢
因?yàn)槔锩娴拇a意思就是基于鏈接名稱獲取連接字符串,這個(gè)是一對(duì)一的關(guān)系,不需要做特意的變更,因?yàn)橐粋€(gè)程序啟動(dòng)后,這個(gè)對(duì)應(yīng)關(guān)系是固定的!
關(guān)鍵點(diǎn)在于ConnectionStringSelector
基于訪問上下文,修改當(dāng)前的連接名稱?。。?/p>
測(cè)試
改動(dòng)后,我啟動(dòng)測(cè)試下
在權(quán)限page的Action中做如下只讀標(biāo)記
/// <summary>
///
/// </summary>
/// <param name="input"></param>
/// <returns></returns>
[HttpGet]
[UseReadOnlyConnectionAttribute]//關(guān)鍵點(diǎn)在這,標(biāo)識(shí)這個(gè)接口走只讀
[TypeFilter(typeof(RoleAttribute), Arguments = new object[] { "data", "view" })]
public async Task<PagedResultDto<RoleInfoListDto>> Page([FromQuery] InputQueryRoleInfo input)
{
//具體實(shí)現(xiàn)代碼
}
然后我去創(chuàng)建一個(gè)新數(shù)據(jù)

會(huì)發(fā)現(xiàn)讀取列表的時(shí)候,是沒有這個(gè)數(shù)據(jù)的

因?yàn)闇y(cè)試階段,我的從數(shù)據(jù)庫沒有從主數(shù)據(jù)庫自動(dòng)同步
而測(cè)試其他表的新增和讀取,則正常!
也就是role的page接口,走的是從數(shù)據(jù)庫的讀取!
結(jié)語
其實(shí)關(guān)鍵點(diǎn)在于IConnectionStringSelector
所以,非接口函數(shù)要實(shí)現(xiàn)的話,我們可以手動(dòng)修改IConnectionStringSelector的數(shù)據(jù),這樣就可以實(shí)現(xiàn)切換主從了!
實(shí)際中,我感覺我上面還是有很多不足的,比如如果我是支持用戶自己選擇數(shù)據(jù)庫的,那么就應(yīng)該改成IConnectionStringSelector
只配置用主還是從
而下一個(gè)地方,則基于實(shí)際配置,自動(dòng)拆分,比如拆分成sqlite的主庫,還是sqlite得從庫!

浙公網(wǎng)安備 33010602011771號(hào)