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.
 
 
 

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):

  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<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既有批量查询与会话未读数性能实现已完成验收和测试加固,待独立代码复验。