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:
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user