Files
yunke/yunke-server/migrations/versions/a7d3e9f1c2b4_msg_daily_stats_table.py
T
浅念 421998be54 perf(api): 会话/5数字弹窗超时修复——日粒度汇总层 msg_daily_stats
根因(服务器实测):sessions 对 owner 全量 message GROUP BY,重账号 5.2万条
3~5s;staff-chat-stats 全时段窗 6.9s——128MB buffer pool 下被推过前端
15s 超时(ECONNABORTED)。EXPLAIN ANALYZE 证实最优 covering 索引也要
18.9s,瓶颈是磁盘不是查询形状。

修复(预聚合,云客同款思路):
- msg_daily_stats 表:每「渠道×账号×会话×上海自然日」一行(收发计数/
  当日最后时间与方向);迁移 a7d3e9f1c2b4(幂等建表)
- 写路径:repos.upsert_messages 落库自动重算本批触及的会话×天(所有
  抓取路径汇到该函数,一处挂全;失败不阻塞写入)
- 读路径:sessions/staff-chat-stats 表有行时读汇总(namedtuple 同形,
  端点主体零改动),空表守卫回退旧聚合(部署回填前不空白)
- 自愈:CLI daily-stats 全量重建(幂等);full_sync_all.sh 夜间追加第 11
  步——顺带修掉尾部 sqlite3 直连 smoke.db 的硬编码(服务器 MySQL 上会炸)
- 口径统一:day 一律 Asia/Shanghai Python 计算(旧 from_unixtime 跟随
  DB 会话时区与今日数割裂);统计窗口对齐自然日(文档化语义变化)

测试:12 新增(汇总层 8 + 端点判别/对拍 4,判别手法=回填后删光 message
仍出完整数据),全量 512 绿。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-03 16:37:36 +08:00

78 lines
2.8 KiB
Python

"""msg_daily_stats 日粒度会话汇总表(会话列表/5数字弹窗超时修复)
Revision ID: a7d3e9f1c2b4
Revises: c53f90b1e6a4
Create Date: 2026-09-03 17:00:00.000000
内容
----
sessions 与 staff-chat-stats 原先每次请求对 owner 全量 message 实时
GROUP BY——重账号 5.2 万条消息实测 3~5s(sessions)/6.9s(全时段
staff-chat-stats),在服务器 128MB buffer pool 下进一步被推过前端 axios
15s 超时(ECONNABORTED,2026-09-03 用户报「聊天记录页数据多时请求失败」)。
本表是预聚合层:每个「渠道×账号×会话×上海自然日」一行(收/发计数、当日
最后一条时间与方向)。维护::
* 写路径:``repos.upsert_messages`` 落库后自动重算本批触及的「会话×天」
(所有抓取路径——页面抓取器微信/WA/群、CLI 手动同步——都汇到该函数)。
* 读路径:两个端点在表有行时读它(毫秒级),空表回退旧实时聚合(部署
回填前的守卫)。
* 自愈:夜间 full_sync_all.sh 的 ``daily-stats`` 步骤全量重建,纠正绕过
钩子的写入(裸 UPDATE/回填脚本);部署回填也用同一条命令
(``python -m app.sync.cli daily-stats``)。
day 为 int yyyymmdd,口径一律 Asia/Shanghai(Python 计算,不用 SQL 日期
函数——from_unixtime 跟随 DB 会话时区,与今日数口径割裂)。
幂等:``sa.inspect`` 先查同名表,存在即跳过。SQLite/MySQL 通用。
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "a7d3e9f1c2b4"
down_revision: str | Sequence[str] | None = "c53f90b1e6a4"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_TABLE = "msg_daily_stats"
def upgrade() -> None:
bind = op.get_bind()
insp = sa.inspect(bind)
if insp.has_table(_TABLE):
return
op.create_table(
_TABLE,
sa.Column("channel", sa.String(length=16), primary_key=True),
sa.Column("owner_account_id", sa.String(length=128), primary_key=True),
sa.Column("peer_kind", sa.String(length=8), primary_key=True),
sa.Column("peer_id", sa.String(length=128), primary_key=True),
sa.Column("day", sa.Integer(), primary_key=True),
sa.Column(
"send_cnt", sa.Integer(), nullable=False, server_default=sa.text("0")
),
sa.Column(
"recv_cnt", sa.Integer(), nullable=False, server_default=sa.text("0")
),
sa.Column("last_ts", sa.BigInteger(), nullable=True),
sa.Column("last_is_send", sa.Boolean(), nullable=True),
sa.Column(
"updated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()
),
)
def downgrade() -> None:
bind = op.get_bind()
insp = sa.inspect(bind)
if not insp.has_table(_TABLE):
return
op.drop_table(_TABLE)