Files
pj_service_cloud/db/20261022_hard_dept_statistics_verification.md
T
2026-10-22 19:22:16 +08:00

15 KiB
Raw Blame History

部门交易统计优化验证记录(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 表中生成并建索引,关闭连接自动删除。

  1. 订单候选覆盖索引 (tenant_id,del_flag,create_time,order_status,wallet_id,user_dept_id,pay_price);普通窄时间索引不足以覆盖读取字段。
  2. 钱包业务编号定位索引 (tenant_id,biz_id,biz_type(8)),复用其他任务的 idx_wt_tenant_bizid_type,不创建第二条同类宽索引。biz_type 为 TINYTEXT,前缀必须保留,该索引不是完整覆盖索引。
  3. 钱包六类合并统计复用现有 idx_wallet_tx_tenant_del_biz_ct_uid,日期范围查询使用前述优化器提示,不追加大宽索引;需确认同名索引及提示在具体 PolarDB 小版本生效。
  4. 脚本识别其他名称的等效可见索引、完整列前缀,重复运行跳过;同名但定义不同会报错,应核对后处理。大表低峰逐条在线建索引,核对长事务、元数据锁、空间和具体 PolarDB 版本支持。
  5. 先准备索引,再发布所有 Wallet 实例的新内部接口,最后滚动发布 Hardware。 原 Wallet 内部 API 未删除,先发布 Wallet 兼容旧 Hardware。
  6. Hardware 与 Wallet 库必须同实例;Hardware 生产数据库账号需要 pjcloudx_wallet.wallet_transaction 的 SELECT 权限。 这是新增依赖,原本查询 wallet_manage 的权限不能代替;本地 root 测试不能证明应用账号权限。请用实际 Hardware 账号先执行只读查询和 EXPLAIN。
  7. 在真实租户上灰度验证默认日期、部门包含/排除、钱包类型的 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 环境开关显式开启。