fix(messages): 聊天记录大对话超时——DESC首屏+keyset游标+群聊直达索引

用户报:聊天记录多时「聊天记录加载失败:请求失败 (ECONNABORTED)」(2026-09-07)。
服务器 EXPLAIN ANALYZE 实测两级根因:
- 单聊 1 万条:ASC 末页 OFFSET 10486 逐条回表 2515ms(DESC LIMIT 15ms,167 倍);
- 群聊 16,290 条:查询刻意不带 owner(chat_group 去重后 owner 错位),现有
  (channel,owner,room,timestamp) 索引被 owner 挡住,退化到 room 单列索引+
  filter+filesort——count 4.7s/首页 5.1s/末页 10.7s;真凶群 53132846289@chatroom
  40,863 条,三段串行 ~40s 必撞前端 axios 15s 超时。

修法(不动共享 MySQL 配置,沿用既往修复模式):
1. 迁移 e6f7a8b9c0d1:message 补 (channel, room_id, timestamp) 索引,群查询
   seek+排序直达+覆盖 count;模型侧双声明防 autogenerate 反 DROP。
2. /api/messages 加 order=asc|desc(默认 asc 不变)与 before_ts/before_id
   keyset 游标(成对,单传 422)——排序列 (timestamp,msg_id) 全序,平手按
   msg_id 决胜,游标页恒 O(page_size)。
3. 前端 ChatMain/ChatTimeline 首屏改「order=desc 取最新 50 反转」单请求
   (原 page1 拿 total+深 OFFSET 末页两请求),「加载更多」改游标上翻;
   hasMoreOlder=已加载<总数。SessionPane 搜索等 order=asc 默认路径不受影响。

本地实测(37,532 条群):desc 首页 0.39s、游标上翻 0.24s,浏览器真机
渲染+上翻 150 条无错。测试 8 增(desc/游标/平手边界/422/窗口叠加/
前端流程不变式),全量 601 绿。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-07 16:59:04 +08:00
parent 085a9b4fd3
commit 9d3cc8e76e
7 changed files with 348 additions and 77 deletions
+37 -9
View File
@@ -12,10 +12,20 @@ Read-only query over the mirrored ``message`` table. Filters:
see ``_MSG_TYPE_FILTER``). Unknown values are rejected with 422.
* ``q`` — substring match on ``text`` (``%``/``_`` escaped
as literals).
* ``order`` — ``asc`` (default, oldest-first timeline) /
``desc`` (newest-first; 聊天窗首屏「取最新一页」用它,反向索引扫描恒
O(page_size),避开 ASC 深 OFFSET 逐行回表——2026-09-07 实测 1 万条对话
末页 2.5s、4 万条群 10.7s 撞前端 15s 超时)。
* ``before_ts``/``before_id`` — keyset 游标(成对,可选):只取严格早于
``(before_ts, before_id)`` 的消息(排序列 (timestamp, msg_id) 全序,
平手按 msg_id 决胜)。「加载更多」上翻历史用它,每页恒 O(page_size);
两参必须成对,单传任一 422。
Ordered by ``timestamp asc`` so the timeline renders oldest-first. The index
``ix_message_channel_owner_talker_time`` covers the hot path
``(channel, owner_account_id, talker_id, timestamp)``.
Ordered by ``(timestamp, msg_id)`` in the requested direction. The index
``ix_message_channel_owner_talker_time`` covers the 1:1 hot path
``(channel, owner_account_id, talker_id, timestamp)``; group queries filter
``channel + room_id`` without owner(群聊 owner 错位,见 ChatMain queryFor),
served by ``ix_message_channel_room_time``.
"""
from __future__ import annotations
@@ -23,7 +33,7 @@ from __future__ import annotations
from typing import Annotated, Literal
import sqlalchemy as sa
from fastapi import APIRouter, Depends, Query
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_session
@@ -70,8 +80,16 @@ async def list_messages(
end: Annotated[int | None, Query(ge=0)] = None,
msg_type: Annotated[MsgTypeParam | None, Query()] = None,
q: Annotated[str | None, Query()] = None,
order: Annotated[Literal["asc", "desc"], Query()] = "asc",
before_ts: Annotated[int | None, Query(ge=0)] = None,
before_id: Annotated[str | None, Query()] = None,
) -> Page[MessageOut]:
"""List messages for a timeline (ordered by ``timestamp asc``)."""
"""List messages for a timeline (ordered by ``(timestamp, msg_id)``)."""
if (before_ts is None) != (before_id is None):
raise HTTPException(
status_code=422, detail="before_ts 与 before_id 必须成对使用"
)
conditions = []
if channel is not None:
conditions.append(Message.channel == channel)
@@ -91,20 +109,30 @@ async def list_messages(
# LIKE 通配符转义: %/_ 按字面匹配
escaped = q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
conditions.append(Message.text.like(f"%{escaped}%", escape="\\"))
if before_ts is not None:
# keyset 游标:严格早于 (before_ts, before_id) 的全序前缀,平手按
# msg_id 决胜——与 order_by 同一全序,翻页无缝
conditions.append(
sa.or_(
Message.timestamp < before_ts,
sa.and_(Message.timestamp == before_ts, Message.msg_id < before_id),
)
)
total_stmt = sa.select(sa.func.count()).select_from(Message)
if conditions:
total_stmt = total_stmt.where(*conditions)
total = (await session.execute(total_stmt)).scalar_one()
ts_order = (
Message.timestamp.desc() if order == "desc" else Message.timestamp.asc()
)
id_order = Message.msg_id.desc() if order == "desc" else Message.msg_id.asc()
list_stmt = sa.select(Message)
if conditions:
list_stmt = list_stmt.where(*conditions)
list_stmt = (
list_stmt.order_by(
sa.nullslast(Message.timestamp.asc()),
Message.msg_id.asc(),
)
list_stmt.order_by(sa.nullslast(ts_order), id_order)
.limit(page_size)
.offset((page - 1) * page_size)
)