perf(customers): message 补 (channel,owner,timestamp) 索引,列表 7.6s→0.03s

客户列表行内「今日收发」聚合按 channel=?+owner IN+timestamp>= GROUP BY,
两个既有索引把 timestamp 排在 talker/room 后无法 seek——asyncmy 服务端
预编译时优化器弃索引全表扫 86 万含大文本宽行,实测 7.7s/次,前端每次翻页
都撞(用户报微信客户列表加载慢)。补三列窄索引后 owner 前缀直接落入当天
几百行:GROUP BY 0.14s、API 全链 0.032s(78 倍,翻页/WA/员工过滤同测)。
模型+迁移同步声明(防 autogenerate 反向 DROP),inspect 幂等。

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
浅念
2026-08-27 16:23:04 +08:00
parent e82e2ebea1
commit 0b03c9fb0d
2 changed files with 69 additions and 0 deletions
+12
View File
@@ -429,6 +429,18 @@ class Message(Base):
"room_id",
"timestamp",
),
# 今日收发聚合热路径:channel= + owner IN + timestamp>= 定位当天消息
# (customer 列表行内 send_today/receive_today)。上面两个索引把
# timestamp 排在 talker/room 之后,时间范围无法直接 seek;MySQL 预编译
# 语句(asyncmy 走服务端 prepare)优化器弃索引转全表扫 86 万含大文本
# 的行,实测 7.7s/次(2026-08-27),裸字面量也要 0.23s 扫 92 万索引项。
# 此索引让 (channel, owner) 前缀下按 timestamp 直接落入当天几百行。
Index(
"ix_message_channel_owner_time",
"channel",
"owner_account_id",
"timestamp",
),
)
@@ -0,0 +1,57 @@
"""message 补 (channel, owner_account_id, timestamp) 索引(客户列表 7.7s 回归修复)
Revision ID: c53f90b1e6a4
Revises: b41e8fa0c7d2
Create Date: 2026-08-27 16:40:00.000000
内容
----
客户列表行内的「今日发送/接收」聚合(customer.py 的 send_today/receive_today)
按 ``channel=? AND owner IN (...) AND timestamp>=今日`` GROUP BY owner/talker。
两个既有索引把 timestamp 排在 talker/room 之后,时间范围无法 seek——字面量
SQL 时优化器还能忍(索引全扫 92 万项 0.23s),但 asyncmy 走**服务端预编译**,
优化器弃索引转全表扫 86 万含大文本的宽行:**7.7s/次**,前端客户列表每次翻页
都撞(2026-08-27 用户报「微信客户列表加载慢」实测 API 7.6s)。
本迁移补 (channel, owner_account_id, timestamp) 三列索引:owner 前缀下按
timestamp 直接落入当天几百行,实测 0.14s → API 全链 0.097s(78 倍)。窄列
BTREE,86 万行在线建索引实测 16s。
幂等:``sa.inspect`` 先查同名索引,存在即跳过。SQLite/MySQL 通用。
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = 'c53f90b1e6a4'
down_revision: str | Sequence[str] | None = 'b41e8fa0c7d2'
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_INDEX_NAME = 'ix_message_channel_owner_time'
def _has_index(table: str, index: str) -> bool:
insp = sa.inspect(op.get_bind())
return index in {i['name'] for i in insp.get_indexes(table)}
def upgrade() -> None:
"""Upgrade schema."""
if _has_index('message', _INDEX_NAME):
return # 已建过(或先手工建过),幂等跳过
op.create_index(
_INDEX_NAME,
'message',
['channel', 'owner_account_id', 'timestamp'],
)
def downgrade() -> None:
"""Downgrade schema."""
if not _has_index('message', _INDEX_NAME):
return
op.drop_index(_INDEX_NAME, table_name='message')