船岸数据同步链路实战:SendFlag、单调版本号与断网三天后的续传

一、事故开篇:断网三天,补传的报文把终审单打回了草稿

某船管公司的船队上线新 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,并发令牌必须应用自管

为什么不能用时间戳当版本号? 三个硬原因:

  1. 船端时钟不准。船上电脑的系统时间靠人对,漂几个小时是常事;夏令时、时区配置错误(船跨时区航行)都能让时钟回拨。时钟回拨后,新事件的时间戳比旧事件还小,排序直接错乱。
  2. 同毫秒并发。两台终端、批量导入都可能在同一毫秒产生事件,时间戳无法定先后。
  3. 单调版本号可以回答「我缺了哪几版」(对账的基础,见第 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 不变,接收端幂等逻辑原样生效。

冷启动(新船装机 / 换电脑) 走快照 + 增量两段式:

  1. 岸基生成该船的基线快照包(全部字典 + 该船历史业务数据的当前态,一个压缩包),船端还原;
  2. 快照带一个水位时间点/版本点,船端上线后只拉取该点之后的增量报文。

不要让新船从 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 取钥验签;密钥走船端首次注册时线下/带外分发,不进报文。

带宽组合拳

  1. 批量打包(100 条/包),摊薄 HTTP 头与 TLS 握手开销;
  2. GZip 压缩;
  3. 只传增量(对账驱动,冷启动才走快照);
  4. 字典数据岸→船推模式改拉模式 + 长缓存:船端本地缓存 IMPA 字典,按 ETag/版本号条件请求,无变更返回 304 零字节;
  5. 大附件(照片、扫描件)不进同步通道,走对象存储 + 报文里只带引用,且非工作时间可设置「仅在 Wi-Fi/廉价链路窗口上传」。

十一、踩坑清单(血泪版)

  1. 用时间戳当版本号:船钟回拨一次,排序全线错乱,终审单被旧报文打回。版本号必须是事务内分配的单调整数。
  2. 重发时重新生成 MessageId:幂等键必须在消息首次创建时定死、重发不变;重新 Guid 等于每次重发都是「新消息」,去重表形同虚设。
  3. 先置 SendFlag 后发送:置位提交了、发送前崩溃,消息静默失踪,连重试机会都没有。顺序永远是「事务内落 spool → 后台发送 → Ack 后置位」。
  4. 接收端对重复消息返回错误 :发送端看到非 Accepted 会无限重试,日志里全是红色错误。重复和旧版本都要回 Accepted------「重放无害」是协议承诺。
  5. Payload 传字段 diff:乱序一条就对不齐,还得维护重放顺序。快照 + 版本号水位,乱序直接丢旧版。
  6. 审批状态用「覆盖式 UPDATE」落地:任何报文都能改状态字段,非法迁移没有闸门。必须走聚合状态机,目标状态已达成时幂等返回。
  7. 字典数据允许船端直接改:双船/双机互改是冲突之源。主数据收回岸基 single-writer,船端只读缓存。
  8. 物理删除不同步:岸基残留 + 旧报文重放「诈尸」。删除一律软删 + 墓碑事件 + 墓碑保留期。
  9. 发送器异常逃出 BackgroundService:一次反序列化坏消息杀死整个循环,积压无人发送直到下次重启。循环体必须 try-catch 全包,坏消息进死信而不是炸进程。
  10. 新船上线从 v1 重放全部历史:卫星账单教做人。冷启动走基线快照 + 增量。
  11. 报文里塞自增主键当身份:船岸两库 Id 空间不同,对不上号。身份一律用业务自然键(单号/编码)。
  12. 只做同步不做对账:死信、误删、还原备份造成的缺口永远发现不了。每日对账 + 版本水位比对是最后防线。

十二、上线 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 后平均字节数,估算月度流量

十三、官方参考资料

  1. EF Core 事务(SaveChanges 默认事务与显式事务):https://learn.microsoft.com/zh-cn/ef/core/saving/transactions
  2. EF Core 处理并发冲突(含 SQLite 应用自管并发令牌):https://learn.microsoft.com/zh-cn/ef/core/saving/concurrency
  3. EF Core 索引(唯一索引 IsUnique):https://learn.microsoft.com/zh-cn/ef/core/modeling/indexes
  4. EF Core ExecuteUpdate/ExecuteDelete:https://learn.microsoft.com/zh-cn/ef/core/saving/execute-insert-update-delete
  5. ASP.NET Core 后台任务与托管服务:https://learn.microsoft.com/zh-cn/aspnet/core/fundamentals/host/hosted-services
  6. BackgroundService 基类与后台任务实现:https://learn.microsoft.com/zh-cn/dotnet/architecture/microservices/multi-container-microservice-net-applications/background-tasks-with-ihostedservice
  7. PeriodicTimer 与 .NET 计时器:https://learn.microsoft.com/zh-cn/dotnet/standard/threading/timers
  8. System.Text.Json 源生成模式:https://learn.microsoft.com/zh-cn/dotnet/standard/serialization/system-text-json/source-generation
  9. HttpClient 使用准则(生命周期/PooledConnectionLifetime):https://learn.microsoft.com/zh-cn/dotnet/fundamentals/networking/http/httpclient-guidelines
  10. 弹性 HTTP 应用(Microsoft.Extensions.Http.Resilience / Polly):https://learn.microsoft.com/zh-cn/dotnet/core/resilience/http-resilience
  11. Polly v8 迁移指南(ResiliencePipeline API):https://www.pollydocs.org/migration-v8
  12. Microsoft.Data.Sqlite 概述:https://learn.microsoft.com/zh-cn/dotnet/standard/data/sqlite/
  13. GZipStream 压缩类:https://learn.microsoft.com/zh-cn/dotnet/api/system.io.compression.gzipstream
  14. HMACSHA256 报文签名:https://learn.microsoft.com/zh-cn/dotnet/api/system.security.cryptography.hmacsha256

相关推荐
卷无止境1 小时前
大模型如何调用工具,一次讲清背后的技术门道
后端·python
90后的晨仔1 小时前
Python 进阶:海象运算符与模式匹配,让你的代码更优雅!
后端
卷无止境7 小时前
智能体开发环境ADE浅析,编程工具的下一次范式跃迁
后端·python
码事漫谈10 小时前
DeepSeek 这波操作很凶
后端
Bs_MoneyMagnet11 小时前
基于springboot+vue的医疗健康便民服务平台的设计与实现 源码+文档
java·vue.js·spring boot·后端·spring
子兮曰13 小时前
Bun v1.4.1 深度解析:从 Zig 到 Rust,一场 11 天、64 个 AI 代理的语言迁徙
前端·后端·bun
IT_陈寒14 小时前
Vue的computed属性竟然坑了我一把
前端·人工智能·后端
薛定谔的悦15 小时前
一套储能 BMS 的充电电流限值,是怎么一步步算出来的
后端
逐米时代16 小时前
AR加知识库提升一次修复率
后端·restful