跳到主要内容

数据库表结构设计规范

适用范围:xuangu 数据库全部 250 张表(fox_/app_/em_/stg_/cninfo_/ths_/tdx_/sse_/tencent_/szse_/jrj_/sina_/sw_/social_/ppi100_ 前缀)。 参考标准:SQL Style Guide(Simon Holywell)、Google BigQuery SQL 最佳实践、SEBI 证券数据模型规范、证券行业 OHLCV 通用 Schema。


1. 表命名​

1.1 前缀分类(强制)​

前缀层级含义示例
fox_DWD多源融合宽表(数据治理层)fox_kline_daily, fox_stock_wide
app_APP应用业务表(用户/策略/内容/留痕)app_users, app_decision_card
em_RAW东财原始数据em_dragon_tiger, em_concept_block
stg_STG中间过渡层(RAW→DWD)stg_news, stg_commodity_spot
cninfo_RAW巨潮资讯原始数据cninfo_announcement
ths_RAW同花顺原始数据ths_f10_lhb
tdx_RAW通达信原始数据tdx_block_index
sse_RAW上交所原始数据sse_stock_snapshot
szse_RAW深交所原始数据szse_stock_snapshot
tencent_RAW腾讯原始数据tencent_stock_quote_daily
jrj_RAW金融界原始数据jrj_market_summary
sina_RAW新浪原始数据sina_financial_statement
sw_RAW申万原始数据sw_index_meta
social_RAW社交情绪原始数据social_sentiment_daily
ppi100_RAW生意社现货原始数据ppi100_spot_daily

1.2 表名格式(强制)​

  • 全小写 snake_case,单词间用下划线分隔
  • 格式:{前缀}_{业务域}_{实体}_{频率/粒度}(频率/粒度可选)
  • 频率后缀:_daily(日频)、_intraday(盘中)、_snapshot(快照)
  • 禁止缩写歧义:info → information,stat → statistics(历史遗留 stat 保留不改)
  • 最大长度 64 字符(MySQL 限制)

2. 列命名​

2.1 通用规则(强制)​

  • 列名统一【全大写】(snake_case 转大写,单词间用下划线分隔) —— 全库 4315 列实测 100% 遵循(CODE/TRADE_DATE/CREATED_AT); ORM 用小写属性名映射(code = Column(...) → CODE)。 🔴 2026-10-07 修正:本节原写「全小写 snake_case」,与实现全面冲突 (新建小写列表必然与全库 4315 列风格割裂),已按实测口径改为全大写。 MySQL 列名不区分大小写,SQL 引用不受影响;金融行业数据模型 (Bloomberg / FactSet 类)也普遍采用大写列名。
  • 表名统一【全小写】 snake_case(与列名相反,注意区分)
  • 使用有意义的英文全称或行业通用缩写(OHLCV、PE、PB、TTM、ROE 等)
  • 禁止拼音列名
  • 布尔列用 is_/has_ 前缀(如 is_st、has_dividend)
  • 百分比列以 _pct 结尾(如 change_pct、turnover_rate_pct)
  • 金额列以 _amount 结尾或明确单位(如 amount(元)、amount_wan(万元))

2.2 股票代码列(强制)​

场景列名类型说明
6 位纯代码codeVARCHAR(6)存储 000001~999999,不含交易所前缀
含交易所前缀symbolVARCHAR(12)存储 SZ000001/SH600000
股票名称nameVARCHAR(20)中文简称

2.3 日期/时间列(强制)​

语义列名类型说明
交易日期(行情类)trade_dateDATE已收盘的交易日,行情类表首选
通用日期dtDATE非行情类或已有历史命名(原 date,已迁移避 MySQL 保留字)
报告日期report_dateDATE财报报告期截止日
发布日期publish_dateDATE/VARCHAR(20)公告/研报/宏观数据发布日
创建时间created_atDATETIME行首次写入时间
更新时间updated_atDATETIME行最后修改时间
数据截止日dtDATE仅「无业务日期列的每日刷新快照表」使用(末列,见 §3.3)

命名统一:本项目统一使用 created_at/updated_at(而非 create_time/update_time), 与 Ruby on Rails / Laravel 等 ORM 框架的 _at 后缀约定一致(业界通用,表示"某事发生的时刻"), 且现有 186 张表已使用 updated_at。 历史遗留的 create_time 不存在,无需迁移。

日期类型:优先使用 DATE 类型。仅当源数据为字符串且无法可靠转换时(如腾讯 K 线 dt VARCHAR(10)), 允许使用 VARCHAR(10) 并在 COMMENT 中标注格式 YYYY-MM-DD。新建表禁止用 VARCHAR 存日期。


3. 表分类与必填列​

3.1 表分类​

每张表必须属于以下五类之一,不同类型适用不同规则:

类型特征示例
时序表按日期逐行累积,PK 含日期列fox_kline_daily, fox_margin
快照表覆盖式写入,每个 code 只保留最新行fox_stock_wide, fox_stock_master
事件表按事件发生时间记录,一条一事件em_dragon_tiger, cninfo_announcement
配置表低频变更的键值/枚举/系统配置app_config, fox_data_source
留痕表任务执行/评分/复盘记录,只增不改app_sync_log, app_task_run, app_dragon_score

🔴 2026-10-07 补充:分类必须「显式声明」,不能靠表名猜。 审计工具 schema_full_audit.py::is_snapshot_like() 用表名关键词 (wide/master/snapshot/profile/valuation/list/meta)判定快照表, 会把 app_checklist_templates(模板表)、app_candidate_lists(用户配置) 等误判为每日快照 —— 见 §3.4。 新建表时在类注释里写明「表类型:时序/快照/事件/配置/留痕」, 并按类型套用必填列矩阵,不要让下游靠猜。

3.2 必填列矩阵(强制)​

列时序表快照表事件表配置表留痕表RAW 层
id BIGINT AUTO_INCREMENT可选(复合 PK)可选(复合 PK)可选(复合 PK)必须必须可选
created_at DATETIME必须必须必须必须必须推荐
updated_at DATETIME必须必须必须必须推荐推荐
dt DATE(数据截止日)—必须(无业务日期列者,§3.3)————
TABLE COMMENT必须必须必须必须必须必须
列 COMMENT必须必须必须必须必须必须

说明:

  • 时序表/快照表/事件表若无 id 列,必须有复合主键(如 (code, trade_date))
  • RAW 层(em_/cninfo_/ths_/tdx_/sse_/szse_/tencent_/jrj_/sina_/sw_/social_/ppi100_)的 created_at/updated_at 为推荐项,允许缺失(部分 RAW 表由第三方库写入,结构不可控)
  • DWD 层(fox_)与 APP 层(app_)必须全部满足

3.2.1 时间戳列的产生方与刷新方(2026-10-07 补,原文缺失)​

「有列」不等于「有值」。实测发现251 张表全部有 created_at+updated_at, 但其中 218 张表的 ORM 类里没有这两个字段的 Column 定义 —— 库层面靠 DEFAULT CURRENT_TIMESTAMP 兜底,ORM 侧完全不知道它们存在。 这带来两个必须写进规范的后果:

问题后果约定
ORM 无 Column 定义序列化出的 ORM 对象不含这两个属性,读路径若依赖会 AttributeError读路径禁止依赖 ORM 属性拿时间戳,改用 func.now() / 显式 SELECT
upsert_rows 的更新集是显式列名列表未列入 update_cols 的列在 ON DUPLICATE KEY UPDATE 里不会被刷新✅ 已自动修复(2026-10-07):upsert_rows() 自动检测模型是否有 updated_at 列,有则追加 func.now() 到更新集(fox_engine/writer/base.py::_update_set())

因此:

  • 新建表:ORM 必须显式声明两列(推荐 Column(DateTime, default=datetime.now)), 不要依赖 DB DEFAULT——否则新表也会重蹈「ORM 不知道它存在」。
  • 存量 218 张表:upsert_rows 已自动处理 updated_at 刷新,无需手工加进 update_cols。 但若绕过 upsert_rows 直接写 SQL,仍须显式刷新 updated_at。
  • 语义约定:存量行回填时用「updated_at ← created_at」(创建即最后修改), 不要用回填时刻——那会篡改历史时间线。

3.3 快照表 dt 约定(数据截止日,2026-10-07 立规)​

问题:每日刷新的快照表(覆盖式写入、无业务日期列)没有一行数据能自证 「这份横截面对应哪一天」。updated_at 是写入时刻不是业务日期——盘前/凌晨 补跑、跨零点收尾会把周日或节假日写成数据日(实测 2026-09-21 07:32 那轮把 09-18 周五收盘的横截面记成 09-20 周日)。依赖 max(updated_at) 推算 as_of 的读路径 (榜单留痕、涨跌分布门禁、热门股新鲜度)全都继承这颗雷。

约定:这类表末列补 dt DATE,取值 = 「截至该写入时刻已收盘的最近交易日」:

dt DATE DEFAULT NULL COMMENT '数据截止日(快照对应的最近已收盘交易日)'
  • 取值统一走 backend/app/core/snapshot_dt.py::snapshot_dt()(15:30 翻页; 日历异常时退回工作日启发式,绝不抛错阻断写入)
  • ORM 定义 Column(Date, default=snapshot_dt, onupdate=snapshot_dt)——Python 侧 default/onupdate 同时覆盖 ORM 写入与 Core executemany;中央 upsert (fox_engine/writer/base.py::upsert_rows)对 Date 型 dt 自动追加进更新映射 (ON DUPLICATE KEY 的更新集是显式列表,漏了会出现「插入带 dt、更新丢 dt」)
  • 读路径直读 dt(Coalesce(dt, date(updated_at)) 兼容迁移期未回填行), 不再纯靠 max(updated_at) 推算

登记表(28 张,清洁源 backend/tools/schema_dt_apply.py::DT_TABLES):

fox_stock_master / fox_company_profile / fox_industry / fox_stock_wide / fox_eps_forecast / fox_stock_f10_info / fox_stock_fscore / tencent_index_info / tencent_future_info / tencent_us_stock_info / em_concept_block / cninfo_stock_base_info / tdx_block_info / tdx_finance_snapshot / tdx_block_index / ths_f10_profile / ths_f10_top_holder / ths_f10_operate / ths_f10_worth / ths_f10_equity / ths_f10_news / ths_f10_capital / ths_f10_lhb / ths_f10_rzrq / ths_f10_dzjy / sw_index_meta / em_org_basicinfo / sse_stock_info

边界(刻意不加 dt):

情形例子理由
自带业务日期列jrj_market_summary(dt)、app_builtin_board_pick(trade_date)业务日期列本身就是数据截止日,加 dt 是重复
有专属语义截止日列fox_valuation.as_of_date、fox_cb_detail.snapshot_date历史遗留且已被代码/文档消费,保留原列名
STG 过渡层stg_stock_master、stg_company_profile中间态,生命周期随融合器走,不对外承诺
静态清单sse_stock_list、szse_index_list变更即覆盖,无「每日横截面」语义

jrj 两张表的历史 dt 列是「跌停家数」,与全库 dt 语义冲突,已改名 limit_down (上游 JSON key 仍为 dt,映射由 downloaders/jrj.py 负责)。

3.4 dt 登记是「显式枚举」,不是启发式(2026-10-07 立规)​

🔴 两套判定并存且互不重合,这是 dt 漏标的根因:

判定方机制结果
backend/tools/schema_dt_apply.py::DT_TABLES硬编码枚举(权威)28 张
backend/tools/schema_full_audit.py::is_snapshot_like()表名启发式(SNAPSHOT_HINTS 匹配 + 无业务日期列)23 张

启发式会漏:7 张 app_* 表(app_checklist_* / app_candidate_lists / app_tactic_signal_snapshot 等)被判定为「快照表且无 dt」, 但它们实际是配置/模板/留痕表(pk=['id']、低频变更、无每日横截面语义), 本就不该有 dt。

约定(强制):

  • DT_TABLES 枚举是唯一真源;新建每日快照表必须手工登记到该清单 + 同步写入本节表格,不要指望审计工具自动发现。
  • 审计工具的启发式结果仅作提示,不得据此新增 dt。
  • 每次新增快照表,须同步三处:schema_dt_apply.py::DT_TABLES → core/database.py::_migrate_add_columns 的 dt 段 → 本节表格。
  • 反向登记:确认为「非每日快照」的表,可显式加入工具的豁免名单并注明理由, 避免每次审计重复人工判定。

4. 主键约定​

4.1 规则​

表类型主键策略示例
配置表/留痕表id BIGINT AUTO_INCREMENT PRIMARY KEYapp_users, app_sync_log
时序表(日频)(code, trade_date) 或 (code, dt) 复合 PKfox_kline_daily, fox_margin
时序表(多粒度)(code, period, dt) 复合 PKfox_kline_bar
快照表code 单列 PK 或 (code, ...) 复合 PKfox_stock_wide(code), fox_stock_master(code)
事件表id BIGINT AUTO_INCREMENT 或 (code, dt) 复合 PKem_dragon_tiger(id), fox_holder(code, dt)
行业/板块表(code, trade_date) 或 (industry_code, trade_date)fox_industry_index_daily

4.2 约束​

  • 禁止无主键表(PRIMARY KEY 或 UNIQUE KEY 至少一个)
  • 复合 PK 列顺序:代码列在前,日期列在后
  • 禁止使用 UUID 作为主键(B+树碎片化)
  • AUTO_INCREMENT 列必须为 BIGINT(不用 INT,防止大表溢出)

5. COMMENT 规范(强制)​

5.1 TABLE COMMENT​

  • 每张表必须有 COMMENT,中文简述表的用途
  • 格式:'{层级} · {业务域} · {实体} · {频率}'
  • 示例:'DWD · 日K线 · 前复权 · 日频'、'APP · 用户持仓 · 逐笔'

5.2 列 COMMENT​

  • 每列必须有 COMMENT
  • 格式:'{中文含义}({单位})',单位可选
  • 金额列必须注明单位:'成交额(元)'、'总市值(万元)'
  • 百分比列必须注明:'涨跌幅(%)'
  • 枚举列列出取值:'信号方向: 100=看涨, -100=看跌, 0=弃权'
  • 外键列注明引用表:'用户ID → app_users.id'

6. NULL 策略​

6.1 规则​

列类别NULL 策略说明
主键列NOT NULL强制
日期/时间键列NOT NULLtrade_date/dt/code 等 PK 组成部分
created_at/updated_atNULL 允许(DEFAULT CURRENT_TIMESTAMP)历史数据迁移兼容
业务数据列允许 NULL(表示"未取到")与 DEFAULT 0 区分:0 是有效值,NULL 是缺失
全列 NULL禁止若整列所有行均为 NULL,说明列定义错误或数据未写入,须修复

6.2 DEFAULT 值约定​

数据类型推荐 DEFAULT说明
BIGINT/INT (非 PK)DEFAULT 0 或 DEFAULT NULL计数类用 0,度量类用 NULL
DECIMALDEFAULT 0 或 DEFAULT NULL价格/金额用 NULL(0 是有效价格)
VARCHARDEFAULT '' 或 DEFAULT NULL名称类用 '',可选属性用 NULL
DATE/DATETIMEDEFAULT NULL 或 DEFAULT CURRENT_TIMESTAMP时间戳用 CURRENT_TIMESTAMP
TEXT/JSONDEFAULT NULL大文本/JSON 不设 DEFAULT

6.3 全 NULL 列治理(分类处置,2026-10-07)​

「整列全 NULL」不是一个问题而是四类,处置方式各自不同 (扫描:backend/tools/schema_full_audit.py data):

类别识别特征处置
时间戳列全 NULLupdated_at/created_at 整列为空确认列定义 + DEFAULT 正确;存量行按业务时间列/写入路径一次性回填,写入器保证新行有值
写路径缺失治理列(source_json/quality 等)写入方漏填修写入器(统一走 upsert_rows)+ 常量回填(如历史行 source_json='{"source":"legacy"}')
死列上游永不返回、全仓代码零引用逐列确认后 DROP(宁缺勿滥:DROP 不可逆,必须逐列单独确认,不许批量)
语义 NULL(白名单)数据源天然不提供的可选属性保留;列 COMMENT 必须写明「源侧不提供/仅特定场景有值」,否则视为漏写

判定「死列」前先全仓 grep 列名(含 ORM 模型、SQL 字符串、前端 src/ 与 mobile/),并确认上游响应确实不含该字段——「本库没有」不等于「上游没有」 (先例:研报目标价一直被取数端丢弃,曾被误判为上游无此数据)。

6.4 回填纪律:能修的不删,造数是红线(2026-10-07 立规)​

🔴 总原则:全 NULL 列的首选处置是「回填」或「标注」,不是 DROP。 DROP 不可逆,误判代价远高于留一列;规范 §6.3 的「死列 → DROP」是最后手段, 且必须逐列单独确认。

三条红线:

红线说明反例(禁止)
🔴 不删表、不删列除非满足 §6.3「死列」全部条件 + 逐列确认为了"表更干净"批量 DROP
🔴 不造数财务/股价/比率列禁止 DEFAULT 0 兜底给 fox_stock_wide.operate_cashflow 填 0
🔴 不篡改历史时间存量时间戳回填用「同表另一时间戳列」,不用回填时刻updated_at = NOW() 回填历史行

为什么禁止填 0:选股器的阈值判定把 0 当成有效值而非缺失。 327 列财务/估值列一旦填 0,会让「现金流为 0 的公司」混入选股结果, 比留 NULL 危险得多。NULL 语义是「未取到」,0 语义是「真的是 0」。

回填前必须先判定成因,不能盲填。2026-10-07 实测的三种成因:

成因判据处置
上游有数据,写路径没搬源表该列非空率 100%,目标表 0%从源表 JOIN 回填真实值 + 修写路径
写路径从未填该列ORM 用 mixin 声明了,写入函数没赋值存量回填 + 写路径补上
源侧确实不提供上游 API 响应不含该字段不回填,改 COMMENT 标注 + 登记白名单

实例(fox_stock_wide 16 个财务列): 融合器 writer/fusers/stock_wide.py 直接调 TDX TCP 实时接口取财务, 不走落库表 tdx_finance_snapshot。TCP 不可用时静默降级(仅 logger.warning) → 16 列恒 NULL,而上游落库表 5332 行全部有值。 处置:① 从 tdx_finance_snapshot JOIN 回填真实值; ② 补 TCP 降级兜底(读落库表),否则明天重跑又会写回 NULL。

6.5 回填工具(三个,按规模分工)​

工具适用规模机制
tools/schema_backfill_empty_cols.py小表 + 时间戳列逐表独立连接批量回填,WHERE col IS NULL 幂等
tools/schema_backfill_huge_table.py> 100 万行超大表按主键首列前缀分片(--width/--prefix),每片独立 commit
tools/schema_backfill_big_table.py中大表按主键首列等距分片

⚠️ 使用前必读 §8.1「大表分片纪律」与 §8.2「基础设施下限」。

6.6 语义 NULL 白名单(2026-10-07 登记)​

以下列确认为源侧不提供,保留列、不回填、COMMENT 已标注。 新增同类列须追加到本表,并跑 schema_full_audit.py data 复核。

表列不提供的原因
fox_unified_selectionddx / ddx_3d / ddx_5d / ddx_red_10d通达信专有算法,统一宽表无法复现
fox_unified_selectionpopularity_rank / rank_change / browse_rank / concern_rank_7days / newfans_ratio / bigfans_ratio / pop_*股吧人气为东财平台独有
fox_unified_selectionmutual_netbuy_amt / hsgt_hold_ratio北向资金 2024-08 起停止披露
fox_unified_selectionis_issue_break / is_bps_break需发行价 / 每股净资产,当前无落库列
fox_unified_selectionwin_market_5days / 10days / 20days需大盘基准列,融合器未 JOIN
fox_unified_selectionnowinterst_ratio / nowinterst_ratio_5d需股东变动时序,当前无落库列
fox_unified_selectionlisting_yield_year / listing_volatility_year需上市首日价,当前无落库列
fox_unified_selectionorg_rating东财宽表恒为 0;真值见 fox_stock_wide.rating_*
sse_stock_snapshotbid1 ~ bid5非交易时段采集,源侧无盘口
fox_stock_wide14 个财务列的 361 行这 361 只股票不在 tdx_finance_snapshot 中(次新股/退市/代码变更)

⚠️ UNAVAILABLE_FILTERS 与本表必须同步: services/screener/registry.py::UNAVAILABLE_FILTERS 是「筛选 DSL 层」的不可用字段声明。 2026-10-07 实测发现两者并不同步——该表只登记 9 个字段, 而宽表实际有 100+ 个恒 NULL 列。新增不可用字段时两处都要改, 否则预设组会把恒 NULL 列当可用字段暴露给用户(静默返回空结果)。

⚠️ 大表回填必须先读 §8.1「分片纪律」——朴素做法(LIMIT 分批 / 建索引 / 全键排序)实测全部失败。

执行前必须备份(mysqldump --no-create-info --complete-insert), 备份路径记入变更记录;回填脚本全部支持 dry-run(缺省即dry-run,须显式 --apply)。


7. 数据类型约定​

7.1 股票/指数代码​

场景类型说明
6 位 A 股代码VARCHAR(6)不用 INT(保留前导零)
含交易所前缀VARCHAR(12)SZ000001/SH600000/BJ830799
指数代码VARCHAR(10)000001.SH/399001.SZ

7.2 价格/金额​

场景类型说明
股价(元)DECIMAL(10,2) 或 DECIMAL(12,4)高价股/ETF 用 4 位小数
成交额(元)DECIMAL(18,2) 或 BIGINT大市值日成交额可达百亿级
市值(元)DECIMAL(18,2) 或 BIGINT同上
PE/PBDECIMAL(12,4)保留 4 位小数精度

7.3 百分比​

场景类型说明
涨跌幅DECIMAL(8,4)单位 %,保留 4 位小数
换手率DECIMAL(8,4)同上

7.4 大文本/JSON​

场景类型说明
短 JSON(< 16KB)JSON 或 TEXT优先 JSON(MySQL 校验)
长 JSON(> 16KB)MEDIUMTEXT如 AI 分析结果
新闻正文MEDIUMTEXT长文本

7.5 类型一致性约定(2026-10-07 立规,31 处漂移实测)​

🔴 三处声明必须一致:ORM 模型(Column(...) 类型)· init_db.sql(DDL)· 真实数据库(information_schema.COLUMNS)。三者任一漂移,轻则审计工具漏报、 重则运行时 DataError / 精度丢失。

ORM 类型名 vs MySQL DDL 类型名(易混,对照表):

SQLAlchemy ORMMySQL DDL说明
SmallIntegerSMALLINT不用 TINYINT(后者 ORM 无原生对应)
IntegerINT—
BigIntegerBIGINT—
Numeric(p,s)DECIMAL(p,s)MySQL NUMERIC = DECIMAL(同义词)
FloatDOUBLE / FLOAT金融列禁用(精度不可控)
String(n)VARCHAR(n)—
TextTEXT—
BooleanTINYINT(1)MySQL 无原生 BOOLEAN,是 TINYINT 同义词

漂移类型分类(2026-10-07 实测 31 处,17 张表):

类别典型漂移根因修复策略
INT 家族TINYINT ↔ SMALLINT/INTORM 用 SmallInteger/Integer,init_db.sql 写 TINYINT以 ORM 为准,改 init_db.sql
Numeric 家族DOUBLE ↔ DECIMAL(p,s)ORM 用 Numeric,init_db.sql 写 DOUBLE金融列必须 DECIMAL,改 init_db.sql
精度不一致DECIMAL(10,4) ↔ DECIMAL(12,4)ORM 改了精度,init_db.sql 未同步以 ORM 为准
VARCHAR 长度VARCHAR(32) ↔ VARCHAR(128)ORM 扩长度,init_db.sql 未同步以 ORM 为准
TEXT ↔ VARCHARVARCHAR(255) ↔ TEXTORM 改 Text(长文本),init_db.sql 仍 VARCHAR以 ORM 为准
CHAR ↔ VARCHARCHAR(6) ↔ VARCHAR(6)ORM 改 String,init_db.sql 仍 CHAR以 ORM 为准(VARCHAR 更灵活)

检测工具:tools/audit_type_drift.py(逐表比对 ORM 声明 vs init_db.sql DDL, 输出漂移清单)。新建表 / 改 ORM 类型后必跑,0 漂移才算通过。

修复流程:

  1. 发现漂移 → 以 ORM 为准(ORM 是代码侧 source of truth)
  2. 改 init_db.sql(DDL 模板,全新环境建库用)
  3. 写迁移脚本 database/fix_*.sql(存量环境 ALTER TABLE,幂等 / 可重跑)
  4. 重跑 audit_type_drift.py 确认 0 漂移

实例(2026-10-07,31 处修复):

表列漂移修复
app_smart_strategiesenabled / auto_runTINYINT → INTORM 用 Integer
cninfo_block_trade_statdeal_amount / premium_rateDOUBLE → DECIMAL金融列必须 DECIMAL
fox_finance_indicatorgross_margin_pct 等 4 列DECIMAL(10,4) → DECIMAL(12,4)ORM 扩精度
em_org_surveyreceive_placeVARCHAR(255) → TEXT长文本
fox_factor_score_dailycodeCHAR(6) → VARCHAR(6)与其他表统一

完整清单:database/fix_type_drift_20261007.sql(17 张表 31 处 ALTER)。

纪律(强制):

  • 🔴 改 ORM 类型后,必须同步改 init_db.sql——否则全新环境建库出来的表与存量库类型不同
  • 🔴 新建表必跑 audit_type_drift.py——0 漂移才算通过(§12 检查清单已纳入)
  • 🔴 迁移脚本命名:database/fix_{描述}_{日期}.sql(如 fix_type_drift_20261007.sql), 与 fix_snapshot_dt_20261007.sql / fix_empty_business_cols_20261007.sql 同命名约定
  • ⚠️ ALTER TABLE 修改类型须谨慎:大表(> 100 万行)ALTER 可能锁表数分钟, 低峰期执行;DECIMAL 精度扩大不影响数据,缩小会截断(先确认无溢出)

8. 索引约定​

  • 查询高频列组合建复合索引,列顺序与 WHERE 条件匹配
  • 时序表按日期范围查询:(trade_date) 或 (code, trade_date) PK 已覆盖
  • 禁止在低基数列(如 market、is_st)上建单列索引
  • 索引命名:idx_{表名简写}_{列名},如 idx_kline_code_date
  • 唯一索引:uk_{表名简写}_{列名}

8.1 大表全量 UPDATE 的分片纪律(2026-10-07 新增,踩坑实测)​

🔴 背景:xuangu 库总容量 43GB,而默认 innodb_buffer_pool_size 仅 128MB(占库容量 0.3%)。这让任何全表扫描都退化为纯磁盘 I/O, 实测 SELECT COUNT(*) 在 592 万行的 em_margin_trading 上需 >100 秒。

踩过的坑(不要重复):

尝试实测结果结论
UPDATE ... WHERE col IS NULL LIMIT 5000 分批每批都全表扫;单批跑满 280s 被系统杀❌ 无索引时分批无意义
建索引 ADD INDEX (source_json)TEXT 列报 BLOB/TEXT used in key specification without a key length❌ 必须写前缀 (col(64))
建前缀索引 source_json(64)592 万行建索引本身超 280s❌ 大表上索引成本过高
调大 buffer_pool 到 2GB首次读盘成本无法消除⚠️ 该做,但不解决单表 3.9GB 的问题
SELECT pk FROM t ORDER BY pk 取全键分片592 万行排序本身超 280s❌ 物化全键不可行
WHERE pk LIKE 'xx%' 按主键首列前缀分片1,325,002 行单片一次成功✅ 可行方案

分片粒度(--width)实测标定(tdx_gpcw_finance,3414 万行 / 3.9GB):

--width最大片结果
2(60%)1107 万行❌ OOM(SIGKILL)
3(002%)630 万行❌ OOM
3(300%)618 万行⚠️ 能跑完但不稳,618 万可跑 / 630 万即崩
4(0021%)66 万行✅ 稳定,可批量连跑

经验值:单片控制在 100 万行以内,60 万行最稳。 --width 是按需加深的旋钮(2→3→4),非固定值——大表用 4,普通表用 2 够用。 --prefix 可只跑指定子集,便于分次推进。

⚠️ 内存是硬约束,不是可调优项。2026-10-07 实测:大片 UPDATE 会在 InnoDB buffer pool 堆大量脏页,多任务并发时系统 swap 被打满 → 进程被 OOM Kill (表现为无任何报错直接消失,极易误判为"跑完了")。因此:

  • 一次只跑一个大表任务,禁止并发跑多个回填。
  • 观测点:free -m 的 available 与 swap 使用率;available < 1GB 时先停手。
  • 中断可断点续跑(WHERE col IS NULL 天然幂等),不需要回滚重来。

约定(强制):

  • 对 > 100 万行的表做全量 UPDATE,必须按主键首列前缀分片 (WHERE code LIKE '60%' 走聚簇索引 range),每片独立 commit。
  • 绝不用 OFFSET 分页取键值——累计扫描量 O(n²/chunk), 大表上会指数级变慢(实测 592 万行即超 280s)。
  • 分片必须可断点续跑:幂等条件(WHERE col IS NULL)+ 每片独立事务。
  • 小表(< 100 万行)可用 schema_backfill_empty_cols.py --tables-from, 但仍须逐表独立连接 + 独立提交(单连接跑全程会因异常/重启把已完成的表一起回滚)。
  • 配套工具:backend/tools/schema_backfill_huge_table.py(前缀分片,支持 --width/--prefix)、 schema_backfill_empty_cols.py(小表 / 时间戳列)、 schema_backfill_big_table.py(中大表等距分片)。

8.2 基础设施下限(2026-10-07 实测,建议写进部署检查单)​

项实测值说明
库总容量43 GBSUM(DATA_LENGTH+INDEX_LENGTH)
innodb_buffer_pool_size128 MB(默认)仅占库容量 0.3%,任何全表扫退化
建议值≥ 2 GB物理内存 11.7GB 条件下;43GB 库无法全缓存,但热点常驻可显著改善
持久化SET GLOBAL 会写入 mysqld-auto.cnf重启后仍生效;/etc/my.cnf 在部分环境只读时用此方式

⚠️ 调优 buffer pool 不能修复「无索引列的全表 UPDATE」—— 前者是减少读盘,后者是消除全表扫。两者独立,勿相互替代。


9. 字符集与排序规则​

  • 全库统一 utf8mb4,排序规则 utf8mb4_unicode_ci(已完成 115 表 CONVERT)
  • 禁止使用 utf8mb4_general_ci(排序不准确)
  • 禁止使用 latin1

10. 分层架构约束​

RAW (em_/cninfo_/ths_/tdx_/sse_/szse_/tencent_/jrj_/sina_/sw_/social_/ppi100_)
↓ STG 清洗/标准化
STG (stg_)
↓ 多源融合/派生计算
DWD (fox_) ← 对外提供统一数据模型
↓ 业务聚合/留痕
APP (app_) ← 前端/API 直接消费
  • RAW 层:忠实记录源数据,列名尽量与源 API 响应字段对应
  • STG 层:字段标准化(代码归一、日期转换、单位统一)
  • DWD 层:多源融合,列名遵循本规范,单位口径统一
  • APP 层:面向业务的聚合/留痕,允许冗余列减少 JOIN

11. 存量表迁移计划​

11.1 差距与现状(2026-10-07 复检)​

项目2026-10-06 审计现状处置
缺 TABLE COMMENT124 张0已补齐
缺列 COMMENT多批0已补齐
缺 created_at~120 张0已补齐(默认值 CURRENT_TIMESTAMP)
缺 updated_at~60 张0已补齐
时间戳默认值异常—0已补齐
无主键表—0—
AUTO_INCREMENT 非 BIGINT8 张0已改 BIGINT
排序规则非 utf8mb4_unicode_ci—0115 表 CONVERT 已完成
快照表缺 dt28 张0dt 方案已补 + 存量回填(见 11.3)
全 NULL 列待统计2026-10-07 实测245 列 / 120 表已分类治理,见 §11.4
VARCHAR 存日期~10 张96 列嫌疑P3:历史遗留暂不改(改类型牵动读写两侧)
init_db.sql 缺表75 张0已补齐(见 §11.3 批次 4)
ORM vs 真实库列漂移app_user_favorites 缺 4 列0启动迁移已补齐(见 §11.3 批次 5)
ORM vs init_db.sql 类型漂移—031 处已修复,真实库已执行迁移(见 §11.3 批次 7 + §7.5)

复检命令:cd backend && venv/bin/python -X utf8 tools/schema_full_audit.py structure (结构,秒级)+ ... data(数据面,全表扫描约 8 分钟)。 「现状」列即该工具 2026-10-07 输出(结构项全 0)。

11.1.1 🔴 审计工具的盲区(2026-10-07 全量审计实测)​

schema_full_audit.py structure 的「结构项全 0」只在其11 个检查项内成立。 它不做下列两项比对,因此 P0/P1 级问题会全部漏检:

漏检项后果应补的检查
ORM 声明 vs 真实库列集app_user_favorites 真实库缺 4 列,ORM + API 正在读写 → 调用即1054 Unknown column,且启动日志一直"正常"逐表比对 Base.metadata 与 information_schema
ORM 主键 vs 真实库主键(当前 0 漂移,但机制缺失)get_pk_constraint 逐表比对

⚠️ 已修复(2026-10-07):init_db.sql 缺失 75 张表的问题已补齐(见 §11.3 批次 4), 现包含全部 251 张表。全新环境建库不会再缺表。

⚠️ 另注意:create_all(checkfirst=True) 只建缺失表,绝不给已存在的表补列。 新增列必须同时进 core/database.py::_migrate_add_columns(), 否则存量库会静默缺列(app_user_favorites 即此坑的实例,已由批次 5 修复)。

11.2 迁移原则​

  1. 只加不删:新增列用 ALTER TABLE ADD COLUMN,不删除现有列
  2. 向后兼容:新增列允许 NULL 或设 DEFAULT,不影响现有写入逻辑
  3. 分批执行:每批 ≤ 20 张表,执行后验证 ORM 模型与 API 响应
  4. ORM 同步:ALTER 后必须同步更新 backend/app/models/db_models.py / fox_models.py / stg_models.py
  5. DDL 三处同步:database/init.sql、backend/init_db.sql 与启动迁移 (app/core/database.py::_migrate_add_columns())都要带上同一变更; 存量环境靠启动迁移或 database/fix_*.sql 留档,全新环境靠两份 init DDL

11.3 已执行批次(2026-10-06 ~ 10-07)​

批次内容留档
规范化 ALTERcreated_at / updated_at / TABLE COMMENT / 列 COMMENT 批量补齐docs/plans/fix-schema-convention-2026-10-06.sql
F1~F5 收尾剩余表/列注释;47 表 created_at、126 表 updated_at 补默认值;app_dbadmin_sql_history 补 updated_at;8 表自增主键 INT→BIGINT(一次性脚本,按 .gitignore 约定不入库,结果以审计复检为准)审计复检 0 缺口
dt 方案28 张快照表补 dt DATE 末列;jrj 旧 dt(跌停家数)改名 limit_down;存量 67 万行按 updated_at 折算交易日回填(0 残留 NULL)database/fix_snapshot_dt_20261007.sql + backend/tools/schema_dt_apply.py --backfill
init_db.sql 补齐从真实库导出 75 张缺失表的 DDL 追加到 init_db.sql(含 fox_kline_daily/fox_stock_wide 等核心表),现包含全部 251 张表tools/export_missing_tables_ddl.py(导出工具)
P0 启动迁移app_user_favorites 补齐 4 列(status/note/source_strategy_id/source_run_id)—— ORM + API 已引用但存量库缺列,收藏接口调用即报 1054core/database.py::_migrate_add_columns() 新增迁移块
upsert updated_at 自动刷新upsert_rows() 自动检测模型是否有 updated_at 列,有则在 ON DUPLICATE KEY UPDATE 中追加 func.now()——218 张表 ORM 未声明时间戳列(由 DB DEFAULT 兜底),但 upsert 的显式列名列表不会刷新它fox_engine/writer/base.py::_update_set()
ORM vs init_db.sql 类型漂移修复17 张表 31 处类型漂移(INT 家族 4 / Numeric 家族 21 / VARCHAR/TEXT/CHAR 6),全部以 ORM 为准修正 init_db.sql + 写迁移脚本。真实库已执行(2026-10-07,audit_type_drift.py 复检 0 漂移)database/fix_type_drift_20261007.sql(已执行) + tools/audit_type_drift.py(检测工具,见 §7.5)

dt 的 DDL 已内置进启动迁移 _migrate_add_columns()(幂等,列已存在即跳过), 存量环境升级无需手工执行;database/fix_snapshot_dt_20261007.sql 是同一批变更的 显式 SQL 留档,供全新环境/其他副本对齐与审计。

11.4 全 NULL 列回填批次(2026-10-07,245 列分类治理)​

依据docs/audits/schema-design-audit-2026-10-07.md(251 表 4315 列全量审计)。 全程零 DROP——处置一律是「回填」或「COMMENT 标注」。

类别规模处置留档 / 工具
B类 时间戳列全 NULL11 列updated_at ← created_at(创建即最后修改),共 22,161 行tools/schema_backfill_empty_cols.py --class b
A 类 source_json 血缘列89 表(全部 RAW 层)按表名前缀回填血缘标签,共约 5,968 万行tools/schema_backfill_empty_cols.py --class a + schema_backfill_huge_table.py(tdx_gpcw_finance 4,925 万行走前缀分片)
A 类写路径根治102 个继承 mixin 的模型RawGovernanceMixin.source_json 加 defaultmodels/db_models.py + tests/test_raw_governance_columns.py(207 用例)
C 类 语义 NULL约 100 列不回填,改 COMMENT 标注「源侧不提供」+ 登记白名单database/fix_empty_business_cols_20261007.sql §3
D 类 写路径缺陷fox_stock_wide 14 财务列 / fox_unified_selection 26 列从上游表JOIN 回填真实值 + 修写路径同上 §1/§2 + writer/fusers/stock_wide.py 降级兜底

D 类两处根因(都不是"忘了填"):

表根因修复
fox_stock_wide融合器 fusers/stock_wide.py:230 直调 TDX TCP 实时接口取财务,不走落库表。TCP 不可用时静默降级(仅 logger.warning)→ 14 列恒 NULL,而 tdx_finance_snapshot 5332 行全部有值① 从落库表回填;② 补 TCP 降级兜底(读落库表),否则明天重跑又写回 NULL
fox_unified_selection新融合器 unified_selection_sync.py 只 JOIN 6 张源表,这 26 列是旧东财宽表遗留 → 迁移到本表起从未写过从 em_stock_selection_daily 按 code 回填;长期需接入融合链路

⚠️ fox_unified_selection 回填的跨日取舍(2026-10-07 老板确认): 旧表最新交易日 2026-09-29,新表 date=2026-09-30,两者不重叠 → 最初按 (code, date) JOIN 的写法匹配数为 0,回填"执行成功但一行没改" (无声失败,极易误判为已修)。现改为按 code 匹配旧表最新日快照, 接受「新表 09-30 的行携带 09-29 数据」的跨日偏差。 财报/机构持股类指标日间变动小,影响可接受;涉及涨跌/突变类指标时须注意。 长期解法:把这 26 列接入 unified_selection_sync.py。

⚠️ fox_stock_wide 剩余 361 行不回填:这 361 只股票在 tdx_finance_snapshot 中根本不存在(LEFT JOIN 验证),属源侧不提供, 不是漏写。

A 类根因(不是"忘了填"这么简单):RawGovernanceMixin 声明了 source_json, 但 RAW 层downloaders(ths_f10_service.py / sse_data.py 等)逐个手写字段, 从不赋值;quality 因有 default="ok" 兜底所以非空 —— 掩盖了同类缺失。

🔴 回填治标,写路径才治本(2026-10-07 二次实测)​

回填完成后复扫发现 47 张表又出现全 NULL——因为采集任务还在跑, 新写入的行同样不填 source_json(sse_dividend 回填后行数 3096 → 7117, 新增行血缘依然全空)。只回填存量 = 治标。

已在 RawGovernanceMixin.source_json 加 default='{"source":"legacy_unlabeled"}', 一处覆盖102 个 RAW 模型(实测 ORM 构造时属性为 None, 但 INSERT 落库为默认值 —— 已由 test_raw_governance_columns.py 端到端验证)。

⚠️ 两个必须知道的边界:

边界说明
MySQL 不允许 TEXT/BLOB/JSON 列带 DB 级 DEFAULT实测 ERROR 1101。所以 SQLAlchemy 的 default 只在 ORM 层生效,不下推为 DDL;information_schema.COLUMNS.COLUMN_DEFAULT 仍为 NULL。_migrate_add_columns() 因此只报告缺口,不假装能改
ORM default 覆盖不到裸 SQL / executemanyRAW 表大量走 session.add(Model(...))(受default 保护),但若改写成裸 SQL 或 executemany,default 同样失效。这类路径需逐处显式赋值

因此正确姿势是:ORM default 兜底 + 关键写路径显式赋值双保险, 而非指望 default 一劳永逸。

📌 由此推出一条通用规范:新增 RAW 表时, ① 继承 RawGovernanceMixin;② 写入路径显式给 source_json 赋真实血缘 (形如 {"source":"xxx","api":"yyy"})。只靠 default 会得到 legacy_unlabeled 这种占位血缘,追溯价值有限。

C 类标注要点:无上游数据的列保留并注释,如 ddx(通达信专有算法)、popularity_rank(东财平台独有)、 mutual_netbuy_amt(2024-08 停披露)、bid1~5(非交易时段无值)。 ❌ 切勿填 0——见 §6.4 红线。

遗留:em_margin_trading(592 万行)等大表因43GB 库的buffer pool 仅 128MB(见 §8.2),前缀分片仍偏慢,需后续分次推进。


12. 新建表检查清单​

新建表前逐项确认:

  • 表名符合前缀分类规则
  • 表名 snake_case 全小写,无歧义缩写
  • 列名全大写 snake_case(§2.1;与表名相反,别写混)
  • 有 TABLE COMMENT
  • 每列有 COMMENT,含单位/取值说明;语义 NULL 列须写明「源侧不提供」(§6.4)
  • 有 created_at DATETIME DEFAULT CURRENT_TIMESTAMP
  • 有 updated_at DATETIME DEFAULT CURRENT_TIMESTAMP(不写 DB 级 ON UPDATE, 由写入方/ORM 自管——SQLite 测试库不支持 DB 级 ON UPDATE,写了会与测试环境口径分叉)
  • 两个时间戳列都在 ORM 里有Column 定义(§3.2.1)—— 不靠 DB DEFAULT 兜底,否则 ORM 侧不知道它们存在
  • 有主键(AUTO_INCREMENT 或复合 PK)
  • 日期列用 DATE 类型(非 VARCHAR)
  • 金额/百分比列类型与精度符合 §7 约定;金融列禁用 FLOAT/DOUBLE(用 DECIMAL)
  • 快照表(无业务日期列)末列有 dt DATE(数据截止日,§3.3), 并已登记进 schema_dt_apply.py::DT_TABLES(§3.4)
  • 已在类注释里显式声明表类型(时序/快照/事件/配置/留痕,§3.1)—— 不要让审计工具靠表名猜
  • 写入器会填 source_json/updated_at(§6.4 A 类根因: mixin 声明了但写入函数不赋值,等于没声明)
  • 无全 NULL 列(所有列至少有部分行有值,或落入 §6.4 白名单)
  • 字符集 utf8mb4,排序规则 utf8mb4_unicode_ci
  • ORM 模型已同步定义
  • 已同步进 backend/init_db.sql(2026-10-07 起已包含全部 251 张表)
  • 已跑 tools/audit_type_drift.py 确认 0 漂移(§7.5:ORM 类型 vs init_db.sql DDL 一致)
  • 已确定该表属于哪一层(RAW/STG/DWD/APP)
  • backend/tools/schema_full_audit.py 两阶段审计对本表无新告警
  • 若为大表(> 100 万行):已确认全量 UPDATE 走前缀分片(§8.1)

相关文档​

  • 数据契约(分层/单位/血缘):docs/docs/reference/data-contract.md
  • 数据模型概览:docs/docs/data-management/data-model.md
  • 初始化 DDL:backend/init_db.sql;dt 迁移留档 database/fix_snapshot_dt_20261007.sql;类型漂移迁移 database/fix_type_drift_20261007.sql
  • 全量审计报告(2026-10-07,251 表 4315 列):docs/audits/schema-design-audit-2026-10-07.md
  • 全 NULL 列回填留档:database/fix_empty_business_cols_20261007.sql
  • ORM 模型定义:backend/app/models/*.py(11 个文件合计 251 张表, 不是只有 db_models.py——那是单文件 137 张的口径)
  • 表结构审计与 dt 回填工具:backend/tools/schema_full_audit.py、schema_dt_apply.py、audit_type_drift.py(ORM vs init_db.sql 类型漂移检测,§7.5)
  • 全 NULL 列回填工具(§6.5):schema_backfill_empty_cols.py(小表/时间戳)、 schema_backfill_huge_table.py(超大表前缀分片)、schema_backfill_big_table.py(中大表)