首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >收银小票打印任务:网络抖动下怎么保证不丢不漏

收银小票打印任务:网络抖动下怎么保证不丢不漏

原创
作者头像
用户5598620
发布于 2026-10-09 10:03:25
发布于 2026-10-09 10:03:25
1150
举报

导读:门店收银高峰期,打印机突然离线、网络抖一下,小票要么没出、要么同一单打了两张,顾客和店员都很懵。本文复盘一套"本地任务队列 + 有限重试 + 幂等补打"的打印任务设计,看完你能拿到可直接照搬的队列表结构、重试退避参数和补打对账清单。

一、为什么小票打印不能做成"同步请求 + 重试几次"

很多收银系统早期把打印做成同步调用:前端点"结账",同步请求打印机驱动,失败了就原地重试。这在单机、稳定网络下能用,一旦遇到蓝牙打印机断连、Wi-Fi 抖动、驱动排队,问题就来了:

  • 同步阻塞结账流程,顾客在收银台等着,高峰期每一秒都敏感;
  • 重试逻辑写在前端,页面一关,重试就断了,打印任务直接丢;
  • 同一笔订单在"请求已发出、响应没回来"的边界上重试,会出现重复打印。

要点:打印是"可以延后、不能丢失"的任务,应该和生产解耦。结账时先把打印任务落库,再异步消费,前端只保证"任务已创建",不保证"此刻已打印"。

代码语言:javascript
复制
// 结账成功后的打印任务入队(伪代码,队列表见下)
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 };  // 先返回成功,打印异步完成

二、打印任务队列怎么设计

队列表不需要引入消息中间件,业务库一张表就够(门店场景并发量远低于核心交易):

代码语言:sql
复制
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)
);

关键点三个:

  1. 幂等键:order_no + print_type + unique_key 唯一。补打、重试都带上同一个幂等键,数据库层面挡住"同一单打两次"。
  2. 状态机:pending → retrying → done;超过最大重试次数转 failed,进入补打清单,不无限重试。
  3. 下次重试时间:由退避策略计算写入,消费进程按 next_retry_at <= now 取任务,避免每次轮询都扫全表。

三、消费进程的轮询与退避怎么写

用一个常驻轻量进程每 5 秒扫一次待打印任务,打印失败按"指数退避 + 上限"安排下次重试:

代码语言:go
复制
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 秒,避免高峰期内积压任务全部挤在同一时刻重试。

四、补打与对账:怎么保证"该打的都打了"

机器重试解决不了的问题,靠补打清单兜底。每天营业结束生成打印对账单:

  • 状态为 failed 的任务列成清单,店员核对后逐条补打;
  • 补打时仍用原幂等键,打印成功把原任务标记 done,不留重复;
  • 对账口径:当日实收订单数 == 打印任务 done 数 + 补打 done 数,两边对不上就查 failed 清单。
代码语言:sql
复制
# 生成当日补打清单(示意 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;

五、高峰期并发打印怎么控

门店早晚高峰会瞬间产生几十笔订单,消费进程如果无脑并发拉起所有待打印任务,打印机队列会瞬间爆掉,表现为"后打的先出、重复出票"。控制手段有两个:

  1. 单打印机串行消费:同一台打印机同一时间只处理一个任务,消费进程按打印机维度加锁(Redis 锁或进程内信号量),打印机 ID 相同则排队;
  2. 任务优先级:小票类型上区分"结账小票/退款小票/预打单",退款和结账优先,预打单让路,避免高峰误打。
代码语言:javascript
复制
// 单打印机串行信号量(示意)
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 店打印机上。隔离方式:

  1. 门店维度分区:打印任务表加 store_id,消费进程按门店过滤,只处理本店任务;
  2. 打印机绑定门店:打印机注册表记录 printer_id → store_id,任务分配时按门店取打印机,杜绝跨店错打;
  3. 门店级补打清单:failed 清单按门店生成,各店只对账自己的任务,避免高峰误清别人门店的补打项。
代码语言:sql
复制
-- 按门店消费待打印任务(示意)
SELECT * FROM print_task
WHERE store_id = ?
  AND status IN ('pending','retrying')
  AND next_retry_at <= NOW()
ORDER BY id ASC LIMIT 20;

要点:任务隔离在表设计时就做,比事后补救的改动面小得多;多门店规模上来后,再把单表按门店分片或迁移到独立队列,都只是平滑演进。隔离字段从第一版就加上,后面无论门店数量如何增长,消费逻辑都不需要返工。

踩坑清单

  • 同步打印阻塞结账:结账链路里做打印 IO,高峰期直接拖慢收银,应改为异步任务;
  • 前端重试:页面刷新/关闭后重试中断,任务丢失,应把任务落库由后端消费;
  • 无幂等键:同一单在超时边界重试会重复出票,必须用唯一键挡;
  • 无限重试:打印机会一直忙碌甚至卡死,必须设上限并转人工补打;
  • 退避太短:高峰期所有失败任务同一秒重试,把打印机打爆,要设上限并用指数退避。

工程落地建议

门店收银的打印任务完全可以先用业务库单表实现,不需要引入独立消息队列;量上来后再平滑迁移到本地队列或消息中间件。核心是先保证"任务不丢、不重打",再谈吞吐。实现时把幂等键、重试上限、补打对账三件套一次做齐,后续维护成本会低很多。

常见问题

Q:收银小票打印失败,怎么保证这单不会漏打?

A:结账时先把打印任务写入本地队列(pending 状态),由后台进程异步消费并带幂等键,失败按指数退避重试,超过上限转补打清单。任务不依赖前端页面存活,页面关闭也不丢。

Q:同一笔订单重复出票怎么避免?

A:用 order_no + print_type + 唯一键 做数据库唯一约束,重试、补打都携带同一幂等键,数据库层挡住重复插入;对账时以"订单数 == done 数"校验。

复盘清单

  • 结账链路已与打印解耦(先入队后异步打印)
  • 打印任务表含幂等唯一键
  • 重试次数有上限,超限转 failed 补打清单
  • 退避使用指数策略且设 30 秒上限
  • 每日打印对账(订单数 == done 数)已跑通

结语

打印任务看着是小功能,但"不丢不漏"四个字背后是队列、幂等、重试、对账一整套可靠性设计。先保证任务落库、再异步消费、最后对账兜底,这套思路同样适用于消息通知、导出任务等其他异步场景。方案按自身业务取舍即可。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

目录
  • 一、为什么小票打印不能做成"同步请求 + 重试几次"
  • 二、打印任务队列怎么设计
  • 三、消费进程的轮询与退避怎么写
  • 四、补打与对账:怎么保证"该打的都打了"
  • 五、高峰期并发打印怎么控
  • 六、多门店场景怎么隔离任务
  • 踩坑清单
  • 工程落地建议
  • 常见问题
    • Q:收银小票打印失败,怎么保证这单不会漏打?
    • Q:同一笔订单重复出票怎么避免?
  • 复盘清单
  • 结语
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档