轻松聘AI开发
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

24 KiB

R01-E 批查询与会话未读数:独立复验报告(第三~十一节)

日期:2026-09-18 分支:refactor/r01-structure-performance HEAD / upstream:b893ed67f078eedb0c4b8a03341311bd79342354(ahead/behind = 0 / 0) 本轮性质:独立复验 —— 不改生产代码、不改迁移、不暂存、不提交、不推送 上游依据:docs/r01-e-batch-query-unread-count-performance-report.md(实施与测试加固报告)


0. 结论

R01-E独立代码与真实MySQL性能复验通过,可以进入精确暂存与独立提交检查点。

复验项 结论
独立查询预算(E1 = 20/20/20,E2 = 3/3/3) 通过(H2 与真实 MySQL 双侧实测)
E1 collector 完整输出 H2/MySQL 深比较 通过(逐字段、含 null/时间/集合顺序/关联数据)
E2 会话未读 H2/MySQL 深比较(10 项语义) 通过
MySQL EXPLAIN FORMAT=JSON 通过(基础表命中既有索引,无需新增 V46)
副作用与隔离(25 张表快照 + 反证 + 零残留) 通过
OBS-E01 保留既有语义,作为产品负责人待决策项上报
独立复验时全量 mvn test / mvn clean verify 2787 / 0 / 0 / 0
提交前收口 mvn clean verify 2788 / 0 / 0 / 0(增加 1 个 EXPLAIN 变异门禁)
OpenAPI 176 paths / 214 operations,openapi-rc.json 未改
生产代码 diff 0 个文件

独立复验新发现(3 项,均如实记录): F1 实施测试的 EXPLAIN 断言 doesNotContain("\"table_name\": \"messages\", ...") 是空断言—— MySQL 的 EXPLAIN JSON 里 table_name 存的是别名(本语句为 m / mr), "table_name": "messages" 永不出现,故该断言无论如何都会通过。结论本身(当时夹具下确实未全扫)成立, 但断言不具备鉴别力。提交前收口已将实施测试改为解析 JSON 树,按别名分别定位 m / mr / ranked,校验 access_type、key、temporary 与 filesort,并以错误索引、 基础表 ALL、缺失节点三类变异证明断言有效。 F2 H2 与 MySQL 对同一 JSON 列的回读表示不同(详见 §4.2),已记录原始值并按语义归一后比较。 F3 实施报告 §9 记 using_filesort=false;本轮在真实规模夹具下实测 using_filesort=true (窗口函数 ROW_NUMBER() 的排序代价),详见 §6.4。


1. 复验范围与不重叠声明

  • 从第三~十一节执行,未重做第一、二节库存,未修改任何生产代码。
  • 复用既有文件(未重复创建):
    • mydreamjob-server/src/test/java/com/mydreamjob/common/r01e/R01EQueryBudgetAcceptanceTest.java(实施测试,11 项)
    • mydreamjob-server/src/test/java/com/mydreamjob/common/r01e/R01EMySqlQueryCountAndExplainTest.java(实施测试,4 项)
    • docs/r01-e-batch-query-unread-count-performance-report.md
  • 独立测试不复用实施测试的任何夹具、断言或辅助方法:夹具、快照(25 张表、列集合不同)、 计数 DataSource、EXPLAIN 解析均为独立实现。

2. 本轮新增文件(未跟踪)

mydreamjob-server/src/test/java/com/mydreamjob/common/r01e/R01EIndependentCrossDatabaseVerificationTest.java   独立测试,16 项
mydreamjob-server/src/test/java/com/mydreamjob/common/r01e/R01EIndependentSideEffectIsolationTest.java        独立测试,10 项
docs/r01-e-independent-verification-20260918.md                                                              本报告

生产代码:0 个文件(git diff --stat 为空)。


3. 独立查询预算验证(任务 1)

方法:独立实现的 CountingDataSource 包装真实 DataSource,记录真实 JDBC 语句文本与条数; 两侧在夹具灌入完成之后才 reset(),故计数不含测试准备与清理 SQL(断言实测语句全部为 SELECT)。

3.1 E1:1 / 10 / 50 条投递

夹具 H2 实测 MySQL 实测 目标
与实施报告同基线的最小夹具(2 用户 + 1 公司 + N 岗位 + N 投递) [20, 20, 20] [20, 20, 20] 20/20/20 ✔
本类丰富夹具(12 个 section 全部有数据)+ N 投递 [22, 22, 22] [22, 22, 22] 恒定 ✔
  • 两者都不随记录数增长;与实施报告「20/20/20」的结论独立复现一致。
  • 丰富夹具为 22(比最小夹具多 2):多出的是「本人持有公司 → offers 企业侧分支」以及 「存在会话 → conversations/messages 两条批量查询」这两个按分支固定的查询,仍与 N 无关。
  • 证据:target/r01e-independent/e1-budget.txt、e1-budget-rich.txt。

3.2 E2:1 / 20 / 100 会话

会话数 H2 实测 MySQL 实测 目标
1 3 3 3 ✔
20 3 3 3 ✔
100 3 3 3 ✔
  • 实测 SQL 文本恰好为 3 条,形态固定且可辨认:
    1. select ... from conversation_members ... where user_id=?
    2. select ... from conversations ... where id in (?,...)
    3. 分组未读查询(含 ROW_NUMBER())——只出现 1 次
  • 证据:target/r01e-independent/e2-budget.txt(含两侧完整 SQL 文本)。

3.3 空会话页不执行未读查询

断言 H2 MySQL
listConversations(无会话用户) 返回空 ✔ ✔
实测语句数 1 1
该语句不含 ROW_NUMBER ✔ ✔
含 row_number 的语句数 0 0

即:空会话页只发出一条成员关系查询后短路返回,不下发未读聚合查询。


4. E1 H2 / MySQL 深比较(任务 2)

4.1 方法

同一个 JVM 内同时持有两个 Spring 上下文:

  • H2 侧:测试自身上下文,jdbc:h2:mem:r01e_ind;MODE=MySQL(本类独占的内存库,Flyway 全量迁移);
  • MySQL 侧:@BeforeAll 手工引导的第二个上下文(Testcontainers mysql:8.4,mysql-it 配置, WebApplicationType.NONE,命令行参数覆盖数据源),Flyway 在容器内真实执行 45 个迁移。

两侧灌入同一份等价夹具(显式时间戳、无 CURRENT_TIMESTAMP 依赖),覆盖 全部 12 个 section, 并额外放入一个「外人」用户的同类数据。

先决条件已实证:bothEnginesAreLiveAndFixtureIdsAgree 断言两侧 27 个自然键 → 主键映射完全相等 (同一 JVM、两侧均为全新建库),故后续可直接比较含主键的完整输出。

4.2 深比较结果

canonicalResult(...) 把 collector 的完整 section 输出规范化为确定性文本:显式呈现 null、 时间按 LocalDateTime 呈现、List 保持原序(集合顺序)、Map 按 key 排序、BigDecimal 去尾零。

e1-h2.txt  ==  e1-mysql.txt      (均 6213 字节,逐字符相同)

JSON 列的表示差异(F2,如实记录)——e1-raw-json.txt 原文:

h2:    profile.skills="{\"java\":3,\"sql\":2}" | preferences.preferredCities="[\"北京\",\"上海\"]"
mysql: profile.skills={"sql": 2, "java": 3} | preferences.preferredCities=["北京", "上海"]
  • H2(MODE=MySQL)把 JSON 列回读为被转义的 JSON 字符串标量;
  • MySQL 8回读为 JSON 对象字面量(键重排、冒号后加空格)。
  • 两者语义相同、表示不同:比较的是语义。规范化对确实是 JSON 的字符串递归解码后按键排序, 非 JSON 字符串原样保留(content、coverLetter 等不受影响)。

反证:把 dump 中任意一个字段值("张三" → "张四")置换后断言必须不再相等——通过, 说明规范化是逐字段敏感的,而不是只比条数、hash 或长度。

4.3 其他断言

断言 结果
section 顺序 精确等于 profile, preferences, resumes, applications, application-timelines, interviews, interview-timelines, offers, offer-timelines, feedback, favorites, notifications, conversations, messages, candidate-scoring, scoring-appeals ✔
关联数据 应用↔时间线、面试↔时间线、Offer↔时间线、会话↔消息的分组关系随主键一并跨库一致 ✔
其他用户数据不得混入 dump 中不含 李四 / 外人的简介 / 外人简历 / 外人求职信 / 外人评价 / 外人通知 / 外人会话 / out-m1 / 外人Offer / 外人理由 / 外人面试 / outsider ✔
跨库自有行数 23 张表的自有行数两侧完全一致 ✔
非 ACTIVE 简历、read_at 为 null / 非 null 的通知、score 为 null 的维度分、tags 为 null 的评价、cover_letter 为 null 的投递 均已覆盖并通过 ✔

5. E2 H2 / MySQL 深比较(任务 3)

10 项语义逐条覆盖,两侧结果逐值一致(会话列表完整视图规范化后逐字符相同):

# 语义 H2 MySQL 结果
1 本人发送未读按现状计入(OBS-E01) c1 = 2(本人 1 + 他人 1) c1 = 2 ✔ 一致
1b 同上,把本人那条标记已读后 1 1 ✔
2 他人发送未读计入 ✔ ✔ ✔
3 message_reads 排除 读 1 条后 2 → 1 同 ✔
3b 另一读者的已读记录不影响本人 不受影响 不受影响 ✔
4 RECALLED 排除 撤回 1 条后 2 → 1 同 ✔
5 多会话分组 c1=2, c2=1, c3=1 同 ✔
6 非当前用户会话不返回 只返回 c1/c2/c3;cOutsider 不在 同 ✔
7 聚合缺失补 0 全部撤回/已读时聚合缺行 → 0 同 ✔
8 最近第 500 条计入、第 501 条排除 501 条未读 → 500;把最旧一条标已读 → 仍 500;把最新一条标已读 → 499 同 ✔
9 created_at 相同的排序语义 同刻 3 条 → 3;其中 1 条已读 → 2;同刻 501 条 → 500 同 ✔
10 空及重复 conversationIds 重复 [c1,c1] 只产 1 行且与 [c1] 逐值相同;空列表由 Service 短路、不下发 IN () 同 ✔

第 8 项证明方式:不止断言「等于 500」,而是通过「标已读最旧一条 → 不变」证明最旧一条本就在窗口之外, 「标已读最新一条 → 499」证明窗口保留的是最新一侧。

第 9 项的诚实边界:ROW_NUMBER() ... ORDER BY m.created_at DESC 没有 tiebreaker, 故当 501 条 created_at 完全相同时,「保留哪 500 条」由引擎决定; 但窗口计数与引擎无关,恒为 500——两侧实测一致。此点已计入遗留风险(R2)。


6. MySQL EXPLAIN FORMAT=JSON(任务 4)

被测语句与 MessageRepository.countUnreadByConversationIds 逐字相同,绑定同一批参数。 夹具:先铺入 500 个背景会话 × 20 条 = 10 000 条消息(不属于查询范围),再替换目标会话—— 否则 messages 表几乎只含查询范围内的行,MySQL 选择全扫是正确的代价决策, 却会让 EXPLAIN 失去验收意义。

6.1 逐表结果(messages / message_reads / 派生结果 ranked 分开报告)

注意:MySQL 的 EXPLAIN FORMAT=JSON 中 table_name 是别名。 被测语句的别名为 messages m、message_reads mr、派生表 ranked, 故表节点分别为 m / mr / ranked。

规模 selectivity ranked(派生结果) messages(别名 m) message_reads(别名 mr) temporary filesort
1 会话 × 3 条 0.01% ALL(派生结果顺序扫描)· rows=3 ref · idx_messages_conversation_created · rows=3 eq_ref · uk_message_reads · rows=1 true true
20 会话 × 3 条 0.27% ALL · rows=60 range · idx_messages_conversation_created · rows=60 eq_ref · uk_message_reads · rows=1 true true
100 会话 × 3 条 1.36% ALL · rows=300 range · idx_messages_conversation_created · rows=300 eq_ref · uk_message_reads · rows=1 true true
1 会话 × 600 条(> 500 窗口) 2.73% ALL · rows=600 ref · idx_messages_conversation_created · rows=600 eq_ref · uk_message_reads · rows=1 true true

判读:

  • 派生结果 ranked 的 access_type = ALL 必须准确表述为「派生结果顺序扫描」—— 这是 MySQL 对 ROW_NUMBER() 派生表物化后顺序读取物化结果的固有形态, possible_keys/key 均为 null(派生表上不存在索引)。不得笼统写成「无全表扫描」。
  • messages 基础表在四个验收断言规模下命中既有索引 idx_messages_conversation_created(ref / range); 更高选择性探测会转为 ALL,详见下一节,不能笼统表述为“没有全表扫描” ✔
  • message_reads 基础表在所有规模下均为 eq_ref 命中 uk_message_reads ✔ (possible_keys 同时列出 idx_message_reads_message / idx_message_reads_reader)
  • possible_keys、key、rows_examined_per_scan、filtered、using_temporary_table、using_filesort 均已逐规模记录,原文见 target/r01e-independent/e2-explain.txt。

6.2 代价拐点(选择性探测,只记录不判定)

规模 selectivity messages 访问方式
2 会话 × 600 条(1 200 行) 5.45% range · idx_messages_conversation_created(命中索引)
5 会话 × 600 条(3 000 行) 13.63% ALL · key=null · rows=13 093(转为全扫)
20 会话 × 600 条(12 000 行) 54.53% ALL · key=null · rows=22 016(转为全扫)

结论:拐点被夹逼在 5.45% 与 13.63% 之间。也就是说, 只有当「被查询的会话集合占 messages 全表 6% 以上」时,MySQL 才会放弃索引改走全扫—— 这是代价决策,不是缺索引。真实场景中一个用户的会话列表远低于此比例, 故本项判定 PASS,不新增 V46(严格遵守指令)。

6.3 using_temporary_table = true 的归属

true 出现在 grouping_operation 层,是 GROUP BY ranked.conversation_id 的固有代价, 与派生表物化同源,不是新增索引可以消除的。

6.4 using_filesort 的更正(F3)

实施报告 §9 记录 using_filesort=false(其夹具为 1 会话 × 200 条)。 本轮在带背景数据的真实规模夹具下,1/20/100/600 四档全部实测 using_filesort=true—— 来源是窗口函数 ROW_NUMBER() OVER (PARTITION BY conversation_id ORDER BY created_at DESC) 的排序。 这是既有实现的固有代价,本轮不改动,仅如实更正记录。


7. 副作用与隔离(任务 5)

独立测试 R01EIndependentSideEffectIsolationTest,运行在共享的 integration H2 库上(与实施测试同库, 因此可以直接观测实施测试的夹具残留)。

7.1 逐字段快照(25 张表,独立于实施测试的 11 张)

users(6 列)、user_roles、companies、jobs、applications、application_status_history、 interviews、interview_status_history、offers、offer_status_history、candidate_feedback、 job_favorites、notifications、audit_logs、auth_tokens、conversations、conversation_members、 messages(含 version)、message_reads、student_profiles、student_preferences、resume_versions、 candidate_dimension_scores、candidate_scoring_appeals、candidate_scoring_appeal_history

用例 结论
collector.collect() 前后 25 张表逐字段零变化 ✔
listConversations() 前后 25 张表逐字段零变化 ✔
listConversations + getConversation + collect 组合 25 张表逐字段零变化 ✔

读取路径零写:夹具覆盖全部 12 个 section(collect 走满所有分支),快照仍零变化。

7.2 通知 / 审计 / RabbitMQ

项 结论
notifications 行数与逐字段内容 读前后完全相等 ✔
audit_logs 行数与逐字段内容 读前后完全相等 ✔
RabbitMQ 以 @MockBean RabbitTemplate 注入,读路径结束后 verifyNoInteractions(rabbitTemplate) 通过——零 AMQP 交互 ✔
MQ 配置 mysql-it / integration-test 均置 app.notification.mq-enabled: false、spring.rabbitmq.enabled: false,NotificationConsumer 条件装配关闭 ✔

7.3 反证:快照必须真的能发现变化

反证用例 期望 结果
修改一行(users.nickname) 断言必须失败 AssertionError,含 users ✔
新增一行(notifications) 断言必须失败 AssertionError,含 notifications ✔
删除一行(message_reads) 断言必须失败 AssertionError,含 message_reads ✔
仅单列变化(messages.status → RECALLED) 断言必须失败 AssertionError,含 messages ✔

若无此反证,「零变化」可能只是没在比。

7.4 夹具零残留

检查 结果
本类自有夹具(r01eiso-%)退场后残留 0(@AfterEach 精确清理 + @AfterAll 门禁)✔
实施测试的夹具(r01e-%,排除本类前缀)残留 0 ✔
实施测试遗留的 messages / r01e-co / r01e-job 0 ✔

关于此前「缺 @AfterEach 导致 60 项失败」的专项复验:H2 内存库按 JVM 共享, 只有让 R01EQueryBudgetAcceptanceTest 先于独立类运行,残留检查才具备鉴别力。 为消除运行顺序的不确定性,本轮追加一次逆序运行:

mvn -o -Dsurefire.runOrder=reversealphabetical -Dtest="R01EQueryBudgetAcceptanceTest,R01EMySqlQueryCountAndExplainTest,R01EIndependentCrossDatabaseVerificationTest,R01EIndependentSideEffectIsolationTest" test
→ 实际顺序:R01EQueryBudgetAcceptanceTest(11) → R01EMySqlQueryCountAndExplainTest(4)
             → R01EIndependentSideEffectIsolationTest(10) → R01EIndependentCrossDatabaseVerificationTest(16)
→ 独立复验:Tests run: 41, Failures: 0, Errors: 0, Skipped: 0
→ 提交前收口:Tests run: 42, Failures: 0, Errors: 0, Skipped: 0(新增 EXPLAIN 变异门禁)

即:实施测试跑完后,独立类的 implementationFixturesLeaveNoResidue 与 @AfterAll 门禁 直接确认其夹具残留为 0,旧问题已修复且可被独立观测。


8. OBS-E01(任务 6)

观察项(本轮不改变既有语义,作为产品负责人待决策项上报):

本人发送且没有当前用户 message_reads 记录的消息,计入本人未读数。

证据 H2 MySQL
会话内「本人 1 条 + 他人 2 条,均未读」 未读 = 3(不是 2) 3
会话内「他人 1 条 + 本人 1 条 + 1 条撤回」 未读 = 2 2
把本人发送的那条写入 message_reads 未读减为 1 1
  • 该行为源自未读查询的 WHERE 中没有发送者过滤(只按 conversation_id 与读者 message_reads 判定)。
  • 本轮未修改 SQL、未修改任何生产代码,仅将既有语义如实锁定并双侧取证。
  • 若产品确认应排除本人发送的消息,属业务语义变更,需另立批次,不在本次性能收束范围内。

9. 门禁结果(任务完成后的规定命令)

# 命令 Tests Failures Errors Skipped 结果
1 mvn -Dtest="R01EQueryBudgetAcceptanceTest,R01EMySqlQueryCountAndExplainTest,R01EIndependentCrossDatabaseVerificationTest,R01EIndependentSideEffectIsolationTest" test 41 0 0 0 SUCCESS
1b 同上 + -Dsurefire.runOrder=reversealphabetical(残留门禁) 41 0 0 0 SUCCESS
2 mvn -Dtest="*PersonalData*Test,*Export*Test,*Messaging*Test,*Message*Test,*Conversation*Test,*Recall*Test" test 150 0 0 0 SUCCESS
3 独立复验 mvn test 2787 0 0 0 SUCCESS
4 独立复验 mvn clean verify 2787 0 0 0 SUCCESS
5 提交前收口 mvn clean verify 2788 0 0 0 SUCCESS
5 python ..\docs\verify_openapi.py paths 176 / operations 214 — — — All checks passed!

测试计数对账:独立复验为 2761(R01-C 阶段二基线)+ 26(独立新增:16 跨库 + 10 副作用)= 2787; 提交前收口再增加 1 个 EXPLAIN 变异门禁,最终为 2788 ✔ (实施测试的 11 + 4 = 15 已含在 2761 内,本轮未新增实施测试。)

9.1 实施测试 / 独立测试 明细

类别 测试类 项数 引擎
实施测试 R01EQueryBudgetAcceptanceTest 11 H2
实施测试 R01EMySqlQueryCountAndExplainTest 4 MySQL(Testcontainers)
独立测试 R01EIndependentCrossDatabaseVerificationTest 16 H2 + MySQL(同 JVM 双上下文)
独立测试 R01EIndependentSideEffectIsolationTest 10 H2(共享 integration 库)
合计 41

9.2 Testcontainers / MySQL 实际执行证据

  • Docker Server 29.6.2;mysql:8.4 容器日志实测:Container mysql:8.4 started in PT7.4093471S。
  • R01EMySqlQueryCountAndExplainTest 标注 @Testcontainers(disabledWithoutDocker = true)—— 若 Docker 不可用该类的 4 项会被 skip;实测 Skipped = 0,故容器真实启动并执行。
  • R01EIndependentCrossDatabaseVerificationTest 断言 MySql.container.isRunning() 为 true, 并在 MySQL 上真实执行了 Flyway 全量迁移、跨库深比较、ANALYZE TABLE 与 EXPLAIN FORMAT=JSON。
  • 两类 MySQL 用例耗时分别为 122.5 s 与 17.2 s(clean verify 单次运行),与容器冷启动 + 万级夹具灌入相符。

10. 生产代码与契约不变性

项 结果
生产代码 diff 0 个文件(git diff --stat 空)
V1–V45 迁移 / Flyway 未修改(目录 git status 0 项,仍为 45 个版本文件) ✔
pom.xml / 依赖 未修改 ✔
ErrorCode 未修改 ✔
公网接口 / DTO / OpenAPI 契约 未修改(176 / 214) ✔
docs/openapi-rc.json 未修改 ✔
新增迁移 V46 未新增(EXPLAIN 未发现必须新增索引的情形) ✔
Security / Token / 权限 / requestId 未修改
评分、排名、推荐、申诉、邀约、Offer、撤回与已读语义 未修改
分布式缓存 / 冗余未读列 / 异步最终一致未读数 未引入

11. Git 状态(未暂存 / 未提交 / 未推送)

项 结果
分支 / HEAD refactor/r01-structure-performance / b893ed67f078eedb0c4b8a03341311bd79342354(未变)
HEAD 与 upstream 0 / 0 ✔
暂存区 git diff --cached --name-only 空 ✔
本轮新增(未跟踪) 2 个独立测试类 + 本报告
既有未跟踪文件 完整保留,未删除、未移动

确认:全程未执行 git add / commit / push,未执行 reset / restore / checkout / clean。


12. 遗留风险

# 风险 说明
R1 OBS-E01 语义 本人发送且无本人 message_reads 的消息计入本人未读数。既有实现如此,本轮未改;如需变更属业务语义变更,须另立批次
R2 created_at 并列无 tiebreaker 未读窗口的 ORDER BY m.created_at DESC 无二级排序键。并列跨越 500 边界时「保留哪 500 条」由引擎决定(计数恒为 500,两侧一致)。messages.created_at 为秒级 DATETIME,高并发同秒写入可触发
R3 派生表物化 MySQL 对 ROW_NUMBER() 派生表物化,ranked 为派生结果顺序扫描(access_type=ALL),且 using_temporary_table=true、using_filesort=true 为固有代价。大会话(> 每表 6% 选择性)下 messages 转为全扫,属代价决策
R4 测试自污染 本轮测试均按前缀精确清理;若将来新增同类夹具而不清理,仍可能污染共享 H2 库(R01-C 已建隔离门禁)
R5 实施测试断言鉴别力(F1) 原 EXPLAIN 断言为空断言;提交前收口已改为 JSON 结构断言,并以三类变异证明有效

R01-E独立代码与真实MySQL性能复验通过,可以进入精确暂存与独立提交检查点。