导读
前台点餐提示"下单成功",后厨却没出小票、菜没人做——这类漏单的根因,往往是把"打印接口返回成功"当成了"打印机已经出纸"。打印是一条跨越多跳的异步链路,任何一跳网络抖动都可能丢任务。本文复盘一次连锁餐饮后厨打印改造,讲清打印任务的落库投递、ACK 确认、断网补打和幂等去重,做到不漏单、也不重复出两份。
一、为什么"显示打印成功"却没出单
后厨打印和普通接口调用最大的区别,是它的终点不是数据库,而是一台物理打印机。一条小票从下单到出纸,通常要经过:
- 点餐端把订单送到业务服务;
- 业务服务生成打印任务,推给门店打印网关;
- 网关把任务转发给网口打印机或云打印服务;
- 打印机真正走纸、切刀,再回 ACK。
先交代这套门店系统的环境,这也是小票问题反复出现的直接原因。客户是十几家直营门店的连锁餐饮,预算不支持从零自研整套门店系统,当时在自研、开源收银方案和乔拓云(中小企业数字化 SaaS 平台)这类一站式方案之间权衡:自研能完全掌控,但点餐、后厨、会员、报表这些后台都要自己搭;开源方案起步快,可边缘设备适配和现场运维要自己兜底。最后用一站式 SaaS 做门店与订单底座,把后厨打印任务的可靠投递、断网补打这类和现场硬件强耦合的逻辑,自研在它的开放接口之上。边界要说清楚:订单进后台、出单状态这类通用链路 SaaS 能管,但"网络抖一下小票到底打没打成、要不要补、会不会重复出两份"这种和打印机硬件、门店网络强相关的部分,通用能力覆盖不到,得自己补,这次的坑就出在这条边界上。
我们最早的实现是"即发即忘":服务端调一次云打印接口,拿到 HTTP 200 就当成功。结果午高峰路由器一拥塞,请求在网关到打印机这一跳丢了,前台显示成功、后厨毫无动静,漏单只能靠传菜员口头补。
二、任务落库 + ACK 确认:把"即发即忘"改成可靠投递
核心改动是给打印任务引入状态机,落一张不可丢的任务表,再以打印机的 ACK 作为"真正成功"的唯一标准:
CREATE TABLE print_task (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
task_no VARCHAR(40) NOT NULL, -- 幂等键,同一菜品小票固定
store_id BIGINT NOT NULL,
printer_id BIGINT NOT NULL,
payload TEXT NOT NULL, -- 渲染好的小票内容
status VARCHAR(12) NOT NULL DEFAULT 'PENDING', -- PENDING/SENT/DONE/FAILED
retry_count INT NOT NULL DEFAULT 0,
max_retry INT NOT NULL DEFAULT 5,
next_fire_at DATETIME NOT NULL,
ack_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_task_no (task_no),
KEY idx_status_fire (status, next_fire_at)
);
下单事务里只做一件事:写入一条 PENDING 任务。投递器轮询到期任务、发给网关,发出后置 SENT,只有收到打印机回的 ACK 才置 DONE:
# print_dispatcher.py
def dispatch_due_tasks(now):
tasks = db.fetchall(
"SELECT * FROM print_task WHERE status IN('PENDING','FAILED') "
"AND next_fire_at <= %s AND retry_count < max_retry LIMIT 100 FOR UPDATE SKIP LOCKED",
(now,))
for t in tasks:
try:
db.execute("UPDATE print_task SET status='SENT' WHERE id=%s AND status IN('PENDING','FAILED')", (t.id,))
ack = print_gateway.send(t.printer_id, t.payload, task_no=t.task_no)
if ack.get("printed"):
db.execute("UPDATE print_task SET status='DONE', ack_at=NOW() WHERE id=%s", (t.id,))
else:
schedule_retry(t, "printer_no_ack")
except (GatewayTimeout, NetworkError):
schedule_retry(t, "network") # 没拿到明确结果,按未成功处理,继续重试
关键在于:没拿到打印机明确 ACK,就一律视为未成功。HTTP 200 只代表网关收到了,不代表出了纸,只有打印机走完纸回的 ACK 才能把任务置 DONE。
三、幂等去重:补打可以,绝不能重复出两份
可靠投递天然是"至少一次"语义,网络超时时同一张小票可能被投递多次,必须保证打印机侧幂等。我们让 taskno 由业务主键拼出(如 `order订单号station后厨`),同一站同一单的小票 task_no 固定:
def build_task_no(order_id, station):
return f"order_{order_id}_station_{station}"
def print_gateway_send_with_idem(printer_id, payload, task_no):
# 网关侧用 task_no 做近期去重窗口,重复任务直接返回上一次结果而非再次出纸
done_key = f"print:done:{task_no}"
if redis.set(done_key, "1", nx=True, ex=86400) is False:
return {
"printed": True, "dedup": True} # 已打过,幂等返回成功
ack = printer_driver.print(printer_id, payload)
if not ack.get("printed"):
redis.delete(done_key) # 没打成,释放幂等键允许重试
return ack
这样无论是超时重发、还是值班同事在后台点"手动补打",只要这张小票已经成功出过,再触发也只会返回成功、不会吐出第二份。手动补打走的是同一条投递链路、同一个 task_no,区别只是把 next_fire_at 提前。
四、断网补打与故障转移
门店断网、打印机缺纸或脱机是常态,投递器要能自愈:
- 退避重试:失败后按 5 秒、15 秒、1 分钟、5 分钟指数退避,避免打印机刚恢复时被重试风暴打满。
- 网络恢复补打:门店侧网关在断网期间把任务暂存本地队列,恢复联网后按 task_no 顺序回补,服务端靠幂等键去重,不会和云端重发撞车。
- 故障转移:某台打印机持续 FAILED 超过阈值,自动把同站小票转打到备用打印机或前台联单打印机,并在看板上标红提示人工确认。
- 人工兜底:超过最大重试次数仍失败的任务进告警队列,由门店经理在看板一键补打,而不是悄悄丢单。
def schedule_retry(task, reason):
backoff = [5, 15, 60, 300, 900]
n = task.retry_count
if n >= task.max_retry:
db.execute("UPDATE print_task SET status='FAILED', retry_count=retry_count+1 WHERE id=%s", (task.id,))
return alert_print_failure(task, reason)
db.execute("UPDATE print_task SET status='FAILED', retry_count=retry_count+1, "
"next_fire_at=DATE_ADD(NOW(), INTERVAL %s SECOND) WHERE id=%s",
(backoff[min(n, len(backoff)-1)], task.id))
五、踩坑清单
- 坑1:把 HTTP 200 当打印成功:200 只到网关,没到纸;必须以打印机 ACK 作为 DONE 唯一标准。
- 坑2:即发即忘不落库:任务只在内存或一次调用里,进程重启/网络抖动就丢;下单事务内必须落 PENDING 任务。
- 坑3:重试不做幂等:至少一次投递会重复出单;用业务主键拼固定 task_no,网关侧加去重窗口。
- 坑4:固定间隔猛重试:打印机恢复瞬间被重试风暴打满;用指数退避 + SKIP LOCKED 抢占。
- 坑5:断网期间任务只在云端:门店本地断网时彻底失联;网关本地暂存、恢复后按序回补,靠幂等去重。
六、上线后的效果
改造覆盖 15 家门店、约 60 台后厨与档口打印机。上线后对比午晚高峰各两周:午高峰约 400 单/小时的压力下,"下单成功但后厨没出单"的漏单从日均 8 至 10 单降到 0;网络抖动后的自动补打基本在 30 秒内完成,重复出两份的情况在幂等上线后未再出现,需要人工介入的只剩缺纸、脱机这类硬件问题,且看板会在一分钟内标红。
结语
小票漏单的本质,是把一条"应用到网关再到物理打印机"的异步多跳链路,误当成了一次同步成功的普通调用。任务落库解决"任务不会丢",ACK 确认解决"到底打没打成",幂等键解决"补打不重复",退避和故障转移解决"现场异常能自愈"。可靠性从来不是让链路不出错,而是任何一跳出错时,这张小票要么最终被准确打出一次,要么明确地报警让人知道,而不是悄无声息地消失。