"""成本口径改造 PR7 — 财务瀑布 / 口径对数区的覆盖索引。 生产事故(2026-07-28):`GET /cmdb/cost/finance/waterfall` 一次 GROUP BY 扫 6 个月 `cmdb_cost_facts`(~550 万行,全表 840 万行)—— 优化器对 ~12%/月 的 date 选择率放弃 `ix_facts_date_int` 走 TableFullScan,宽行 HashJoin+HashAgg 直接打爆 TiDB serverless 的 `tidb_mem_quota_query` → 错误 8175,页面 500。 修复分两层(本迁移是其中之一): * **按月分块**(cost_router 侧)——单查询窗口收窄到一个月,从根上 封顶单 SQL 内存(错误 8175 的直接解)。 * **本覆盖索引** `ix_facts_date_measure (date, integration_id, cost_bucket, amount_usd, amount_usd_list, amount_usd_public)` —— 月度口径聚合的全部列都在索引里,执行计划从 840 万行宽行 TableFullScan 变 IndexRangeScan(已在生产实测:单月聚合走索引、 无回表)。财务瀑布与「成本口径」对数区共用。 **生产已于 2026-07-28 先行手工建成此索引**(在线 DDL 54s,避免部署 管道里跑长 DDL)——因此本迁移用 information_schema 存在性守卫做成 幂等 no-op;其它环境(本地 / CI / 未来重建)由本迁移正常建索引。 TiDB online DDL,无锁;索引含金额列(DECIMAL(14,6) 可作索引键), 纯读侧优化,0 行为变化。 """ from __future__ import annotations import sqlalchemy as sa from alembic import op revision = "431" down_revision = "430" branch_labels = None depends_on = None _INDEX_NAME = "ix_facts_date_measure" _TABLE = "cmdb_cost_facts" def _index_exists(bind) -> bool: return bind.execute( sa.text( "SELECT 1 FROM information_schema.STATISTICS " "WHERE TABLE_SCHEMA = DATABASE() " " AND TABLE_NAME = :t AND INDEX_NAME = :i LIMIT 1" ), {"t": _TABLE, "i": _INDEX_NAME}, ).scalar() is not None def upgrade() -> None: bind = op.get_bind() if not _index_exists(bind): op.create_index( _INDEX_NAME, _TABLE, [ "date", "integration_id", "cost_bucket", "amount_usd", "amount_usd_list", "amount_usd_public", ], ) def downgrade() -> None: bind = op.get_bind() if _index_exists(bind): op.drop_index(_INDEX_NAME, table_name=_TABLE)