# 赋码管理大批量生成改造方案(异步任务化) ## 一、背景 「生成二维码数据包」(`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\ | 仅同步完成时返回;异步模式为 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`。 6. 并发闸门:同一时间最多 `maxConcurrentTasks`(默认 1)个生成任务。占用失败时返回「已有生成任务进行中」并带上当前任务进度。 7. 线程池拒绝时把任务置为失败并归还并发许可。 ### 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` 循环。该条件确保只删从未入批次的临时托盘,不会误删用户后续已绑定的托盘; - 任务置为「已取消」。 3. 任务不在本进程运行(进程重启后的残留)时,取消接口直接清理残留数据并置为「已取消」。 > 说明:生成一半的数据包不可用,必须清理。这里采用物理删除而非逻辑删除,是因为这些行从未对外展示,逻辑删除只会留下几十万条垃圾并浪费索引空间;用户主动删除已生成数据包仍沿用原有逻辑删除逻辑。 ### 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`)。 5. 轮询期间提供“取消生成”按钮,调用取消接口;按钮点击后置灰,等待任务进入终态。 6. 页面关闭或组件卸载时停止轮询;重新进入页面时,可通过任务编号继续查询(任务编号可暂存在本地状态)。 ### 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` 列与索引 |