一、事故开篇:断网三天,补传的报文把终审单打回了草稿
某船管公司的船队上线新 PMS 第二个月,SHIP01 在太平洋上遭遇卫星链路故障,连续断网 71 小时。
恢复联网后,船端积压的 2300 多条报文开始补传。岸基值班员第二天早上发现一件怪事:物料申请单 (2026)HKMW-TEC-MRP-0001,三级审批(Manager1 张三、Manager2 李四、Manager3 王五)早在断网前就全部完成、岸基状态是「终审通过」,补传报文里却夹着一条「提交审批」的旧报文,岸基照单全收后,单子状态被回退成了「审批中」------终审意见 ApproveMemo3 还在,但状态机被一条迟到的旧事件打回了原型。
更麻烦的是第二艘船 SHIP02 的问题:断网期间两台船端电脑(大副机和船长机,SQLite 各自独立)都修改了同一条 IMPA 物料字典里「高压油泵柱塞」的参考单价,恢复联网后两版数据先后到达岸基,后到的把先到的覆盖了,而先到的那版才是轮机长确认过的正确价格。
把两次事故拆开看,船岸同步链路的三大坑全部现形:
| 坑 | 事故表现 | 根因 |
|---|---|---|
| 重复 | 同一条审批报文岸基入账两次(重试导致) | 报文无幂等键,接收端不去重 |
| 乱序 | 旧的「提交」报文晚于「终审」报文到达,状态被打回 | 报文无版本号,接收端无法判断新旧 |
| 冲突 | 两台船端电脑改同一条字典数据互相覆盖 | 同一数据存在多个写入方,无冲突检测 |
而这一切的源头,要从老系统每张业务表上都有的那个字段说起------SendFlag。
💬 互动一下 :如果你接手一套系统,发现所有业务表都有个 SendFlag 字段,你的第一反应是「这设计真朴素」还是「这里面一定有故事」?
二、SendFlag 模式的本质:它是一个没有信封的发送标记位
老系统(VB6/Delphi 时代)的同步逻辑非常直白:每张业务表加一个 SendFlag 字段,0 = 待发送,1 = 已发送。船端程序定时跑一段扫描逻辑,把 SendFlag=0 的行捞出来拼成报文发走,发成功就 UPDATE ... SET SendFlag=1。
pascal
// 老系统(Delphi 伪代码)的典型同步逻辑
qry.Sql.Text := 'SELECT * FROM mrp_apply WHERE SendFlag = 0';
qry.Open;
while not qry.Eof do
begin
BuildAndSendMessage(qry); // 拼报文、HTTP 发走
qry.Next;
end;
// 发完之后......
ExecSql('UPDATE mrp_apply SET SendFlag = 1 WHERE SendFlag = 0'); // 一把全置位
在那个没有消息中间件、船端就是一台单机、同步本质上是「定时报表导出」的年代,这个设计完全合理:零依赖、肉眼可调试、出问题了上船查字段就知道哪条没发。它就是最朴素的 Outbox 思想萌芽------用数据库字段记住「哪些数据还没送走」。
但把它放进一个有重试、有并发、有双向同步的现代系统里,五个坑一个接一个:
坑 1:无版本号,接收端无法判断新旧。 SendFlag 只回答「发没发」,不回答「这是第几版」。一条单据被改了 5 次,发出去 5 条报文,岸基收到的顺序如果乱了(断网补传 + 重试,乱序是常态),根本无法知道哪条新哪条旧------开篇事故 1 就是这么来的。
坑 2:无幂等键,重试必然重复入账。 发送端收不到响应时,它无法区分「报文没到」还是「报文到了但 ACK 丢了」,只能重发。重发的报文和首次报文内容一模一样,接收端没有任何字段可以认出「这条我处理过」,于是入账两次。
坑 3:标记位与业务数据同事务,但「发送」不在事务内,崩溃窗口真实存在。 看老代码最后那行 UPDATE ... SET SendFlag=1:如果 HTTP 实际已成功、但置位语句执行前进程崩溃/断电,重启后这条会被重发(重复,坑 2 兜底);反过来,更危险的写法是先置位后发送 ------置位事务提交了、发送前崩溃,这条数据永远不会再被捞起,静默丢失。两个失败窗口,标记位模式只能靠运气躲过一个。
坑 4:全表扫描 WHERE SendFlag=0,数据量上来后又慢又锁。 每张业务表都要扫,SendFlag 上没索引(老系统表结构里基本没有),断网三天积压几万行后,一次扫描全表锁几分钟,船端操作员界面卡死。
坑 5:删除的数据同步不过去。 行都被物理删除了,SendFlag 随行了,扫描器永远看不到「有一条数据被删了」这件事。岸基那条数据就此永久残留,对账时永远对不平。
结论先行:SendFlag 不是错,它是「只有一个布尔位的 Outbox」。 现代化改造不是删掉它,而是把它升级成一套带信封(Envelope)、单调版本号、独立 spool 表、接收端幂等去重的同步协议。下面逐件拆。
三、同步报文信封设计:给每条消息办一张身份证
同步协议的第一原则:线上只传信封,不传裸数据行。 每个信封自包含全部路由、去重、排序所需的信息,接收端不需要回查任何上下文就能决定「收下 / 丢弃 / 入冲突队列」。
csharp
// Shared/Sync/Envelope.cs ------ 船岸两端共用的报文契约(打成共享 NuGet 或链接文件)
namespace Pms.Shared.Sync;
/// <summary>同步报文信封:线上传输的最小自包含单元</summary>
public sealed record Envelope
{
/// <summary>幂等键:全链路唯一,重发时保持不变。船端 Guid(断网也能本地生成)</summary>
public required Guid MessageId { get; init; }
/// <summary>聚合类型,如 "MaterialApply" / "ImpaDictionary"</summary>
public required string AggregateType { get; init; }
/// <summary>聚合业务键,如单号 (2026)HKMW-TEC-MRP-0001;跨库稳定,不用自增 Id</summary>
public required string AggregateKey { get; init; }
/// <summary>数据归属船舶:SHIP01 / SHIP02;岸基下发报文为 "SHORE"</summary>
public required string ShipId { get; init; }
/// <summary>单调版本号:同一 (ShipId, AggregateType, AggregateKey) 内严格 +1</summary>
public required long Version { get; init; }
/// <summary>事件类型:Created / Updated / Submitted / Approved(L1/L2/L3) / Rejected / Deleted</summary>
public required string EventType { get; init; }
/// <summary>事件发生后的聚合快照(JSON)。传快照而非 diff:接收端无需按序重放即可落地</summary>
public required JsonElement Payload { get; init; }
/// <summary>事件发生时间(UTC)。仅用于展示与排查,严禁用于排序------船钟会漂</summary>
public required DateTimeOffset OccurredAt { get; init; }
/// <summary>协议版本,报文结构演进时用(呼应前文 API 版本管理的 Expand-Contract)</summary>
public int SchemaVersion { get; init; } = 1;
/// <summary>对信封规范字段 + Payload 的 HMAC-SHA256 签名(Base64),防篡改</summary>
public string? Signature { get; set; }
}
/// <summary>批量上报请求:卫星链路按字节收费,把 N 个信封打成一个 HTTP 包</summary>
public sealed record SyncBatch(string ShipId, IReadOnlyList<Envelope> Messages);
/// <summary>批量应答:逐条告诉发送端每条消息的处理结果</summary>
public sealed record SyncAck(IReadOnlyList<MessageAck> Results);
public sealed record MessageAck(Guid MessageId, AckStatus Status, string? Detail);
public enum AckStatus
{
/// <summary>成功落地(或重复/旧版本被幂等丢弃,对发送端而言都是「不必再发」)</summary>
Accepted,
/// <summary>永久失败:报文结构错、签名错、聚合类型不认识。重发一万遍也没用,进死信</summary>
Rejected,
/// <summary>暂时失败:岸基正在维护/依赖未就绪。发送端退避后重试</summary>
RetryLater
}
几个设计决策必须讲透:
为什么用 Guid 做 MessageId 而不是数据库自增 Id? 船端断网期间要离线生成报文,自增 Id 需要中心化分配,断网时两台船端电脑会分配出相同的 Id。Guid 本地生成即全局唯一(碰撞概率可忽略),且重发同一条消息时 MessageId 保持不变------这是幂等去重的锚点。
为什么 AggregateKey 用业务单号而不是自增主键? 船端 SQLite 和岸基 SQL Server 是两套独立库,自增 Id 各不相同(船端 Id=42 的单子在岸基可能是 Id=8831)。业务单号 (2026)HKMW-TEC-MRP-0001 是跨库稳定的身份。这也呼应 DDD 聚合设计:聚合应有一个领域赋予的自然键。
为什么 Payload 传快照而不是字段 diff? diff 方案要求接收端必须按顺序重放全部变更才能得到当前状态,乱序一条就对不齐;快照方案下,收到任意一条报文都能直接落地该行的完整状态,旧版本报文自然被版本号挡掉。代价是报文大一点------用第 10 节的 GZip 压缩找补。
版本号怎么生成:事务内 MAX+1,但要防并发
版本号是整个协议的排序基准,要求同一 (ShipId, AggregateType, AggregateKey) 内严格单调递增 。两个要点:一是分配动作必须和业务修改在同一个数据库事务里(否则版本号会发出去但业务回滚,出现版本空洞------空洞虽不致命但污染对账);二是并发修改同一聚合时不能分配出相同版本。
csharp
// Infrastructure/Sync/AggregateVersionTable.cs ------ 独立计数表分配单调版本号
namespace Pms.Infrastructure.Sync;
/// <summary>
/// 版本计数表:每个聚合一行,记录当前已分配的最大版本。
/// 不用 MAX(version)+1 扫业务事件表,原因有二:
/// ① 事件归档后 MAX 查不到历史;② 计数表单行加锁,语义清晰。
/// </summary>
public sealed class AggregateVersion
{
public long Id { get; set; }
public required string ShipId { get; set; }
public required string AggregateType { get; set; }
public required string AggregateKey { get; set; }
public long CurrentVersion { get; set; }
public byte[]? RowVersion { get; set; } // SQL Server 用 rowversion;SQLite 见下文
}
public sealed class VersionAllocator(PmsDbContext db)
{
/// <summary>
/// 在调用方已开启的事务内,为聚合分配下一个版本号。
/// EF Core 默认把单次 SaveChanges 的全部变更包在一个事务里,
/// 见官方文档:https://learn.microsoft.com/zh-cn/ef/core/saving/transactions
/// </summary>
public async Task<long> NextVersionAsync(
string shipId, string aggregateType, string aggregateKey, CancellationToken ct)
{
var counter = await db.AggregateVersions
.FirstOrDefaultAsync(
v => v.ShipId == shipId
&& v.AggregateType == aggregateType
&& v.AggregateKey == aggregateKey, ct);
if (counter is null)
{
counter = new AggregateVersion
{
ShipId = shipId, AggregateType = aggregateType,
AggregateKey = aggregateKey, CurrentVersion = 0
};
db.AggregateVersions.Add(counter);
}
var next = counter.CurrentVersion + 1;
counter.CurrentVersion = next;
// 注意:此处不单独 SaveChanges!由调用方在业务事务末尾统一提交,
// 保证「业务修改 + 版本分配 + spool 写入」三者同生共死。
return next;
}
}
并发说明:船端单机 SQLite 的写入天然串行化(SQLite 是库级写锁,Microsoft.Data.Sqlite 下写操作排队执行),同聚合并发分配在船端不会撞车;岸基 SQL Server 多实例部署时,靠计数表上的并发令牌兜底------RowVersion(SQL Server rowversion 类型)或 SQLite 下应用自管的 xmin 整数令牌,撞了就捕获 DbUpdateConcurrencyException 重试整个事务。这正是本系列前文《EF Core 持久化 DDD 聚合实战》讲过的结论:SQLite 没有数据库生成的 rowversion,并发令牌必须应用自管。
为什么不能用时间戳当版本号? 三个硬原因:
- 船端时钟不准。船上电脑的系统时间靠人对,漂几个小时是常事;夏令时、时区配置错误(船跨时区航行)都能让时钟回拨。时钟回拨后,新事件的时间戳比旧事件还小,排序直接错乱。
- 同毫秒并发。两台终端、批量导入都可能在同一毫秒产生事件,时间戳无法定先后。
- 单调版本号可以回答「我缺了哪几版」(对账的基础,见第 8 节),时间戳回答不了。
时间戳(OccurredAt)只用于展示和排查,排序永远只认 Version。
四、发送端:独立 spool 表 + 状态机,别再蹭 SendFlag
4.1 为什么不复用业务表上的 SendFlag
前文讲过「可丢走 Channel,不可丢走 Outbox」。落到船岸场景,这句话翻译过来是:待发报文必须落在一张独立的、带状态机的 spool(发件盘)表里,而不是业务表上的一个布尔位。 原因:
- 一条业务修改可能产生多条报文(聚合快照 + 领域事件拆分),布尔位表达不了一对多;
- spool 表需要记录重试次数、下次重试时间、最后错误、状态迁移历史,这些塞进业务表会污染领域模型;
- 发送器要
SELECT ... WHERE Status=Pending AND NextAttemptAt<=? ORDER BY ... LIMIT 100这样的查询,独立表加索引干净利落; - 业务表的
SendFlag可以保留,但它降级为 spool 表的派生投影(该聚合最新信封是否已 Ack),不再承担发送职责。
csharp
// Infrastructure/Sync/OutboxSpool.cs
namespace Pms.Infrastructure.Sync;
/// <summary>船端发件盘:每条待发/已发报文一行。状态机见 SpoolStatus</summary>
public sealed class OutboxSpool
{
public long Id { get; set; }
public required Guid MessageId { get; set; } // = Envelope.MessageId,唯一索引
public required string ShipId { get; set; }
public required string AggregateType { get; set; }
public required string AggregateKey { get; set; }
public long Version { get; set; }
public required string EnvelopeJson { get; set; } // 序列化后的完整信封,发送时零拼装
public SpoolStatus Status { get; set; } = SpoolStatus.Pending;
public int AttemptCount { get; set; }
public DateTime NextAttemptAt { get; set; } // 退避后的下次重试时间
public string? LastError { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? AckedAt { get; set; }
}
public enum SpoolStatus
{
Pending = 0, // 待发送
Sending = 1, // 已被某发送器认领(防止两个循环重复捞取)
Acked = 2, // 岸基确认,等待归档清理
DeadLetter = 3 // 永久失败(Rejected)或重试超限,人工介入
}
建表索引(EF Core 唯一索引配置):
csharp
// 在 OnModelCreating 中
modelBuilder.Entity<OutboxSpool>(b =>
{
b.ToTable("sync_spool");
b.HasIndex(x => x.MessageId).IsUnique(); // 幂等:同一条消息只入盘一次
b.HasIndex(x => new { x.Status, x.NextAttemptAt }); // 发送器捞取主查询
b.HasIndex(x => new { x.ShipId, x.AggregateType, x.AggregateKey, x.Version }); // 对账
b.Property(x => x.EnvelopeJson).HasColumnType("TEXT"); // SQLite;SQL Server 用 nvarchar(max)
});
4.2 事务内写业务 + 写 spool:这是不丢消息的唯一保证
以物料申请三级审批为例,审批通过的领域方法执行后,在同一个 SaveChanges 事务里分配版本号、写信封、入 spool:
csharp
// Application/Sync/SyncDomainEventInterceptor.cs ------ 在 SaveChanges 拦截器里统一入盘
public sealed class SpoolWritingInterceptor(ShipContext ship, VersionAllocator versions)
: SaveChangesInterceptor
{
public override async ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData, InterceptionResult<int> result,
CancellationToken ct = default)
{
var ctx = eventData.Context!;
var entries = ctx.ChangeTracker.Entries<AggregateRoot>()
.Where(e => e.State is EntityState.Added or EntityState.Modified)
.ToList();
foreach (var entry in entries)
{
var agg = entry.Entity;
// 遍历聚合上待派发的领域事件(DDD 聚合内 Raise 的事件,前文讲过)
foreach (var domainEvent in agg.TakePendingEvents())
{
var version = await versions.NextVersionAsync(
ship.ShipId, agg.GetType().Name, agg.BusinessKey, ct);
var envelope = new Envelope
{
MessageId = Guid.CreateVersion7(), // .NET 9+ 顺序 Guid,索引友好
AggregateType = agg.GetType().Name,
AggregateKey = agg.BusinessKey, // 如 (2026)HKMW-TEC-MRP-0001
ShipId = ship.ShipId,
Version = version,
EventType = domainEvent.GetType().Name,
Payload = JsonSerializer.SerializeToElement(
agg, SyncJsonContext.Default.PolymorphicAggregate),
OccurredAt = DateTimeOffset.UtcNow
};
envelope.Signature = HmacSigner.Sign(envelope, ship.SigningKey);
ctx.Set<OutboxSpool>().Add(new OutboxSpool
{
MessageId = envelope.MessageId,
ShipId = envelope.ShipId,
AggregateType = envelope.AggregateType,
AggregateKey = envelope.AggregateKey,
Version = envelope.Version,
EnvelopeJson = JsonSerializer.Serialize(envelope, SyncJsonContext.Default.Envelope),
CreatedAt = DateTime.UtcNow,
NextAttemptAt = DateTime.UtcNow
});
}
}
return await base.SavingChangesAsync(eventData, result, ct);
// 业务表 + 版本计数表 + spool 表在这一个 SaveChanges 里同事务提交:
// 要么全部落盘,要么全部回滚。失败窗口被压缩为零。
}
}
4.3 发送器:认领 → 批量打包 → 退避重试 → Ack 归档
发送器是一个 BackgroundService。注意本系列前文《BackgroundService 生命周期》讲过的铁律:ExecuteAsync 里的异常必须自己兜住,否则一个未捕获异常会让整个后台服务静默退出、再也不重试。
csharp
// Infrastructure/Sync/SpoolSenderBackgroundService.cs
public sealed class SpoolSenderBackgroundService(
IServiceScopeFactory scopeFactory,
SyncHttpClient http,
IOptions<SyncOptions> options,
ILogger<SpoolSenderBackgroundService> log) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
// PeriodicTimer:前文讲过,比 System.Threading.Timer 更适合 async 循环
// https://learn.microsoft.com/zh-cn/dotnet/standard/threading/timers
using var timer = new PeriodicTimer(options.Value.PollInterval); // 船端 30s
while (!stoppingToken.IsCancellationRequested)
{
try
{
await PumpOnceAsync(stoppingToken);
}
catch (OperationCanceledException) { break; }
catch (Exception ex)
{
// 异常自兜:绝不让异常逃出循环杀死宿主
log.LogError(ex, "船岸同步发送循环异常,将在下个周期重试");
}
try { await timer.WaitForNextTickAsync(stoppingToken); }
catch (OperationCanceledException) { break; }
}
}
private async Task PumpOnceAsync(CancellationToken ct)
{
using var scope = scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<PmsDbContext>();
// ① 认领:用条件 UPDATE 把一批 Pending 置为 Sending,原子领取,防多实例重复捞
var batch = await db.OutboxSpools
.Where(s => s.Status == SpoolStatus.Pending && s.NextAttemptAt <= DateTime.UtcNow)
.OrderBy(s => s.Id)
.Take(options.Value.BatchSize) // 100 条一批
.ToListAsync(ct);
if (batch.Count == 0) return;
foreach (var s in batch) s.Status = SpoolStatus.Sending;
await db.SaveChangesAsync(ct);
// ② 打包 + 压缩 + 签名后发送(Polly 策略在 SyncHttpClient 内部)
var envelopes = batch
.Select(s => JsonSerializer.Deserialize(s.EnvelopeJson, SyncJsonContext.Default.Envelope)!)
.ToList();
SyncAck? ack;
try
{
ack = await http.PostBatchAsync(new SyncBatch(db.ShipId, envelopes), ct);
}
catch (Exception ex)
{
// 整批网络失败:全部退回 Pending,按指数退避排下次重试
log.LogWarning(ex, "批量上报失败,{Count} 条退回重试", batch.Count);
foreach (var s in batch)
{
s.Status = SpoolStatus.Pending;
s.AttemptCount++;
s.LastError = ex.Message;
s.NextAttemptAt = DateTime.UtcNow + Backoff(s.AttemptCount);
if (s.AttemptCount >= options.Value.MaxAttempts) s.Status = SpoolStatus.DeadLetter;
}
await db.SaveChangesAsync(ct);
return;
}
// ③ 按逐条 Ack 结果落状态
var byId = ack.Results.ToDictionary(r => r.MessageId);
foreach (var s in batch)
{
var r = byId.GetValueOrDefault(s.MessageId);
if (r?.Status == AckStatus.Accepted)
{
s.Status = SpoolStatus.Acked;
s.AckedAt = DateTime.UtcNow;
}
else if (r?.Status == AckStatus.Rejected)
{
s.Status = SpoolStatus.DeadLetter; // 永久失败,别再重试
s.LastError = r.Detail;
}
else
{
s.Status = SpoolStatus.Pending; // RetryLater 或 Ack 缺失
s.AttemptCount++;
s.NextAttemptAt = DateTime.UtcNow + Backoff(s.AttemptCount);
}
}
await db.SaveChangesAsync(ct);
}
// 指数退避 + 上限:1min → 2 → 4 → 8 ... 封顶 30min。断网三天也不会打爆卫星链路
private static TimeSpan Backoff(int attempt) =>
TimeSpan.FromMinutes(Math.Min(30, Math.Pow(2, attempt - 1)));
}
HTTP 客户端侧挂 Polly v8 弹性管道(Microsoft.Extensions.Http.Resilience 标准弹性处理器,内置重试 + 熔断 + 总超时):
csharp
services.AddHttpClient<SyncHttpClient>("shore-sync", client =>
{
client.BaseAddress = new Uri(options.Value.ShoreBaseUrl);
client.Timeout = TimeSpan.FromMinutes(5); // 卫星链路 RTT 高,超时放宽
})
.ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler
{
AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate,
// 长连接复用 + 连接生命周期,遵循官方 HttpClient 准则:
// https://learn.microsoft.com/zh-cn/dotnet/fundamentals/networking/http/httpclient-guidelines
PooledConnectionLifetime = TimeSpan.FromMinutes(10)
})
.AddStandardResilienceHandler(o =>
{
o.Retry.MaxRetryAttempts = 3;
o.Retry.UseJitter = true; // 抖动,避免多船同时补传打爆岸基
o.Retry.Delay = TimeSpan.FromSeconds(5);
o.CircuitBreaker.BreakDuration = TimeSpan.FromMinutes(2);
o.AttemptTimeout.Timeout = TimeSpan.FromMinutes(2);
o.TotalRequestTimeout.Timeout = TimeSpan.FromMinutes(10);
});
失败窗口逐个过一遍(这是同步链路设计必须回答的问题):
| 崩溃/失败时机 | 后果 | 兜底机制 |
|---|---|---|
| 业务事务提交前 | 业务和 spool 都没写 | 本地事务回滚,等于没发生 |
| 事务提交后、发送前 | spool 里躺着 Pending | 下个轮询周期捞起重发 |
| 发送中、请求没到岸基 | 岸基什么都没收到 | 重试,接收端无副作用 |
| 岸基处理成功、ACK 丢失 | 船端以为失败 | 重发,接收端按 MessageId 幂等去重(第 5 节) |
| 岸基处理失败 | 返回 Rejected/RetryLater | Rejected 进死信人工看;RetryLater 退避重试 |
可见「至少一次 + 接收端幂等」覆盖了全部窗口。Channel 在这里只用于唤醒发送器(业务写入后通知一句「有新货了」,省去等 30 秒轮询);通知丢了大不了晚 30 秒发,所以它可丢------spool 表才是不丢的那一层。
五、接收端:幂等落库三关------去重、版本、状态机
岸基(以及岸→船方向的船端)收到批量报文后,逐条过三关。
5.1 第一关:MessageId 去重表(Inbox)
csharp
// 岸基 Infrastructure/Sync/InboxMessage.cs
public sealed class InboxMessage
{
public long Id { get; set; }
public required Guid MessageId { get; set; }
public required string ShipId { get; set; }
public DateTime ReceivedAt { get; set; }
public AckStatus Result { get; set; }
}
// OnModelCreating:
// b.HasIndex(x => x.MessageId).IsUnique(); // 唯一索引是去重的最终防线
// 另建版本水位表:
public sealed class AggregateWatermark
{
public long Id { get; set; }
public required string ShipId { get; set; }
public required string AggregateType { get; set; }
public required string AggregateKey { get; set; }
public long LastVersion { get; set; } // 已落地的最大版本
public DateTime UpdatedAt { get; set; }
}
// 唯一索引 (ShipId, AggregateType, AggregateKey)
批量处理在每个聚合一个事务里完成「登记 inbox + 写水位 + 落业务数据」,保证三者原子:
csharp
// 岸基 Application/Sync/InboundSyncService.cs
public async Task<IReadOnlyList<MessageAck>> HandleBatchAsync(
SyncBatch batch, CancellationToken ct)
{
var acks = new List<MessageAck>(batch.Messages.Count);
// 同一聚合的消息必须串行处理:按聚合分组,组内按 Version 排序
foreach (var group in batch.Messages
.GroupBy(m => (m.AggregateType, m.AggregateKey)))
{
foreach (var env in group.OrderBy(m => m.Version))
{
acks.Add(await HandleOneAsync(batch.ShipId, env, ct));
}
}
return acks;
}
private async Task<MessageAck> HandleOneAsync(string shipId, Envelope env, CancellationToken ct)
{
// 先验签:签名不对直接 Rejected,不进任何业务流程
if (!HmacSigner.Verify(env, ShoreKeys.GetFor(shipId)))
return new MessageAck(env.MessageId, AckStatus.Rejected, "签名校验失败");
await using var tx = await db.Database.BeginTransactionAsync(ct);
try
{
// 第一关:MessageId 去重。唯一索引兜底,先 INSERT,撞唯一键 = 重复消息
try
{
db.InboxMessages.Add(new InboxMessage
{
MessageId = env.MessageId, ShipId = shipId,
ReceivedAt = DateTime.UtcNow, Result = AckStatus.Accepted
});
await db.SaveChangesAsync(ct); // 撞唯一索引会抛 DbUpdateException
}
catch (DbUpdateException)
{
await tx.RollbackAsync(ct);
// 重复消息:重放无害,直接回 Accepted(让发送端别再发)
return new MessageAck(env.MessageId, AckStatus.Accepted, "duplicate");
}
// 第二关:版本号水位判定
var wm = await db.Watermarks.FindAsync(
[shipId, env.AggregateType, env.AggregateKey], ct);
if (wm is not null && env.Version <= wm.LastVersion)
{
// 旧版本/重复版本:数据已是更新的状态,本条安全丢弃
await tx.CommitAsync(ct); // 提交 inbox 登记,下次同 MessageId 直接走第一关
return new MessageAck(env.MessageId, AckStatus.Accepted,
$"stale: local v{wm.LastVersion} >= incoming v{env.Version}");
}
// 第三关:业务落地(含状态机合法性校验,见 5.2)
var applyResult = await applier.ApplyAsync(env, ct);
if (applyResult.IsPermanentFailure)
{
await tx.RollbackAsync(ct);
return new MessageAck(env.MessageId, AckStatus.Rejected, applyResult.Error);
}
// 推进水位
if (wm is null)
db.Watermarks.Add(new AggregateWatermark
{
ShipId = shipId, AggregateType = env.AggregateType,
AggregateKey = env.AggregateKey, LastVersion = env.Version,
UpdatedAt = DateTime.UtcNow
});
else
wm.LastVersion = env.Version;
await db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
return new MessageAck(env.MessageId, AckStatus.Accepted, null);
}
catch (Exception ex)
{
await tx.RollbackAsync(ct);
// 未知异常 = 可能没处理完,让发送端重试(幂等保证重试安全)
return new MessageAck(env.MessageId, AckStatus.RetryLater, ex.Message);
}
}
注意版本号对乱序 的天然处理:如果 v3 先到、v2 后到,v2 到达时水位已是 3,3 >= 2 直接丢弃------而因为 Payload 是快照,v3 落地时数据已经是最新状态,丢掉 v2 不丢任何信息。这就是传快照而非 diff 的回报。不需要专门的乱序缓冲区(除非业务要求「v2 和 v3 之间的中间态也要审计留痕」,那是审计需求不是同步需求)。
5.2 第三关里审批报文的特殊处理:状态机只接受合法迁移
开篇事故 1 的「终审单被打回审批中」,根因是老接收端不管什么报文都直接 UPDATE 覆盖状态字段。新方案里,聚合根的状态机是唯一入口,非法迁移不执行、不报错污染流程,而是幂等返回当前状态:
csharp
// 接收端把信封 Payload 物化为聚合后,调用与船端完全相同的领域方法
// (Domain 层船岸共享,这是 DDD 分层在同步场景的红利:规则只写一遍)
public async Task<Result> ApplyAsync(Envelope env, CancellationToken ct)
{
var agg = await repository.FindAsync(env.AggregateKey, ct);
var outcome = env.EventType switch
{
nameof(MaterialApplySubmitted) => ReplaySubmit(agg!, env),
nameof(MaterialApplyApproved) => ReplayApprove(agg!, env),
nameof(MaterialApplyRejected) => ReplayReject(agg!, env),
nameof(MaterialApplyDeleted) => ReplayDelete(agg!, env),
_ => Result.PermanentFail($"未知事件类型 {env.EventType}")
};
// 关键:「目标状态已经达到」不算失败。
// 比如重复收到 L3 审批通过,而单子已是 Approved → 幂等成功,而不是报错
if (outcome is { IsAlreadyInTargetState: true })
return Result.Ok();
return outcome.IsSuccess ? Result.Ok() : outcome;
}
// 领域方法内部(与船端同一套代码):
public void Approve(int level, string manager, string memo)
{
if (Status == ApplyStatus.Approved)
return; // 已终审:重复审批幂等返回,状态机不回退、不抛错
if (Status == ApplyStatus.Rejected)
throw new DomainException("已驳回的单据不能审批");
if (level == 2 && Status < ApplyStatus.L1Approved)
throw new DomainException("一级审批尚未完成,不能跳级");
if (level == 3 && Status < ApplyStatus.L2Approved)
throw new DomainException("二级审批尚未完成,不能跳级");
// 只有合法迁移才改状态
Status = level switch
{
1 => ApplyStatus.L1Approved,
2 => ApplyStatus.L2Approved,
3 => ApplyStatus.Approved,
_ => throw new DomainException("非法审批级别")
};
SetApproveFields(level, manager, memo);
}
状态迁移白名单(接收端和船端共用):
Draft ──Submit──▶ Pending ──ApproveL1──▶ L1Approved ──ApproveL2──▶ L2Approved
└──Reject──▶ Rejected(终态)
L2Approved ──ApproveL3──▶ Approved(终态)
任何状态 ──Delete(墓碑)──▶ Deleted(终态)
非法迁移(如 Approved → Pending)在领域方法里根本没有代码路径,开篇事故 1 在模型层就被堵死。
六、冲突检测与解决:先定「这块数据谁说了算」
重复和乱序靠协议解决,冲突靠架构决策解决------核心手段是给每个聚合指定唯一写入方(single-writer),让冲突在设计上就不发生:
| 数据类别 | 方向 | 主数据源(single-writer) | 冲突可能性 |
|---|---|---|---|
| 船舶业务数据(申请单、工单、库存交易、保养记录) | 船 → 岸 | 船端(按 ShipId 隔离,SHIP01 的单子只有 SHIP01 能改) | 几乎为零 |
| 字典/基础数据(IMPA 物料字典、供应商、港口表、费率) | 岸 → 船 | 岸基(船端只读,本地缓存) | 零 |
| 全局配置(审批流配置、系统参数) | 岸 → 船 | 岸基 | 零 |
开篇事故 2(两台船端电脑改同一条字典)的根治办法不是发明冲突合并算法,而是收回船端对字典的写权限:字典是岸基主数据,船端只能看、只能引用,改价必须走岸基流程下发。这一条决策消掉了 90% 的真实冲突。
剩下真正可能发生冲突的场景:船端电脑整机更换/双机热备时,两台设备短暂同时写入(离线后各自改了同一聚合)。处理策略分三层:
csharp
// 岸基 Application/Sync/ConflictPolicy.cs
public enum ConflictStrategy
{
/// <summary>主数据源优先:非主写入方的报文直接拒收(业务数据的默认策略)</summary>
SingleWriter,
/// <summary>版本号 Last-Writer-Wins:高版本覆盖低版本(仅限可容忍丢失的弱一致数据)</summary>
LastWriterWins,
/// <summary>入人工队列:两个版本都保留,对账界面上由人裁决</summary>
ManualQueue
}
public sealed class SyncConflictLog
{
public long Id { get; set; }
public required string ShipId { get; set; }
public required string AggregateType { get; set; }
public required string AggregateKey { get; set; }
public long IncomingVersion { get; set; }
public long LocalVersion { get; set; }
public required string IncomingJson { get; set; } // 两个版本都留档
public required string LocalJson { get; set; }
public ConflictStatus Status { get; set; } = ConflictStatus.Pending;
public string? Resolution { get; set; } // KeepIncoming / KeepLocal / Merged
public DateTime DetectedAt { get; set; }
public DateTime? ResolvedAt { get; set; }
}
LWW 的适用边界必须说清楚 :last-writer-wins 本质是「高版本覆盖低版本」,在我们的协议里因为版本号单调,同船同聚合不会出现两个高版本分叉;真正的分叉只发生在双设备离线各自演进 (版本号各自从 1 计数)的场景。此时 LWW 会静默丢掉其中一个版本的全部修改------所以 LWW 只允许用于「丢了也不心疼」的数据(如本地缓存的字典副本),业务单据冲突一律进 ManualQueue,在岸基对账界面上把两个版本并排展示,张三李四们人工选 KeepIncoming / KeepLocal / 手工合并后以新版本重新下发。
检测时机就放在接收端第二关:当报文的 (ShipId, AggregateKey) 已存在水位,但报文来源不是该聚合登记的主写入方,或版本序列出现分叉(对端汇报的版本历史与本地不连续),写一条 SyncConflictLog 并回 RetryLater(不丢消息,等裁决后重放)。
七、对账与重放:同步链路的最后安全网
协议再严密,也要假设「总有消息会神秘失踪」(死信、人工误删、数据库文件损坏后还原)。对账(Reconciliation)就是定期把两岸的版本水位摊开比一遍。
csharp
// 岸基对账接口:船端上报各聚合最大版本,岸基比差距
public sealed record ReconcileRequest(
string ShipId,
IReadOnlyList<AggregateVersionPoint> LocalMaxVersions);
public sealed record AggregateVersionPoint(string AggregateType, string AggregateKey, long MaxVersion);
public sealed record ReconcileResponse(
IReadOnlyList<MissingRange> MissingOnShore, // 船有岸无:船端补推
IReadOnlyList<MissingRange> MissingOnShip); // 岸有船无:船端拉取
public sealed record MissingRange(string AggregateType, string AggregateKey, long FromVersion, long ToVersion);
// 岸基 Application/Sync/ReconcileService.cs
public async Task<ReconcileResponse> ReconcileAsync(ReconcileRequest req, CancellationToken ct)
{
var shoreMax = await db.Watermarks
.Where(w => w.ShipId == req.ShipId)
.Select(w => new AggregateVersionPoint(w.AggregateType, w.AggregateKey, w.LastVersion))
.ToListAsync(ct);
var shoreDict = shoreMax.ToDictionary(p => (p.AggregateType, p.AggregateKey));
var shipDict = req.LocalMaxVersions.ToDictionary(p => (p.AggregateType, p.AggregateKey));
var missingOnShore = new List<MissingRange>(); // 船端版本更高 → 岸基漏了
foreach (var p in req.LocalMaxVersions)
{
if (shoreDict.TryGetValue((p.AggregateType, p.AggregateKey), out var s)
&& p.MaxVersion > s.MaxVersion)
missingOnShore.Add(new MissingRange(p.AggregateType, p.AggregateKey, s.MaxVersion + 1, p.MaxVersion));
}
var missingOnShip = new List<MissingRange>(); // 岸基版本更高 → 船端漏了(岸→船方向)
foreach (var p in shoreMax)
{
if (shipDict.TryGetValue((p.AggregateType, p.AggregateKey), out var sh)
&& p.MaxVersion > sh.MaxVersion)
missingOnShip.Add(new MissingRange(p.AggregateType, p.AggregateKey, sh.MaxVersion + 1, p.MaxVersion));
}
return new ReconcileResponse(missingOnShore, missingOnShip);
}
补传走的是重放 而不是重发新消息:spool 表里 Acked 的信封保留 30 天(归档窗口),对账发现缺口时直接按 (AggregateKey, Version) 捞出旧信封重发------MessageId 不变,接收端幂等逻辑原样生效。
冷启动(新船装机 / 换电脑) 走快照 + 增量两段式:
- 岸基生成该船的基线快照包(全部字典 + 该船历史业务数据的当前态,一个压缩包),船端还原;
- 快照带一个水位时间点/版本点,船端上线后只拉取该点之后的增量报文。
不要让新船从 v1 开始重放三年报文------那是把对账机制当初始化用,卫星流量会让账单非常难看。
八、删除与软删除:把「删除」也当成一种事件
SendFlag 的坑 5(删除同步丢失)解法:物理删除在同步链路里不存在,只有软删除 + 墓碑(tombstone)事件。
csharp
// 领域层:删除 = 状态迁移,不是行消失
public void Delete(string @operator)
{
if (IsDeleted) return; // 幂等
IsDeleted = true;
Raise(new MaterialApplyDeleted(BusinessKey)); // 正常产生一条 v=N+1 的信封
}
- 删除事件和其他事件走完全相同的信封通道,
EventType=Deleted,Payload 是墓碑{ AggregateKey, DeletedAt, DeletedBy }; - 接收端落地
IsDeleted=true(配合前文讲过的HasQueryFilter全局过滤),并在水位表记录墓碑版本; - 墓碑保留期:业务行软删后至少保留 18 个月(覆盖报文归档窗口 + 审计要求),到期后由清理任务物理归档;墓碑本身在水位/归档表中长期保留,否则一条迟到的旧版本更新报文可能把已删数据「复活」。
反例警告:如果船端用物理删除 + 不同步删除事件,岸基数据永久残留;如果删了业务行但 spool 里同聚合的旧信封还在重放窗口内,重放时接收端按快照落地会让数据「诈尸」------所以接收端落地前必须先查墓碑水位,incoming.Version <= tombstone.Version 的更新一律丢弃。
九、.NET 实现要点速查
序列化:用 System.Text.Json 源生成,别用反射。 船端可能走 Native AOT 发布(前文《Native AOT 裁剪》讲过),反射序列化在裁剪后会炸;且源生成器启动更快、内存更省。枚举转字符串、中文不转义:
csharp
// Shared/Sync/SyncJsonContext.cs
[JsonSourceGenerationOptions(
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false,
UseStringEnumConverter = true, // 枚举序列化为字符串,版本演进友好
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping)] // 中文不转 \uXXXX,省字节
[JsonSerializable(typeof(Envelope))]
[JsonSerializable(typeof(SyncBatch))]
[JsonSerializable(typeof(SyncAck))]
[JsonSerializable(typeof(ReconcileRequest))]
[JsonSerializable(typeof(ReconcileResponse))]
public partial class SyncJsonContext : JsonSerializerContext;
源生成模式官方文档:System.Text.Json 源生成。
双 Provider 下的 spool 表映射 :船端 SQLite、岸基 SQL Server 共用一套实体配置,差异点只在列类型与并发令牌------TEXT vs nvarchar(max)、rowversion vs 应用自管整数令牌,用 IProviderConfiguration 按 Provider 分流(前文《EF Core 持久化 DDD 聚合实战》第九节有完整双 Provider 写法,这里不重复)。
批量状态更新用 ExecuteUpdate 减负 :归档清理 Acked 超 30 天的 spool 行、死信批量重置这类操作,不必走 ChangeTracker,直接 ExecuteUpdateAsync/ExecuteDeleteAsync 一条 SQL 搞定:
csharp
await db.OutboxSpools
.Where(s => s.Status == SpoolStatus.Acked && s.AckedAt < DateTime.UtcNow.AddDays(-30))
.ExecuteDeleteAsync(ct);
后台服务两个坑再强调 (前文都讲过,这里只贴结论):BackgroundService.ExecuteAsync 内所有异常自兜,否则未处理异常会导致进程退出;PeriodicTimer + WaitForNextTickAsync 是 async 循环的正解,别再用 System.Threading.Timer 回调套 Task.Run。
船端时钟 :版本号绝不依赖时钟,但 OccurredAt/CreatedAt 仍要写------船端写入本地时间的同时标注 UTC 偏移;接收端发现某船时钟漂移超过阈值(比如与服务器差 2 小时)时,在健康检查/对账报告里告警,提示船员对时。
十、安全与带宽:卫星链路按字节收费,每个字节都要算
压缩 :批量信封先用 GZipStream 压成 .json.gz 再 POST,JSON 文本压缩率通常 80% 以上(重复字段名多):
csharp
public async Task<SyncAck> PostBatchAsync(SyncBatch batch, CancellationToken ct)
{
var json = JsonSerializer.SerializeToUtf8Bytes(batch, SyncJsonContext.Default.SyncBatch);
using var ms = new MemoryStream();
await using (var gz = new GZipStream(ms, CompressionLevel.SmallestSize, leaveOpen: true))
await gz.WriteAsync(json, ct);
using var req = new HttpRequestMessage(HttpMethod.Post, "api/sync/batch")
{
Content = new ByteArrayContent(ms.ToArray())
};
req.Content.Headers.ContentType = new("application/json");
req.Content.Headers.ContentEncoding.Add("gzip"); // 接收端 AutomaticDecompression 自动解
// ... 加 HMAC 签名头后发送
}
签名防篡改:HTTPS 保证传输层机密性,报文级 HMAC-SHA256 保证「到了岸基的报文确实是该船发的、没被中间代理改写」(纵深防御,也防岸基侧日志/中转环节被动手脚)。签名覆盖信封全部规范字段 + Payload 原文:
csharp
public static string Sign(Envelope e, byte[] key)
{
var canonical = $"{e.MessageId}|{e.ShipId}|{e.AggregateType}|{e.AggregateKey}|{e.Version}|{e.EventType}";
using var hmac = new HMACSHA256(key); // https://learn.microsoft.com/zh-cn/dotnet/api/system.security.cryptography.hmacsha256
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(canonical));
return Convert.ToBase64String(hash);
}
每船一把独立密钥,岸基按 ShipId 取钥验签;密钥走船端首次注册时线下/带外分发,不进报文。
带宽组合拳:
- 批量打包(100 条/包),摊薄 HTTP 头与 TLS 握手开销;
- GZip 压缩;
- 只传增量(对账驱动,冷启动才走快照);
- 字典数据岸→船推模式改拉模式 + 长缓存:船端本地缓存 IMPA 字典,按 ETag/版本号条件请求,无变更返回 304 零字节;
- 大附件(照片、扫描件)不进同步通道,走对象存储 + 报文里只带引用,且非工作时间可设置「仅在 Wi-Fi/廉价链路窗口上传」。
十一、踩坑清单(血泪版)
- 用时间戳当版本号:船钟回拨一次,排序全线错乱,终审单被旧报文打回。版本号必须是事务内分配的单调整数。
- 重发时重新生成 MessageId:幂等键必须在消息首次创建时定死、重发不变;重新 Guid 等于每次重发都是「新消息」,去重表形同虚设。
- 先置 SendFlag 后发送:置位提交了、发送前崩溃,消息静默失踪,连重试机会都没有。顺序永远是「事务内落 spool → 后台发送 → Ack 后置位」。
- 接收端对重复消息返回错误 :发送端看到非 Accepted 会无限重试,日志里全是红色错误。重复和旧版本都要回
Accepted------「重放无害」是协议承诺。 - Payload 传字段 diff:乱序一条就对不齐,还得维护重放顺序。快照 + 版本号水位,乱序直接丢旧版。
- 审批状态用「覆盖式 UPDATE」落地:任何报文都能改状态字段,非法迁移没有闸门。必须走聚合状态机,目标状态已达成时幂等返回。
- 字典数据允许船端直接改:双船/双机互改是冲突之源。主数据收回岸基 single-writer,船端只读缓存。
- 物理删除不同步:岸基残留 + 旧报文重放「诈尸」。删除一律软删 + 墓碑事件 + 墓碑保留期。
- 发送器异常逃出 BackgroundService:一次反序列化坏消息杀死整个循环,积压无人发送直到下次重启。循环体必须 try-catch 全包,坏消息进死信而不是炸进程。
- 新船上线从 v1 重放全部历史:卫星账单教做人。冷启动走基线快照 + 增量。
- 报文里塞自增主键当身份:船岸两库 Id 空间不同,对不上号。身份一律用业务自然键(单号/编码)。
- 只做同步不做对账:死信、误删、还原备份造成的缺口永远发现不了。每日对账 + 版本水位比对是最后防线。
十二、上线 Checklist
- 每张业务表的修改路径都经过「事务内分配版本号 + 写 spool」,代码评审里 grep 不到任何绕过拦截器直接改状态的入口
- spool 表
MessageId唯一索引、(Status, NextAttemptAt)查询索引已建;inbox 表MessageId唯一索引已建 - 故障演练(上线前必做):断网 72 小时 → 恢复 → 验证①无重复入账 ②无乱序回退 ③积压全部 Ack ④对账零缺口
- 故障演练:发送途中 kill 进程 / 拔网线 / ACK 丢失模拟(防火墙丢响应包),验证重试幂等
- 故障演练:岸基返回 500 / 400 / 签名失败三种情况,确认分别走 RetryLater / Rejected 死信 / Rejected 死信
- 时钟回拨测试:把船端系统时间往回拨 3 小时,验证版本号排序不受影响、仅产生时钟告警
- 冷启动演练:全新船端环境用基线快照初始化,对账零缺口
- 双机演练:两台船端终端离线各自改单,恢复后验证冲突入
ManualQueue而非静默覆盖 - 字典写权限核查:船端账号在数据库层面无字典表 UPDATE/INSERT 权限(纵深防御)
- 死信队列有监控告警(死信 > 0 即通知岸基值班),对账缺口有每日报表
- HMAC 密钥按船独立、带外分发;HTTPS 证书校验未被任何
RemoteCertificateValidationCallback => true绕过 - 报文保留/归档窗口(spool 30 天、墓碑 18 个月、快照基线)配置确认
- 卫星流量预算:压测一批典型报文的 gzip 后平均字节数,估算月度流量
十三、官方参考资料
- EF Core 事务(SaveChanges 默认事务与显式事务):https://learn.microsoft.com/zh-cn/ef/core/saving/transactions
- EF Core 处理并发冲突(含 SQLite 应用自管并发令牌):https://learn.microsoft.com/zh-cn/ef/core/saving/concurrency
- EF Core 索引(唯一索引 IsUnique):https://learn.microsoft.com/zh-cn/ef/core/modeling/indexes
- EF Core ExecuteUpdate/ExecuteDelete:https://learn.microsoft.com/zh-cn/ef/core/saving/execute-insert-update-delete
- ASP.NET Core 后台任务与托管服务:https://learn.microsoft.com/zh-cn/aspnet/core/fundamentals/host/hosted-services
- BackgroundService 基类与后台任务实现:https://learn.microsoft.com/zh-cn/dotnet/architecture/microservices/multi-container-microservice-net-applications/background-tasks-with-ihostedservice
- PeriodicTimer 与 .NET 计时器:https://learn.microsoft.com/zh-cn/dotnet/standard/threading/timers
- System.Text.Json 源生成模式:https://learn.microsoft.com/zh-cn/dotnet/standard/serialization/system-text-json/source-generation
- HttpClient 使用准则(生命周期/PooledConnectionLifetime):https://learn.microsoft.com/zh-cn/dotnet/fundamentals/networking/http/httpclient-guidelines
- 弹性 HTTP 应用(Microsoft.Extensions.Http.Resilience / Polly):https://learn.microsoft.com/zh-cn/dotnet/core/resilience/http-resilience
- Polly v8 迁移指南(ResiliencePipeline API):https://www.pollydocs.org/migration-v8
- Microsoft.Data.Sqlite 概述:https://learn.microsoft.com/zh-cn/dotnet/standard/data/sqlite/
- GZipStream 压缩类:https://learn.microsoft.com/zh-cn/dotnet/api/system.io.compression.gzipstream
- HMACSHA256 报文签名:https://learn.microsoft.com/zh-cn/dotnet/api/system.security.cryptography.hmacsha256