16 KiB
R01-E 既有批量查询与会话未读数性能实现:验收与测试加固报告
日期:2026-09-18
分支:refactor/r01-structure-performance
HEAD:b893ed67f078eedb0c4b8a03341311bd79342354(== upstream)
本轮性质:验收与测试加固 —— 生产代码 diff = 0,未暂存、未提交、未推送
0. 结论
R01-E既有批量查询与会话未读数性能实现已完成验收和测试加固,待独立代码复验。
观察项(本轮不改变既有语义):
OBS-E01:当前本人发送且没有
message_reads记录的消息也计入本人未读数, 待产品负责人单独确认;本轮不改变既有语义。
库存前置结论:E1 与 E2 在 HEAD 上均已处于目标终态(分别由 R01-D2、R01-D3 交付), 故本轮未修改任何生产代码,改为以查询预算特征测试 + 跨库一致性 + EXPLAIN + 副作用快照做验收加固。
1. PersonalDataCollector 修复前调用链(E1)
调用链:PersonalDataExportWorker.run → PersonalDataCollector.collect(userId) → 12 个 section 构建方法 → PersonalDataExportBuilder.build(...) → 落盘。
查询形态(逐处核对):全部为批量读取 —— findByStudentUserIdOrderByCreatedAtDesc /
findByUserId / findAllById / ...In...OrderBy...,且批量历史查询
(ApplicationStatusHistory / InterviewStatusHistory / OfferStatusHistory / AppealHistory)
均使用 findByXxxIdInOrderByXxxIdAscCreatedAtAsc(...)。
循环内是否再查:否。11 处 forEach(行 169/199/241/290/349/368/382/409/420/440/460/472)
只消费预先批量取回的数据与 groupBy(...) 结果做内存组装,循环体内零 Repository 调用。
N 增长公式:不受记录条数影响(见 §4)。
2. 会话未读数修复前调用链(E2)
调用链:MessagingController → MessagingService.listConversations(userId):
memberRepository.findByUserId(userId)(1 条)- 空则
return List.of()—— 不执行IN () conversationRepository.findAllById(convIds)(1 条)messageRepository.countUnreadByConversationIds(convIds, userId)(1 条分组查询)- 内存排序(
updatedAt降序)后getOrDefault(cid, 0L)补 0
countUnread(convId, userId)(私有)仅被 getConversation(单会话详情)调用;
会话列表路径不再逐会话 count。
3. 修复前后查询次数公式
| 场景 | 修复前(R01-D2/D3 之前) | 当前(HEAD) |
|---|---|---|
| E1 导出 | O(N):每类数据在循环内按 ID 再查 |
O(1) 常数(每个数据类别固定 1 条批量查询) |
| E2 会话列表 | O(K):每会话 1 次 countUnread |
O(1) 常数 3 条(成员、会话、分组未读各 1 条) |
4. 1 / 10 / 50 数据规模下 E1 SQL 数(实测)
R01EQueryBudgetAcceptanceTest.personalDataCollectQueryCountIsFlat,
经 CountingDataSource 统计真实 JDBC 语句(prepareStatement/createStatement):
| 投递记录数 | 实测 SQL 条数 |
|---|---|
| 1 | 20 |
| 10 | 20 |
| 50 | 20 |
恒定 20 条,与记录数完全无关(每次 mvn 为全新 JVM + 全新 H2 库;断言为精确相等)。
5. 1 / 20 / 100 会话规模下 E2 SQL 数(实测)
R01EQueryBudgetAcceptanceTest.unreadQueryCountIsFlat:
| 会话数 | 实测 SQL 条数 | 每会话未读 |
|---|---|---|
| 1 | 3 | 1 |
| 20 | 3 | 1(逐会话校验全部为 1) |
| 100 | 3 | 1(逐会话校验全部为 1) |
恒定 3 条,不随会话数增长;三种规模下逐会话 unreadCount 值全部正确。
6. 批量 Repository / 投影契约
| 组件 | 契约 |
|---|---|
| E1 | 既有 ...In... 批量方法 + groupBy(values, idExtractor) 内存分组;Map<Long, List<T>> 强类型键 |
| E2 | MessageRepository.countUnreadByConversationIds(List<Long> conversationIds, Long readerUserId) → List<ConversationUnreadCount> 投影(conversationId / unreadCount);原生查询、全参数化,无字符串拼接用户可控 ID |
7. 未读数 WHERE / GROUP BY 的准确语义(逐字取自原生查询)
SELECT ranked.conversation_id AS conversationId, COUNT(*) AS unreadCount
FROM ( SELECT m.id, m.conversation_id, m.status,
ROW_NUMBER() OVER (PARTITION BY m.conversation_id ORDER BY m.created_at DESC) AS row_no
FROM messages m
WHERE m.conversation_id IN (:conversationIds) ) ranked
LEFT JOIN message_reads mr ON mr.message_id = ranked.id AND mr.reader_user_id = :readerUserId
WHERE ranked.row_no <= 500
AND (ranked.status IS NULL OR ranked.status <> 'RECALLED')
AND mr.id IS NULL
GROUP BY ranked.conversation_id
| 语义点 | 事实 |
|---|---|
| 可见范围 | 由会话成员关系界定(调用方先取 findByUserId 得到 convIds),非由用户传入 ID 决定 |
| 会话页之外 | 不查询(只传当前用户的会话 id 列表) |
| 撤回消息 | 排除(status <> 'RECALLED') |
| 已读消息 | 排除(message_reads 存在该读者的记录) |
| 窗口上限 | 每会话最近 500 条(row_no <= 500) |
| 发送者过滤 | 无 —— 见 OBS-E01 |
| 未返回的会话 | Service 侧 getOrDefault(cid, 0L) 补 0 |
OBS-E01 的实测证据:同一会话内「本人 1 条 + 对方 2 条、均未读」时
unreadCount = 3(最初按"他人发送才计"预期的 2 被实测纠正)。
本轮如实锁定既有语义,未做任何行为改变。
8. H2 / MySQL 逐值一致结果
R01EMySqlQueryCountAndExplainTest(Testcontainers mysql:8.4,4/4 通过,耗时 34.94s → 容器真实启动):
| 用例 | 结论 |
|---|---|
groupedUnreadMatchesPerConversationSemantics |
MySQL 上分组 SQL 与 Service 路径逐值一致;c1: 3 条 SENT − 1 条 RECALLED − 1 条已读 = 1;c2: 1 |
emptyAndForeignConversations |
空 id 列表与不含该会话的 id 列表均不返回 |
conversationScaleIsFlatAndCorrect |
1 / 20 / 100 会话下 Service 结果条数与逐会话 unreadCount(各 3 条未读 = 3)全部正确 |
explainUnreadQuery |
见 §9 |
H2 与 MySQL 一致性:同一组夹具语义(撤回排除、已读排除、多会话聚合、缺失补 0)在两侧 得到相同数值;H2 侧见 §4/§5/§13,MySQL 侧见上表。
9. MySQL EXPLAIN 摘要
对与 countUnreadByConversationIds 逐字相同的 SQL、同一批绑定参数执行
EXPLAIN FORMAT=JSON(MySQL 8.4;提交前收口测试增加 10,000 条非目标背景消息,
使 1 个目标会话 × 200 条消息保持高选择性):
| 项 | 值 |
|---|---|
| JSON 长度 | 3898 字符 |
using_temporary_table |
true(位于 grouping_operation 层,为 GROUP BY conversation_id 的固有代价) |
using_filesort |
提交前实施夹具(1 会话 × 200 条、10,000 条背景)为 false;独立复验的 1/20/100/600 四档真实规模均为 true |
派生表 ranked |
access_type = ALL,即对物化后的派生结果顺序扫描;不能表述成“没有全表扫描” |
基础表 messages(别名 m) |
access_type = ref/range,命中 idx_messages_conversation_created,不是 ALL |
基础表 message_reads(别名 mr) |
access_type = eq_ref,命中 uk_message_reads |
| 其它字段 | possible_keys / key / cost_info / rows_examined_per_scan 均已出现;摘要落盘 target/r01e-mysql-explain.txt |
判读:分组所需的临时表及窗口函数排序属于既有 SQL 的固有代价;最小实施夹具与
真实规模对 using_filesort 的代价选择不同,而独立复验四档真实规模均为
using_temporary_table=true、using_filesort=true。ranked 的 ALL 是派生结果顺序扫描;
必须与基础表区分。基础表 messages 在高选择性场景命中既有复合索引且不是 ALL,
message_reads 以唯一键 eq_ref 访问。
独立复验发现原实施测试使用
doesNotContain("\"table_name\": \"messages\" ..."),但 MySQL JSON 使用别名 m,
因此该断言不具鉴别力。提交前已改为解析 JSON 树,分别定位唯一的 m、mr、ranked
节点,并校验访问类型、索引、临时表和 filesort;错误索引、m=ALL、缺失 mr 三种变异
都会使断言失败。
10. 使用到的索引
V9__messaging.sql 中既有索引(本轮未新增任何迁移或索引):
| 索引 | 表 | 列 |
|---|---|---|
idx_messages_conversation_created |
messages |
(conversation_id, created_at) |
idx_message_reads_message |
message_reads |
(message_id) |
idx_message_reads_reader |
message_reads |
(reader_user_id) |
idx_conv_members_user |
conversation_members |
(user_id) |
idx_conversations_updated |
conversations |
(updated_at) |
未读查询的 WHERE m.conversation_id IN (...) 与
ROW_NUMBER() ... ORDER BY m.created_at DESC 由
idx_messages_conversation_created (conversation_id, created_at) 支撑;
LEFT JOIN message_reads 由 idx_message_reads_message / idx_message_reads_reader 支撑。
独立复验还确认:当目标会话消息占全表约 5.45% 时仍使用索引,约 13.63% 时优化器会 基于代价转为基础表全扫;这是选择性变化,不是索引缺失。窗口排序、派生表物化及高占比 场景仍是遗留性能风险,但 EXPLAIN 未证明必须新增索引,故本轮未新增 V46。
11. 行级副作用快照
R01EQueryBudgetAcceptanceTest 对 11 张表做稳定字段行级快照(按主键排序)并逐字段比对:
| 表 | 快照列 |
|---|---|
users |
id, phone, nickname, role, is_active |
user_roles |
user_id, role |
conversations |
id, type, created_by, updated_at |
conversation_members |
conversation_id, user_id |
messages |
id, conversation_id, sender_user_id, status |
message_reads |
id, message_id, reader_user_id |
notifications |
id, user_id, type, title |
audit_logs |
id, user_id, action, entity_type |
auth_tokens |
id, user_id, token_type, revoked |
applications |
id, job_id, student_user_id, status |
job_favorites |
id, job_id, student_user_id |
| 用例 | 结论 |
|---|---|
listConversationsIsReadOnly |
会话列表 GET 前后 11 张表逐字段零变化 ✔ |
personalDataCollectIsReadOnly |
collector.collect() 前后 11 张表逐字段零变化 ✔ |
12. RabbitMQ / 通知 / 审计结果
| 项 | 结论 |
|---|---|
| 通知 | notifications 行数在 collect() 前后完全相等 ✔ |
| 审计 | audit_logs 行数在 collect() 前后完全相等 ✔;且快照逐字段零变化 ✔ |
| RabbitMQ | 两条路径(listConversations、collect)均不发送消息;测试期间无新增 MQ 相关行 ✔ |
| 日志 | 未新增记录消息内容 / 手机号 / Token / 导出内容的日志语句(生产 diff = 0,无新增日志) |
13. 新增测试及变异/反证
| 测试类 | Tests | 内容 |
|---|---|---|
common/r01e/R01EQueryBudgetAcceptanceTest |
11 | E1/E2 查询预算(H2,真实 JDBC 计数)、既有未读语义(含 OBS-E01)、已读/撤回排除、多会话聚合与补 0、跨用户不泄漏、11 张表行级副作用快照、通知/审计零新增 |
common/r01e/R01EMySqlQueryCountAndExplainTest |
4 | MySQL 8 上分组 SQL 与逐会话语义逐值一致、空列表与不属用户的 id 不返回、1/20/100 会话规模、EXPLAIN FORMAT=JSON |
反证设计:查询预算断言为精确相等(非"不显著增长")——若任一实现退化为逐条查询,
baseline[1] != baseline[0] 会立即失败;副作用快照为逐字段相等,任何写入都会被捕获。
测试类通过 CountingDataSource 统计真实 JDBC 语句,不是 Mockito verify Repository。
本轮自查发现并修复的测试缺陷(诚实记录):R01EQueryBudgetAcceptanceTest 初版只有
@BeforeEach 清理、缺 @AfterEach,退场后遗留 r01e-* 用户/公司/会话,导致全量运行中
PersonalDataExportControllerTest(15)、CompetitionRankingControllerTest(13) 等 60 项失败。
已补 @AfterEach 精确清理,复跑 R01EQueryBudgetAcceptanceTest,PersonalDataExportControllerTest, CompetitionRankingControllerTest → 39/39 通过。这是本轮新增测试自身的缺陷,非既有实现问题。
14. 定向、关联、全量和 clean verify 数量
| 命令 | 结果 |
|---|---|
关联回归(*PersonalData*Test,*Export*Test,*Messaging*Test,*Message*Test,*Conversation*Test,*Recall*Test) |
150 / 150,F=0 E=0 S=0 ✔ |
| 性能/验收定向 | 见 §14.1 |
mvn test |
见 §14.1 |
mvn clean verify |
见 §14.1 |
14.1 全量与 clean verify
| 命令 | Tests | Failures | Errors | Skipped | BUILD |
|---|---|---|---|---|---|
| 性能/验收定向(含 MySQL Testcontainers) | 18 | 0 | 0 | 0 | SUCCESS |
mvn test |
2761 | 0 | 0 | 0 | SUCCESS |
mvn clean verify |
2761 | 0 | 0 | 0 | SUCCESS |
2761 = 2746(R01-C 阶段二基线)+ 本轮新增 15(H2 11 + MySQL 4),与要求「不低于 2746 + 新增数」一致。 Skipped=0 且 MySQL 用例耗时 34.94s,证明 Testcontainers 真实执行、未被跳过。
关联回归:*PersonalData*Test,*Export*Test,*Messaging*Test,*Message*Test,*Conversation*Test,*Recall*Test
→ 150 / 150,F=0 E=0 S=0 ✔
15. OpenAPI 数量
python ..\docs\verify_openapi.py → paths: 176 / operations: 214 / All checks passed!
git diff -- docs/openapi-rc.json → 空 ✔
16. 精确修改文件
生产代码:0 个文件。
新增测试(2)
mydreamjob-server/src/test/java/com/mydreamjob/common/r01e/R01EQueryBudgetAcceptanceTest.java
mydreamjob-server/src/test/java/com/mydreamjob/common/r01e/R01EMySqlQueryCountAndExplainTest.java
文档(1):本报告
17. 是否修改迁移、ErrorCode、接口、DTO、依赖
| 项 | 结果 |
|---|---|
| V1–V45 / Flyway | 未修改 ✔ |
ErrorCode |
未修改 ✔ |
| 公网接口 / DTO / OpenAPI 契约 | 未修改(176/214) ✔ |
pom.xml / 依赖 |
未修改 ✔ |
| Security / Token / 权限 / requestId | 未修改 ✔ |
| 评分、排名、推荐、申诉、邀约、Offer、撤回与已读语义 | 未修改 ✔ |
| 分布式缓存 / 冗余未读列 / 异步最终一致未读数 | 未引入 ✔ |
18. Git 状态
| 项 | 结果 |
|---|---|
| 分支 / HEAD | refactor/r01-structure-performance / b893ed67f078eedb0c4b8a03341311bd79342354(未变) |
| HEAD 与 upstream | 0 0 ✔ |
git diff --cached --name-only |
空 ✔ |
git diff --check |
exit 0 ✔ |
| 生产代码 diff | 0 个文件 ✔ |
V1–V45 / openapi-rc.json / pom.xml / ErrorCode |
均未修改 ✔ |
| 历史未跟踪文件 | 完整保留 ✔ |
| 本轮新增(未跟踪) | docs/r01-e-batch-query-unread-count-performance-report.md、mydreamjob-server/src/test/java/com/mydreamjob/common/r01e/(2 个测试类) |
19. 明确未暂存、未提交、未推送
确认:未执行 git add;未创建提交;未推送。 未执行 reset/restore/checkout/clean。
20. 遗留风险
| # | 风险 | 说明 |
|---|---|---|
| R1 | OBS-E01 语义 | 本人发送且未读的消息计入本人未读数。当前实现如此,本轮未改变;若产品确认应排除,需另立批次(属业务语义变更,不属性能收束) |
| R2 | 500 条窗口 | 未读统计只看每会话最近 500 条;超过 500 条的会话其更早未读不计入。属既有设计,本轮未改动 |
| R3 | 派生表物化 | MySQL 8 对 ROW_NUMBER() 派生表可能物化;ranked.row_no <= 500 的过滤在物化后生效,大会话下存在额外扫描成本。EXPLAIN 证据见 §9 |
| R4 | 测试自污染 | 本轮新增测试的清理为按前缀精确删除;若将来有人新增同类夹具而不清理,仍可能污染共享 H2 库(R01-C 已就同类问题建立隔离门禁,本轮范围外) |
R01-E既有批量查询与会话未读数性能实现已完成验收和测试加固,待独立代码复验。