test(perf)+sql(h2): T9 压测探针与 UserBase 登记交接脚本
build / build-push (push) Successful in 1m31s

Perf200kProbe(20 万答卷/78 万索引行,独立库 bsch_formcenter_perf):
- V10 量化:列表首屏 p95=22ms、三条件筛选 p95=313ms、深分页 keyset p95=31ms、
  统计 p95=1~334ms、导出 2 万级 4.1~8.1s(冷缓冲池首跑 22.1s 已如实记档)
- 铁律取证:org.hibernate.SQL 日志抓真实语句——第一段只 select id、不含 values_json;
  翻页谓词是 answer.id<游标;EXPLAIN 显示首屏/计数走 idx_answer_form_time 且 using_index=true
- P26:8 线程×250 并发实测 answer_no 重号率=1392/2000(69.6%,"不限次+报名号"组合)
- P37:新答卷后 cached=true、升版后 cached=false(铁律④语义与实现一致)
- P35/P31:information_schema 复核索引基数与 ROW_FORMAT=Dynamic(本机 8.0.45)
- 类名不带 Test/IT → 不进 mvn test 门禁(190 用例仍是快门禁),手点 -Dtest 才跑

PerfTreeCallsProbe(控制器形状,复用同一份数据):
- P44 请求级取树次数:列表/详情/导出=2、统计=3、筛选候选=1(P44 的"1~2 次"上界在统计路径实测为 3)
- V10 请求级延迟:筛选 p95=163ms、导出 2297ms/1.15MB
- P36 升版补建实测:19,600 行表单的发布=373,272ms 且本次补出 0 行——
  P36 记档的"2 万级秒级"不成立,N+1 逐行探测是主因(数字交验收轮裁决是否触发 publish 处改造)

sql/h2_userbase_app_and_modules.sql(H2 + 第一个快应用登记交接件):
中心应用六条模块行(手册 §2.2,含 path 空 fc:scope:all 锚点)+ 快应用五条模块行
(方案 §6.2 checklist,导入/导出为 path 空锚点)+ 组/角色授权与开通 + 验收查询 + 回滚段。
AI 不代跑(沿 flow-center 先例;H2 既定路径是超管在管理端录入,脚本为批量/复核形态)。
步骤 3.5 显式记录"快应用数据视图的后端守卫仍扫中心树"这一过渡口径(T9 报告 P78 附注)。

docs/T6-report.md 已按 P69 从本仓 docs/ 挪至 form-center/docs/(本提交不含该文件)。
This commit is contained in:
chenxing
2026-10-15 12:57:32 +08:00
parent 12a8df4fef
commit bf39c220f2
3 changed files with 1086 additions and 0 deletions
+202
View File
@@ -0,0 +1,202 @@
-- ============================================================================
-- 表单中心 H2 + 第一个快应用:UserBase 侧应用行/模块行/授权登记脚本
-- 编制:Qoder(T9 联调轮)|日期:2026-10-15
-- 依据:《表单中心执行手册》§2.2(中心应用六条模块行表 + 验证口径)、
-- 《表单中心建设方案 v1.4》§6.2(快应用登记 checklist R8)、§6.4(customId=fc:scope:all)、
-- §6.5(中心=BUSINESS 应用,模块行与快应用模块行两棵树不混)
-- 先例:D:\SoftProject\yunhai\UserBase\flow-center接入UserBase菜单.sql(同一套形态与幂等写法)
--
-- ⚠ 纪律:本脚本**不由 AI 执行**(沿 flow-center 先例"AI 不代跑,交由你在 dev / prod 各自执行")。
-- H2 的既定路径是 UserBase 管理端(超管)逐项录入;本脚本是同一份数据的批量/复核形态,
-- 两者取其一即可,重复执行安全(全部按唯一键做"不存在才插",不覆盖人工改过的值)。
-- 目标库=**UserBase 的库**(本地 bsch_userbase / 160 容器网 userbase 库),不是 bsch_formcenter。
-- MySQL 5.7 兼容(无 CTE / 无窗口函数)。
-- ============================================================================
-- ----------------------------------------------------------------------------
-- 步骤 0:定位 form-center 的应用主键(关键,排第一大坑)
-- 开放接口按「调用方 app_key → application_info 主键 id」反查,而 app_module.app_id 存的是【主键 id】,
-- 不是 app_key(100xxx)。中心后端签名用的 app_key 由 projman 注入(P7:jar 内占位 100021 上线前替换)。
-- 期望下面这条命中一行;命中 0 行=应用行还没建(走 0b 建,或去管理端建)。
-- ----------------------------------------------------------------------------
SELECT id AS app_id_pk, app_key, name, app_code, app_nature, pc_url
FROM application_info
WHERE app_code = 'form-center' OR app_key = 100021;
SET @center_app_id = 0; -- ← 按上一步结果填:form-center 在 application_info 的主键 id
SET @center_app_key = 100021; -- ← 按 UserBase 注册所得 app_key 填(占位值仅本地)
SET @operator = 0; -- create_user 占位(0=系统)
-- 0b(可选):应用行缺失时才需要。app_secret 请改成 UserBase 注册实际下发的值,
-- 不要把这里的占位串带到任何环境(D7:一期无写 oapi,应用行由超管建=本行就是那个"手工建")。
-- INSERT INTO application_info (app_key, app_secret, name, app_code, app_nature, pc_url, pc_icon,
-- clients, create_time, create_user)
-- SELECT @center_app_key, 'REPLACE-WITH-REAL-SECRET', '表单中心', 'form-center', 'BUSINESS',
-- 'http://base.yunhaixingtong.com/form-center/front/', 'category', JSON_ARRAY('pc'),
-- NOW(), @operator
-- WHERE @center_app_id = 0;
-- ----------------------------------------------------------------------------
-- 步骤 1:中心应用六条模块行(执行手册 §2.2 表;path 必须逐字等于 front/src/router/routes.ts)
-- ①表单设计 /admin/forms ②数据管理 /admin/data ③统计 /admin/stats
-- ④字典管理 /admin/dicts ⑤我的填写 /my(全员) ⑥数据全量【path 留空 + customId=fc:scope:all】
-- 第⑥行=纯权限锚点:导航不渲染、后端 DataScopeService 只扫 path 空节点的 customId(P47 收紧后口径),
-- 授权给哪个组,该组的数据范围即升级为"全部"(方案 §6.4)。
-- 子页面路由(/admin/forms/designer、/admin/data/detail、/app/:appCode/data/detail)
-- **不建模块行**(手册 §2.2 注记 + P63/P77):不占 modId、不进导航,前端按 meta.parentPath 归位。
-- custom_id 用 200~205 段(避开 flow-center 的 100~105;后端 resolveModId 只认数字 customId)
-- ----------------------------------------------------------------------------
INSERT INTO app_module
(app_id, parent_id, name, custom_id, path, icon, level, sort_no, create_user, create_time)
SELECT * FROM (
SELECT @center_app_id AS app_id, NULL AS parent_id, '表单设计' AS name, '200' AS custom_id,
'/admin/forms' AS path, 'word' AS icon, 1 AS level, 200 AS sort_no, @operator AS create_user, NOW() AS create_time
UNION ALL SELECT @center_app_id, NULL, '数据管理', '201', '/admin/data', 'category', 1, 201, @operator, NOW()
UNION ALL SELECT @center_app_id, NULL, '统计', '202', '/admin/stats','tongji', 1, 202, @operator, NOW()
UNION ALL SELECT @center_app_id, NULL, '字典管理', '203', '/admin/dicts','zidingyi', 1, 203, @operator, NOW()
UNION ALL SELECT @center_app_id, NULL, '我的填写', '204', '/my', '', 1, 204, @operator, NOW()
-- 锚点行:path 空 + customId 协议值(非数字 customId 是刻意的,前端 modId 会回退主键 id,不影响消费)
UNION ALL SELECT @center_app_id, NULL, '数据全量', 'fc:scope:all', '', '', 1, 205, @operator, NOW()
) t
WHERE @center_app_id > 0
AND NOT EXISTS (SELECT 1 FROM app_module m WHERE m.app_id = t.app_id AND m.custom_id = t.custom_id);
-- ----------------------------------------------------------------------------
-- 步骤 2:第一个快应用(演练对象,方案 §6.2 checklist R8 四步的前两步)
-- 快应用=真实应用行 + 无自有后端 + pc_url 指中心分发路由。app_code 一经确定不可改
-- (表单归属列 app_code 与模块树查询都按它走,P25/D8)。
-- ----------------------------------------------------------------------------
SET @quick_code = 'fc-drill'; -- ← 演练用快应用 app_code(前端 mock 桩也叫这个名字)
SET @quick_app_key = 0; -- ← UserBase 注册所得 app_key(无后端应用也要有身份行)
SELECT id AS quick_app_id_pk, app_key, name, app_code, app_nature
FROM application_info WHERE app_code = @quick_code;
SET @quick_app_id = 0; -- ← 按上一步结果填;0=还没建,走 2b
-- 2b(可选):快应用应用行(BUSINESS 之外按平台既有 nature 填;无自有后端=不建 projman 项目)
-- INSERT INTO application_info (app_key, app_secret, name, app_code, app_nature, pc_url, pc_icon,
-- clients, create_time, create_user)
-- SELECT @quick_app_key, 'REPLACE-WITH-REAL-SECRET', '表单演练应用', @quick_code, 'BUSINESS',
-- CONCAT('http://base.yunhaixingtong.com/form-center/app/', @quick_code, '/'), 'edit',
-- JSON_ARRAY('pc'), NOW(), @operator
-- WHERE @quick_app_id = 0;
-- SET @quick_app_id = LAST_INSERT_ID(); -- ← 紧接着上面那条 INSERT 生效;否则手工填
-- 模块行五条(方案 §6.2 表的典型配法)。path 形态:
-- 填报 = /f/{code}(该快应用名下的表单 code,经 resolve 换 uuid——快应用不授权定义管理行,§3.4)
-- 数据查看/统计 = /app/{app_code}/data|stats(AppContext.vue 的分派段)
-- 导入/导出 = path 留空的按钮级锚点(导出按钮显隐按它判,P76 修的就是"判哪棵树")
INSERT INTO app_module
(app_id, parent_id, name, custom_id, path, icon, level, sort_no, create_user, create_time)
SELECT * FROM (
SELECT @quick_app_id AS app_id, NULL AS parent_id, '填报' AS name, '300' AS custom_id,
CONCAT('/f/', @quick_code, '_form') AS path, 'edit' AS icon, 1 AS level, 300 AS sort_no,
@operator AS create_user, NOW() AS create_time
UNION ALL SELECT @quick_app_id, NULL, '数据查看', '301', CONCAT('/app/', @quick_code, '/data'), 'category', 1, 301, @operator, NOW()
UNION ALL SELECT @quick_app_id, NULL, '统计', '302', CONCAT('/app/', @quick_code, '/stats'), 'tongji', 1, 302, @operator, NOW()
UNION ALL SELECT @quick_app_id, NULL, '导入', '303', '', '', 1, 303, @operator, NOW()
UNION ALL SELECT @quick_app_id, NULL, '导出', '304', '', '', 1, 304, @operator, NOW()
) t
WHERE @quick_app_id > 0
AND NOT EXISTS (SELECT 1 FROM app_module m WHERE m.app_id = t.app_id AND m.custom_id = t.custom_id);
-- ----------------------------------------------------------------------------
-- 步骤 3:授权 + 开通(方案 §6.1 三层:①应用开通(校级)②模块授权(组/角色)③数据范围)
-- 判定口径(UserBase 侧现成实现):最终模块权限 =(组授权模块 ∩ 已开通应用)∪(启用角色授权模块)。
-- 组授权路径**必须先开通应用**(开通不继承,子组要自己开通或挂在已开通的祖先链上)。
-- ----------------------------------------------------------------------------
-- 3.1 中心管理员组(示例:把 @grp_admin 换成目标学校的管理员组 id;sys_group 里查)
SET @grp_admin = 0; -- ← 中心管理员组
SET @grp_all = 0; -- ← 全员组(学校根组/基础组,"我的填写"给它)
SET @grp_teacher = 0; -- ← 快应用教师组(数据查看 + 导出锚点)
SET @grp_student = 0; -- ← 快应用学生组(只给填报行)
-- 3.2 应用开通(中心 + 快应用各一条;@school_id 是要开通的学校 id)
SET @school_id = 0; -- ← 目标学校 id
INSERT INTO sys_group_app_rel (group_id, app_id, create_user, create_time)
SELECT g.group_id, a.app_id, @operator, NOW()
FROM (SELECT @grp_admin AS group_id, @center_app_id AS app_id UNION ALL
SELECT @grp_all, @center_app_id UNION ALL
SELECT @grp_teacher, @quick_app_id UNION ALL
SELECT @grp_student, @quick_app_id) a
JOIN (SELECT 1 AS dummy) g ON a.group_id > 0
WHERE NOT EXISTS (SELECT 1 FROM sys_group_app_rel r
WHERE r.group_id = a.group_id AND r.app_id = a.app_id);
-- 3.3 中心模块授权:管理员组=前五行(除锚点),全员组=只"我的填写",
-- 要看全校数据的组=额外勾"数据全量"锚点行(customId=fc:scope:all)
INSERT INTO sys_group_module_perm (group_id, module_id, inherit_flag, create_user, create_time)
SELECT @grp_admin, m.id, 0, @operator, NOW()
FROM app_module m
WHERE m.app_id = @center_app_id AND @grp_admin > 0 AND COALESCE(m.custom_id,'') <> 'fc:scope:all'
AND NOT EXISTS (SELECT 1 FROM sys_group_module_perm p WHERE p.group_id = @grp_admin AND p.module_id = m.id);
INSERT INTO sys_group_module_perm (group_id, module_id, inherit_flag, create_user, create_time)
SELECT @grp_all, m.id, 0, @operator, NOW()
FROM app_module m
WHERE m.app_id = @center_app_id AND @grp_all > 0 AND m.path = '/my'
AND NOT EXISTS (SELECT 1 FROM sys_group_module_perm p WHERE p.group_id = @grp_all AND p.module_id = m.id);
-- 数据全量锚点(V42 的授权动作;只给确实要看全校数据的组)
INSERT INTO sys_group_module_perm (group_id, module_id, inherit_flag, create_user, create_time)
SELECT @grp_admin, m.id, 0, @operator, NOW()
FROM app_module m
WHERE m.app_id = @center_app_id AND @grp_admin > 0 AND m.custom_id = 'fc:scope:all'
AND NOT EXISTS (SELECT 1 FROM sys_group_module_perm p WHERE p.group_id = @grp_admin AND p.module_id = m.id);
-- 3.4 快应用模块授权:教师组=数据查看+统计+导出锚点(不给填报也算得出数据);学生组=只给填报行
INSERT INTO sys_group_module_perm (group_id, module_id, inherit_flag, create_user, create_time)
SELECT @grp_teacher, m.id, 0, @operator, NOW()
FROM app_module m
WHERE m.app_id = @quick_app_id AND @grp_teacher > 0 AND COALESCE(m.custom_id,'') <> '303' -- 303=导入(1.5 预留位不授)
AND NOT EXISTS (SELECT 1 FROM sys_group_module_perm p WHERE p.group_id = @grp_teacher AND p.module_id = m.id);
INSERT INTO sys_group_module_perm (group_id, module_id, inherit_flag, create_user, create_time)
SELECT @grp_student, m.id, 0, @operator, NOW()
FROM app_module m
WHERE m.app_id = @quick_app_id AND @grp_student > 0 AND m.level = 1 AND m.path LIKE '/f/%'
AND NOT EXISTS (SELECT 1 FROM sys_group_module_perm p WHERE p.group_id = @grp_student AND p.module_id = m.id);
-- 3.5 ⚠ 过渡口径(T9 报告 §5.4 末段 / 问题单 P78 附注,需裁决后才可能取消这一步):
-- 快应用的数据视图复用 /api/form/admin/data/**,而后端守卫扫的是【中心会话树】(签名调用方=表单中心自己),
-- 不消费快应用自己的"数据查看"行。只勾快应用行的教师组进应用数据视图会被后端拒。
-- 演练要端到端跑通,就必须给同一批组**再勾中心的 /admin/data 行**(下面这条就是干这个的)。
-- 这不是"权限放宽"的偷懒——它把两棵树的关系显式写在登记脚本里,避免演练时把它当 bug 报。
INSERT INTO sys_group_module_perm (group_id, module_id, inherit_flag, create_user, create_time)
SELECT @grp_teacher, m.id, 0, @operator, NOW()
FROM app_module m
WHERE m.app_id = @center_app_id AND @grp_teacher > 0 AND m.path = '/admin/data'
AND NOT EXISTS (SELECT 1 FROM sys_group_module_perm p WHERE p.group_id = @grp_teacher AND p.module_id = m.id);
-- ----------------------------------------------------------------------------
-- 步骤 4:验收(对应任务书 T9 演练清单与手册 §2.2"验证"行)
-- ①超管登录中心:/form-center/api/base/GetUserMod 返回步骤 1 的树(前端导航恰 5 项,锚点行不出现)
-- ②无授权用户:只见"我的填写"
-- ③快应用上下文:/form-center/api/base/GetUserMod?appCode=fc-drill 返回步骤 2 那棵树
-- ⚠ 这条要 **H4 上线**(UserBase /oapi/user/modules 的可选 appCode 参数)+ 中心消费侧接线才通;
-- 中心侧当前按 P3 返回空树+warn,见 T9 报告问题单 P78。
-- ④教师组账号进 /form-center/app/fc-drill/data:导出按钮出现(锚点判定生效,P76)
-- ⑤学生组账号:V16 门户可见性由开通闸门决定;进应用只见填报
-- ----------------------------------------------------------------------------
SELECT '中心模块行数(期望 6)' AS chk, COUNT(*) AS n FROM app_module WHERE app_id = @center_app_id
UNION ALL
SELECT '快应用模块行数(期望 5)', COUNT(*) FROM app_module WHERE app_id = @quick_app_id
UNION ALL
SELECT '中心 path 空锚点数(期望 1)', COUNT(*) FROM app_module
WHERE app_id = @center_app_id AND (path IS NULL OR path = '')
UNION ALL
SELECT '已开通中心应用的组数', COUNT(*) FROM sys_group_app_rel WHERE app_id = @center_app_id
UNION ALL
SELECT '已授权中心模块的组-行对数', COUNT(*) FROM sys_group_module_perm p
JOIN app_module m ON m.id = p.module_id WHERE m.app_id = @center_app_id;
-- ----------------------------------------------------------------------------
-- 回滚(演练完要撤就跑这段;顺序与建立相反)
-- DELETE p FROM sys_group_module_perm p JOIN app_module m ON m.id=p.module_id
-- WHERE m.app_id IN (@center_app_id, @quick_app_id);
-- DELETE FROM sys_group_app_rel WHERE app_id IN (@center_app_id, @quick_app_id);
-- DELETE FROM app_module WHERE app_id IN (@center_app_id, @quick_app_id);
-- 应用行不在此删(D7:应用身份由超管在管理端维护,脚本不碰主数据删除)
-- ----------------------------------------------------------------------------
@@ -0,0 +1,662 @@
package com.yunhai.formcenter.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.yunhai.formcenter.enums.FormEnums;
import com.yunhai.formcenter.model.AnswerModels;
import com.yunhai.formcenter.model.FormModels;
import com.yunhai.formcenter.model.ReadModels;
import com.yunhai.platform.utils.model.BoaModule;
import com.yunhai.platform.utils.model.BoaRoleInfo;
import com.yunhai.platform.utils.model.BoaUser;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Primary;
import org.springframework.jdbc.core.JdbcTemplate;
import ch.qos.logback.classic.Level;
import ch.qos.logback.classic.Logger;
import ch.qos.logback.classic.spi.ILoggingEvent;
import ch.qos.logback.core.read.ListAppender;
import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Collectors;
/**
* T9 压测探针(自验项 <b>V10</b> 的量化半边 + 问题单 <b>P26/P35/P37/P44</b> 的实测口径)。
*
* <p><b>命名即隔离</b>:类名不带 Test/IT 后缀 → 默认 {@code mvn test} 门禁不收集
* (pom surefire includes 只收 {@code *Test}/{@code *IT}),手点才跑:
* {@code mvn -B -o test -Dtest=Perf200kProbe}。20 万级造数与 20s 级导出不该进每轮门禁。</p>
*
* <p><b>独立库</b>:造数落 {@code bsch_formcenter_perf}(URL 带 createDatabaseIfNotExist=true、
* ddl-auto=update),不碰开发库 bsch_formcenter;压测数据<b>必须真提交</b>(要的是真实表与真实索引),
* 跑完由 local-debug 的清理命令整库 DROP。</p>
*
* <p><b>量级(V10 原文)</b>:全库 20 万答卷、单表单典型 2 万 → 10 张表单 × 2 万;
* searchable 四字段各写索引行(≈78 万),每 50 行置 1 行软删(让谓词真实参与)。</p>
*
* <p><b>口径边界(报告要点名)</b>:测的是 <b>service+SQL 层</b>延迟,不含 HTTP/CAS/JSON 序列化
* 的用户面开销,也不含 UserBase 签名调用的网络耗时(模块树被桩顶掉;P44 单独量化<b>每请求取树次数</b>)。
* 本机 MySQL 8.0.45 与 160 容器网 mysql8 同大版本。</p>
*/
@SpringBootTest(properties = {
"spring.datasource.url=jdbc:mysql://localhost:3306/bsch_formcenter_perf?createDatabaseIfNotExist=true"
+ "&useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai"
+ "&allowPublicKeyRetrieval=true",
"spring.jpa.hibernate.ddl-auto=update"
})
@DisplayName("T9 压测探针(V10 阈值 + P26 重号率 + P35 索引选择性 + P37 缓存语义 + P44 取树次数)")
class Perf200kProbe {
private static final long SCH = 990600L;
private static final String APP = "form-center";
private static final int FORMS = 10;
private static final int PER_FORM = 20000;
private static final String SCHEMA = "{\"groups\":[{\"key\":\"base\",\"label\":\"基本信息\",\"fields\":["
+ "{\"key\":\"name\",\"label\":\"姓名\",\"type\":\"text\",\"required\":true,\"searchable\":true},"
+ "{\"key\":\"score\",\"label\":\"分数\",\"type\":\"number\",\"precision\":1,\"searchable\":true},"
+ "{\"key\":\"grade\",\"label\":\"年级\",\"type\":\"select\",\"required\":true,\"searchable\":true,"
+ "\"options\":[{\"label\":\"高一\",\"value\":\"g1\"},{\"label\":\"高二\",\"value\":\"g2\"},"
+ "{\"label\":\"高三\",\"value\":\"g3\"},{\"label\":\"初三\",\"value\":\"g4\"}]},"
+ "{\"key\":\"birthday\",\"label\":\"出生日期\",\"type\":\"date\",\"searchable\":true},"
+ "{\"key\":\"remark\",\"label\":\"备注\",\"type\":\"textarea\"}"
+ "]}]}";
/** 与 Criteria 生成同形的手写语句(EXPLAIN 用,绑定值换成字面量;对照日志里取到的真实 SQL) */
private static final String SQL_LIST_FIRST_PAGE =
"select a.id from answer a where a.sch_id=990600 and a.app_code='form-center' and a.code='perf_f01' "
+ "and a.is_deleted=0 order by a.id desc limit 20";
private static final String SQL_LIST_KEYSET_DEEP =
"select a.id from answer a where a.sch_id=990600 and a.app_code='form-center' and a.code='perf_f01' "
+ "and a.is_deleted=0 and a.id<18000 order by a.id desc limit 20";
private static final String SQL_COUNT =
"select count(a.id) from answer a where a.sch_id=990600 and a.app_code='form-center'"
+ " and a.code='perf_f01' and a.is_deleted=0";
private static final String SQL_GROUP_BY_INDEX =
"select x.v_str, count(distinct x.answer_id) from answer_index x where x.sch_id=990600"
+ " and x.code='perf_f01' and x.field_key='grade' group by x.v_str";
@Autowired
private JdbcTemplate jdbc;
@Autowired
private FormDefService defService;
@Autowired
private AnswerService answerService;
@Autowired
private AnswerReadService readService;
@Autowired
private AnswerStatsService statsService;
@Autowired
private AnswerExportService exportService;
@Autowired
private CountingModuleTreeSource moduleTree;
private final List<String> report = new ArrayList<>();
private final List<String> sqlLog = new ArrayList<>();
private final AtomicInteger userSeq = new AtomicInteger(910000);
/** 模块树桩的计数版(P44):桩顶掉签名调用的同时把<b>调用次数</b>留下来,否则 P44 只能靠读代码猜 */
static class CountingModuleTreeSource extends InMemoryModuleTreeSource {
final AtomicInteger calls = new AtomicInteger();
@Override
public List<BoaModule> authorizedModules(BoaUser user) {
calls.incrementAndGet();
return super.authorizedModules(user);
}
}
@TestConfiguration
static class PerfGatewayConfiguration {
@Bean
@Primary
CountingModuleTreeSource countingModuleTreeSource() {
return new CountingModuleTreeSource();
}
@Bean
@Primary
InMemorySubjectDirectory perfSubjectDirectory() {
return new InMemorySubjectDirectory();
}
}
@Test
@DisplayName("V10:20 万答卷下列表/筛选/统计/导出量化阈值 + 计划断言(不含 values 列、深分页走 keyset)")
void runAll() throws Exception {
BoaUser manager = manager();
long setupStart = System.nanoTime();
seedDefinitions();
seedAnswers();
log("SETUP 造数耗时(ms)=" + ms(System.nanoTime() - setupStart) + " 答卷行=" + count("answer")
+ " 索引行=" + count("answer_index") + " 库=bsch_formcenter_perf 租户域=" + SCH);
String uuid = formUuidOf("perf_f01");
v10ListAndFilter(manager, uuid);
v10DeepKeyset(manager, uuid);
v10Stats(manager, uuid);
v10Export(manager, uuid);
plans(manager, uuid);
p37CacheSemantics(manager, uuid);
p44TreeCalls(manager, uuid);
p26AnswerNoDuplicates();
write();
}
// ==================== 造数 ====================
private void seedDefinitions() {
Integer forms = jdbc.queryForObject("select count(*) from form where sch_id=" + SCH, Integer.class);
if (forms != null && forms >= FORMS + 1) {
log("SEED 定义行已存在(" + forms + "),跳过重建");
return;
}
for (int i = 1; i <= FORMS; i++) {
String code = String.format("perf_f%02d", i);
String uuid = createForm(code, "压测表单 " + code, SCHEMA);
defService.publish(SCH, uuid, "压测种子");
}
// P26 专用:开报名号 + 不限次(问题单点名的长尾组合)
String noUuid = createForm("perf_answerno", "压测表单 报名号",
"{\"groups\":[{\"key\":\"g\",\"label\":\"组\",\"fields\":["
+ "{\"key\":\"name\",\"label\":\"姓名\",\"type\":\"text\",\"required\":true}]}]}");
defService.publish(SCH, noUuid, null);
FormModels.FormConfigRequest config = new FormModels.FormConfigRequest();
config.setUuid(noUuid);
config.setAnswerNoEnabled(1);
config.setFillLimit(FormEnums.FillLimit.UNLIMITED.getCode());
defService.updateConfig(SCH, config);
}
private String createForm(String code, String name, String schemaJson) {
FormModels.FormCreateRequest create = new FormModels.FormCreateRequest();
create.setCode(code);
create.setName(name);
String uuid = defService.create(SCH, create).getFormUuid();
FormModels.FormSaveRequest save = new FormModels.FormSaveRequest();
save.setUuid(uuid);
save.setSchemaJson(schemaJson);
defService.save(SCH, save);
return uuid;
}
/**
* 答卷造数:先建 2 万行的数字辅助表(自乘倍增,无递归 CTE——沿方案 §7.7 的 5.7 保守口径),
* 再按表单批量插入。<b>索引行只给未删行的 searchable 字段写</b>,与线上写入器同规则(V3/P28)。
*/
private void seedAnswers() {
int answers = count("answer");
if (answers >= FORMS * PER_FORM) {
log("SEED 答卷已就位(" + answers + "),跳过造数");
if (count("answer_index") == 0) {
seedIndexRows();
}
return;
}
jdbc.execute("DROP TABLE IF EXISTS perf_num");
jdbc.execute("CREATE TABLE perf_num (n INT NOT NULL, PRIMARY KEY (n)) ENGINE=InnoDB");
jdbc.update("INSERT INTO perf_num (n) VALUES (1)");
while (jdbc.queryForObject("select count(*) from perf_num", Integer.class) < PER_FORM) {
Integer max = jdbc.queryForObject("select max(n) from perf_num", Integer.class);
jdbc.update("insert into perf_num (n) select n + ? from perf_num", max);
}
for (int i = 1; i <= FORMS; i++) {
String code = String.format("perf_f%02d", i);
jdbc.update("insert into answer (uuid, form_uuid, sch_id, app_code, code, version, submitter_id,"
+ " subject_id, status, values_json, answer_no, idempotency_key, is_deleted,"
+ " create_time, update_time, create_by, update_by)"
+ " select replace(uuid(),'-',''), ?, ?, ?, ?, 1, 300000 + mod(n.n, 9000), null,"
+ " case when mod(n.n,3)=0 then 'done' else 'pending' end, n.val, null,"
+ " replace(uuid(),'-',''), case when mod(n.n,50)=0 then 1 else 0 end,"
+ " date_sub(now(), interval n.n minute), date_sub(now(), interval n.n minute),"
+ " 'perf', 'perf'"
+ " from (select n.n as n, json_object("
+ " 'name', concat('stu', lpad(mod(n.n,4000),4,'0')),"
+ " 'score', mod(n.n,101),"
+ " 'grade', elt(1 + mod(n.n,4),'g1','g2','g3','g4'),"
+ " 'birthday', date_format(date_sub(curdate(), interval mod(n.n,400) day),'%Y-%m-%d'),"
+ " 'remark', repeat('若',30)) as val"
+ " from perf_num n where n.n <= ?) n",
formUuidOf(code), SCH, APP, code, PER_FORM);
}
seedIndexRows();
}
/**
* 索引行造数:与线上 {@code AnswerIndexService} 同规则——只给<b>未删</b>答卷的 searchable 字段写行,
* 文本/选项进 v_str、数值进 v_num、日期进 v_date(四条语句各写一列,位置对齐表列,不玩动态拼接)。
*/
private void seedIndexRows() {
String head = "insert into answer_index (answer_id, sch_id, code, field_key, v_str, v_num, v_date,"
+ " create_time, update_time, create_by, update_by) select a.id, a.sch_id, a.code, ";
String tail = ", now(), now(), 'perf', 'perf' from answer a where a.sch_id=" + SCH + " and a.is_deleted=0";
jdbc.update(head + "'name', json_unquote(json_extract(a.values_json,'$.name')), null, null" + tail);
jdbc.update(head + "'grade', json_unquote(json_extract(a.values_json,'$.grade')), null, null" + tail);
jdbc.update(head + "'score', null, cast(json_unquote(json_extract(a.values_json,'$.score'))"
+ " as decimal(20,6)), null" + tail);
jdbc.update(head + "'birthday', null, null,"
+ " cast(json_unquote(json_extract(a.values_json,'$.birthday')) as date)" + tail);
jdbc.execute("analyze table answer");
jdbc.execute("analyze table answer_index");
}
// ==================== V10 四段 ====================
private void v10ListAndFilter(BoaUser manager, String uuid) {
ReadModels.ListRequest first = new ReadModels.ListRequest();
first.setFormUuid(uuid);
first.setSize(20);
sample("V10 列表 首屏(无筛选,20 行)", 60, () -> readService.list(manager, SCH, first), "阈值 p95≤1500");
ReadModels.ListRequest filtered = new ReadModels.ListRequest();
filtered.setFormUuid(uuid);
filtered.setSize(20);
filtered.setFilters(Arrays.asList(eq("grade", "g2"), range("score", "30", "80"), prefix("name", "stu0")));
sample("V10 列表 三条件筛选(select 等值 + number 区间 + text 前缀,均在 SQL 层)", 60,
() -> readService.list(manager, SCH, filtered), "阈值 p95≤1500");
ReadModels.ListRequest statusOnly = new ReadModels.ListRequest();
statusOnly.setFormUuid(uuid);
statusOnly.setSize(20);
statusOnly.setStatus("done");
sample("V10 列表 状态过滤", 30, () -> readService.list(manager, SCH, statusOnly), "阈值 p95≤1500");
}
private void v10DeepKeyset(BoaUser manager, String uuid) {
String deepCursor = jdbc.queryForObject("select uuid from answer where code='perf_f01' and sch_id=" + SCH
+ " and is_deleted=0 order by id desc limit 1 offset 15000", String.class);
ReadModels.ListRequest deep = new ReadModels.ListRequest();
deep.setFormUuid(uuid);
deep.setSize(20);
deep.setCursor(deepCursor);
sample("V10 列表 深分页 keyset(游标在第 15000 行之后)", 40, () -> readService.list(manager, SCH, deep),
"阈值 p95≤1500;与下面裸 OFFSET 对照");
long offsetMs = timed(5, () -> jdbc.queryForList(
"select id from answer where sch_id=" + SCH + " and app_code='" + APP + "' and code='perf_f01'"
+ " and is_deleted=0 order by id desc limit 15000,20"));
log("V10 对照 裸 LIMIT 15000,20 平均(ms)=" + offsetMs + " —— keyset 不吃这个扫描成本(铁律②)");
}
private void v10Stats(BoaUser manager, String uuid) {
ReadModels.QueryRequest total = new ReadModels.QueryRequest();
total.setFormUuid(uuid);
sample("V10 统计 总数+按状态", 20, () -> statsService.stats(manager, SCH, total), "阈值 p95≤3000");
ReadModels.QueryRequest grouped = new ReadModels.QueryRequest();
grouped.setFormUuid(uuid);
grouped.setGroupByField("grade");
sample("V10 统计 按字段分组(grade)", 20, () -> statsService.stats(manager, SCH, grouped), "阈值 p95≤3000");
ReadModels.QueryRequest filteredStats = new ReadModels.QueryRequest();
filteredStats.setFormUuid(uuid);
filteredStats.setFilters(Collections.singletonList(eq("grade", "g2")));
sample("V10 统计 带筛选分组", 20, () -> statsService.stats(manager, SCH, filteredStats), "阈值 p95≤3000");
}
private void v10Export(BoaUser manager, String uuid) {
ReadModels.QueryRequest req = new ReadModels.QueryRequest();
req.setFormUuid(uuid);
for (int i = 0; i < 3; i++) {
long start = System.nanoTime();
long rows = exportService.export(manager, SCH, req, new ByteArrayOutputStream());
log("V10 导出 单表单全量(2 万级)第 " + (i + 1) + " 轮:行数=" + rows + " 耗时(ms)="
+ ms(System.nanoTime() - start) + " 阈值=20000");
}
ReadModels.QueryRequest filtered = new ReadModels.QueryRequest();
filtered.setFormUuid(uuid);
filtered.setFilters(Collections.singletonList(eq("grade", "g1")));
long start = System.nanoTime();
long rows = exportService.export(manager, SCH, filtered, new ByteArrayOutputStream());
log("V10 导出 带筛选(grade=g1):行数=" + rows + " 耗时(ms)=" + ms(System.nanoTime() - start));
}
// ==================== 铁律与计划取证 ====================
private void plans(BoaUser manager, String uuid) {
List<String> statements = captureSql(manager, uuid);
sqlLog.addAll(statements);
Optional<String> phase1 = statements.stream()
.filter(s -> s.startsWith("select") && s.contains("from answer ") && !s.contains("answer_index")
&& !s.toLowerCase().contains("count("))
.findFirst();
if (!phase1.isPresent()) {
log("铁律① 取证失败:日志里没抓到 answer 主表查询(见 perf-sql-and-plans.txt)");
} else {
String sql = phase1.get();
String selectList = sql.substring("select".length(), sql.indexOf(" from answer ")).trim();
log("铁律① 第一段 select 列=[" + selectList + "] 含 values_json=" + sql.contains("values_json")
+ "(期望 false)");
// 游标在本机日志里是内联字面量(id<19980)或占位符(id<?)两种形态都算 keyset 证据
boolean keyset = statements.stream().anyMatch(s -> s.matches("(?s).*\\.id\\s*<\\s*(\\d+|\\?).*"));
log("铁律② 抓到的语句里存在 keyset 谓词(id<?)=" + keyset + "(本次带游标调用,见下条 EXPLAIN)");
log("铁律② 带游标的第一段 SQL=" + trim(statements.stream()
.filter(s -> s.contains("<") && s.contains("from answer ") && !s.contains("answer_index"))
.reduce((a, b) -> b).orElse("-")));
}
for (String sqlText : Arrays.asList(SQL_LIST_FIRST_PAGE, SQL_LIST_KEYSET_DEEP, SQL_COUNT, SQL_GROUP_BY_INDEX)) {
String json = jdbc.queryForObject("explain format=json " + sqlText, String.class);
sqlLog.add("EXPLAIN " + sqlText + "\n" + json);
log("EXPLAIN " + trim(sqlText) + " → " + pickKeys(json));
}
log("P35 idx_answer_form_time 选择性(information_schema.statistics):" + jdbc.queryForList(
"select index_name, seq_in_index, column_name, cardinality from information_schema.statistics"
+ " where table_schema='bsch_formcenter_perf' and table_name='answer'"
+ " and index_name in ('idx_answer_form','idx_answer_form_time') order by index_name,"
+ " seq_in_index").stream().map(m -> m.toString()).collect(Collectors.joining(" ; ")));
log("表规模与行格式(P10/P31 同项在 160 的核对手法):" + jdbc.queryForList(
"select table_name, row_format, table_rows, data_length from information_schema.tables"
+ " where table_schema='bsch_formcenter_perf' and table_name in ('answer','answer_index')"
+ " order by table_name").stream().map(m -> m.toString()).collect(Collectors.joining(" ; ")));
}
/** 打开 org.hibernate.SQL 日志抓真实生成语句(含一次带游标的翻页,让 keyset 谓词出现在样本里) */
private List<String> captureSql(BoaUser manager, String uuid) {
Logger sql = (Logger) LoggerFactory.getLogger("org.hibernate.SQL");
Level old = sql.getLevel();
ListAppender<ILoggingEvent> appender = new ListAppender<>();
appender.start();
sql.addAppender(appender);
sql.setLevel(Level.DEBUG);
try {
ReadModels.ListRequest page = new ReadModels.ListRequest();
page.setFormUuid(uuid);
page.setSize(20);
ReadModels.ListView firstPage = readService.list(manager, SCH, page);
page.setCursor(firstPage.getNextCursor());
readService.list(manager, SCH, page);
ReadModels.ListRequest filtered = new ReadModels.ListRequest();
filtered.setFormUuid(uuid);
filtered.setSize(20);
filtered.setFilters(Collections.singletonList(eq("grade", "g2")));
readService.list(manager, SCH, filtered);
ReadModels.QueryRequest stats = new ReadModels.QueryRequest();
stats.setFormUuid(uuid);
stats.setGroupByField("grade");
statsService.stats(manager, SCH, stats);
} finally {
sql.detachAppender(appender);
sql.setLevel(old);
}
return appender.list.stream().map(ILoggingEvent::getFormattedMessage).map(String::trim)
.filter(s -> s.toLowerCase().startsWith("select")).collect(Collectors.toList());
}
// ==================== P26 / P37 / P44 ====================
private void p37CacheSemantics(BoaUser manager, String uuid) {
ReadModels.QueryRequest req = new ReadModels.QueryRequest();
req.setFormUuid(uuid);
req.setGroupByField("grade");
statsService.stats(manager, SCH, req);
long baseline = statsService.stats(manager, SCH, req).getTotal();
// ① 新答卷不失效(方案 §5.5 v1.2:提交是高频事件,每次清缓存等于没有缓存,容忍 N 分钟延迟)
// 自填身份:普通用户 subject 只能=自己(V17),这里干脆不传 subject,走"仅本人提交"的常态路径
answerService.submit(plainUser(userSeq.incrementAndGet()), submitFor("perf_f01",
"{\"name\":\"缓存语义\",\"score\":7,\"grade\":\"g2\",\"birthday\":\"2008-01-01\",\"remark\":\"P37\"}"));
ReadModels.StatsView afterSubmit = statsService.stats(manager, SCH, req);
log("P37 新答卷后:total=" + afterSubmit.getTotal() + "(基线 " + baseline + ")cached="
+ afterSubmit.getCached() + " → 期望 cached=true 且 total 未即时反映新行");
// ② 升版才失效
FormModels.FormSaveRequest save = new FormModels.FormSaveRequest();
save.setUuid(uuid);
save.setSchemaJson(SCHEMA.replace("{\"key\":\"remark\"",
"{\"key\":\"extra\",\"label\":\"附加\",\"type\":\"text\"},{\"key\":\"remark\""));
defService.save(SCH, save);
defService.publish(SCH, uuid, "P37 升版失效验证");
ReadModels.StatsView afterPublish = statsService.stats(manager, SCH, req);
log("P37 升版后:total=" + afterPublish.getTotal() + " cached=" + afterPublish.getCached()
+ " → 期望 cached=false(键里带当前发布 version,换版即换键)");
}
private void p44TreeCalls(BoaUser manager, String uuid) {
ReadModels.ListRequest list = new ReadModels.ListRequest();
list.setFormUuid(uuid);
list.setSize(20);
ReadModels.QueryRequest stats = new ReadModels.QueryRequest();
stats.setFormUuid(uuid);
// 详情用的 uuid 先取好——否则取首屏那一次 list 会被算进详情的计数里
String detailUuid = cursorOfFirstRow(manager, list);
log("P44 每请求取树次数:列表=" + probe(() -> readService.list(manager, SCH, list))
+ " 统计=" + probe(() -> statsService.stats(manager, SCH, stats))
+ " 导出=" + probe(() -> exportService.export(manager, SCH, stats, new ByteArrayOutputStream()))
+ " 详情=" + probe(() -> readService.detail(manager, SCH, detailUuid)));
log("P44 口径:阈值内(V10 p95 达标)按一期现状保持,不改成控制器内一次取树两处复用;"
+ "生产侧每次取树=一次 UserBase 签名调用,网络耗时不在本探针口径内(类注释已记档)");
}
private String cursorOfFirstRow(BoaUser manager, ReadModels.ListRequest list) {
return readService.list(manager, SCH, list).getRows().get(0).getAnswerId();
}
/** 跑一次并返回这段时间里模块树被取了几次 */
private int probe(Runnable body) {
int before = moduleTree.calls.get();
body.run();
return moduleTree.calls.get() - before;
}
/** P26:answer_no 当日序号 count+1 的重号率——并发真实提交才量得出来(不限次 + 报名号的长尾组合) */
private void p26AnswerNoDuplicates() throws Exception {
int threads = 8;
int perThread = 250;
ExecutorService pool = Executors.newFixedThreadPool(threads);
List<Callable<Integer>> jobs = new ArrayList<>();
for (int t = 0; t < threads; t++) {
final int seed = t * perThread;
jobs.add(() -> {
Integer uid = userSeq.incrementAndGet();
BoaUser user = plainUser(uid);
int ok = 0;
for (int i = 0; i < perThread; i++) {
try {
answerService.submit(user, submitFor("perf_answerno",
"{\"name\":\"并发" + (seed + i) + "\"}"));
ok++;
} catch (Exception ignored) {
// 撞 uk/规则被拒是预期分支;重号率只看落库行
}
}
return ok;
});
}
List<Future<Integer>> futures = pool.invokeAll(jobs);
pool.shutdown();
int accepted = 0;
for (Future<Integer> f : futures) {
accepted += f.get();
}
Integer rows = jdbc.queryForObject("select count(*) from answer where code='perf_answerno'", Integer.class);
Integer distinct = jdbc.queryForObject("select count(distinct answer_no) from answer"
+ " where code='perf_answerno' and answer_no is not null", Integer.class);
Integer maxSeq = jdbc.queryForObject("select ifnull(max(cast(substring_index(answer_no,'-',-1) as unsigned)),"
+ " 0) from answer where code='perf_answerno'", Integer.class);
int duplicated = rows == null || distinct == null ? 0 : rows - distinct;
log("P26 answer_no 重号率:并发提交成功=" + accepted + " 落库行=" + rows + " 不同报名号=" + distinct
+ " 重号行=" + duplicated + " 最大当日序号=" + maxSeq
+ "(一期无 uk,重号=真重复;裁决触发条件=暴露真实需求,届时另立问题单加 uk+撞号重试)");
}
// ==================== 小工具 ====================
private BoaUser manager() {
BoaUser user = new BoaUser();
user.setId(900001);
user.setType(2);
user.setRoleIds(Collections.singletonList(7));
user.setCurrentRole(role(7));
moduleTree.grantManageDefaults(900001);
return user;
}
private BoaUser plainUser(Integer id) {
BoaUser user = new BoaUser();
user.setId(id);
user.setType(0);
user.setRoleIds(Collections.singletonList(3));
user.setCurrentRole(role(3));
return user;
}
private static BoaRoleInfo role(Integer roleId) {
BoaRoleInfo info = new BoaRoleInfo();
info.setRoleId(roleId);
info.setSchId((int) SCH);
return info;
}
private AnswerModels.SubmitRequest submitFor(String code, String valuesJson) {
AnswerModels.SubmitRequest req = new AnswerModels.SubmitRequest();
req.setFormCode(code);
req.setIdempotencyKey(java.util.UUID.randomUUID().toString().replace("-", ""));
req.setValues(json(valuesJson));
return req;
}
private static JsonNode json(String body) {
try {
return com.yunhai.formcenter.common.FormSchemaValidator.mapper().readTree(body);
} catch (Exception e) {
throw new IllegalStateException("压测 JSON 写错了:" + body, e);
}
}
private static ReadModels.FilterItem eq(String key, String value) {
ReadModels.FilterItem item = new ReadModels.FilterItem();
item.setKey(key);
item.setValue(value);
return item;
}
private static ReadModels.FilterItem range(String key, String from, String to) {
ReadModels.FilterItem item = new ReadModels.FilterItem();
item.setKey(key);
item.setFrom(from);
item.setTo(to);
return item;
}
private static ReadModels.FilterItem prefix(String key, String value) {
return eq(key, value);
}
private String formUuidOf(String code) {
return jdbc.queryForObject("select uuid from form where sch_id=" + SCH + " and code='" + code + "'",
String.class);
}
private int count(String table) {
Integer n = jdbc.queryForObject("select count(*) from " + table + " where sch_id=" + SCH, Integer.class);
return n == null ? 0 : n;
}
/** 预热一次(首查含缓存冷启/计划编译),单独报冷值;随后 times 次取 p50/p95/p99 */
private void sample(String label, int times, Runnable body, String thresholdNote) {
long coldStart = System.nanoTime();
body.run();
long cold = ms(System.nanoTime() - coldStart);
List<Long> samples = new ArrayList<>();
for (int i = 0; i < times; i++) {
long start = System.nanoTime();
body.run();
samples.add(ms(System.nanoTime() - start));
}
Collections.sort(samples);
long p95 = samples.get(Math.min(samples.size() - 1, (int) Math.ceil(samples.size() * 0.95) - 1));
log(label + " | n=" + samples.size() + " 冷启=" + cold + "ms p50=" + samples.get(samples.size() / 2)
+ "ms p95=" + p95 + "ms p99=" + samples.get(Math.min(samples.size() - 1,
(int) Math.ceil(samples.size() * 0.99) - 1)) + "ms max=" + samples.get(samples.size() - 1)
+ "ms | " + thresholdNote);
}
private long timed(int times, Runnable body) {
long total = 0;
for (int i = 0; i < times; i++) {
long start = System.nanoTime();
body.run();
total += ms(System.nanoTime() - start);
}
return total / times;
}
private static long ms(long nanos) {
return nanos / 1_000_000L;
}
private static String trim(String s) {
String one = s.replaceAll("\\s+", " ").trim();
return one.length() > 300 ? one.substring(0, 300) + "…" : one;
}
/** EXPLAIN JSON 里递归收 key/rows/access_type/using_index,只留判据所需 */
private static String pickKeys(String explainJson) {
try {
Set<String> keys = new LinkedHashSet<>();
collect(json(explainJson), keys);
return String.join(" ", keys);
} catch (Exception e) {
return "解析失败:" + e.getMessage();
}
}
private static void collect(JsonNode node, Set<String> out) {
if (node == null || node.isNull()) {
return;
}
if (node.isObject()) {
node.fields().forEachRemaining(entry -> {
String name = entry.getKey();
JsonNode value = entry.getValue();
if ((name.equals("key") || name.equals("rows") || name.equals("rows_examined_per_scan")
|| name.equals("access_type") || name.equals("using_index")
|| name.equals("query_cost") || name.equals("key_length")) && value.isValueNode()) {
out.add(name + "=" + value.asText());
}
collect(value, out);
});
} else if (node.isArray()) {
node.forEach(child -> collect(child, out));
}
}
private void log(String line) {
report.add(line);
System.out.println("[PERF] " + line);
}
private void write() throws Exception {
Path dir = Paths.get(System.getProperty("user.dir"), "..", "local-debug", "perf-20261015");
Files.createDirectories(dir);
Files.write(dir.resolve("perf-summary.txt"), report, StandardCharsets.UTF_8);
Files.write(dir.resolve("perf-sql-and-plans.txt"), sqlLog, StandardCharsets.UTF_8);
System.out.println("[PERF] 证据落盘:" + dir.toAbsolutePath().normalize());
}
}
@@ -0,0 +1,222 @@
package com.yunhai.formcenter.service;
import com.yunhai.formcenter.controller.api.FormAnswerReadController;
import com.yunhai.formcenter.model.ReadModels;
import com.yunhai.platform.utils.model.BoaModule;
import com.yunhai.platform.utils.model.BoaRoleInfo;
import com.yunhai.platform.utils.model.BoaUser;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Primary;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.mock.web.MockHttpServletResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
/**
* T9 压测补探针(<b>控制器形状</b>):{@code Perf200kProbe} 直调服务层,量到的"每请求取树次数"少了一次
* 控制器侧 {@code FormAdminGuard.requireAdmin} 的取树——那正是 P44 问的东西("每请求 1~2 次"里的 2 从哪来)。
* 本探针打 {@link FormAnswerReadController} 的端点方法(会话用户 + 请求体,与线上调用形状一致),
* 把 P44 的数字钉成<b>请求级</b>,并顺带给出 V10 三项在含守卫形状下的延迟。
*
* <p>数据复用 {@code bsch_formcenter_perf}(由 {@code Perf200kProbe} 造好,20 万答卷 / 78 万索引行)。
* 命名不带 Test/IT 后缀 → 默认门禁不收集;手点:{@code mvn -B -o test -Dtest=PerfTreeCallsProbe}。</p>
*/
@SpringBootTest(properties = {
"spring.datasource.url=jdbc:mysql://localhost:3306/bsch_formcenter_perf?createDatabaseIfNotExist=true"
+ "&useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai"
+ "&allowPublicKeyRetrieval=true",
"spring.jpa.hibernate.ddl-auto=none"
})
@DisplayName("T9 压测补探针:请求级取树次数(P44)+ 含守卫形状下的 V10 延迟")
class PerfTreeCallsProbe {
private static final long SCH = 990600L;
private static final int MANAGER_ID = 900001;
@Autowired
private JdbcTemplate jdbc;
@Autowired
private FormDefService defService;
@Autowired
private FormAnswerReadController controller;
@Autowired
private CountingModuleTreeSource moduleTree;
static class CountingModuleTreeSource extends InMemoryModuleTreeSource {
final AtomicInteger calls = new AtomicInteger();
@Override
public List<BoaModule> authorizedModules(BoaUser user) {
calls.incrementAndGet();
return super.authorizedModules(user);
}
}
@TestConfiguration
static class ProbeGatewayConfiguration {
@Bean
@Primary
CountingModuleTreeSource countingModuleTreeSource() {
return new CountingModuleTreeSource();
}
@Bean
@Primary
InMemorySubjectDirectory probeSubjectDirectory() {
return new InMemorySubjectDirectory();
}
}
private final List<String> report = new ArrayList<>();
@Test
@DisplayName("P44:列表/详情/统计/筛选候选/导出 各自一次请求取几棵树;同时给含守卫的请求级延迟")
void requestShape() throws Exception {
BoaUser manager = manager();
String uuid = jdbc.queryForObject("select uuid from form where sch_id=" + SCH + " and code='perf_f01'",
String.class);
String sampleAnswer = jdbc.queryForObject("select uuid from answer where sch_id=" + SCH
+ " and code='perf_f01' and is_deleted=0 order by id desc limit 1", String.class);
ReadModels.ListRequest list = new ReadModels.ListRequest();
list.setFormUuid(uuid);
list.setSize(20);
ReadModels.ListRequest filtered = new ReadModels.ListRequest();
filtered.setFormUuid(uuid);
filtered.setSize(20);
filtered.setFilters(Arrays.asList(eq("grade", "g2"), range("score", "30", "80")));
ReadModels.QueryRequest stats = new ReadModels.QueryRequest();
stats.setFormUuid(uuid);
stats.setGroupByField("grade");
log("P44 请求级取树次数(控制器形状,含 requireAdmin):列表="
+ probe(() -> controller.list(manager, null, list))
+ " 详情=" + probe(() -> controller.detail(manager, null, sampleAnswer))
+ " 统计=" + probe(() -> controller.stats(manager, null, stats))
+ " 筛选候选=" + probe(() -> controller.filterFields(manager, null, uuid))
+ " 导出=" + probe(() -> exportThroughController(manager, stats)));
// 请求级延迟(比服务层多一次守卫取树;桩里取树是内存操作,真 UserBase 是签名 HTTP 调用)
sample("V10 请求级 列表 首屏", 40, () -> controller.list(manager, null, list));
sample("V10 请求级 三条件筛选", 40, () -> controller.list(manager, null, filtered));
sample("V10 请求级 统计(缓存命中态)", 20, () -> controller.stats(manager, null, stats));
long start = System.nanoTime();
int bytes = exportThroughController(manager, stats);
log("V10 请求级 导出(2 万级,写满 MockHttpServletResponse 缓冲区):字节=" + bytes
+ " 耗时(ms)=" + (System.nanoTime() - start) / 1_000_000L + " 阈值=20000");
p36PublishTiming();
Files.write(Paths.get(System.getProperty("user.dir"), "..", "local-debug", "perf-20261015",
"perf-request-shape.txt"), report, StandardCharsets.UTF_8);
}
private int exportThroughController(BoaUser manager, ReadModels.QueryRequest body) {
try {
MockHttpServletResponse response = new MockHttpServletResponse();
controller.export(manager, null, body, response);
return response.getContentAsByteArray().length;
} catch (Exception e) {
throw new IllegalStateException("导出端点调用失败:" + e.getMessage(), e);
}
}
/**
* P36 的实测项:升版发布时同步补建历史索引行({@code backfillAfterPublish})在 2 万行表单上到底多久。
* P36 记档的估计是"2 万级秒级",而 T9 的处置线写的是"若实测不可接受,改点在 FormDefService.publish 一处"——
* 所以要量的是 publish 这一步本身,不是整段流程。
*/
private void p36PublishTiming() {
String uuid = jdbc.queryForObject("select uuid from form where sch_id=" + SCH + " and code='perf_f02'",
String.class);
Integer current = jdbc.queryForObject("select current_version from form where uuid='" + uuid + "'",
Integer.class);
String schema = jdbc.queryForObject("select schema_json from form_version where form_id="
+ "(select id from form where uuid='" + uuid + "') and version=" + current, String.class);
com.yunhai.formcenter.model.FormModels.FormSaveRequest draft =
new com.yunhai.formcenter.model.FormModels.FormSaveRequest();
draft.setUuid(uuid);
draft.setSchemaJson(schema.replace("{\"key\":\"remark\"",
"{\"key\":\"p36_extra\",\"label\":\"P36 计时字段\",\"type\":\"text\",\"searchable\":true},"
+ "{\"key\":\"remark\""));
long saveStart = System.nanoTime();
defService.save(SCH, draft);
long saveMs = (System.nanoTime() - saveStart) / 1_000_000L;
long publishStart = System.nanoTime();
defService.publish(SCH, uuid, "P36 升版补建索引计时(新增 searchable 字段)");
long publishMs = (System.nanoTime() - publishStart) / 1_000_000L;
Integer indexed = jdbc.queryForObject("select count(*) from answer_index where sch_id=" + SCH
+ " and code='perf_f02' and field_key='p36_extra'", Integer.class);
long live = jdbc.queryForObject("select count(*) from answer where sch_id=" + SCH
+ " and code='perf_f02' and is_deleted=0", Long.class);
log("P36 升版补建实测:草稿保存=" + saveMs + "ms;**发布=" + publishMs + "ms**(含同步补建索引);"
+ "本表未删答卷=" + live + " 行,新字段索引行补出=" + indexed + " 行"
+ "(P36 记档的\"2 万级秒级\"与实测的差值见 T9 报告 §4.5)");
}
private int probe(Runnable body) {
int before = moduleTree.calls.get();
body.run();
return moduleTree.calls.get() - before;
}
private void sample(String label, int times, Runnable body) {
long coldStart = System.nanoTime();
body.run();
long cold = (System.nanoTime() - coldStart) / 1_000_000L;
List<Long> ms = new ArrayList<>();
for (int i = 0; i < times; i++) {
long start = System.nanoTime();
body.run();
ms.add((System.nanoTime() - start) / 1_000_000L);
}
Collections.sort(ms);
log(label + " | n=" + ms.size() + " 冷启=" + cold + "ms p50=" + ms.get(ms.size() / 2) + "ms p95="
+ ms.get(Math.min(ms.size() - 1, (int) Math.ceil(ms.size() * 0.95) - 1)) + "ms max="
+ ms.get(ms.size() - 1) + "ms");
}
private BoaUser manager() {
BoaUser user = new BoaUser();
user.setId(MANAGER_ID);
user.setType(2);
user.setRoleIds(Collections.singletonList(7));
BoaRoleInfo current = new BoaRoleInfo();
current.setRoleId(7);
current.setSchId((int) SCH);
user.setCurrentRole(current);
moduleTree.grantManageDefaults(MANAGER_ID);
return user;
}
private static ReadModels.FilterItem eq(String key, String value) {
ReadModels.FilterItem item = new ReadModels.FilterItem();
item.setKey(key);
item.setValue(value);
return item;
}
private static ReadModels.FilterItem range(String key, String from, String to) {
ReadModels.FilterItem item = new ReadModels.FilterItem();
item.setKey(key);
item.setFrom(from);
item.setTo(to);
return item;
}
private void log(String line) {
report.add(line);
System.out.println("[PERF] " + line);
}
}