云打印不丢单架构-时间轮+状态驱动推送+断线恢复

0 阅读16分钟

云打印不丢单架构:时间轮调度 + 状态驱动推送 + 断线批量恢复

摘要:本文以双十一大促为背景,复盘一套生产环境的云打印调度链路。每个打印任务带全局唯一标识(防重打 + 防丢单的底座),经数据转换后下发到 TCP 集群网关,按打印机长连接地址路由到对应节点,再进入推送时钟轮与超时时钟轮双轮调度。真正决定推送的不是定时器到点,而是打印机上报的空闲状态;只有收到设备 200 回执才出队并回调业务。断线时先出队、重连后批量恢复,超时由独立轮兜底。全文含 C# 完整实现与压测数据。


一、一条小票要经过五跳

1.1 先看双十一那天的压力曲线

平时这套系统的量级很温和,一到双十一就完全变了个样子。下面是某餐饮 SaaS 平台大促当天的脱敏大盘数据:

指标平日双11 峰值倍数
单日打印任务量41 万280 万6.8×
每分钟打印任务62042006.8×
单台设备平均待打印任务1.2 条8.7 条7.2×
设备断线率(门店 WiFi 拥塞 + 4G 基站过载)0.3%4.1%13.7×
入队到出纸平均耗时1.4 秒4.8 秒3.4×

注意最后两行。 任务量涨 6.8 倍是预期之内的,但设备断线率涨了 13.7 倍——这才是双十一真正的难点。

原因很直白:大促当天商圈里所有门店的 WiFi 都在抢带宽,4G 基站同时承载大量用户,打印机作为低优先级 IoT 设备,掉线概率被放大了一个数量级。而每一台掉线的打印机,背后都是一串正在排队等待推送的任务。

于是就出现了三个必须同时解决的问题:

任务量 6.8 倍  →  调度不能靠"一条任务一个定时器"
断线率 13.7 倍 →  掉线瞬间必须能把任务接住、重连后还要能捞回来
延迟 3.4 倍    →  重试节奏不能太激进,否则自己把自己打爆

双十一这种「量级跃迁 + 网络劣化」叠加的场景,会把平时藏得很深的调度缺陷全部放大暴露出来。下面就按这条链路一层层拆。

1.2 一条小票要经过五跳

先把完整链路摆出来,后面所有设计都是为这条链路服务的:

① 业务接口(外卖/ERP/进销存)
        │  订单数据
        ▼
② 数据转换层(HTML 模板 / ESC 指令)
        │
        ▼
③ TCP 集群服务网关 API
        │  按 printerCode → 查长连接归属节点
        ▼
④ 对应 TCP 服务器节点(持有该打印机的 socket)
        │
        ▼
⑤ 推送时钟轮 + 超时时钟轮
        │
        ▼
⑥ 打印机(长连接上报状态 / 回传 200)

关键点在于第 ③④ 跳:打印机和 TCP 服务器之间是长连接。一台打印机在某一时刻只挂在集群中的某一个节点上。所以任务下发前必须先算出「这台打印机的连接现在挂在哪个节点」,然后把它路由过去。

这就带来了两个必须解决的问题:

  1. 节点挂了或打印机重连到别的节点怎么办? → 需要断线恢复(第九节)
  2. 任务到点了,但打印机正在忙/离线,还能不能推? → 需要状态驱动推送(第七节)

二、为什么不用 DelayQueue / 定时线程池

在写时间轮之前,我们试过三种常规方案,都不合适:

方案问题
DelayQueue<T>每条任务一个延迟对象,10 万待打印任务 = 10 万个堆节点。GC 压力大,且无法按打印机分片批量处理
ScheduledThreadPoolExecutor时间堆 O(logN) 插入,同样无法批量触发;任务数上万后调度抖动明显
数据库轮询 WHERE next_time < NOW()秒级精度做不到;每秒扫全表,索引压力扛不住

时间轮的优势在于三个操作都是常数级:

  • 插入:算一次取模 → O(1)
  • 删除:按 (格子, key) 直接命中 → O(1)
  • 触发:指针走一格,批量取出该格所有到期任务 → O(k),k 是该格任务数

而且天然支持同一格批量处理——这正是「断线重连后批量恢复」需要的能力。


三、时间轮的核心:格子 + 圈数

核心实现在 TimeWatch 里。它是一个哈希时间轮,由三部分组成:

        ┌──── 指针每 2 秒走一格 ────┐
        ▼                          │
   ┌────────┬────────┬────────┬────┴───┬────────┐
   │ 格 1   │ 格 2   │ 格 3   │  ...   │ 格 30  │
   │ 任务A  │ 任务B  │        │        │ 任务C  │
   │ loop=3 │ loop=1 │        │        │ loop=2 │
   └────────┴────────┴────────┴────────┴────────┘
        │
        └── 每格是个 ConcurrentDictionary<订单号, Lattice>

格子位置由「还要走多少步」对「盘大小」取模决定。
圈数 LoopNum 记录「要绕几圈才轮到」,指针每经过一次就减 1,减到 0 才真正触发。

/// <summary>
/// 时钟类:哈希时间轮
/// </summary>
public class TimeWatch
{
    /// <summary>时钟盘大小(格子数量)</summary>
    private int RouletteSize;

    /// <summary>指针走动间隔(毫秒)</summary>
    private int TimeIntervalSecond;

    /// <summary>每格是一个 订单号 → 格子单元 的字典</summary>
    private ConcurrentDictionary<int, ConcurrentDictionary<string, Lattice>> Lattices;

    /// <summary>当前指针位置</summary>
    private int CurrentPointer;

    /// <summary>上次走格时间,用于精度修正</summary>
    private DateTime LastRunTime;

    private Timer Timer;
    private Action<Dictionary<string, Lattice>> Action;
}

指针移动时,减圈 + 触发:

private void Timer_Elapsed(object sender, ElapsedEventArgs e)
{
    // 记录上次运行时间,供 FillLattice 做误差补偿
    LastRunTime = DateTime.Now;

    // 指针走一格,走到底回到 1
    CurrentPointer += 1;
    if (CurrentPointer > RouletteSize) CurrentPointer = 1;

    var dics = Lattices[CurrentPointer];
    var tasks = new Dictionary<string, Lattice>();

    foreach (var item in dics)
    {
        // 走到这一格,圈数减一
        if (item.Value.LoopNum > 0) item.Value.LoopNum -= 1;

        // 圈数归零 = 真正到期
        if (item.Value.LoopNum == 0)
        {
            dics.TryRemove(item.Key, out _);
            tasks.Add(item.Key, item.Value);
        }
    }

    if (tasks.Count > 0) Action(tasks);   // 批量交给业务处理
}

注意 LoopNum = 0 的处理:格子里的任务不一定都到期,只有圈数减到 0 才出格。所以同一个格子里可以同时躺着「1 圈后到期」和「3 圈后到期」的不同任务,互不干扰。


四、FillLattice 的精度修正(最容易被忽略的地方)

这一小段代码是整个时间轮里最容易被写错的地方,值得单独讲。

public int FillLattice(string key, string printerCode, int timeout, DateTime createTime)
{
    timeout = timeout * 1000;                              // 转毫秒

    var bout = timeout / TimeIntervalSecond;               // 需要走几格
    var drift = timeout % TimeIntervalSecond;              // 不足一格的余数

    // 关键 1:补偿"距上次走格已经过了多少毫秒"
    drift += (int)((DateTime.Now - LastRunTime).TotalMilliseconds);

    // 关键 2:只要有余数就向上取整,0.几格算 1 格,1.几格算 2 格
    bout += drift > TimeIntervalSecond ? 2 : 1;

    int loopNum, location;
    var remainder = bout % RouletteSize;

    if (remainder == 0)
    {
        // 正好整圈
        loopNum = bout / RouletteSize;
        location = CurrentPointer;
    }
    else
    {
        loopNum = bout / RouletteSize;
        location = CurrentPointer + remainder > RouletteSize
            ? (CurrentPointer + remainder) - RouletteSize
            : CurrentPointer + remainder;

        loopNum += 1;   // 跨圈要多算一圈
    }

    Lattices[location].TryAdd(key, new Lattice
    {
        LoopNum = loopNum,
        PrinterCode = printerCode,
        Index = location,
        CreateTime = createTime
    });

    return location;
}

为什么必须加 drift 补偿

假设时钟每 2 秒走一格,你在「上一格刚走完 1.9 秒」的时刻插入一个 timeout = 2 秒 的任务。

  • 不补偿:bout = 1 格。但因为 0.1 秒后指针就要走到下一格,这个任务实际上只等了 0.1 秒就被触发 —— 提前了 1.9 秒。
  • 补偿后:drift = 0 + 1900ms = 1900 < 2000,bout += 1 → 2 格,实际等待约 1.9 + 2 = 3.9 秒。虽然比 2 秒多,但绝不会提前。

宁可晚一点,也绝不能提前。 打印任务提前触发意味着「打印机还没准备好就推了」,这才是丢单的根源。

为什么用「圈数」而不是「绝对时间戳」

用绝对时间戳的做法(存 nextRunAt,每格扫一遍比较)在任务量大时会把每格变成一次全表扫描。
用圈数则是分散摊销:插入时就确定好落在哪一格、绕几圈,运行时只做一次整数减法。10 万个任务分散在 30 格里,每格只有 3000 个左右,指针走一格只处理这 3000 个。


五、任务唯一标识:防重打与防丢单的共同底座

前面所有的调度设计,都建立在一个前提上:每个打印任务必须有一个全局唯一、可回溯的标识。

没有它,时间轮不知道「这一格里的两份任务是不是同一单」;打印机不知道「这条指令是不是打过」;断线恢复时也捞不回该捞的任务。

5.1 唯一标识怎么生成

关键在于:不是随机 UUID,而是业务语义键。

/// <summary>
/// 构建任务唯一标识
/// 格式:{业务单号}_{单据类型}_v{版本号}
/// 例:ORDER20261026000135_RECEIPT_v1
/// </summary>
public static string BuildTaskId(string bizId, string printType, int version = 1)
{
    return $"{bizId}_{printType}_v{version}";
}

三段结构各自解决一个问题:

组成作用举例
bizId业务实体唯一性——同一订单的多次重试必然相同ORDER20261026000135
printType同单多票区分——一单要打「小票」和「后厨单」两张RECEIPT / KITCHEN
version补打与重打区分——业务主动补打时递增,视为新任务v1 → v2

为什么不能用随机 UUID?

因为随机值在重试时会重新生成,两次重试的标识不一样,判重逻辑完全失效——等于没做防重打。唯一标识的生命周期必须跨越所有重试轮次保持不变。

5.2 三处落地:一个标识,三个用途

这个标识要在三个地方同时落地,缺一不可:

① 时间轮的 key

// Lattices[location] 是 ConcurrentDictionary<唯一标识, Lattice>
Lattices[location].TryAdd(taskId, new Lattice
{
    PrinterCode = printerCode,
    LoopNum     = loopNum,
    Index       = location,
    CreateTime  = createTime
});

TryAdd 在这里承担了同格去重职责:同一任务无论被 FillLattice 多少次,同一格里最多只有一份。

② 打印机指令的序号

下发给打印机的每条指令都携带这个标识(或由它派生的指令序号),打印机打印完成后原样回传:

下发:  PRINT #ORDER20261026000135_RECEIPT_v1 <内容>
回执:  200    #ORDER20261026000135_RECEIPT_v1

这样即使出现「回执迟到」——比如第 3 次重试的指令发出后没响应,第 5 次又发了一次,最后收到两条成功回执——也能凭标识判断:这是同一单,只算一次。

③ 状态表主键

CREATE TABLE print_task (
  task_id       VARCHAR(128) PRIMARY KEY,   -- 唯一标识
  printer_code  VARCHAR(64)  NOT NULL,
  status        VARCHAR(16)  NOT NULL,      -- SCHEDULED/WAIT_ACK/PRINTED/PARKED/TIMEOUT
  lattice_index INT,                        -- 当前所在格子,出队时要用
  retry_count   INT DEFAULT 0,
  created_at    DATETIME,
  updated_at    DATETIME,
  INDEX idx_printer_status (printer_code, status)
);

第九节的断线恢复靠 WHERE printer_code = ? AND status = 'PARKED' 捞任务,第十节的超时判定靠 WHERE task_id = ? 查状态——全部依赖这个主键。

5.3 防重打:三层闸门

有了唯一标识,防重打就变成三道闸门的串联:

层级机制拦截效果
调度层唯一标识是时间轮的 key,TryAdd 保证同格唯一同一任务不会在轮里出现两份,避免重复调度
出队层收到 200 立即出队 + 状态置 PRINTED任务一旦完成就离开轮,后续重试根本命中不到
设备层打印机收到已标记打印过的标识,直接丢弃兜底的物理级去重,防「回执迟到」造成的重打

关键点:出队必须及时。 如果收到 200 后延迟出队,这期间的重试还会再推一次——设备层虽然能拦住,但已经浪费了一次网络往返,高峰期会放大成性能问题。

5.4 防丢单:三条找回路径

唯一标识让每一类丢单场景都有对应的找回手段:

场景 A:任务还在排队,但推送失败
  → 凭标识在时间轮里定位格子,重新入轮

场景 B:打印机断线,任务挂起
  → 凭标识 + 状态 PARKED 落库,重连后批量捞出恢复

场景 C:真的超时了
  → 凭标识记录 TIMEOUT + 失败原因,业务侧可精确补打

场景 D:日志里看不出打没打
  → 凭标识反查状态表,一条 SQL 定位到最终状态

场景 D 是最实用的。以前排查丢单要翻一堆日志拼时间线,现在直接:

SELECT task_id, printer_code, status, retry_count, created_at, updated_at
FROM print_task
WHERE task_id = 'ORDER20261026000135_RECEIPT_v1';

一行结果就能看出:进过几次轮、重试了几次、最后停在哪,还是压根没进来。

5.5 一句话总结

唯一标识让「同一件事」在任何时刻、任何节点、任何重试轮次下,都能被精确认出来。

防重打靠它判「是不是同一单」,防丢单靠它找「这单到底在哪」。整个调度体系的可观测性,本质上都源于这一个字段。


六、双轮设计:推送轮 + 超时轮

线上跑的是两个独立的时间轮,职责完全不同:

推送时钟轮超时时钟轮
职责决定「什么时候尝试推送」决定「多久还没打印成功就判失败」
格子间隔2 秒3 秒
格子数量30 格30 格
一圈时长60 秒90 秒
触发后动作检查设备状态 → 推送标记超时 → 出队 → 通知业务
能否重复入轮能(重试机制的核心)不能(一次性判定)
任务进入
   │
   ├──► 推送轮:首次 2 秒后尝试
   │        │
   │        ├── 设备空闲 ──► 推送 ──► 收到 200 ──► 出队 + 回调业务
   │        │                        └── 无回执 ──► 重新入推送轮(间隔 +2 秒)
   │        │
   │        └── 设备异常 ──► 移出推送轮,转轮询等待
   │
   └──► 超时轮:90 秒后判定
            └── 仍未成功 ──► 出队 + 通知业务「打印超时」

为什么要拆成两个轮?

因为它们的语义完全不同:

  • 推送轮是「可重入的」——同一任务可以被反复放进去,每次间隔递增(2s → 4s → 6s…)。它回答的是「什么时候再试一次」。
  • 超时轮是「一次性的」——任务只在里面躺一次,走完一圈还没成功就直接判死刑。它回答的是「什么时候该放弃」。

如果合成一个轮,要么重试次数不可控,要么超时判定被重试逻辑污染。


七、状态驱动推送:不是到点就推

这是整套架构里最反直觉、也最关键的一点。

普通定时任务的逻辑是:到点 → 执行。
这里的逻辑是:到点 → 问一句设备能不能接 → 能才推,不能就等下一轮。

/// <summary>
/// 推送轮每格到期时的处理逻辑
/// </summary>
private void OnPushTick(Dictionary<string, Lattice> tasks)
{
    foreach (var (orderId, lattice) in tasks)
    {
        var printerCode = lattice.PrinterCode;
        var conn = ConnectionRegistry.Get(printerCode);

        // ① 连接不在本节点 → 该打印机的任务不该由本节点推
        if (conn == null || !conn.IsOnline)
        {
            // 转交路由中心,等待重连后再恢复(见第九节)
            TaskBuffer.Park(orderId, lattice);
            continue;
        }

        // ② 设备上报状态:忙 / 缺纸 / 离线 → 本轮不推,重新入轮
        if (!conn.IsIdle)
        {
            RequeueToPushWheel(orderId, lattice, GetNextInterval(lattice));
            continue;
        }

        // ③ 设备空闲 → 真正推送
        bool sent = conn.Send(BuildPayload(orderId));

        if (!sent)
        {
            // 发送失败:说明连接已断,先出队挂起
            TaskBuffer.Park(orderId, lattice);
            continue;
        }

        // ④ 已发送,但还没收到 200 —— 不做出队,等回执
        TaskBuffer.MarkWaitingAck(orderId, lattice);
    }
}

为什么不能「到点硬推」

打印机的物理特性决定了它不能并发接收:

  • 热敏打印头一次只能打一张,正在打的时候你推第二张,指令会进设备缓冲区,轻则乱序,重则被设备丢弃。
  • 设备离线时推送,TCP 层可能返回「写成功」(进了内核缓冲区),但设备根本收不到 —— 这就是静默丢单。

所以推送前必须查两个状态:连接是否在本节点 + 设备是否空闲。


八、出队时机:收到 200 才算完成

状态机设计成四态,只有 PRINTED 是终态:

     ┌──────────┐   入推送轮   ┌──────────────┐
     │ CREATED  │────────────►│  SCHEDULED   │
     └──────────┘             └──────────────┘
                                    │
                         设备空闲且发送成功
                                    │
                                    ▼
                             ┌──────────────┐
                             │  WAIT_ACK    │◄─── 无回执,超时重入推送轮
                             └──────────────┘
                                    │
                        收到设备 200 回执
                                    │
                                    ▼
                             ┌──────────────┐
                             │   PRINTED    │  ← 出队 + 回调业务
                             └──────────────┘

    异常分支:
    WAIT_ACK 无回执 + 超时轮到期 ──► TIMEOUT  ← 出队 + 通知业务
    连接断开                    ──► PARKED   ← 出队挂起,等重连恢复

出队只发生在这三个时刻:

时机触发条件后续动作
PRINTED收到设备 200移除推送轮 + 超时轮,回调业务成功
TIMEOUT超时轮走完一圈仍未 200移除推送轮,回调业务失败
PARKED连接断开移除推送轮(保留任务数据),等重连恢复

除这三个之外,任何情况下都不出队。 这条规则的重要性在于:只要任务还在轮里,就还有被救回来的机会。

/// <summary>
/// 打印机回传 200 后的处理
/// </summary>
public void OnPrinterAck(string orderId, string printerCode)
{
    // ① 从推送轮移除
    var pushIdx = GetLatticeIndex(orderId, PrinterWheel);
    if (pushIdx > 0) PrinterWheel.RemoveLattice(pushIdx, orderId);

    // ② 从超时轮移除(不再需要判超时了)
    var timeoutIdx = GetLatticeIndex(orderId, TimeoutWheel);
    if (timeoutIdx > 0) TimeoutWheel.RemoveLattice(timeoutIdx, orderId);

    // ③ 更新状态 + 回调业务方
    TaskStore.UpdateStatus(orderId, PrintStatus.PRINTED);
    CallbackClient(orderId, PrintStatus.PRINTED);
}

九、断线恢复:先出队,重连后批量恢复

这是整套方案里最容易出问题、也是最体现功力的一段。

问题在哪

打印机的长连接可能挂到集群的任意节点。考虑这个场景:

时刻 T1:打印机 P001 连在 节点 A 上,3 个任务排队等推送
时刻 T2:节点 A 网络抖动,P001 断开
时刻 T3:P001 重连,被负载均衡打到 节点 B

如果 P001 断开时我们没有处理那 3 个任务,它们会一直躺在节点 A 的推送轮里空转 —— 但 P001 已经不归节点 A 管了。这些任务永远推不出去。

解法:出队挂起 + 重连恢复

第一步:断线时立即出队

/// <summary>
/// 打印机连接断开事件
/// </summary>
public void OnPrinterDisconnected(string printerCode)
{
    // 找出这台打印机的所有排队任务
    var pending = TaskStore.GetPendingByPrinter(printerCode);

    foreach (var task in pending)
    {
        // 从推送轮移除 —— 避免在错误的节点上空转
        var idx = GetLatticeIndex(task.OrderId, PrinterWheel);
        if (idx > 0) PrinterWheel.RemoveLattice(idx, task.OrderId);

        // 状态置为 PARKED,数据保留在库里(不删!)
        TaskStore.UpdateStatus(task.OrderId, PrintStatus.PARKED);
    }
}

第二步:重连后批量恢复

/// <summary>
/// 打印机重新注册连接(可能落在任意节点)
/// </summary>
public void OnPrinterRegistered(string printerCode, PrinterConnection conn)
{
    // 从库里捞出所有挂起任务,按创建时间排序保证 FIFO
    var parked = TaskStore.GetParkedByPrinter(printerCode)
                         .OrderBy(t => t.CreateTime)
                         .ToList();

    if (parked.Count == 0) return;

    // 批量恢复进推送轮
    foreach (var task in parked)
    {
        // 断线恢复的任务,首次推送用最小间隔,尽快出纸
        var location = PrinterWheel.FillLattice(
            task.OrderId,
            printerCode,
            DefaultSendStartSecond,    // 2 秒
            task.CreateTime);

        TaskStore.UpdateStatus(task.OrderId, PrintStatus.SCHEDULED);
        TaskStore.UpdateLatticeIndex(task.OrderId, location);
    }

    Log.Info($"[重连恢复] 打印机 {printerCode} 恢复 {parked.Count} 个任务");
}

三个必须注意的细节

细节 1:FillLattice 是 TryAdd,重复恢复会静默失败

Lattices[location].TryAdd(key, new Lattice {...});

TryAdd 的好处是防止同一订单被重复插入同一格。所以恢复前必须先确认任务已从原格移除,否则 TryAdd 返回 false,任务看起来「恢复了」其实根本没进去。

建议把恢复逻辑改成:先 RemoveLattice 全盘扫一遍,再 FillLattice。宁可多扫一次,不要留幽灵任务。

细节 2:恢复要限速,不能一次性全推

如果一台打印机断线 10 分钟,积压了 200 单,重连瞬间全部进推送轮(都是 2 秒后到期),会在同一格触发 —— 然后 200 个任务同时尝试推送同一台设备。

public void OnPrinterRegistered(string printerCode, PrinterConnection conn)
{
    var parked = TaskStore.GetParkedByPrinter(printerCode)
                         .OrderBy(t => t.CreateTime).ToList();

    // 限速:每轮最多恢复 N 个,其余延后
    const int RecoveryBatch = 5;

    for (int i = 0; i < parked.Count; i++)
    {
        var task = parked[i];
        // 第 1 批 2 秒后,第 2 批 4 秒后,第 3 批 6 秒后……
        int delay = DefaultSendStartSecond + (i / RecoveryBatch) * NextSendIncreasSecond;

        var location = PrinterWheel.FillLattice(task.OrderId, printerCode, delay, task.CreateTime);
        TaskStore.UpdateStatus(task.OrderId, PrintStatus.SCHEDULED);
        TaskStore.UpdateLatticeIndex(task.OrderId, location);
    }
}

细节 3:超时轮要判断「是否因为断线而超时」

如果任务是在 PARKED 状态下超时的,业务方应该收到「设备离线」而不是「打印超时」—— 这两种失败的处置方式完全不同(前者等设备回来补打,后者要人工核对)。

private void OnTimeoutTick(Dictionary<string, Lattice> tasks)
{
    foreach (var (orderId, lattice) in tasks)
    {
        var task = TaskStore.Get(orderId);

        var reason = task.Status switch
        {
            PrintStatus.PARKED  => TimeoutReason.DEVICE_OFFLINE,   // 断线导致
            PrintStatus.WAIT_ACK => TimeoutReason.NO_ACK,          // 设备无回执
            _                    => TimeoutReason.UNKNOWN
        };

        // 出队
        var idx = GetLatticeIndex(orderId, TimeoutWheel);
        if (idx > 0) TimeoutWheel.RemoveLattice(idx, orderId);

        TaskStore.UpdateStatus(orderId, PrintStatus.TIMEOUT);
        CallbackClient(orderId, PrintStatus.TIMEOUT, reason);
    }
}

十、超时轮:兜底的最后一道闸

超时轮的设计原则很简单:每个任务进推送轮的同时,必须进一次超时轮。

/// <summary>
/// 任务首次进入调度
/// </summary>
public void Enqueue(PrintTask task)
{
    // ① 进推送轮,首次 2 秒后尝试
    var pushIdx = PrinterWheel.FillLattice(
        task.OrderId, task.PrinterCode, DefaultSendStartSecond, task.CreateTime);

    // ② 同时进超时轮,90 秒后(30 格 × 3 秒)判定失败
    var timeoutSec = TimeoutClockSecond * TimeoutClockLatticeNum;
    TimeoutWheel.FillLattice(
        task.OrderId, task.PrinterCode, timeoutSec, task.CreateTime);

    TaskStore.UpdateStatus(task.OrderId, PrintStatus.SCHEDULED);
    TaskStore.UpdateLatticeIndex(task.OrderId, pushIdx);
}

超时轮为什么不跟随推送轮的重试节奏?

因为业务关心的是「这单多久没打出来」,而不是「试了几次」。

推送轮可能重试了 15 次(2s、4s、6s…),但业务侧只想知道「90 秒了还没出来,赶紧告诉我」。所以超时轮是独立于重试次数的绝对时间闸。


十一、配置参数说明

参数默认值含义调整建议
DefaultSendStartSecond2任务入轮后多久首次尝试推送给设备上报状态留出时间。设太小会在设备状态未知时硬推
NextSendIncreasInSecond2重试间隔递增步长重试间隔 = 2s, 4s, 6s, 8s…。弱网环境可调大到 3~5s,避免频繁重试打爆带宽
LoopClockSecond2推送轮指针走动间隔决定推送精度。设为 2 意味着任务最多延迟 2 秒命中
LoopClockLatticeNum30推送轮格子数量一圈时长 = 2 × 30 = 60 秒。这是「单个任务最多在推送轮里待多久」的理论上限
TimeoutClockSecond3超时轮指针走动间隔决定超时判定精度。设为 3 意味着超时判定误差 ±3 秒
TimeoutClockLatticeNum30超时轮格子数量一圈时长 = 3 × 30 = 90 秒。这就是业务侧的「打印超时阈值」

参数之间的约束关系

调参时有三条硬约束:

  1. 超时轮一圈时长 > 推送轮一圈时长 90s > 60s,否则任务会在推送轮还没轮完一圈时就被判超时。

  2. NextSendIncreasInSecond 不能 ≥ 推送轮一圈时长 如果重试间隔增长到超过 60 秒,任务会绕圈后撞上自己 —— 相当于「自己覆盖自己」,TryAdd 会失败导致任务丢失。

  3. LoopClockLatticeNum 必须是整数格 一圈时长 = LoopClockSecond × LoopClockLatticeNum。改成 60 格 × 1 秒,精度更高但 CPU 占用翻倍。30 格 × 2 秒是实测的平衡点。


十二、压测数据

本地压测。为了让数据更接近双十一的真实口径,压测不是「平铺均匀下发」,而是按大促峰值模型回放:峰值段 30 分钟内集中打入 8 万单(约 4400 单/分钟),同时注入 15% 网络丢包、10% 设备随机离线、5% 断线重连(含跨节点重连)。

方案丢单率重复打印率平均出纸延迟峰值 CPU
定时器直推(无状态检查)3.2%1.8%1.4s22%
+ 时间轮调度2.4%1.8%1.6s8%
+ 状态驱动推送0.7%0%2.0s9%
+ 断线批量恢复0.1%0%2.3s11%
+ 超时轮兜底0%0%2.4s12%

几个值得注意的结论:

  1. 时间轮最大的收益不是降丢单,而是降 CPU:22% → 8%。因为不再为每条任务维护独立定时器。
  2. 状态驱动推送是降丢单的转折点:2.4% → 0.7%。这一步的收益远大于调度算法本身的优化。
  3. 断线恢复把最后 0.7% 补掉:剩下的丢单基本都是断线场景,恢复机制一上直接归零。
  4. 延迟涨了 1 秒(1.4s → 2.4s),代价可以接受 —— 餐饮后厨 2.4 秒出纸完全够用。

0.1% 的残余丢单来自「设备硬件故障」(物理卡纸),这类只能靠人工巡检,软件层解决不了。

12.1 双十一当天发生了什么

这套机制在大促当天实际跑了两个峰值段(上午 10:00-12:00、晚上 19:00-23:00):

观察项平日双11 当天说明
未出纸的打印任务0.02%0.03%基本持平——量涨 6.8 倍,丢单率没跟着涨
重复打印00唯一标识 + 设备层去重兜住了
断线触发恢复的打印机日均 12 台峰值 380 台单次平均恢复 6.3 个任务
恢复后成功出纸—99.6%剩余 0.4% 是物理卡纸/缺纸
推送轮峰值负载每格 ~180 任务每格 ~1400 任务单格处理耗时仍在 200ms 以内

最值得说的一行是断线触发恢复的打印机从 12 台涨到 380 台——这就是前面说的「断线率放大 13.7 倍」落到实处的样子。380 台设备同时断线重连,如果没有批量限速恢复,重连瞬间就会把推送轮打出一个尖峰。

所以我们在大促前把恢复批次从 10 个调到了 5 个(见第九节「细节 2:恢复要限速」),单批间隔拉长到 2 秒。代价是积压多的门店恢复慢一点,但换来的是整条链路不被单台设备的重连打穿。


十三、踩过的坑

坑 1:FillLattice 不补偿 drift,任务提前触发

第一版没加 drift 补偿,结果「2 秒后推送」的任务有时 0.1 秒就推了。设备还没上报状态,直接推过去被静默丢弃。加上 drift 后解决。

坑 2:TryAdd 静默失败,恢复时任务凭空消失

断线恢复时用 FillLattice 重新入轮,但原格里的任务没删。TryAdd 返回 false,新实例没进去,旧实例又被后续逻辑清掉了 —— 任务彻底丢失。现在恢复前强制先 RemoveLattice。

坑 3:断线恢复不限速,200 单瞬间轰同一台设备

一次断线 10 分钟,积压 200 单。重连后全部进「2 秒后到期」那一格,同一时刻 200 个任务尝试推同一台打印机,设备直接缓冲区溢出。加了批量限速(每批 5 个,批间隔 +2 秒)后解决。

坑 4:超时轮和推送轮时长配置反了

最早配的是「推送轮 90 秒一圈、超时轮 60 秒一圈」,结果任务在推送轮里还在重试,超时轮已经把它判死了。改成「推送 60s / 超时 90s」才正常。

坑 5:用 DateTime.Now 算 drift 遇到时钟回拨

LastRunTime 和 DateTime.Now 做差,如果系统时钟回拨,差值会变成负数,drift 被算成负数,bout 直接少跑一格。生产环境建议换 Stopwatch 的单调时钟。


十四、总结

整个架构的职责分工:

模块解决什么关键设计
任务唯一标识防重打与防丢单的共同底座业务语义键(非 UUID),三处落地:轮 key / 指令序号 / 表主键
时间轮海量任务的低成本调度格子 + 圈数,插入/删除 O(1),批量触发
双轮拆分重试逻辑与超时判定解耦推送轮可重入,超时轮一次性
状态驱动推送不往忙/离线的设备硬推推送前查「连接归属 + 设备空闲」
200 才出队避免静默丢单只有收到设备回执才进终态
断线批量恢复跨节点重连后的任务不丢PARKED 状态 + 重连后限速恢复
超时轮兜底无法恢复的任务要能被感知绝对时间闸,独立于重试次数

四条核心原则:

  1. 同单必同标识。每个任务一个全局唯一标识,跨越所有重试轮次不变——防重打、防丢单都源于它。
  2. 到点 ≠ 该推。时间轮只负责「叫醒」,推不推要看设备状态。
  3. 发出 ≠ 完成。只有收到 200 才算真正打印成功,在此之前任务不能出队。
  4. 出队 ≠ 结束。断线出队是「挂起」而不是「丢弃」,重连后必须能捞回来。

写在双十一之后

这套架构目前支撑着映美云打印的线上调度链路,已经连续护航多个双十一大促峰值。回头看,真正让系统扛住大促的并不是某个「高深算法」,而是几个很朴素的坚持:

  • 每张单子都有唯一标识,从头到尾认得出是同一件事
  • 到点了先问设备能不能收,不自作聪明地硬推
  • 纸真的出来了才敢出队,不把「已发送」当成「已打印」
  • 断线只是挂起,不当成丢失

双十一真正考验的不是 TPS 数字,而是这些边界条件在极端压力下还成不成立。任务量涨 6.8 倍只是把并发拉高,断线率涨 13.7 倍才是把设计的缝隙全部撕开。

如果你的打印链路也准备迎接下一次大促,建议提前做一次「断线风暴」演练:人为切断 30% 设备的连接再恢复,看任务能不能全部捞回来。这一关过了,大促基本就稳了。


参考资料

  • 映美云开放平台(API 文档 / SDK / 设备管理):open.jolimark.com
  • 云打印 API 对接说明(Java / PHP / Python):open.jolimark.com/document
  • Netty HashedWheelTimer 源码:哈希时间轮的经典实现,本文 TimeWatch 的圈数设计与其同源
  • Kafka TimingWheel:分层时间轮在消息系统中的工程实践

本文代码为方案说明,实际接入请以平台最新接口文档为准。