编辑 | blame | 历史 | 原始文档

赋码管理大批量生成改造方案(异步任务化)

一、背景

「生成二维码数据包」(POST /mes/pro/bag-code/generate)与「生成原始数据」(POST /mes/pro/bag-code/raw-generate)在几十万条数据量下直接超时,前端收不到响应。

经代码定位,超时不是单一原因,而是三处叠加:

瓶颈 位置 说明
逐条向 Redis 要码 MesProBagCodeServiceImpl.java:143-144、176-177 每个袋码生成「追溯码 + 业务码」两次,MesCodeRedisDAO.generate() 每次执行一次 Redis SADD 往返(MesCodeRedisDAO.java:53)。30 万袋 = 60 万次网络往返,仅此一步即分钟级
单事务巨量插入 MesProBagCodeServiceImpl.java:154、185 insertBatch 默认 1000 条/批,但整批在同一个 @Transactional 内完成,几十万行形成超大事务
同步长请求 Controller 直接返回生成结果 请求线程从头执行到尾;网关/浏览器超时后连接断开,但服务端仍继续写库。用户重试会再生成一整包重复数据,且无幂等保护

此外,selectMaxBagNo() 对 mes_pro_bag_code 做 MAX(bag_no),当前表没有 bag_no 索引,数据量上来后每次生成都要全表扫描。

二、目标与非目标

目标

  1. 几十万条(单次上限 100 万)生成不再依赖单次 HTTP 请求时长,提交后立即返回任务编号。
  2. 生成过程可见进度、可取消;失败/取消不留下半成品数据包。
  3. 消除逐条 Redis 往返与超大事务,生成吞吐与数据量解耦。
  4. 小批量保持同步秒回,不改变使用体验。
  5. 接口具备幂等性,网络重试不会重复生成。

非目标

  • 不改造「破损替换」「绑定批次/托盘」等其他赋码操作;它们本身是少量数据的短事务。
  • 不做服务重启后的断点续跑(见 5.7 取舍说明)。
  • 不调整 16 位随机码的生成规则与 biz:code:used 集合机制。

三、总体方案

采用与现有二维码导出任务(MesProQrExportTask)一致的「小批量同步、大批量异步」双路径:

前端提交生成
   │
   ├─ 袋码总数 ≤ sync-threshold(默认 20000)
   │     └─ 请求线程内完成:批量取码 → 分段落库 → 返回原有结果(async=false)
   │
   └─ 袋码总数 > sync-threshold
         ├─ 落任务记录,注册运行态,投递后台线程池,立即返回 taskId + uuid(async=true)
         └─ 前端按 taskId 轮询进度;完成后刷新列表;取消则删除已生成的部分数据

同步与异步共用同一套「分段生成」执行器,避免两份逻辑。

四、任务模型

4.1 状态机

状态值 名称 说明
10 排队中 已落库,等待线程池接管
20 生成中 后台线程执行中
25 取消中 已收到取消请求,正在停止并清理部分数据
30 已完成 全部袋码(及托盘)已落库
40 已取消 用户取消,部分数据已清理
50 已失败 异常终止,部分数据已清理

终态:30 / 40 / 50。

4.2 表结构

新增 mes_pro_bag_code_generate_task:

字段 类型 说明
id BIGINT 主键
request_id VARCHAR(64) 幂等键,唯一索引;前端每次提交生成一个 UUID
biz_type VARCHAR(16) PACKAGE(数据包)/ RAW(原始数据)
uuid VARCHAR(32) 本次生成组 UUID(提交时即生成,失败/取消后用于清理)
item_id / item_code / item_name BIGINT / VARCHAR 品种快照
unit_type VARCHAR(32) 计量单位类型(吨 / 48袋 / 100袋)
per_pallet INT 每托袋数;吨类型为 0
pallet_count INT 托盘数;原始数据为 0
total_count INT 袋码总数
finished_count INT 已生成袋码数
status TINYINT 状态,见 4.1
error_msg VARCHAR(500) 失败原因
finish_time DATETIME 完成/终止时间
creator / create_time / updater / update_time / deleted — 沿用 BaseDO

索引:

  • UNIQUE KEY uk_request_id (request_id)
  • KEY idx_status (status)
  • KEY idx_uuid (uuid)

迁移脚本:sql/mysql/mes_pro_bag_code_generate_task.sql。

4.3 袋码表索引

新增 KEY idx_bag_no (bag_no),用于 selectMaxBagNo() 与袋序号排序。

迁移脚本:sql/mysql/mes_pro_bag_code_bag_no_index.sql(先查 information_schema.statistics 再建,兼容重复执行)。

五、详细设计

5.1 接口

1)生成数据包 / 原始数据(沿用原路径)

方法 路径 说明
POST /mes/pro/bag-code/generate 合并后的数据包生成入口
POST /mes/pro/bag-code/raw-generate 原始袋码生成入口

请求参数不变。响应扩展:

字段 类型 说明
async Boolean true=已转后台任务;false=本次同步完成
taskId Long 异步任务编号;同步时为 null
uuid String 生成组 UUID,两种模式都返回
type / perPallet String / Integer 数据包模式下返回
totalCount Integer 袋码总数
palletCount Integer 托盘总数;原始数据为 0
palletIds List<Long> 仅同步完成时返回;异步模式为 null,避免上千个 ID 撑大响应

后端按 totalCount 自动分流,前端不需要各自计算阈值。

2)任务查询(轮询)

方法 路径 说明
GET /mes/pro/bag-code/generate-task/get?id= 查询任务状态与进度

响应字段:id、bizType、status、statusName、progress(0~100)、totalCount、finishedCount、palletCount、itemId、itemCode、itemName、unitType、perPallet、uuid、errorMsg、createTime、finishTime。

3)任务取消

方法 路径 说明
POST /mes/pro/bag-code/generate-task/cancel?id= 请求取消;已终态任务拒绝

权限:提交/取消用 mes:pro-bag-code:create,查询用 mes:pro-bag-code:query。

5.2 提交阶段

  1. 校验品种、计量单位类型,换算袋码总数(与原逻辑一致)。
  2. 数量校验:totalCount ≤ maxTaskCount(默认 1,000,000),超出直接报错。
  3. 幂等处理:requestId 非空时先查任务表;命中则直接返回已有任务,不重复生成。并发重复提交由 uk_request_id 唯一索引兜底,捕获冲突后回查。
  4. 数量 ≤ syncThreshold:走同步执行,返回 async=false。
  5. 数量 > syncThreshold:
  • 生成任务 UUID;
  • 插入任务记录(状态=10);
  • 先注册内存运行态,再投递线程池(与导出任务一致,避免清理任务误回收);
  • 返回 async=true、taskId、uuid、totalCount、palletCount。
  1. 并发闸门:同一时间最多 maxConcurrentTasks(默认 1)个生成任务。占用失败时返回「已有生成任务进行中」并带上当前任务进度。
  2. 线程池拒绝时把任务置为失败并归还并发许可。

5.3 批量取码(核心优化)

将「逐条 SADD」改为「批量脚本写入」:

  1. Java 侧按 5000 个一批生成候选码(ThreadLocalRandom 精确生成 16 位数字)。
  2. 一次 Lua 脚本原子完成:逐个 SADD 到 biz:code:used,返回本批实际写入成功的码。
  3. 若成功数不足(随机碰撞,概率极低),只对缺额继续生成下一批,直到凑齐。
  4. 追溯码与业务码各走一次批量流程。

效果:30 万袋从 60 万次网络往返降到约 120 次(每次 5000 个)。

袋码与托盘码都走该批量方法;generate() 保留给其他少量场景。

5.4 分段落库

  1. 一次性分配袋序号:任务开始时调用一次 allocateBagNoStart(totalCount, selectMaxBagNo()),后续各段用 startNo + offset 推算。取消产生的序号空洞允许存在(序号只要求唯一、不回退),因此不要按段重复分配。
  2. 托盘(仅数据包模式):按 1000 个/批插入,保留 palletId 与 tempCode 两个数组供袋码段引用,避免堆积整表 DO。
  3. 袋码:按 chunkSize(默认 5000)分段落库,每段落库后回写一次 finished_count。
  4. 事务边界:每个分段的插入在独立的 REQUIRES_NEW 事务中完成,避免几十万行单事务。分段方法必须由执行器跨 Bean 调用,保证事务代理生效。
  5. 每段开始前检查取消标记与最长运行时间,段内不再做数据库查询。

5.5 进度与节流

  • finished_count 每段(5000 条)更新一次,30 万条约 60 次更新,开销可忽略。
  • 生成过程以数据库写入为主,不叠加 CPU 密集操作,因此不做 CPU 节流;仅保留最长运行时间(默认 30 分钟)兜底。
  • 每段写入前检查取消标记。

5.6 取消

  1. 取消接口把内存中的取消标记置位,任务进入「取消中」。
  2. 执行线程在当前分段结束后停止,随后:
  • 物理删除该 uuid 下已生成的袋码(DELETE ... WHERE uuid = ? LIMIT 5000 循环);
  • 物理删除本次已创建的托盘:托盘表新增 generate_uuid 记录生成组,按 DELETE ... WHERE generate_uuid = ? AND batch_id IS NULL AND code IS NULL LIMIT 5000 循环。该条件确保只删从未入批次的临时托盘,不会误删用户后续已绑定的托盘;
  • 任务置为「已取消」。
  1. 任务不在本进程运行(进程重启后的残留)时,取消接口直接清理残留数据并置为「已取消」。

说明:生成一半的数据包不可用,必须清理。这里采用物理删除而非逻辑删除,是因为这些行从未对外展示,逻辑删除只会留下几十万条垃圾并浪费索引空间;用户主动删除已生成数据包仍沿用原有逻辑删除逻辑。

5.7 失败与重启回收

  • 执行异常:清理该 uuid 的部分数据,任务置为「已失败」,error_msg 截断到 500 字符。
  • 进程重启:内存运行态丢失。启动后与每 5 分钟一次的清理任务扫描非终态任务,凡不在运行态注册表中的,一律置为「已失败」并清理部分数据。
  • 不实现断点续跑:续跑需要精确处理分段事务边界、托盘映射与取消空洞,复杂度高;而重新生成一次的成本远低于维护续跑状态机。UI 明确提示“任务中断,请重新生成”。

5.8 幂等与重复提交

  • 前端每次打开生成弹窗生成一个 requestId,同一次提交的重试复用同一个值(如网络重试)。
  • 后端以 requestId 唯一索引保证同一请求只落一个任务;重复提交直接返回已有任务。
  • 不传 requestId 时仍可用,但失去网络级幂等保护;前端必须传。

5.9 配置项

新增 mes.bag-code.generate 前缀配置:

配置 默认值 说明
sync-threshold 20000 不超过该数量同步生成
chunk-size 5000 每段袋码/每批取码数量
max-concurrent-tasks 1 同时运行的后台生成任务数
max-task-count 1000000 单次生成袋码上限
max-runtime-minutes 30 单任务最长运行时间
poll-interval-seconds 2 仅作为前端轮询建议值下发(可选)

六、前端联调要点

本节只描述行为与字段,不提供前端代码。

6.1 涉及页面

  • 赋码管理列表页(mes/pro/bagCode)
  • 「生成二维码数据包」弹窗(generate-form.vue)
  • 「生成原始数据」入口(如存在独立弹窗,同样处理)

6.2 提交流程

  1. 打开生成弹窗时生成一个 requestId(UUID),关闭重开则重新生成;同一次提交的自动重试复用同一个 requestId。
  2. 调用原生成接口,请求体增加 requestId。
  3. 响应 async=false:维持现有成功提示与列表刷新行为。
  4. 响应 async=true:弹窗不立即关闭,展示进度条,按 poll-interval-seconds(建议 2 秒)轮询任务查询接口,直到状态进入终态。
  • 状态 30:提示“生成完成,共 N 条袋码”,关闭弹窗并刷新列表。
  • 状态 40:提示“任务已取消”。
  • 状态 50:展示 errorMsg,允许用户重新提交(重新生成 requestId)。
  1. 轮询期间提供“取消生成”按钮,调用取消接口;按钮点击后置灰,等待任务进入终态。
  2. 页面关闭或组件卸载时停止轮询;重新进入页面时,可通过任务编号继续查询(任务编号可暂存在本地状态)。

6.3 字段展示

字段 展示位置 说明
progress 生成弹窗 进度条百分比
finishedCount / totalCount 生成弹窗 文案“已生成 x / y 条”
statusName 生成弹窗 任务状态标签
palletCount 完成提示 数据包模式提示自动生成的托盘数
errorMsg 生成弹窗 失败原因

6.4 注意事项

  • 异步模式响应中 palletIds 为 null,前端不要再用 palletIds.length 判断托盘数,改用 palletCount。
  • 同一用户同时只能运行一个后台生成任务;被拒绝时后端会返回当前任务进度,前端可直接切换为查询该任务。
  • 不要用“请求是否超时”来判断任务失败;提交接口在转异步后应快速返回。

七、性能估算与验收标准

7.1 估算

环节 改造前(30 万袋) 改造后
Redis 取码 60 万次 RTT,分钟级 约 120 次 RTT,秒级
数据库写入 1 个超大事务 60 个 5000 行事务
HTTP 响应 等待全部完成,必然超时 提交后 1 秒内返回 taskId
用户重试 产生重复数据包 幂等键去重

7.2 验收标准

  1. 30 万袋码 + 对应托盘可完整生成,进度可从 0 推进到 100。
  2. 提交接口在 1 秒内返回;生成过程中列表可正常查询其他数据。
  3. 取消后数据库中该 uuid 无残留袋码与托盘,任务状态为已取消。
  4. 模拟进程重启:残留任务在一次清理周期内变为已失败,部分数据被清理。
  5. 相同 requestId 重复提交只产生一个任务、一个生成组。
  6. 2 万条以内仍为同步返回,响应结构与旧版兼容(新增字段不影响旧前端解析)。
  7. 现有「破损替换 / 绑定批次 / 绑定托盘 / 导出」回归通过。

八、风险与取舍

风险 应对
Redis biz:code:used 集合持续增长(几十万级/次) 现网已有该机制,集合只存 16 字符短串,单次几十万条约几十 MB;后续可评估按前缀分片,本次不动
清理 30 万行部分数据耗时 分段 DELETE ... LIMIT 5000,避免长事务与主从延迟尖峰
bag_no 为 INT,理论上限约 21 亿 单次上限 100 万;按当前量级可支撑多年,后续如需长期使用再迁移 BIGINT
异步任务期间服务重启 不续跑,标记失败并清理,提示重新生成
同步路径仍受网关超时约束 阈值内单次最多 2 万条,批量取码后预计 1~3 秒,留足余量

九、实施步骤与状态追踪

序号 内容 状态
1 方案文档确认 已完成
2 新增任务表、袋码 bag_no 索引、托盘 generate_uuid 迁移脚本 已完成
3 新增任务 DO / Mapper / 状态枚举 / 配置项 已完成
4 MesCodeRedisDAO 增加批量取码方法 已完成
5 抽取分段生成执行器(同步/异步共用) 已完成
6 新增任务 Service(提交、查询、取消、回收)与线程池配置 已完成
7 改造两个生成接口的同步/异步分流与响应字段 已完成
8 新增任务查询/取消接口与权限 已完成
9 启动与定时清理任务 已完成
10 前端联调(弹窗进度、轮询、取消) 已完成
11 编译检查与大批量实测 已完成(本地 mom-thzy:30 万条约 76 秒生成完毕,提交响应 0.18 秒;取消清理无残留;幂等与并发闸门均验证通过)

迁移脚本

脚本 说明
sql/mysql/mes_pro_bag_code_generate_task.sql 任务表
sql/mysql/mes_pro_bag_code_bag_no_index.sql 袋码 bag_no 索引
sql/mysql/mes_pro_pallet_generate_uuid.sql 托盘 generate_uuid 列与索引