# 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)`: 1. `memberRepository.findByUserId(userId)`(1 条) 2. 空则 `return List.of()` —— **不执行 `IN ()`** 3. `conversationRepository.findAllById(convIds)`(1 条) 4. `messageRepository.countUnreadByConversationIds(convIds, userId)`(**1 条分组查询**) 5. 内存排序(`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>` 强类型键 | | E2 | `MessageRepository.countUnreadByConversationIds(List conversationIds, Long readerUserId)` → `List` 投影(`conversationId` / `unreadCount`);**原生查询、全参数化**,无字符串拼接用户可控 ID | --- ## 7. 未读数 WHERE / GROUP BY 的准确语义(逐字取自原生查询) ```sql 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既有批量查询与会话未读数性能实现已完成验收和测试加固,待独立代码复验。**