15 KiB
部门交易统计优化验证记录(2026-10-22)
历史记录:下文验证的是旧订单统计口径的性能优化,已由本次钱包账本统计替代。 下文的 106 项回归证明与旧口径保持一致,不能作为商户、部门报表已经统一到
wallet_transaction的证据。当前支付与退款均按流水发生时间、历史部门、原始金额和流水条数汇总;生产差额须使用 只读对账脚本 按实际租户和权限另行核实。本次未执行生产对账。
范围与数据库
目标接口:POST /hard/hard-total/page-list-dept,同时优化共用统计逻辑的同步/异步部门导出。用户反馈线上超过 30 秒未返回。线上为 PolarDB MySQL 8 兼容版;本次实际连接的是用户提供的本机 MySQL 8.0.46 测试库,未连接或修改生产数据库。
本次仅新增部门统计链路;保留此前 page-list-cs 的改动和工作区其他并行任务的改动。部门前端 src/views/hard/hard-dept-total/index.vue 初始化没有商户统计页面的重复请求问题,因此本次不修改部门前端。
根因与实现
旧链路先查询 UPMS 部门,再依次执行六次 Wallet 汇总 RPC;随后把全部候选订单明细读入 JVM,按每组最多 10000 订单、每 RPC 最多 10 组请求历史流水,Wallet 每组单独查询和排序,最后在 Java 按历史部门累加。订单量和历史流水量同时影响数据库扫描、结果映射、RPC 传输及 JVM 内存。原流水索引不能直接按租户、业务编号定位,订单缺少以租户/删除标记/创建时间开头的覆盖索引。
新链路:一次 UPMS 部门查询 + 一次 Wallet 条件聚合 RPC + 一次 Hardware 支付部门聚合 SQL。只返回部门级金额、次数,不再回传逐笔订单和流水快照。导出共用这条链路,不再每 200 订单重复请求历史快照;Excel 仍按部门结果分批写入。
- Wallet 新增内部
/wallet-transaction/department-statistics,用 CASE 同时统计六类收支;原内部接口保留。 - Hardware 新增
HardDepartmentStatisticsMapper,逐单用高选择性业务编号定位最新有效支付流水,回退订单部门后再按部门筛选/汇总。 - 原始
biz_id普通等值保证可用索引,额外 BINARY 等值模拟旧 Java Map 的精确字符串匹配;biz_type同样校验精确字节。订单编号用concat('', a.id),保持完整大整数并让列侧排序规则优先,避免连接与历史列的字符排序规则冲突。 - 显式限制订单租户、历史流水租户和钱包表租户,避免带库名前缀的表被租户插件忽略。入口要求有效租户上下文。对于历史异常的跨租户钱包引用,有钱包类型筛选时新查询会按同租户条件排除;这是对异常关联的隔离收紧。
- 超过 1 秒的成功统计记录部门 RPC、钱包聚合 RPC、订单聚合、合并各段耗时,便于灰度定位。
- 部门/钱包 RPC 失败或 SQL 异常终止统计,不返回假零。空部门结果保留原行为。
保留的统计口径
| 项目 | 口径 |
|---|---|
| 订单范围 | hard_order.create_time,状态 5,20,10;金额 pay_price、每单一次,包含零金额 |
| 历史部门 | 同租户、同钱包、精确字符串业务编号、PAYMENT=4,有效且部门非空的最大流水 ID;快照不限时间和金额符号;没有快照回退 user_dept_id |
| 部门筛选 | 还原历史部门后才做包含/排除,isInclude=null 与包含一致;deptId 继续只控制展示部门查询 |
| 钱包类型 | 当前钱包类型,标量和列表同时传入时取交集;不新增删除钱包过滤 |
| 批量充值/扣减 | BATCH=2,严格按金额正/负拆分 |
| 单次充值/扣减 | UPDATE=1 和 RECHARGE=3 合并,严格按金额正/负拆分 |
| 退款 | REFUND=5 原符号合计,零金额也计次数 |
| 退还食堂 | RETURN_CANTEEN=8 先带符号求和,组装时取绝对值;零金额也计次数 |
| 部门列表 | 保留 UPMS 顺序、已删除标记、零交易部门;支付或退款单边存在也保留 |
| 隔离范围 | 保持租户和所选部门条件;未为此接口新增原本不存在的 DataScope 权限规则 |
部门 Excel 同时修正原有问题:8 个次数合计未累计、Long 类型扣减金额转元时截断小数、转换直接修改原 VO;改为独立导出 Map,全部金额/次数完整累计,并修正消费净金额标题。页面响应仍用原字段和分单位。
正确性与文件验证
- 新 Hardware SQL 回归 11 项:逐笔旧 SQL + 独立 Java 历史归属为基线,覆盖 21 组可选条件、24 组最终部门组合、时间/状态边界、现金回退、删除钱包、大整数编号、异常字符串和租户隔离。
- 新 Hardware Service/Controller 回归 13 项:16 个金额/次数字段、RPC 次数/失败、筛选快照、空/删除部门、退款单边结果、页面与导出一致性和进度。
- 新 Wallet SQL 回归 8 项:真实聚合 XML + 生产租户插件,对照原六次聚合,覆盖符号/零金额/钱包类型交集。
- 部门真实 Excel 回归:201 部门跨 200 行批边界逐列验证 16 个数值及合计,重复导出不污染统计对象,统计异常不返回部分附件;原订单/商户导出测试保留。
- 真实 MySQL 回归 3 项全部通过:既有数据 23 组筛选(14 个初始部门);源 SQL 精确业务号/业务类型、最新有效快照和大整数边界;索引迁移重复、改名、覆盖列重排、窄索引、业务号前缀截断和不可见索引判断。后两项写入只在 TEMPORARY 表或独立 scratch 库。
- H2 测试只适配其 AS CHAR/AS BINARY 默认长度为 1 的方言差异;真实 MySQL 测试直接执行源 XML,无字符转换影子 SQL。
最终联合回归 106 项实际执行通过,0 失败/错误,覆盖 18 个测试类。另 2 项独立性能用例未在联合轮启用:原订单统计的百万性能开关未开启;钱包索引候选测试已在独立轮实际执行通过,不把联合轮跳过算作通过。
联合日志:/private/tmp/hard-dept-final-verification.log;Wallet 候选对照日志:/private/tmp/hard-dept-mysql-wallet-candidates.log。真实 MySQL 3 项回归和百万部门性能测试均实际启用并执行。索引 scratch 库清理后数量为 0;所有连接临时表在测试结束关闭连接后删除;任务文件凭证检查未发现提供的数据库密码。
真实 MySQL 性能验证
压测克隆测试库真实表结构及原索引到连接临时表,生成 100 万订单、100 万钱包流水、1 万钱包,时间分布 730 天、两个租户;包含历史部门变化、重复业务号、尾空格、错钱包、区间外快照、现金支付、删除记录及零金额。默认查询最近三个月,再分别测试部门包含、部门排除和钱包类型筛选。每项交替执行新/旧版本 3 次取中位数;计时旧订单链路总预算 30 秒、新 SQL 单次预算不超过 30 秒。部门包含/排除在旧链路仅改变最终 Java 过滤,最终回归复用默认查询已验证的全部门旧结果作为对照,避免重复搬运相同 10 万订单。旧计时样本超时会明确记录;若没有完整旧结果,仅为正确性另给旧链路 90 秒预算,绝不把该额外执行伪装为性能样本。
必须代码与索引配套上线。 原索引下默认三个月旧支付链路 12372.47 ms、新支付 SQL 14921.99 ms,说明仅修改 SQL 不能解决历史流水定位成本;钱包六次汇总则为 2222.59 ms,新一次聚合为 175.51 ms。支付必须添加业务号定位及订单时间覆盖索引后验收,不能仅发布代码。
造数后必须刷新临时表统计。初轮未刷新时,原空表索引误估每个钱包只有 1 条流水,新支付查询误选钱包编号索引并在新增索引阶段超过 30 秒。ANALYZE 仅作用于本次连接临时表;刷新后支付查询按业务编号定位,默认三个月中位数 550.01 ms。生产不自动执行 ANALYZE,应结合实际统计维护窗口验收计划。
还发现业务号索引会干扰 Wallet 汇总:它仅按 tenant_id 扫描 90 万行,默认约 1.2 秒。对照现有业务时间索引与普通时间索引后,前者只读取约 62015 行(后者约 110551 行),采用有日期范围且未指定 walletId 时的 INDEX 优化器提示,保留定向钱包自由选择索引。提示针对 MySQL 8.0.20+;不支持提示或没有同名索引时可回退,正确性不依赖提示,但灰度必须确认实际 PolarDB 计划已采用时间范围扫描。
钱包候选 3 次采样中位数(ms),每项均完整对照原六次查询的 12 个字段:
| 场景 | 无提示 | 业务类型时间索引 | 普通时间索引 |
|---|---|---|---|
| 默认三个月 | 966.70 | 186.04 | 179.19 |
| 部门包含 | 1016.21 | 83.94 | 129.76 |
| 部门排除 | 1161.30 | 130.97 | 164.09 |
| 钱包类型 | 1095.75 | 120.85 | 150.13 |
默认场景约 7ms 的两候选差距不作为决策依据;业务类型时间索引扫描量更少,其余三场景更快,故选择该既有索引。
最终源 SQL + 配套索引 + 刷新临时统计后的验收结果(ms):
| 场景 | 旧支付链路 | 新支付聚合 | 旧钱包六次汇总 | 新钱包一次汇总 | 新核心阶段中位数之和 |
|---|---|---|---|---|---|
| 默认三个月 | 19442.08 | 508.69 | 2222.16 | 159.73 | 668.42 |
| 部门包含 | 复用全部门基线 | 477.83 | 2278.88 | 159.31 | 637.14 |
| 部门排除 | 复用全部门基线 | 475.08 | 2243.06 | 177.53 | 652.61 |
| 钱包类型 | 948.86 | 242.78 | 1762.37 | 140.42 | 383.20 |
默认场景旧、新共用相同数据与配套索引,核心阶段中位数之和约 21.66 秒 → 0.67 秒;“之和”是两个阶段各自 3 次中位数相加,不是完整 HTTP 接口中位数。新增索引也会改变旧大 IN 查询的计划,因此不能假定仅给旧链路加索引就会提速。前面原索引的 12.37 秒支付样本与这里 19.44 秒样本属于不同索引/统计阶段,应分开解释。
所有新支付结果与旧逐单归属一致;钱包全部 12 个统计字段与旧六次 SQL 一致。默认旧流程向 JVM/服务间返回 106348 订单、43922 快照,执行 11 次快照 SQL;新 SQL 返回 57 个支付部门和 25 个钱包部门,服务中保留 UPMS 的零交易部门。钱包类型筛选旧流程返回 34294 订单、14561 快照并执行 4 次快照 SQL。
最终 EXPLAIN ANALYZE 已确认:
- 订单走
idx_hard_order_dept_ct_cover创建时间覆盖范围扫描,默认读 111182 索引项、106348 候选订单。 - 历史流水走
idx_wt_tenant_bizid_type的 tenant_id + biz_id + biz_type 定位;默认 loops=106348,平均每次仅约 0.432 条候选;四场景均不存在逐单全表扫描。 - NO_MERGE 生效,先物化部门结果,只有一个历史标量子查询;外层部门筛选/分组没有再次执行该表达式。
- Wallet 走现有
idx_wallet_tx_tenant_del_biz_ct_uid的五个业务类型/日期区间,默认读取约 62015 行后聚合,不再按租户扫描 90 万行。 - 缺失索引提示的只读探针在真实 MySQL 返回 warning 3128 并正常生成回退计划,无 SQL 执行错误;PolarDB 是否识别提示仍需按具体版本验收。
索引、权限与发布顺序
配套脚本:db/20261022_hard_dept_statistics_indexes.sql。本次未在任何业务表加索引;脚本实际执行验证仅使用测试专用 scratch 库,结束删除;百万数据仅在当前连接 TEMPORARY 表中生成并建索引,关闭连接自动删除。
- 订单候选覆盖索引
(tenant_id,del_flag,create_time,order_status,wallet_id,user_dept_id,pay_price);普通窄时间索引不足以覆盖读取字段。 - 钱包业务编号定位索引
(tenant_id,biz_id,biz_type(8)),复用其他任务的idx_wt_tenant_bizid_type,不创建第二条同类宽索引。biz_type为 TINYTEXT,前缀必须保留,该索引不是完整覆盖索引。 - 钱包六类合并统计复用现有
idx_wallet_tx_tenant_del_biz_ct_uid,日期范围查询使用前述优化器提示,不追加大宽索引;需确认同名索引及提示在具体 PolarDB 小版本生效。 - 脚本识别其他名称的等效可见索引、完整列前缀,重复运行跳过;同名但定义不同会报错,应核对后处理。大表低峰逐条在线建索引,核对长事务、元数据锁、空间和具体 PolarDB 版本支持。
- 先准备索引,再发布所有 Wallet 实例的新内部接口,最后滚动发布 Hardware。 原 Wallet 内部 API 未删除,先发布 Wallet 兼容旧 Hardware。
- Hardware 与 Wallet 库必须同实例;Hardware 生产数据库账号需要
pjcloudx_wallet.wallet_transaction的 SELECT 权限。 这是新增依赖,原本查询 wallet_manage 的权限不能代替;本地 root 测试不能证明应用账号权限。请用实际 Hardware 账号先执行只读查询和 EXPLAIN。 - 在真实租户上灰度验证默认日期、部门包含/排除、钱包类型的 16 项金额/次数与导出;检查历史流水业务编号索引和相关子查询实际 loops。观察分段日志、DB CPU/IO、RPC 时延后扩大范围。
本次结果是本机 MySQL 的 SQL、Java 映射/归集和文件回归结果,不是生产 PolarDB 接口时延承诺;不包含网关、UPMS/Wallet 的真实网络与连接池排队。大范围无日期查询仍要读取该范围全部相关订单,不能把百万样本耗时直接外推到线上全部历史数据。
复现
先在 shell 中设置本地测试库的 MYSQL_HOST、MYSQL_PORT、MYSQL_USER、MYSQL_PWD,不要把凭证写入仓库。仓库根目录执行:
HARD_DEPT_MYSQL_TEST=true mvn -o -pl pjcloud-hardware/pjcloud-hardware-biz -am \
-Dtest=HardDepartmentStatisticsMySqlTest \
-Dsurefire.failIfNoSpecifiedTests=false -DfailIfNoTests=false \
'-DargLine=-Xmx768m -Dfile.encoding=UTF-8 -Djava.awt.headless=true' test
HARD_DEPT_MYSQL_TEST=true HARD_DEPT_MYSQL_BENCHMARK=true \
mvn -o -pl pjcloud-hardware/pjcloud-hardware-biz -am \
-Dtest=HardDepartmentStatisticsMySqlBenchmarkTest -Dhard.dept.benchmark.finalOnly=true \
-Dsurefire.failIfNoSpecifiedTests=false -DfailIfNoTests=false \
'-DargLine=-Xmx1536m -Dfile.encoding=UTF-8 -Djava.awt.headless=true' test
环境开关未启用时真实 MySQL 测试跳过;不能把跳过视为验证通过。macOS 文件回归需保留 java.awt.headless=true,避免 AWT 初始化导致测试 JVM 退出。
去掉 hard.dept.benchmark.finalOnly 可连同原索引阶段一起执行;设置 hard.dept.benchmark.walletCandidates=true 可单独复现三个钱包索引选择方案。所有模式仍要求两个 MySQL 环境开关显式开启。