1. 为什么"打印任务"值得专门做一个服务模块
如果你做过电商订单、物流面单或者内容平台批量输出的项目,大概率遇到过这种场景:一台共享打印机卡纸,后面排了几百个任务纹丝不动;或者用户点了打印,前端转了三圈弹个"任务失败",你打开日志发现打印服务压根没收到请求;更惨的是,一次超时重试之后,同一张凭证被重复打印了上百份。
我在维护"未来之窗"这套内容平台时,写到了系列第六十三回,正好做到打印任务服务模块。这个模块在架构图上不起眼,放在"基础设施"那一栏,往往是最后才有人关心。但线上跑起来之后,问题全集中在"打印"这个看似简单的动作上。最后我们干脆把它从业务代码里拆出来,独立成一个服务,内部代号"东方仙盟筑基期"——寓意是把最基础的地基打牢,后面才有资格谈金丹元婴。
打印任务服务模块,本质上是解决一件事:把"线上内容"安全、稳定、可控地变成"纸质文件"。它不只是调一下打印机驱动就完事,而是要对任务的整个生命周期负责。从订单录入打印需求、生成可打印文件、投递到指定打印机、跟踪打印结果、处理失败重试,到最终归档,每一个环节都要有明确的状态、日志和兜底方案。
这篇内容适合谁看?如果你手上正打算做打印服务,或者你在处理打印机对接、任务队列、重试幂等这些问题,我的经验可以直接省掉你几周的试错。我也把真实踩过的坑放在了后面,尤其是那种"看上去任务成功、实际上根本没打出来"的隐蔽问题,建议直接拉到第七章看。
2. 任务建模:先把"打印"想清楚,再写代码
我最早犯的错误,就是拿到一个打印需求后,直接写代码调打印机。先拼接文件路径,再调lp命令,打印成功就返回,失败就抛异常。看起来没问题,但运行了半个月就崩了:用户需要查历史任务,需要补打,需要统计某个打印机打了多少份,还要支持不同业务来源的权限控制。所有需求堆在一起,发现没有一个统一的数据结构能支撑。
所以在筑基期,第一件事就是定义任务模型。打印任务不是一个"动作",而是一份"记录"。它就像快递面单:面单上不写清楚收件人、地址、重量,快递公司根本没法分拣,出了问题也没法追溯。任务模型同理。
我最终保留了这些核心字段:
| 字段 | 含义 | 设计原因 |
|---|---|---|
| task_id | 任务全局唯一ID | 贯穿日志、队列、数据库的唯一标识 |
| biz_id | 业务幂等ID | 业务方传的订单号/凭证ID,用于去重,防止重复提交 |
| source_type | 业务来源标识 | 区分是订单打印、证书打印还是报表打印 |
| doc_type | 文档类型 | 如 PDF、图片、纯文本、ESC/POS 指令 |
| file_url | 待打印文件的地址 | 任务服务从对象存储拉取,不直接接收大文件二进制 |
| printer_id | 目标打印机ID | 决定走哪条打印机队列,对应哪台物理设备 |
| priority | 优先级 | 一般分普通和加急,避免低优先级任务阻塞急救场景 |
| status | 当前状态 | 状态机的核心字段,告诉你任务此刻在哪个阶段 |
| retry_count | 已重试次数 | 用于限制无限重试,超过阈值进人工处理 |
| next_retry_at | 下次重试时间 | 配合指数退避,避免重试风暴 |
| created_by | 创建人/系统 | 审计需要 |
| created_at / started_at / finished_at | 时间戳 | 统计任务耗时、排查卡单 |
有了这个模型,业务方只管提交一个 JSON,剩下的排队、调度、重试都交给打印任务服务模块。
举个例子,一个典型的打印任务 JSON 长这样:
{ "task_id": "08df3c2e-5f2a-4b7a-9c1e-6a3f2f1f2a0e", "biz_id": "ORD-20250107-0001", "source_type": "order_print", "doc_type": "pdf", "file_url": "https://static.example.com/print/orders/08df3c2e.pdf", "printer_id": "printer_datang_01", "priority": "normal", "status": "WAITING", "retry_count": 0, "max_retry": 5, "next_retry_at": null, "created_by": "order-service" }关于biz_id我要多说一句,这是幂等设计的关键。业务方在提交打印任务时,同一个订单只能有一个活动中的打印任务。服务端通过对biz_id做唯一约束,如果重复提交就直接返回已有任务,而不是重新创建。后面第七章会讲,如果这一步不做,重试时间稍微长一点,重复打印的单子能把仓库打懵。
筑基期的任务模型不要过度设计。我看到有人一上来就搞工作流引擎、状态机框架,还做了好多业务字段进去,最后连自己都说不清每个字段的用途。先把 MVP 字段定好,用 YAGNI 原则,也就是"你不需要现在就把它设计出来"的原则,等真出现新的打印需求,再扩展模型也来得及。
3. 状态机设计:每一步都得有据可查
任务模型定下来之后,最核心的是状态机。打印任务的运行状态,决定了你在排查问题时能定位到哪一步。如果没有状态机,你只知道"坏了",但说不清是文件没生成、任务没入队、打印机离线还是打印到一半失败。这就像你隔着一条河看对岸着火了,不知道烧到哪一层,扑救自然无从谈起。
我把打印任务的状态分成了这几个阶段:
| 状态 | 含义 | 进入条件 |
|---|---|---|
| WAITING | 任务已创建,等待被调度 | 提交后初始状态 |
| QUEUED | 已进入打印队列 | 调度器按 printer_id 放入对应队列 |
| RENDERING | 正在生成可打印文件 | 从 file_url 下载并转成目标格式 |
| SPOOLING | 已投递到打印系统 | 适配层调用 lp / 打印 API 之后 |
| PRINTING | 打印机正在输出 | 打印系统确认收到任务,输出中 |
| SUCCESS | 打印完成 | 适配层确认成功 / 预计完成时间到期 |
| FAILED | 最终失败 | 重试次数耗尽或不可恢复错误 |
| RETRYING | 失败等待重试 | 可恢复错误,进入延迟队列 |
| TIMEOUT | 任务超时 | 超过单次执行时限 |
| CANCELED | 取消 | 用户主动取消,且尚未开始打印 |
用一张流转规则说明:
WAITING -> QUEUED -> RENDERING -> SPOOLING -> PRINTING -> SUCCESS 所有非终态节点都可以进入 RETRYING RETRYING -> WAITING(重新入队)或者 FAILED(超限) 只有未进入 SPOOLING 的任务允许 CANCELED 超过时限的任务进入 TIMEOUT这里有一条硬规则:重试不能绕过已完成的物理事实。什么意思?如果打印机实际已经打印出来了,只是回执超时,你直接把它标记成 SUCCESS 没问题;但如果有一次投递任务并没有成功,你不能直接把状态从 SPOOLING 改到 SUCCESS。每次状态变更都要有依据,要么是打印机返回的作业号,要么是系统日志里的退出码,要么是人工确认。
状态不能只放在 Redis 里,也不能只存在内存里。Redis 挂了、服务重启了,内存里的状态全部丢失,你都不知道哪些任务打印到一半。我的方案是:Redis 只做队列索引和临时锁,数据库表print_task存任务全量信息。每次状态变更都更新数据库,并在日志里打一条带task_id的状态流转记录。这样就算服务重启,也能根据数据库状态恢复:比如查到一批任务还停在 SPOOLING 阶段,可以对它们做超时扫描,重新投递或标记失败。
筑基期的状态机不要追求复杂,但要保证可审计。我曾经遇到过一个任务,从 QUEUED 直接跳到 SUCCESS,查了三天才明白是某个同事在测试代码里手动改了数据库。没有审计日志,任何状态流转都解释不了。
4. 队列与调度:别让一台打印机拖垮整个仙盟
任务模型和状态机都有了,接下来是队列调度。为什么不能直接起一个线程池,来一个任务调一次打印机?因为打印机的处理能力非常有限。一台打印机同时只能处理一个任务队列,你如果并发把几十个文件丢给它,驱动层会先排队,但谁先谁后不可控;一旦某个文件卡住,后面所有任务全部堵死。
所以我用了"按打印机分队列"的策略。Redis 里每条队列对应一台打印机:
queue:print:{printer_id}调度器只做一件事:从队列左侧取任务,交给适配层处理,处理完确认,再取下一条。
为什么选 Redis 而不是直接上消息队列?筑基期阶段,Redis 通常已经在了,业务团队对它的运维成本很低。用 Redis List 的BLPOP做消费,天然支持阻塞等待,结构简单,出问题也好排查。等以后真的需要复杂的路由、多消费组、消息回溯,再迁移到独立消息中间件也不迟。初期就上重型消息队列,会让整个模块的部署和调试成本陡增。
一个简化的 worker 调度逻辑,用 Python 写大概长这样:
import redis import json r = redis.Redis(host="redis.internal", port=6379, decode_responses=True) while True: # 阻塞等待队列任务,超时设为 30 秒 _, payload = r.blpop(f"queue:print:{printer_id}", timeout=30) if not payload: continue task = json.loads(payload) try: # 先把任务置为处理中,防止重复调度 mark_processing(task["task_id"]) adapter = get_printer_adapter(task["printer_id"]) job_id = adapter.submit(file_url=task["file_url"], options=task.get("options")) # 记录打印系统作业号,用于后续状态查询 mark_spooled(task["task_id"], job_id=job_id) except Exception as exc: # 可恢复的失败,进延迟队列重试 handle_failure(task, exc)这里有个细节:任务从队列取出来后,要先mark_processing,而不是直接调打印机。因为BLPOP取出后如果进程崩溃,任务就丢了。我在数据库里维护了一个"处理中"标记,再配一个超时扫描器,定期把处理中超时的任务捞回来重新入队。虽然不完美,但至少能把任务丢失概率降到很低。
优先级怎么做?我建了两条队列:queue:print:urgent:{printer_id}和queue:print:normal:{printer_id}。调度器消费时优先处理加急队列,没有加急任务再消费普通队列。这个粒度在筑基期够用,不需要复杂的优先级队列算法。
超时扫描是另一个必须做的组件。我起了一个独立进程,每 60 秒扫一次print_task表,找出所有处于 SPOOLING/PRINTING 且超过 10 分钟没动过的任务。这些任务大概率是打印机卡纸、驱动挂起或者断网。扫描器直接把它们置为 FAILED,走重试逻辑,避免占用队列位置。
红队测试的时候,我曾经把这些组件全部关掉,然后模拟一个假打印机只收文件不反馈,结果队列里堆了两千多个"半死不活"的任务。有了超时扫描和状态机,至少系统能自己发现异常,而不是等用户来投诉。
5. 打印机适配层:别让业务代码依赖某个打印机型号
打印机适配层是整个模块最容易"翻车"的地方,因为打印机品牌太多了,驱动协议五花八门。有小票热敏机用 ESC/POS 指令,有激光打印机走 PostScript/PCL,有共享打印机挂在 Windows,有云打印机走 HTTP API。如果你让每个业务方直接对接具体打印机的驱动,后续每次换设备都是一场灾难。
适配层的作用,就是把这些差异封装成统一接口。我在筑基期只暴露两个方法:submit(file_url, options)和query_status(job_id)。提交方法返回打印系统作业号,查询方法返回作业状态,业务方和调度器都不关心底层到底是 CUPS 还是云 API。
以 Linux 环境为例,最常见的接法就是调用 CUPS 的lp命令:
import subprocess class CupsPrinterAdapter: def __init__(self, printer_name): self.printer_name = printer_name def submit(self, file_url, options=None): local_path = download_file(file_url) cmd = ["lp", "-d", self.printer_name, local_path] result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) if result.returncode != 0: raise PrintAdapterError(result.stderr) # lp 输出示例: request id is PrinterName-123 job_id = parse_job_id(result.stdout) return job_idWindows 环境下,我推荐用 SumatraPDF 这种轻量工具做命令行打印,因为它支持通过-print-to "打印机名"参数静默打印 PDF,而且开源免费。核心调用是:
SumatraPDF.exe -print-to "Printer Name" -silent file.pdf云打印机则简单很多,一般就一个 HTTP 接口,提交文件后轮询任务 ID。这几类适配器我都封装在同一个接口下面,调度器调用时根本无感知。
但这里有个重要原则:渲染层和适配层一定要分离。业务方不应该把 HTML 裸传给打印服务,适配层也不应该负责拼内容。比如在筑基期,所有待打印文件统一由上游生成 PDF/A 格式,字体全部嵌入,适配层只负责"投递文件"。这样做的好处是,打印机的字体兼容性问题被控制在文件生成环节,而不是在适配层一杯乱炖。
为什么必须 PDF/A?因为普通 PDF 如果字体没嵌入,换一台打印机后很容易出现缺字、方块字、排版错乱。PDF/A 强制嵌入字体,是打印领域最省心的格式。如果你的场景是小票热敏机,那就直接用 ESC/POS 指令生成,不要转 PDF 再打印,因为热敏机的解析器通常对 PDF 支持很差。
6. 一次完整的打印任务流转:可复现的最小闭环
理论知识讲得再多,不如直接跑通一个最小闭环。我在本地用 Python + Redis 做了个演示环境,这里分享完整链路,你可以照着在自己的测试环境里面搭一套。
环境准备:
# 安装 Redis sudo apt-get install redis-server # 安装 Python 依赖 pip install redis # Linux 环境验证 CUPS 命令可用 lpstat -p -d然后我们模拟一个打印任务,从提交到成功,代码如下。先看提交端:
import redis import json import uuid r = redis.Redis(decode_responses=True) def create_print_task(biz_id, file_url, printer_id): # 幂等校验:如果该 biz_id 已有活动任务,直接返回 existing = r.get(f"print:biz:{biz_id}") if existing: return json.loads(existing) task = { "task_id": str(uuid.uuid4()), "biz_id": biz_id, "file_url": file_url, "printer_id": printer_id, "status": "WAITING", "retry_count": 0, } # 记录幂等关系 r.set(f"print:biz:{biz_id}", json.dumps(task), ex=86400) # 推入打印机队列 r.rpush(f"queue:print:{printer_id}", json.dumps(task)) return task create_print_task("ORD-20250107-0001", "/tmp/demo.pdf", "printer_datang_01")再看消费端 worker,我在解释型环境里跑:
import redis import json import time import subprocess r = redis.Redis(decode_responses=True) def process_one(): payload = r.blpop("queue:print:printer_datang_01", timeout=5) if not payload: return None task = json.loads(payload[1]) task_id = task["task_id"] # 处理中 r.hset(f"print:task:{task_id}", "status", "PROCESSING") try: # 模拟调用 lp 命令 result = subprocess.run( ["lp", "-d", "printer_datang_01", task["file_url"]], capture_output=True, text=True, timeout=30 ) if result.returncode != 0: raise RuntimeError(result.stderr) r.hset(f"print:task:{task_id}", "status", "SUCCESS") r.hset(f"print:task:{task_id}", "finished_at", time.time()) return task_id except Exception as exc: # 进入重试逻辑,这里简化为直接记录失败 r.hset(f"print:task:{task_id}", "status", "FAILED") r.hset(f"print:task:{task_id}", "error", str(exc)) return task_id while True: process_one() time.sleep(0.1)这段代码虽然简化了状态流转的细节,但已经体现了一个完整闭环:幂等键防止重复创建,任务先进队再消费,消费时改状态,成功/失败都留痕。在这个基础上,把FAILED后重新计算延迟时间并丢回延迟队列,就是重试机制。
关于"补打"场景,我要特别提醒一个设计思路:用户点了补打,不要直接把原任务重新入队,而是创建一个新的任务,通过biz_id关联到原任务。这样原任务的打印记录不会被覆盖,审计和统计都清晰。
我在测试环境里跑这个闭环时,特意把打印机指向一个不存在的设备,然后观察 worker 的行为。第一次我完全没有重试逻辑,任务失败后状态永远停在 FAILED,需要人工介入。后来加了延迟队列和指数退避,才真正做到"任务失败后自动重试、最终兜底进人工处理区"。
7. 踩坑实录:打印灵兽失控的那几个夜晚
这一节本来想叫"曾经踩过的坑",但想了想,我们的内部代号是东方仙盟,这些故障案例像极了那些"灵兽失控"的场面——表面上是设备问题,实际上是工程设计的缺口。我挑四个最有代表性的写在这里,每个都给出完整的排查链路和修复方案。
第一个坑是重复打印。某天凌晨,仓库同事打电话说订单面单炸了,同一张订单的单号被打出来三张,而且都在同一批订单里。我第一反应是打印机驱动的问题,但工作人员说只有几百个订单发生了重复。排查链路:查任务日志发现多个task_id对应的biz_id是同一个;查 Redis 队列发现业务方在超时后自动重试了提交接口;再查代码,发现创建任务的幂等判断只在状态为 SUCCESS 时生效,而任务还在 WAITING 时,同样的biz_id又进来了。根因清楚了,修复方案:幂等键在任务创建阶段就锁死,任务只要存在,不管什么状态都不允许再创建新任务。
第二个坑是 spool 目录占满。现象:某个打印机从下午四点开始"任务一直在队列里排队",但实际一台都没打出来。排查链路:先看服务状态,打印服务正常;再看打印机后台,作业队列里堆了 400 多个文件;登录服务器df -h,发现根目录 100% 占用;再进 CUPS spool 目录,里面有几十个十几 GB 的大文件。根因是上游批量打印超高清图片,生成的文件平均 200MB,spool 目录被塞爆,CUPS 假死。修复方案:在 RENDERING 阶段限制单文件大小,超过 50MB 自动转成高质量 PDF 而不是原图;加一个定时清理脚本,对超过 24 小时的 spool 文件做归档删除;再加磁盘阈值告警。
第三个坑是重试风暴。某次打印机网络波动,适配层调用lp命令丢失连接,异常抛出来后,worker 的重试逻辑是立即重新入队。结果一台打印机离线半小时,worker 在这段时间内重试了上千次,把 Redis 队列和日志全部打爆,CPU 跑满,连带其他打印机的任务也受到阻塞。排查链路:日志里全是同一个task_id的失败记录;看任务的重试计数,发现重试没有间隔,也没有上限。修复方案:重试间隔改成指数退避,next_retry_at = now + min(2 ** retry_count, 60),秒级递增;最大重试 5 次,超过后任务进入FAILED状态,并推送到人工处理通知群。
第四个坑是字体缺失导致乱码。用户打印一份格式化报表,屏幕上打开 PDF 一切正常,打印出来却是整篇方块。排查链路:先怀疑打印机驱动程序,换驱动后问题依旧;然后在打印机上直接打印同一文件,发现其他页面正常,只有带特殊字体的段落乱码;最后点开 PDF 属性,发现生成时没有嵌入字体。根因是上游 HTML 转 PDF 的工具没做字体嵌入配置。修复方案:强制所有待打印文件转成 PDF/A 格式,并设置embeddedfonts=true校验;同时增加打印前验证脚本,解析 PDF 字体信息,未嵌入字体直接拦截进失败队列。
这四个坑有一个共同点:都不是在"功能开发"阶段暴露的,而是在真实运行压力下才出现。所以我给你的建议是,打印任务服务模块上线前,一定要做故障演练。把打印机拔掉、把磁盘塞满、把 Redis 停掉,看看服务怎么恢复。我后来把这套演练脚本固化成了运维预案,每次发布前都会跑一遍。
8. 筑基期之后:从打印任务走向打印中台
最后按惯例聊一下后续规划。我的体会是,筑基期最重要的不是功能多炫,而是稳定不丢单、不重复打印、状态可查。只要这三条做到位,这个模块就已经有了"可靠的公用设施"的样子。
从项目角度看,下一步我打算加这几件事:打印用量统计,按部门、按打印机、按任务类型三个维度汇总;耗材余量监控,对接带余量反馈的设备,墨水或纸张告警;多租户配额,让不同的业务线可以各自设置打印额度,防止某个业务方刷爆公共打印机;管控作业审计,打印内容加水印和二维码,敏感文档必须走审批流程。
筑基期向着金丹期升级,判断标准不是代码写得多花哨,而是能不能把"发布管理"和"可观测性"做扎实。我个人的一个小习惯是:每次发版前,在测试环境用假打印机脚本模拟卡纸和断网,确认任务会自动重试、会进失败队列,而不是一直卡死。这个土办法看起来原始,但已经帮我们挡下过很多次线上事故。希望你做完这个模块之后,也能找到自己的那套"土办法"——它通常比任何监控系统都更早发现问题。