
上个季度做仓储标签打印模块需求本身听着不复杂管理后台的商品列表里放一个“补打标签”按钮点击后在页面上直接显示一张条形码同时把打印任务交给后端异步处理。如果只是画一张条码前端半小时就能搞定真正让我多花了一周时间的是后面的“MQ”。系统上线后同一个 SKU 被重复补打的情况特别多排查下来十有八九是操作员点了两下按钮。这个问题很快就从一个前端 bug 升级成了消息中间件问题前端点两次是不是就算发两条消息两条消息被消费端各打印一次打印机吐出两张一模一样的标签这个锅到底算谁的这篇文章就是围绕“前端HTML生成条形码”和“MQ”这一组合展开的。我会把从浏览器里的条码渲染、按钮防重、到接口幂等、再到 MQ 消费端去重的完整链路都拆开讲一遍也会把“前端点两次算是发两条消息吗”“MQ消息有哪些面试题”这类高频问题结合真实项目说透。如果你正准备做一个类似的标签打印、小票打印、单据打印需求或者刚接触消息队列想搞懂幂等是怎么回事这篇内容可以直接拿去参考。1. 先把项目场景说清楚1.1 需求现场还原一条条码背后的完整链路先说业务背景。我这里是一个仓储进销存系统用户在后台上架商品后需要一个“条形码标签”贴到货架上。页面交互是这样的商品列表里每行有个“打印标签”按钮点击后前端页面弹出一个小窗口窗口里显示商品对应的条形码操作员确认后点“提交打印”打印任务才真正发出去。最开始我把整个逻辑想简单了前端用 JS 库生成条形码图像点击提交时直接调后端 HTTP 接口后端同步调用打印服务。这种方式在小范围试点时没问题但放到多仓库场景后就暴露了问题打印服务偶尔重启、网络偶发超时、打印机队列堵住的时候同步调用容易造成前端一直转圈用户等不及就会再点一次。而且打印服务一旦异常整条业务链路都会卡住。所以后来把设计改成前端负责生成条形码和提交请求后端收到请求后把打印任务封装成一条消息扔进 MQ打印服务作为消费者从队列拉取消息并执行实际打印。这样做的好处有三个前端请求快速返回不用死等打印结果打印服务挂掉时消息不会丢重启后可以继续消费多个打印客户端可以分摊任务不至于一台打印机卡死全仓库跟着瘫痪。1.2 为什么“前端生成条码”和“MQ”会出现在同一个项目很多人第一反应是条形码不就是在浏览器里画个图吗干吗还要扯上消息队列这其实是把两件事混在一起了。前端生成条形码解决的是“展示层”的问题MQ 解决的是“任务调度和可靠传输”的问题两者服务的是不同环节。举个不那么严谨但很好理解的类比前端生成条形码相当于在电脑屏幕上做出一张“电子票据”MQ 相当于负责把这张票据安全送到打印室的“物流车队”。电子票据做得再漂亮如果物流车队不靠谱票据还是到不了打印机车队再靠谱如果票据本身生成错了送到打印机也是一堆乱码。所以这两个技术点不是二选一而是前后衔接、各管一段。在这个项目里真正容易出问题的不是“前端能不能画出条码”而是从按钮点击到 MQ 消费这条链路上任何一个环节重复或者丢失都会产生实际损失。重复会导致同一张标签被打印多次丢失会导致标签根本打不出来。后面几节我重点讲的就是这两个问题怎么防、怎么查、怎么修。1.3 技术选型与“qr zero mq”这种词容易把人带偏先确定技术栈。前端条形码渲染我用的是 JsBarcode核心原因是它轻量、支持 Code128、Code39、EAN 等多种条码格式一个 script 标签就能用不需要构建工具介入。后端消息中间件用的是 RabbitMQ当时团队对 Spring Boot 的集成最熟运维也有现成的集群。幂等控制这块用 Redis 做第一道去重数据库唯一键做第二道兜底。这里我特别想提醒一句网上很多词连在一起说比如“qr zero mq”乍一看像是一个技术方案实际是三个完全不同的东西。QR 是二维码的一种类型用来做信息载体ZeroMQ 是一个高性能网络通信库解决的是进程间、机器间数据传输问题它不是传统意义上的消息队列产品没有 Broker不支持持久化、路由和可靠确认而消息队列 RabbitMQ/RocketMQ 这类中间件才是我们这里说的 MQ。技术选型时先分清“这个是干什么的”不然很容易被词汇误导选了完全不合适的组件。2. 前端 HTML 生成条形码JsBarcode 实操记录2.1 最省事的方案直接用 JsBarcode 渲染前端生成条形码的方案其实有好几种最简单的是找在线 API 生成图片但这种方式在内部系统里不安全外网和客户网络不通时直接白屏。另一种是手写 Canvas 画黑白条纹但 Code128 的编码规则非常繁琐自己实现纯属给自己找麻烦。JsBarcode 的优势就是把编码、校验、渲染、显示数字文本全封装好了调用非常简洁。核心代码其实就一段页面里放一个容器JsBarcode 会向容器中渲染一个 SVG 或者 Canvas。!DOCTYPE html html langzh-CN head meta charsetUTF-8 title商品标签条码生成/title script srchttps://cdn.jsdelivr.net/npm/jsbarcode3.11.6/dist/JsBarcode.all.min.js/script /head body div idbarcodeBox/div button idprintBtn补打标签/button script function renderBarcode(code) { JsBarcode(#barcodeBox, code, { format: CODE128, width: 2, height: 80, displayValue: true, fontSize: 16, margin: 10 }); } // 示例渲染一个商品条码 renderBarcode(6901234567890); /script /body /html这段代码做了什么事format指定条码类型为 Code128这是企业内部标签最常用的格式因为它支持数字、大小写字母和特殊字符比纯数字的 EAN-13 灵活得多。width是每个条码模块的宽度单位是像素height是整个条码区域的高度displayValue表示条码下方要不要显示可读的数字文本。这些参数直接决定了标签能不能被扫描枪稳定识别后面我会细说。2.2 条码参数怎么调才能保证扫描枪一次识别条码这种东西看着都是黑白条纹但打印尺寸、对比度、静区留白都会影响扫码成功率。很多项目第一次做都会踩这个坑网页上显示得挺好看打印出来用扫描枪一扫要么扫不出来要么经常扫错。先说条码高度。Code128 的识别是靠扫描设备对条纹比例做解析条码区域太矮会减小扫描枪的可扫描范围常见的标签至少给到 60 到 80 像素。如果条码要贴到仓储货架上我建议高度不要低于 80 像素否则遇到手持扫描枪角度不对就容易失败。再看宽度JsBarcode 的width参数最小的合理值是 1.5 到 2太细了打印机墨点稍微扩散一下就会糊成一片。还有一个特别容易被忽略的参数是margin也就是条码四周的留白专业术语叫“静区”。扫描枪需要靠静区判断条码的起始和结束位置静区不足是扫码失败的常见原因。我一般设置为 10 以上宁多勿少。另外如果打印用的是一式多联的针式打印机或者热敏打印机的分辨率比较低条码的宽度和高度都要再往上调。最稳妥的办法是做一张测试标签打出来之后用不同品牌的扫描枪各扫几遍实测能扫过再批量复制到其他商品上。2.3 页面渲染和标签打印的几个坑第一个坑是 JsBarcode 默认输出 SVG但有些低版本浏览器的打印控件对 SVG 支持得不好打印出来会出现空白。解决方法是设置output: canvas或者直接把 SVG 转成 PNG 图片再塞进打印区域。我这里是转成 PNG因为后端的打印控件对位图兼容最好。第二个坑是打印区域里不能有额外的边距影响页面布局。很多浏览器打印时会带上页眉页脚导致标签位置偏移。前端可以用media print单独控制打印样式把页面其他内容隐藏掉只保留条码区域并且把page的 margin 设置为 0。media print { body * { visibility: hidden; } #printArea, #printArea * { visibility: visible; } #printArea { position: absolute; left: 0; top: 0; margin: 0; } page { margin: 0; } }第三个坑是条码内容的长度。Code128 虽然有自动切换编码模式的能力但内容过长时条码会变得很宽超过标签宽度就会被裁剪。所以我在前端会做一个校验条码长度超过 20 个字符就给出提示提醒用户检查单据号是否正确。这里的业务条码是我们内部生成的单据号长度一般在 13 到 18 位控制在标签宽度内没有问题。3. “前端点两次”到底会怎样接口幂等方案3.1 从一次点击到消息入队的完整路径先把一次正常的点击流程梳理清楚用户点“补打标签”按钮浏览器生成一个请求请求到达后端接口后端校验数据后把打印任务消息投递到 MQMQ 把消息暂存打印服务消费者拉取消息并执行打印。看起来每一步都没有问题但用户快速点了两下按钮时如果前端代码没有做防重复这个流程就会被执行两遍。问题的关键不是“用户是不是故意点两次”而是我们的系统在用户已经点了一次、请求还没返回时应不应该接受第二次点击。如果前端禁用了按钮但是网络超时导致请求重发如果网关做了重试如果后端业务代码在处理过程中抛了异常但 MQ 消息已经入队这些情况都会产生重复任务。所以“前端点两次算是发两条消息吗”这个问题的答案是如果链路里没有幂等保护那每一层都可能造成两个请求、两条消息、两次打印如果有幂等保护第二次点击会被拦截在某一层最终结果与只点一次相同。3.2 前端不要只做“按钮置灰”还要做请求去重最简单的做法是提交后立即给按钮加 disabled 状态等接口返回再恢复。这个做法能在 90% 的情况下挡住手抖但它挡不住一种场景接口一直没有返回用户刷新页面重新进入之前的请求可能还在执行。所以我还会在请求层加一个基于唯一业务标识的本地锁。具体来说每次点击提交时前端先生成一个requestId作为本次操作唯一标识把这个requestId和商品单据号一起提交给后端。如果用户在同一单据上连续点击两次后端看到同一个requestId就能立刻识别重复请求。为了更稳妥还可以在前端用一个 Set 记录“已提交中的 requestId”在收到响应或请求失败前不重复发送。let pendingRequests new Set(); async function submitPrint(orderNo) { const requestId crypto.randomUUID(); if (pendingRequests.has(orderNo)) return; const payload { requestId, orderNo, barcode: orderNo }; pendingRequests.add(orderNo); try { await fetch(/api/print/task, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify(payload) }); } finally { setTimeout(() pendingRequests.delete(orderNo), 3000); } }看到这里你应该明白了前端防重复只能挡住“用户手抖”这种人很常见的场景它挡不住网络重发、后端接口重放、消费者重复消费。所以前端方案只是第一道门槛真正的重活必须在后端和 MQ 消费端做。3.3 后端幂等Redis 锁加上数据库唯一键后端接口层收到请求后第一件事不是去查库存、生成打印任务而是先做幂等校验。这里我用了 Redis 的setIfAbsent命令作为防重锁只有当请求中的requestId在 Redis 里不存在时才继续处理否则直接返回“重复请求”。PostMapping(/print/task) public Result createPrintTask(RequestBody PrintTaskReq req) { String key print:req: req.getRequestId(); // 如果 requestId 已经存在说明是重复请求 Boolean locked redisTemplate.opsForValue() .setIfAbsent(key, 1, Duration.ofMinutes(10)); if (!Boolean.TRUE.equals(locked)) { return Result.error(重复请求请勿重复提交); } // 继续执行业务逻辑 String taskId UUID.randomUUID().toString(); PrintTask task new PrintTask(); task.setTaskId(taskId); task.setOrderNo(req.getOrderNo()); task.setBarcode(req.getBarcode()); rabbitTemplate.convertAndSend(print.exchange, print.route, task); return Result.success(taskId); }Redis 这套方案的好处是响应快适合高并发场景。但它有个隐患如果 Redis 锁过期时间设置得太短业务还没处理完锁就没了同一个请求还是会重复进入。所以我在数据库层面又加了一张print_task表给request_id字段建了唯一索引插入时用insert ignore或on duplicate key update兜底。这就是典型的“双保险”Redis 拦大部分重复数据库唯一键做最后一道防线。两道防线都过了这条请求才真正生成任务。3.4 幂等键怎么设计才不算白做既然幂等键这么重要那到底拿什么当幂等键很多新手最容易犯的错是拿“条形码内容”当幂等键比如商品条码是6901234567890就以为同一个条码只能打印一次。但业务上完全可能存在“这个商品标签被打湿了需要重新补打一张”的合理场景。如果拿条码本身当幂等键会把正常的补打需求也拦住。正确的幂等键应该是“一次操作请求的唯一身份”。我这里的做法是前端每次点击提交时生成一个全局唯一的requestId。如果用户点在同一个单据上连续点两次第一次的requestId和后端数据库里的那条记录匹配上第二次就会被挡住。但这里要注意如果用户第一次点的时候后端已经成功生成任务只是响应超时了第二次点击前端生成了一个全新的 requestId那它依然会被当成新请求处理。所以真正严谨的设计应该把幂等键分成两级业务上的自然键比如“订单号 打印类型 打印时间”和请求上的随机键requestId。后端优先用业务自然键去重比如同一订单同一打印类型的任务在某个时间窗口内只允许创建一次这样即使 requestId 变化了也能从业务角度兜住重复。4. MQ 消费端幂等与可靠性4.1 为什么 MQ 会重复投递这是设计使然很多第一次接触消息队列的人会问我都已经在后端接口层做了幂等是不是就万事大吉了不是。接口层挡住了“请求重复”但挡不住“消息重复”。这里的根源在于 MQ 的“至少一次”投递保证。为了不丢消息RabbitMQ 默认采用“消息成功消费后返回 ACK否则重新投递”的机制。如果消费者处理完打印任务后、还没来得及发送 ACK 就宕机了MQ 会认为消息处理失败并把这条消息重新投递给另一个消费者。这个过程中消费者可能已经把业务执行了一遍但它没告诉 MQ “我处理完了”所以 MQ 会再投递一次。这就是重复消费的根源。任何 MQ 中间件在“不丢消息”和“消息严格不重复”之间都更倾向于保证前者因为不丢消息更容易做到而重复消息可以通过业务层的幂等去抵消。所以要不要做 MQ 消费端幂等答案不是“看技术文档”而是看业务能不能接受重复。打印一张标签这种操作重复一次就是浪费一张纸、多打一次标签看起来不严重但如果系统里有一千个并发任务重复率哪怕只有千分之一也会造成一堆脏数据和一次事故。4.2 消费端去重与 ACK 实操我在消费端做的第一件事是在业务处理之前先检查这条消息有没有被处理过。具体做法是用消息里的taskId作为唯一标志在 Redis 里执行setIfAbsent。如果任务 ID 已经存在说明这条消息是重复投递直接确认消息并返回如果不存在则执行业务逻辑并在完成后写入标记。RabbitListener(queues print.queue) public void onMessage(Channel channel, MQMessage msg) throws IOException { String dedupKey print:msg: msg.getTaskId(); try { // 第一步幂等检查 Boolean firstConsume redisTemplate.opsForValue() .setIfAbsent(dedupKey, 1, Duration.ofMinutes(30)); if (!Boolean.TRUE.equals(firstConsume)) { // 已经处理过直接确认 channel.basicAck(msg.getDeliveryTag(), false); return; } // 第二步真正执行打印任务 printService.print(msg.getBarcode()); // 第三步处理成功确认消息 channel.basicAck(msg.getDeliveryTag(), false); } catch (Exception e) { // 异常时是否重新入队要视业务情况而定 log.error(打印任务消费失败: {}, msg.getTaskId(), e); channel.basicNack(msg.getDeliveryTag(), false, true); } }这里有个细节容易踩坑如果我把“消费成功的标记”设置成 30 分钟过期那么 30 分钟后的重复消息还会被当成新消息处理。对于打印任务来说打印服务一般不会隔这么久还重放消息所以 30 分钟够用。但如果你处理的是一些时效性很长的业务通知这个过期时间就要根据业务设置或者直接改用数据库唯一键落库永久保存消费记录。4.3 消费者异常和重试的处理消费端还有一个容易被忽略的问题消息处理失败后要不要重新入队、最多重试几次、重试有没有间隔。如果代码里不加控制消费者抛异常后又执行basicNack并且requeuetrue这条消息会无限循环重新投递一方面浪费资源另一方面日志会被刷爆。我的习惯是先把requeue设为false让重试逻辑走 Spring AMQP 内置的 RetryTemplate 或者死信队列。比如设置最多重试 3 次每次间隔 5 秒重试达到上限后把消息丢到死信队列由专门的任务去人工排查。这样既能防止消息丢失也能避免一条问题消息把正常消费流程卡死。这里再补充一点关于打印服务的对比如果打印服务只是临时网络抖动重试几次可能就成功了但如果打印机的驱动崩了重试一万次也成功不了。所以消费者里的重试策略要结合下游的实际情况来定不能盲目重试。日志里一定要把taskId、条码、重试次数全部打出来排查问题时少走很多弯路。5. 常见问题与面试考点速查5.1 MQ 消息有哪些高频面试题结合项目怎么答做这个项目时正好有同事在准备面试天天问我 MQ 的题。其实“MQ消息有哪些面试题”这个问题的答案翻来覆去就是那几类如何保证消息不丢失、如何保证消息不重复消费、如何保证消息顺序、如何解决消息积压、如何做事务消息、延迟队列和死信队列怎么用。我把这些问题套进刚才的标签打印项目里回答就变得很具体。比如“如何保证消息不丢失”分三段生产端要开启确认机制发送消息后等 Broker 确认没确认就重发Broker 端要开启持久化交换机、队列、消息都要设置持久化消费端手动 ACK处理成功后才确认消息。“如何保证消息不重复消费”就是我前面写的 Redis 幂等加数据库唯一键。“如何保证消息顺序”在这个项目里不涉及但如果业务的打印任务对顺序有要求可以用同一个队列和同一个消费者或者给消息加上业务流水号消费端再按流水号排序。这些问题面试考的是原理项目里考的是权衡。能把“为什么 MQ 天然可能重复”和“重复消费怎么消除”讲明白比死记硬背十道面试题有用得多。也有人喜欢翻“黑马 MQ”这类课程笔记来背题但笔记只能帮你快速回忆知识点真正让你有底气的是自己动手搭一遍队列、通一次消息、故意把消费者干掉再看消息怎么恢复。5.2 踩坑清单速查表我把这次项目里遇到过的问题整理成了一张表方便你直接对号入座。现象根因解决办法条码打印出来扫不出条码高度太低或静区不足宽度、高度调大margin 保持 10 以上打印页多了空白页浏览器默认页眉页脚用 page 设置 margin:0单独写 media print同一标签被打印两次前端按钮未防重按钮置灰 本地请求锁同一请求被后端处理两次接口层没有幂等Redis setIfAbsent 数据库唯一键MQ 消息重复消费消费者没做去重用 taskId 做消费去重配合手动 ACK消息无限重复重试异常后 requeuetrue设置重试次数超过后进死信队列丢了打印任务消息和队列未持久化交换机、队列、消息都开持久化这张表里的问题几乎每个做消息队列的项目都会碰到只是触发时机不一样。有些问题可能在测试环境下根本发现不了比如重复消费测试时人工点得慢根本点不出两次一上线遇到真实用户手一抖就炸了。所以在联调阶段建议专门写一个脚本模拟“快速点击提交”“接口超时后重试”“消费者处理消息时突然停机”这三类场景把系统的防重能力验一遍。5.3 我的实操体会这个项目做完后我最大的体会是消息队列本身不复杂复杂的是“消息从哪来、到哪去、中间丢一次或者重复一次会怎样”。你只有把整个链路当成一条完整的数据流来设计才能把前端防重、接口幂等、消费端去重这些点串起来。还有一个很微观但很重要的细节日志里一定要把requestId、taskId、消息投递次数这些字段打全。前期我排查重复打印问题时就是因为日志里只有条码内容没有唯一的任务 ID结果同一张条码出现了两条记录分不清哪一条是重复的、哪一条是正常的。后来统一加了 taskId并把它作为贯穿前端、后端、MQ、打印服务的唯一 traceId问题一下子就清晰了。如果你也要做类似需求我的建议是先别急着写代码先把“一条消息从点击按钮到打印出纸”的路径画出来标出每一个可能重复和可能丢失的节点再决定每一层需要做什么保护。前后端每个环节各做一层能挡住 99% 的异常真正出问题的时候至少有据可查、有日志可跟。这个思路比单纯套一个现成模板管用得多也更能帮助你把一个看似简单的前端条码功能做成一个可靠的生产级模块。