feat(search): MySQL ngram FULLTEXT分支(LIKE 13.5s→索引毫秒级;索引缺失自动回退LIKE)

MySQL LIKE 全扫698k行13.5s撞axios 15s超时→前端ERR_ABORTED;
MATCH...AGAINST(ngram)毫秒级;本地C盘不足索引待服务器建
(索引SQL: CREATE FULLTEXT INDEX ft_msg_text ON message(text) WITH PARSER ngram)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
浅念
2026-08-22 17:52:26 +08:00
parent a8b8d9d81c
commit abc97fe217
+60
View File
@@ -65,6 +65,12 @@ async def _search_messages(
"""
from app.db.fts import fts_available
if session.bind is not None and session.bind.dialect.name == "mysql":
# MySQL: ngram FULLTEXT 索引(LIKE 全扫 698k 行要 13.5s,2026-08-22 实测
# 撞 axios 15s 超时前端 ERR_ABORTED);索引不存在时自动回退 LIKE
return await _search_messages_mysql(
session, q, page, page_size, channel, start, end
)
use_fts = len(q) >= 3 and await fts_available(session)
if use_fts:
return await _search_messages_fts(session, q, page, page_size, channel, start, end)
@@ -132,6 +138,60 @@ async def _search_messages_fts(
return Page(items=items, total=total, page=page, page_size=page_size)
async def _search_messages_mysql(
session: AsyncSession,
q: str,
page: int,
page_size: int,
channel: str | None,
start: int | None,
end: int | None,
) -> Page[MessageOut]:
"""MySQL ngram FULLTEXT MATCH 路径;索引缺失自动回退 LIKE。"""
conds = ["MATCH(m.text) AGAINST(:ftq IN NATURAL LANGUAGE MODE)"]
params: dict[str, Any] = {"ftq": q}
if channel is not None:
conds.append("m.channel = :ch")
params["ch"] = channel
if start is not None:
conds.append("m.timestamp >= :s")
params["s"] = start
if end is not None:
conds.append("m.timestamp <= :e")
params["e"] = end
where = " AND ".join(conds)
try:
total = (
await session.execute(
sa.text(f"SELECT count(*) FROM message m WHERE {where}"), params
)
).scalar_one()
id_rows = (
await session.execute(
sa.text(
f"SELECT m.msg_id FROM message m WHERE {where} "
f"ORDER BY m.timestamp DESC, m.msg_id ASC LIMIT :lim OFFSET :off"
),
{**params, "lim": page_size, "off": (page - 1) * page_size},
)
).scalars().all()
except Exception:
# 索引不存在(部署早期) → 回退 LIKE
return await _search_messages_like(session, q, page, page_size, channel, start, end)
items: list[MessageOut] = []
if id_rows:
rows = (
await session.execute(
sa.select(Message).where(Message.msg_id.in_(list(id_rows)))
)
).scalars().all()
by_id = {r.msg_id: r for r in rows}
items = [MessageOut.model_validate(by_id[mid]) for mid in id_rows if mid in by_id]
return Page(items=items, total=total, page=page, page_size=page_size)
async def _search_messages_like(
session: AsyncSession,
q: str,