場景
目前一個專案中資料持久化採用EF Core + MySQL,使用CodeFirst模式開發,並且對資料進行了分庫,執行期間庫也會動態的增加,按照目前顆粒度分完之後,大概有一兩百個庫,每個庫的資料都是相互隔離的。
借鑑了Github上一個開源的倉庫 arch/UnitOfWork 實現UnitOfWork,核心操作就是每個api請求的時候帶上庫名,在執行CRUD之前先將DbContext切換到目標資料庫,我們在切換資料庫的時候加了一些操作,如檢查資料庫是否已建立、檢查連線是否可用、判斷是否需要 表結構遷移 等
/// <summary>
/// 切換資料庫 這要求資料庫在同一臺機器上 注意:這隻適用於MySQL。
/// </summary>
/// <param name="database">目標資料庫</param>
public void ChangeDatabase(string database)
{
// 檢查連線
......
// 檢查資料庫是否建立
......
var connection = _context.Database.GetDbConnection();
if (connection.State.HasFlag(ConnectionState.Open))
{
connection.ChangeDatabase(database);
}
else
{
var connectionString = Regex.Replace(connection.ConnectionString.Replace(" ", ""), @"(?<=[Dd]atabase=)\w+(?=;)", database, RegexOptions.Singleline);
connection.ConnectionString = connectionString;
}
// 判斷是否需要執行表結構遷移
if(_context..Database.GetPendingMigrations().Any())
{
//自定義的遷移的一些邏輯
_context.Database.Migrate(_context);
}
}
但是當多個操作同時對一個庫進行Migrate的時候,就會出現問題,比如“新增一張表”的操作已經被第一個遷移執行過了,第二個執行的遷移並不知道已經執行過了Migrate,就會報錯表已存在。
於是考慮在執行Migrate的時候,加入一個鎖的機制,對當前資料庫執行Migrate之前先獲取鎖,然後再來決定接下來的操作。由於這邊有的服務無法訪問Redis,這裡使用資料庫來實現鎖的機制,當然用Redis來實現更好,加入鎖的機制只是一種解決問題的思路。
利用資料庫實現遷移鎖
1. 新增 MigrationLocks
表來實現遷移鎖
- 鎖的操作不依賴DbContext例項
- 在執行Migrate之前,嘗試獲取一個鎖,在獲取鎖之前,如果表不存在則建立
CREATE TABLE IF NOT EXISTS MigrationLocks ( LockName VARCHAR(255) PRIMARY KEY, LockedAt DATETIME NOT NULL );
- 成功往表中插入一條記錄,視為獲取鎖成功,主鍵為需要遷移的庫的名稱
INSERT INTO MigrationLocks (LockName, LockedAt) VALUES (@database, NOW());
- 遷移完成後,刪除這條記錄,視為釋放鎖成功;
DELETE FROM MigrationLocks WHERE LockName = @database;
- 為防止 “死鎖” 發生,每次嘗試獲取鎖之前,會對鎖的狀態進行檢查,釋放超過5分鐘的鎖(正常來說,上一個遷移的執行時間不會超過5分鐘)。
SELECT COUNT(*) FROM MigrationLocks WHERE LockName = @database AND LockedAt > NOW() - INTERVAL 5 MINUTE;
2. 封裝一下MigrateLock的實現
/// <summary>
/// 遷移鎖
/// </summary>
public interface IMigrateLock
{
/// <summary>
/// 嘗試獲取鎖
/// </summary>
/// <param name="connection"></param>
/// <returns></returns>
bool TryAcquireLock(IDbConnection connection);
/// <summary>
/// 嘗試獲取鎖
/// </summary>
/// <param name="connection"></param>
/// <returns></returns>
Task<bool> TryAcquireLockAsync(IDbConnection connection);
/// <summary>
/// 釋放鎖
/// </summary>
void ReleaseLock(IDbConnection connection);
/// <summary>
/// 釋放鎖
/// </summary>
/// <returns></returns>
Task ReleaseLockAsync(IDbConnection connection);
}
/// <summary>
/// 遷移鎖
/// </summary>
public class MigrateLock : IMigrateLock
{
private readonly ILogger<MigrateLock> _logger;
public MigrateLock(ILogger<MigrateLock> logger)
{
_logger = logger;
}
private const string CreateTableSql = @"
CREATE TABLE IF NOT EXISTS MigrationLocks (
LockName VARCHAR(255) PRIMARY KEY,
LockedAt DATETIME NOT NULL
);";
private const string CheckLockedSql = "SELECT COUNT(*) FROM MigrationLocks WHERE LockName = @database AND LockedAt > NOW() - INTERVAL 5 MINUTE;";
private const string AcquireLockSql = "INSERT INTO MigrationLocks (LockName, LockedAt) VALUES (@database, NOW());";
private const string ReleaseLockSql = "DELETE FROM MigrationLocks WHERE LockName = @database;";
/// <summary>
/// 嘗試獲取鎖
/// </summary>
/// <param name="connection"></param>
/// <returns></returns>
public bool TryAcquireLock(IDbConnection connection)
{
try
{
CheckLocked(connection);
var result = connection.Execute(AcquireLockSql, new { database = connection.Database });
if (result == 1)
{
_logger.LogInformation("Lock acquired: {LockName}", connection.Database);
return true;
}
_logger.LogWarning("Failed to acquire lock: {LockName}", connection.Database);
return false;
}
catch (Exception ex)
{
if (ex.Message.StartsWith("Duplicate"))
{
_logger.LogWarning("Failed acquiring lock due to duplicate entry: {LockName}", connection.Database);
}
else
{
_logger.LogError(ex, "Error acquiring lock: {LockName}", connection.Database);
}
return false;
}
}
/// <summary>
/// 嘗試獲取鎖
/// </summary>
/// <param name="connection"></param>
/// <returns></returns>
public async Task<bool> TryAcquireLockAsync(IDbConnection connection)
{
try
{
await CheckLockedAsync(connection);
var result = await connection.ExecuteAsync(AcquireLockSql, new { database = connection.Database });
if (result == 1)
{
_logger.LogInformation("Lock acquired: {LockName}", connection.Database);
return true;
}
_logger.LogWarning("Failed to acquire lock: {LockName}", connection.Database);
return false;
}
catch (Exception ex)
{
if (ex.Message.StartsWith("Duplicate"))
{
_logger.LogWarning("Failed acquiring lock due to duplicate entry: {LockName}", connection.Database);
}
else
{
_logger.LogError(ex, "Error acquiring lock: {LockName}", connection.Database);
}
return false;
}
}
/// <summary>
/// 釋放鎖
/// </summary>
public void ReleaseLock(IDbConnection connection)
{
try
{
connection.ExecuteAsync(ReleaseLockSql, new { database = connection.Database });
_logger.LogInformation("Lock released: {LockName}", connection.Database);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error releasing lock: {LockName}", connection.Database);
}
}
/// <summary>
/// 釋放鎖
/// </summary>
public async Task ReleaseLockAsync(IDbConnection connection)
{
try
{
await connection.ExecuteAsync(ReleaseLockSql, new { database = connection.Database });
_logger.LogInformation("Lock released: {LockName}", connection.Database);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error releasing lock: {LockName}", connection.Database);
}
}
/// <summary>
/// 檢查鎖
/// </summary>
private void CheckLocked(IDbConnection connection)
{
connection.Execute(CreateTableSql);
var databaseParam = new
{
database = connection.Database
};
var lockExists = connection.QueryFirstOrDefault<int>(CheckLockedSql, databaseParam);
if (lockExists <= 0)
{
return;
}
_logger.LogWarning("Lock exists and is older than 5 minutes. Releasing old lock.");
connection.Execute(ReleaseLockSql, databaseParam);
}
/// <summary>
/// 檢查鎖
/// </summary>
private async Task CheckLockedAsync(IDbConnection connection)
{
await connection.ExecuteAsync(CreateTableSql);
var databaseParam = new
{
database = connection.Database
};
var lockExists = await connection.QueryFirstOrDefaultAsync<int>(CheckLockedSql, databaseParam);
if (lockExists <= 0)
{
return;
}
_logger.LogWarning("Lock exists and is older than 5 minutes. Releasing old lock.");
await connection.ExecuteAsync(ReleaseLockSql, databaseParam);
}
}
3. 封裝一下MigrateExecutor的實現
/// <summary>
/// 資料庫遷移執行器
/// </summary>
public interface IMigrateExcutor
{
/// <summary>
/// 執行遷移
/// </summary>
/// <param name="dbContext"></param>
void Migrate(DbContext dbContext);
/// <summary>
/// 執行遷移
/// </summary>
/// <param name="dbContext"></param>
/// <returns></returns>
Task MigrateAsync(DbContext dbContext);
/// <summary>
/// 併發場景執行遷移
/// </summary>
/// <param name="dbContext"></param>
/// <param name="wait">是否等待至正在進行中的遷移完成</param>
void ConcurrentMigrate(DbContext dbContext, bool wait = true);
/// <summary>
/// 併發場景執行遷移
/// </summary>
/// <param name="dbContext"></param>
/// <param name="wait">是否等待至正在進行中的遷移完成</param>
/// <returns></returns>
Task ConcurrentMigrateAsync(DbContext dbContext, bool wait = true);
/// <summary>
/// 併發場景執行遷移
/// </summary>
/// <param name="dbContext"></param>
/// <param name="connection"></param>
/// <param name="wait">是否等待至正在進行中的遷移完成</param>
void ConcurrentMigrate(DbContext dbContext, IDbConnection connection, bool wait = true);
/// <summary>
/// 併發場景執行遷移
/// </summary>
/// <param name="dbContext"></param>
/// <param name="connection"></param>
/// <param name="wait">是否等待至正在進行中的遷移完成</param>
Task ConcurrentMigrateAsync(DbContext dbContext, IDbConnection connection, bool wait = true);
}
/// <summary>
/// 資料庫遷移執行器
/// </summary>
public class MigrateExcutor : IMigrateExcutor
{
private readonly IMigrateLock _migrateLock;
private readonly ILogger<MigrateExcutor> _logger;
public MigrateExcutor(
IMigrateLock migrateLock,
ILogger<MigrateExcutor> logger)
{
_migrateLock = migrateLock;
_logger = logger;
}
/// <summary>
/// 執行遷移
/// </summary>
/// <param name="dbContext"></param>
/// <returns></returns>
public void Migrate(DbContext dbContext)
{
try
{
if (dbContext.Database.GetPendingMigrations().Any())
{
dbContext.Database.Migrate();
}
}
catch (Exception e)
{
_logger.LogError(e, "Migration failed");
HandleError(dbContext, e);
}
}
/// <summary>
/// 執行遷移
/// </summary>
/// <param name="dbContext"></param>
/// <returns></returns>
public async Task MigrateAsync(DbContext dbContext)
{
try
{
if ((await dbContext.Database.GetPendingMigrationsAsync()).Any())
{
await dbContext.Database.MigrateAsync();
}
}
catch (Exception e)
{
_logger.LogError(e, "Migration failed");
await HandleErrorAsync(dbContext, e);
}
}
/// <summary>
/// 併發場景執行遷移
/// </summary>
/// <param name="dbContext"></param>
/// <param name="wait">是否等待至正在進行中的遷移完成</param>
/// <returns></returns>
public void ConcurrentMigrate(DbContext dbContext, bool wait = true)
{
if (!dbContext.Database.GetPendingMigrations().Any())
{
return;
}
using var connection = MySqlConnectionHelper.CreateConnection(dbContext.Database.GetDbConnection().Database);
ConcurrentMigrate(dbContext, connection, wait);
}
/// <summary>
/// 併發場景執行遷移
/// </summary>
/// <param name="dbContext"></param>
/// <param name="wait">是否等待至正在進行中的遷移完成</param>
/// <returns></returns>
public async Task ConcurrentMigrateAsync(DbContext dbContext, bool wait = true)
{
if ((await dbContext.Database.GetPendingMigrationsAsync()).Any())
{
return;
}
await using var connection = await MySqlConnectionHelper.CreateConnectionAsync(dbContext.Database.GetDbConnection().Database);
await ConcurrentMigrateAsync(dbContext, connection, wait);
}
/// <summary>
/// 併發場景執行遷移
/// </summary>
/// <param name="dbContext"></param>
/// <param name="connection"></param>
/// <param name="wait">是否等待至正在進行中的遷移完成</param>
public void ConcurrentMigrate(DbContext dbContext, IDbConnection connection, bool wait = true)
{
if (!dbContext.Database.GetPendingMigrations().Any())
{
return;
}
while (true)
{
if (_migrateLock.TryAcquireLock(connection))
{
try
{
Migrate(dbContext);
break;
}
finally
{
_migrateLock.ReleaseLock(connection);
}
}
if (wait)
{
_logger.LogWarning("Migration is locked, wait for 2 seconds");
Thread.Sleep(20000);
continue;
}
_logger.LogInformation("Migration is locked, skip");
}
}
/// <summary>
/// 併發場景執行遷移
/// </summary>
/// <param name="dbContext"></param>
/// <param name="connection"></param>
/// <param name="wait">是否等待至正在進行中的遷移完成</param>
public async Task ConcurrentMigrateAsync(DbContext dbContext, IDbConnection connection, bool wait = true)
{
if ((await dbContext.Database.GetPendingMigrationsAsync()).Any())
{
return;
}
while (true)
{
if (await _migrateLock.TryAcquireLockAsync(connection))
{
try
{
await MigrateAsync(dbContext);
break;
}
finally
{
await _migrateLock.ReleaseLockAsync(connection);
}
}
if (wait)
{
_logger.LogWarning("Migration is locked, wait for 2 seconds");
Thread.Sleep(20000);
continue;
}
_logger.LogInformation("Migration is locked, skip");
break;
}
}
private void HandleError(DbContext dbContext, Exception e)
{
var needChangeList = dbContext.Database.GetPendingMigrations().ToList();
var allChangeList = dbContext.Database.GetMigrations().ToList();
var hasChangeList = dbContext.Database.GetAppliedMigrations().ToList();
if (needChangeList.Count + hasChangeList.Count > allChangeList.Count)
{
int errIndex = allChangeList.Count - needChangeList.Count;
if (hasChangeList.Count - 1 == errIndex && hasChangeList[errIndex] != needChangeList[0])
{
int index = needChangeList[0].IndexOf("_", StringComparison.Ordinal);
string errSuffix = needChangeList[0].Substring(index, needChangeList[0].Length - index);
if (hasChangeList[errIndex].EndsWith(errSuffix))
{
dbContext.Database.ExecuteSqlRaw($"Update __EFMigrationsHistory set MigrationId = '{needChangeList[0]}' where MigrationId = '{hasChangeList[errIndex]}'");
dbContext.Database.Migrate();
}
else
{
throw e;
}
}
else
{
throw e;
}
}
else
{
throw e;
}
_logger.LogInformation("Migration failed, but success on second try.");
}
private async Task HandleErrorAsync(DbContext dbContext, Exception e)
{
var needChangeList = (await dbContext.Database.GetPendingMigrationsAsync()).ToList();
var allChangeList = dbContext.Database.GetMigrations().ToList();
var hasChangeList = (await dbContext.Database.GetAppliedMigrationsAsync()).ToList();
if (needChangeList.Count + hasChangeList.Count > allChangeList.Count)
{
int errIndex = allChangeList.Count - needChangeList.Count;
if (hasChangeList.Count - 1 == errIndex && hasChangeList[errIndex] != needChangeList[0])
{
int index = needChangeList[0].IndexOf("_", StringComparison.Ordinal);
string errSuffix = needChangeList[0].Substring(index, needChangeList[0].Length - index);
if (hasChangeList[errIndex].EndsWith(errSuffix))
{
await dbContext.Database.ExecuteSqlRawAsync($"Update __EFMigrationsHistory set MigrationId = '{needChangeList[0]}' where MigrationId = '{hasChangeList[errIndex]}'");
await dbContext.Database.MigrateAsync();
}
else
{
throw e;
}
}
else
{
throw e;
}
}
else
{
throw e;
}
_logger.LogInformation("Migration failed, but success on second try.");
}
}