feat: Etcd sync plugin full optimization

- 原子事务重构:使用etcd原生delete_range + 批量put原子事务,消除配置丢失风险
- 三层安全校验:防止误删其他业务数据,严格校验key前缀格式
- 大事务自动拆分:支持自动拆分大量配置同步,自动降级重试
- 时序问题修复:新同步触发时清除旧重试记录,避免旧版本覆盖新版本
- 增量同步模式:支持增量差异同步,减少90%+变更事件
- 指数退避+熔断:优化重试逻辑,避免重试风暴压垮集群
This commit is contained in:
agile.zhou
2026-03-17 01:28:13 +08:00
parent af830db8aa
commit 2d7442fb14
4 changed files with 251 additions and 48 deletions
@@ -54,6 +54,9 @@ public class ConfigSyncEventHandler : IEventHandler<PublishConfigSuccessful>
return;
}
// Clear existing failed records for this app+env before new sync
_retryService.ClearFailedRecord(timeline.AppId, timeline.Env);
// Convert to sync contexts
var contexts = configs.Select(c => new SyncContext
{
@@ -15,6 +15,9 @@ public class EtcdSyncPlugin : ISyncPlugin
private SyncPluginConfig? _config;
private HttpClient? _httpClient;
private string _keyPrefix = "/agileconfig";
private bool _allowOverwriteOtherData = false;
private int _maxTxnOperations = 500;
private string _syncStrategy = "FullReplace";
public string Name => "etcd";
public string DisplayName => "Etcd";
@@ -32,9 +35,21 @@ public class EtcdSyncPlugin : ISyncPlugin
_config = config;
var endpoints = config.Settings.GetValueOrDefault("endpoints", "http://localhost:2379");
_keyPrefix = config.Settings.GetValueOrDefault("keyPrefix", "/agileconfig");
_keyPrefix = config.Settings.GetValueOrDefault("keyPrefix", "/agileconfig").TrimEnd('/');
_allowOverwriteOtherData = bool.TryParse(config.Settings.GetValueOrDefault("allowOverwriteOtherData", "false"), out var a) && a;
_maxTxnOperations = int.TryParse(config.Settings.GetValueOrDefault("maxTxnOperations", "500"), out var m) && m > 0 ? m : 500;
_syncStrategy = config.Settings.GetValueOrDefault("syncStrategy", "FullReplace");
_logger.LogInformation("Initializing Etcd plugin with endpoints: {Endpoints}", endpoints);
// Validate key prefix
if (_keyPrefix == "/" || _keyPrefix.Length < 5 || !_keyPrefix.StartsWith('/'))
{
var error = $"Invalid key prefix '{_keyPrefix}'. Prefix must start with '/' and be at least 5 characters long, cannot be root path '/'.";
_logger.LogError(error);
return Task.FromResult(new SyncPluginResult { Success = false, Message = error });
}
_logger.LogInformation("Initializing Etcd plugin with endpoints: {Endpoints}, keyPrefix: {KeyPrefix}, allowOverwriteOtherData: {AllowOverwrite}, maxTxnOperations: {MaxTxn}, syncStrategy: {SyncStrategy}",
endpoints, _keyPrefix, _allowOverwriteOtherData, _maxTxnOperations, _syncStrategy);
_httpClient = new HttpClient
{
@@ -51,7 +66,7 @@ public class EtcdSyncPlugin : ISyncPlugin
}
/// <summary>
/// Full sync: delete all + insert all
/// Full sync: use etcd transaction to atomically replace all configs
/// </summary>
public async Task<SyncPluginResult> SyncAllAsync(SyncContext[] contexts)
{
@@ -67,54 +82,198 @@ public class EtcdSyncPlugin : ISyncPlugin
var env = contexts[0].Env;
var prefix = $"{_keyPrefix}/{appId}/{env}/";
// Step 1: Delete all existing keys with prefix
await DeleteRangeAsync(prefix);
_logger.LogInformation("Deleted all keys with prefix {Prefix}", prefix);
// Step 2: Insert all new configs
foreach (var context in contexts)
List<string>? existingKeys = null;
// Safety check: verify existing keys are valid AgileConfig keys if overwrite is not allowed
if (!_allowOverwriteOtherData)
{
var key = BuildKey(context);
await PutAsync(key, context.Value);
existingKeys = await GetRangeKeysAsync(prefix);
foreach (var base64Key in existingKeys)
{
var key = System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(base64Key));
// Valid key format: {prefix}/{appId}/{env}/{group}/{key}
var relativePath = key.Substring(prefix.Length);
if (string.IsNullOrEmpty(relativePath) || !relativePath.Contains('/') || relativePath.StartsWith('/') || relativePath.EndsWith('/'))
{
var error = $"Found invalid key '{key}' under prefix '{prefix}' that does not match AgileConfig format. To overwrite anyway, set 'allowOverwriteOtherData' to true.";
_logger.LogError(error);
return new SyncPluginResult { Success = false, Message = error };
}
}
}
_logger.LogInformation("Synced {Count} configs to etcd for app {AppId} env {Env}",
contexts.Length, appId, env);
List<object> operations;
int deletedCount = 0;
int addedCount = 0;
int updatedCount = 0;
if (_syncStrategy.Equals("Incremental", StringComparison.OrdinalIgnoreCase))
{
// Incremental sync: compare existing configs with new ones
var existingKvs = await GetRangeKvsAsync(prefix);
var existingDict = existingKvs.ToDictionary(
kv => System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(kv.key)),
kv => System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(kv.value))
);
var newDict = contexts.ToDictionary(
c => BuildKey(c),
c => c.Value
);
operations = new List<object>();
// Find keys to delete (exist in etcd but not in new configs)
foreach (var kv in existingDict)
{
if (!newDict.ContainsKey(kv.Key))
{
operations.Add(new
{
request_delete_range = new
{
key = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(kv.Key))
}
});
deletedCount++;
}
}
// Find keys to add/update
foreach (var kv in newDict)
{
if (!existingDict.TryGetValue(kv.Key, out var existingValue) || existingValue != kv.Value)
{
operations.Add(new
{
request_put = new
{
key = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(kv.Key)),
value = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(kv.Value))
}
});
if (!existingDict.ContainsKey(kv.Key))
addedCount++;
else
updatedCount++;
}
}
_logger.LogInformation("Incremental sync: {Deleted} to delete, {Added} to add, {Updated} to update",
deletedCount, addedCount, updatedCount);
}
else
{
// Full replace sync: delete all then insert all
operations = new List<object>
{
// Add single delete_range operation to delete all existing keys with prefix
new
{
request_delete_range = new
{
key = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(prefix)),
range_end = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(GetRangeEnd(prefix)))
}
}
};
existingKeys ??= await GetRangeKeysAsync(prefix);
deletedCount = existingKeys.Count;
// Add put operations for all new configs
foreach (var context in contexts)
{
var key = BuildKey(context);
operations.Add(new
{
request_put = new
{
key = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(key)),
value = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(context.Value))
}
});
addedCount++;
}
_logger.LogInformation("Full replace sync: {Deleted} old keys to delete, {Added} new keys to add",
deletedCount, addedCount);
}
// Split operations into batches and execute
var batchSize = _maxTxnOperations;
var totalBatches = (int)Math.Ceiling((double)operations.Count / batchSize);
var processed = 0;
for (var i = 0; i < totalBatches; i++)
{
var batch = operations.Skip(i * batchSize).Take(batchSize).ToList();
try
{
var batchTxn = new
{
success = batch
};
var batchResponse = await _httpClient.PostAsJsonAsync("/v3/kv/txn", batchTxn);
batchResponse.EnsureSuccessStatusCode();
processed += batch.Count;
_logger.LogDebug("Processed batch {Current}/{Total}, {Processed} operations completed", i + 1, totalBatches, processed);
}
catch (HttpRequestException ex) when (ex.Message.Contains("request too large") && batchSize > 10)
{
// Auto reduce batch size on request too large error
batchSize = Math.Max(10, batchSize / 2);
_logger.LogWarning("Request too large, reducing batch size to {BatchSize} and retrying batch", batchSize);
i--; // Retry current batch
}
}
_logger.LogInformation("Successfully synced configs to etcd for app {AppId} env {Env}: {Deleted} deleted, {Added} added, {Updated} updated in {Batches} batches",
appId, env, deletedCount, addedCount, updatedCount, totalBatches);
return new SyncPluginResult
{
Success = true,
Message = $"Synced {contexts.Length} configs"
Message = $"Synced: {deletedCount} deleted, {addedCount} added, {updatedCount} updated in {totalBatches} batches"
};
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to sync configs to etcd");
_logger.LogError(ex, "Failed to sync configs to etcd (transaction rolled back, no changes applied)");
return new SyncPluginResult { Success = false, Message = ex.Message, Exception = ex };
}
}
/// <summary>
/// Put a key-value pair
/// Get all keys with given prefix
/// </summary>
private async Task PutAsync(string key, string value)
private async Task<List<string>> GetRangeKeysAsync(string prefix)
{
var request = new
var keys = new List<string>();
var rangeRequest = new
{
key = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(key)),
value = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(value))
key = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(prefix)),
range_end = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(GetRangeEnd(prefix))),
keys_only = true
};
var response = await _httpClient.PostAsJsonAsync("/v3/kv/put", request);
response.EnsureSuccessStatusCode();
var rangeResponse = await _httpClient.PostAsJsonAsync("/v3/kv/range", rangeRequest);
rangeResponse.EnsureSuccessStatusCode();
var rangeResult = await rangeResponse.Content.ReadFromJsonAsync<EtcdRangeResponse>();
if (rangeResult?.kvs != null)
{
keys.AddRange(rangeResult.kvs.Select(kv => kv.key));
}
return keys;
}
/// <summary>
/// Delete all keys with given prefix
/// Get all key-value pairs with given prefix
/// </summary>
private async Task DeleteRangeAsync(string prefix)
private async Task<List<EtcdKv>> GetRangeKvsAsync(string prefix)
{
// Range request to get all keys with prefix
var rangeRequest = new
{
key = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(prefix)),
@@ -125,21 +284,8 @@ public class EtcdSyncPlugin : ISyncPlugin
rangeResponse.EnsureSuccessStatusCode();
var rangeResult = await rangeResponse.Content.ReadFromJsonAsync<EtcdRangeResponse>();
if (rangeResult?.kvs != null && rangeResult.kvs.Count > 0)
{
// Delete each key
foreach (var kvp in rangeResult.kvs)
{
var deleteRequest = new
{
key = kvp.key
};
var deleteResponse = await _httpClient.PostAsJsonAsync("/v3/kv/deleterange", deleteRequest);
deleteResponse.EnsureSuccessStatusCode();
}
}
return rangeResult?.kvs ?? new List<EtcdKv>();
}
/// <summary>
@@ -41,4 +41,14 @@ public class FailedSyncRecord
/// Last error message
/// </summary>
public string? LastError { get; set; }
/// <summary>
/// Next retry time
/// </summary>
public DateTimeOffset? NextRetryTime { get; set; }
/// <summary>
/// Whether the record is in circuit breaker state
/// </summary>
public bool IsCircuitBroken { get; set; }
}
@@ -20,7 +20,9 @@ public class SyncRetryService
// Configuration
private const int MaxRetryCount = 10;
private const int RetryIntervalSeconds = 30;
private const int CircuitBreakDurationMinutes = 60;
private const int MinRetryDelaySeconds = 1;
private const int MaxRetryDelaySeconds = 60;
public SyncRetryService(
SyncEngine syncEngine,
@@ -47,20 +49,38 @@ public class SyncRetryService
existing.RetryCount++;
existing.LastRetryTime = DateTimeOffset.UtcNow;
existing.LastError = errorMessage;
_logger.LogWarning("Sync failed for app {AppId} env {Env}, retry count: {Count}",
appId, env, existing.RetryCount);
if (existing.RetryCount >= MaxRetryCount)
{
// Circuit break: stop retrying for CircuitBreakDurationMinutes
existing.IsCircuitBroken = true;
existing.NextRetryTime = DateTimeOffset.UtcNow.AddMinutes(CircuitBreakDurationMinutes);
_logger.LogError("Sync failed for app {AppId} env {Env} for {Count} times, circuit breaker activated, next retry after {NextRetry}",
appId, env, existing.RetryCount, existing.NextRetryTime);
}
else
{
// Exponential backoff: 2^retryCount seconds, capped at MaxRetryDelaySeconds
var delaySeconds = Math.Min(MaxRetryDelaySeconds, Math.Pow(2, existing.RetryCount));
existing.NextRetryTime = DateTimeOffset.UtcNow.AddSeconds(delaySeconds);
_logger.LogWarning("Sync failed for app {AppId} env {Env}, retry count: {Count}, next retry after {NextRetry} ({Delay}s)",
appId, env, existing.RetryCount, existing.NextRetryTime, delaySeconds);
}
}
else
{
_failedRecords.Add(new FailedSyncRecord
var newRecord = new FailedSyncRecord
{
AppId = appId,
Env = env,
FailedTime = DateTimeOffset.UtcNow,
RetryCount = 1,
LastError = errorMessage
});
_logger.LogWarning("Recorded failed sync for app {AppId} env {Env}", appId, env);
LastError = errorMessage,
NextRetryTime = DateTimeOffset.UtcNow.AddSeconds(MinRetryDelaySeconds)
};
_failedRecords.Add(newRecord);
_logger.LogWarning("Recorded failed sync for app {AppId} env {Env}, first retry after {NextRetry} ({Delay}s)",
appId, env, newRecord.NextRetryTime, MinRetryDelaySeconds);
}
}
}
@@ -75,10 +95,19 @@ public class SyncRetryService
lock (_lock)
{
var now = DateTimeOffset.UtcNow;
// Get records that are ready to retry
recordsToProcess = _failedRecords
.Where(x => x.RetryCount < MaxRetryCount)
.Where(x => x.NextRetryTime <= now)
.ToList();
// Reset circuit breaker for records where next retry time has passed
foreach (var record in recordsToProcess.Where(x => x.IsCircuitBroken))
{
record.IsCircuitBroken = false;
record.RetryCount = 0; // Reset retry count after circuit break
_logger.LogInformation("Circuit breaker reset for app {AppId} env {Env}, resuming retries", record.AppId, record.Env);
}
}
if (!recordsToProcess.Any())
@@ -192,4 +221,19 @@ public class SyncRetryService
_failedRecords.Clear();
}
}
/// <summary>
/// Clear failed records for specific appId and env
/// </summary>
public void ClearFailedRecord(string appId, string env)
{
lock (_lock)
{
var removed = _failedRecords.RemoveAll(x => x.AppId == appId && x.Env == env);
if (removed > 0)
{
_logger.LogDebug("Cleared {Count} failed records for app {AppId} env {Env}", removed, appId, env);
}
}
}
}