导读:门店收银高峰期,打印机突然离线、网络抖一下,小票要么没出、要么同一单打了两张,顾客和店员都很懵。本文复盘一套"本地任务队列 + 有限重试 + 幂等补打"的打印任务设计,看完你能拿到可直接照搬的队列表结构、重试退避参数和补打对账清单。
很多收银系统早期把打印做成同步调用:前端点"结账",同步请求打印机驱动,失败了就原地重试。这在单机、稳定网络下能用,一旦遇到蓝牙打印机断连、Wi-Fi 抖动、驱动排队,问题就来了:
要点:打印是"可以延后、不能丢失"的任务,应该和生产解耦。结账时先把打印任务落库,再异步消费,前端只保证"任务已创建",不保证"此刻已打印"。
// 结账成功后的打印任务入队(伪代码,队列表见下)
const task = {
orderNo: "20261009-000123",
printType: "receipt", // 小票类型
payload: JSON.stringify(receiptData),
status: "pending", // pending / retrying / done
retryCount: 0,
createdAt: Date.now()
};
await enqueuePrintTask(task);
return { ok: true, taskId: task.id }; // 先返回成功,打印异步完成队列表不需要引入消息中间件,业务库一张表就够(门店场景并发量远低于核心交易):
CREATE TABLE print_task (
id BIGINT PRIMARY KEY,
order_no VARCHAR(32) NOT NULL,
print_type VARCHAR(16) NOT NULL,
payload TEXT NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'pending',
retry_count INT NOT NULL DEFAULT 0,
next_retry_at DATETIME NOT NULL,
printed_at DATETIME NULL,
unique_key VARCHAR(64) NOT NULL, -- 幂等键
UNIQUE KEY uk_order_type (order_no, print_type, unique_key)
);关键点三个:
order_no + print_type + unique_key 唯一。补打、重试都带上同一个幂等键,数据库层面挡住"同一单打两次"。next_retry_at <= now 取任务,避免每次轮询都扫全表。用一个常驻轻量进程每 5 秒扫一次待打印任务,打印失败按"指数退避 + 上限"安排下次重试:
func pollPrintTasks() {
for {
tasks := db.Query(`SELECT * FROM print_task
WHERE status IN ('pending','retrying')
AND next_retry_at <= NOW()
ORDER BY id ASC LIMIT 20`)
for _, t := range tasks {
if err := print(t.Payload); err != nil {
t.RetryCount++
if t.RetryCount >= 5 {
t.Status = "failed" // 进入补打清单
t.NextRetryAt = now()
} else {
t.Status = "retrying"
// 退避:1s, 2s, 4s, 8s, 上限 30s
t.NextRetryAt = now().Add(backoff(t.RetryCount))
}
} else {
t.Status = "done"
t.PrintedAt = now()
}
db.Save(t)
}
time.Sleep(5 * time.Second)
}
}
func backoff(n int) time.Duration {
d := time.Duration(1<<n) * time.Second
if d > 30*time.Second { d = 30 * time.Second }
return d
}要点:重试次数到上限后必须停止自动重试,转入人工补打,而不是把打印机堵死;退避上限 30 秒,避免高峰期内积压任务全部挤在同一时刻重试。
机器重试解决不了的问题,靠补打清单兜底。每天营业结束生成打印对账单:
当日实收订单数 == 打印任务 done 数 + 补打 done 数,两边对不上就查 failed 清单。# 生成当日补打清单(示意 SQL)
SELECT order_no, print_type, retry_count, created_at
FROM print_task
WHERE status = 'failed'
AND created_at >= '2026-10-09 00:00:00'
ORDER BY created_at;门店早晚高峰会瞬间产生几十笔订单,消费进程如果无脑并发拉起所有待打印任务,打印机队列会瞬间爆掉,表现为"后打的先出、重复出票"。控制手段有两个:
// 单打印机串行信号量(示意)
const semaphores = new Map(); // printerId -> promise chain
async function printWithLock(printerId, task) {
const prev = semaphores.get(printerId) || Promise.resolve();
const next = prev.then(() => doPrint(printerId, task));
semaphores.set(printerId, next);
return next;
}要点:并发控制的目标不是"多打几单",而是"同一台打印机绝不并行",串行化后配合幂等键,高峰期的打印乱序和重复问题一起消失。
连锁门店每店一台或多台打印机,任务队列不能混在一起消费,否则 A 店订单打到 B 店打印机上。隔离方式:
store_id,消费进程按门店过滤,只处理本店任务;printer_id → store_id,任务分配时按门店取打印机,杜绝跨店错打;-- 按门店消费待打印任务(示意)
SELECT * FROM print_task
WHERE store_id = ?
AND status IN ('pending','retrying')
AND next_retry_at <= NOW()
ORDER BY id ASC LIMIT 20;要点:任务隔离在表设计时就做,比事后补救的改动面小得多;多门店规模上来后,再把单表按门店分片或迁移到独立队列,都只是平滑演进。隔离字段从第一版就加上,后面无论门店数量如何增长,消费逻辑都不需要返工。
门店收银的打印任务完全可以先用业务库单表实现,不需要引入独立消息队列;量上来后再平滑迁移到本地队列或消息中间件。核心是先保证"任务不丢、不重打",再谈吞吐。实现时把幂等键、重试上限、补打对账三件套一次做齐,后续维护成本会低很多。
A:结账时先把打印任务写入本地队列(pending 状态),由后台进程异步消费并带幂等键,失败按指数退避重试,超过上限转补打清单。任务不依赖前端页面存活,页面关闭也不丢。
A:用 order_no + print_type + 唯一键 做数据库唯一约束,重试、补打都携带同一幂等键,数据库层挡住重复插入;对账时以"订单数 == done 数"校验。
打印任务看着是小功能,但"不丢不漏"四个字背后是队列、幂等、重试、对账一整套可靠性设计。先保证任务落库、再异步消费、最后对账兜底,这套思路同样适用于消息通知、导出任务等其他异步场景。方案按自身业务取舍即可。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。