云打印不丢单架构:时间轮调度 + 状态驱动推送 + 断线批量恢复
摘要:本文以双十一大促为背景,复盘一套生产环境的云打印调度链路。每个打印任务带全局唯一标识(防重打 + 防丢单的底座),经数据转换后下发到 TCP 集群网关,按打印机长连接地址路由到对应节点,再进入推送时钟轮与超时时钟轮双轮调度。真正决定推送的不是定时器到点,而是打印机上报的空闲状态;只有收到设备
200回执才出队并回调业务。断线时先出队、重连后批量恢复,超时由独立轮兜底。全文含 C# 完整实现与压测数据。
一、一条小票要经过五跳
1.1 先看双十一那天的压力曲线
平时这套系统的量级很温和,一到双十一就完全变了个样子。下面是某餐饮 SaaS 平台大促当天的脱敏大盘数据:
| 指标 | 平日 | 双11 峰值 | 倍数 |
|---|---|---|---|
| 单日打印任务量 | 41 万 | 280 万 | 6.8× |
| 每分钟打印任务 | 620 | 4200 | 6.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 服务器之间是长连接。一台打印机在某一时刻只挂在集群中的某一个节点上。所以任务下发前必须先算出「这台打印机的连接现在挂在哪个节点」,然后把它路由过去。
这就带来了两个必须解决的问题:
- 节点挂了或打印机重连到别的节点怎么办? → 需要断线恢复(第九节)
- 任务到点了,但打印机正在忙/离线,还能不能推? → 需要状态驱动推送(第七节)
二、为什么不用 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 秒了还没出来,赶紧告诉我」。所以超时轮是独立于重试次数的绝对时间闸。
十一、配置参数说明
| 参数 | 默认值 | 含义 | 调整建议 |
|---|---|---|---|
DefaultSendStartSecond | 2 | 任务入轮后多久首次尝试推送 | 给设备上报状态留出时间。设太小会在设备状态未知时硬推 |
NextSendIncreasInSecond | 2 | 重试间隔递增步长 | 重试间隔 = 2s, 4s, 6s, 8s…。弱网环境可调大到 3~5s,避免频繁重试打爆带宽 |
LoopClockSecond | 2 | 推送轮指针走动间隔 | 决定推送精度。设为 2 意味着任务最多延迟 2 秒命中 |
LoopClockLatticeNum | 30 | 推送轮格子数量 | 一圈时长 = 2 × 30 = 60 秒。这是「单个任务最多在推送轮里待多久」的理论上限 |
TimeoutClockSecond | 3 | 超时轮指针走动间隔 | 决定超时判定精度。设为 3 意味着超时判定误差 ±3 秒 |
TimeoutClockLatticeNum | 30 | 超时轮格子数量 | 一圈时长 = 3 × 30 = 90 秒。这就是业务侧的「打印超时阈值」 |
参数之间的约束关系
调参时有三条硬约束:
-
超时轮一圈时长 > 推送轮一圈时长
90s > 60s,否则任务会在推送轮还没轮完一圈时就被判超时。 -
NextSendIncreasInSecond不能 ≥ 推送轮一圈时长 如果重试间隔增长到超过 60 秒,任务会绕圈后撞上自己 —— 相当于「自己覆盖自己」,TryAdd会失败导致任务丢失。 -
LoopClockLatticeNum必须是整数格 一圈时长 =LoopClockSecond × LoopClockLatticeNum。改成 60 格 × 1 秒,精度更高但 CPU 占用翻倍。30 格 × 2 秒是实测的平衡点。
十二、压测数据
本地压测。为了让数据更接近双十一的真实口径,压测不是「平铺均匀下发」,而是按大促峰值模型回放:峰值段 30 分钟内集中打入 8 万单(约 4400 单/分钟),同时注入 15% 网络丢包、10% 设备随机离线、5% 断线重连(含跨节点重连)。
| 方案 | 丢单率 | 重复打印率 | 平均出纸延迟 | 峰值 CPU |
|---|---|---|---|---|
| 定时器直推(无状态检查) | 3.2% | 1.8% | 1.4s | 22% |
| + 时间轮调度 | 2.4% | 1.8% | 1.6s | 8% |
| + 状态驱动推送 | 0.7% | 0% | 2.0s | 9% |
| + 断线批量恢复 | 0.1% | 0% | 2.3s | 11% |
| + 超时轮兜底 | 0% | 0% | 2.4s | 12% |
几个值得注意的结论:
- 时间轮最大的收益不是降丢单,而是降 CPU:22% → 8%。因为不再为每条任务维护独立定时器。
- 状态驱动推送是降丢单的转折点:2.4% → 0.7%。这一步的收益远大于调度算法本身的优化。
- 断线恢复把最后 0.7% 补掉:剩下的丢单基本都是断线场景,恢复机制一上直接归零。
- 延迟涨了 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 倍,丢单率没跟着涨 |
| 重复打印 | 0 | 0 | 唯一标识 + 设备层去重兜住了 |
| 断线触发恢复的打印机 | 日均 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 状态 + 重连后限速恢复 |
| 超时轮兜底 | 无法恢复的任务要能被感知 | 绝对时间闸,独立于重试次数 |
四条核心原则:
- 同单必同标识。每个任务一个全局唯一标识,跨越所有重试轮次不变——防重打、防丢单都源于它。
- 到点 ≠ 该推。时间轮只负责「叫醒」,推不推要看设备状态。
- 发出 ≠ 完成。只有收到
200才算真正打印成功,在此之前任务不能出队。 - 出队 ≠ 结束。断线出队是「挂起」而不是「丢弃」,重连后必须能捞回来。
写在双十一之后
这套架构目前支撑着映美云打印的线上调度链路,已经连续护航多个双十一大促峰值。回头看,真正让系统扛住大促的并不是某个「高深算法」,而是几个很朴素的坚持:
- 每张单子都有唯一标识,从头到尾认得出是同一件事
- 到点了先问设备能不能收,不自作聪明地硬推
- 纸真的出来了才敢出队,不把「已发送」当成「已打印」
- 断线只是挂起,不当成丢失
双十一真正考验的不是 TPS 数字,而是这些边界条件在极端压力下还成不成立。任务量涨 6.8 倍只是把并发拉高,断线率涨 13.7 倍才是把设计的缝隙全部撕开。
如果你的打印链路也准备迎接下一次大促,建议提前做一次「断线风暴」演练:人为切断 30% 设备的连接再恢复,看任务能不能全部捞回来。这一关过了,大促基本就稳了。
参考资料
- 映美云开放平台(API 文档 / SDK / 设备管理):open.jolimark.com
- 云打印 API 对接说明(Java / PHP / Python):open.jolimark.com/document
- Netty
HashedWheelTimer源码:哈希时间轮的经典实现,本文TimeWatch的圈数设计与其同源 - Kafka
TimingWheel:分层时间轮在消息系统中的工程实践
本文代码为方案说明,实际接入请以平台最新接口文档为准。