数据库表结构设计规范
适用范围:
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 位纯代码 | code | VARCHAR(6) | 存储 000001~999999,不含交易所前缀 |
| 含交易所前缀 | symbol | VARCHAR(12) | 存储 SZ000001/SH600000 |
| 股票名称 | name | VARCHAR(20) | 中文简称 |
2.3 日期/时间列(强制)
| 语义 | 列名 | 类型 | 说明 |
|---|---|---|---|
| 交易日期(行情类) | trade_date | DATE | 已收盘的交易日,行情类表首选 |
| 通用日期 | dt | DATE | 非行情类或已有历史命名(原 date,已迁移避 MySQL 保留字) |
| 报告日期 | report_date | DATE | 财报报告期截止日 |
| 发布日期 | publish_date | DATE/VARCHAR(20) | 公告/研报/宏观数据发布日 |
| 创建时间 | created_at | DATETIME | 行首次写入时间 |
| 更新时间 | updated_at | DATETIME | 行最后修改时间 |
| 数据截止日 | dt | DATE | 仅「无业务日期列的每日刷新快照表」使用(末列,见 §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 KEY | app_users, app_sync_log |
| 时序表(日频) | (code, trade_date) 或 (code, dt) 复合 PK | fox_kline_daily, fox_margin |
| 时序表(多粒度) | (code, period, dt) 复合 PK | fox_kline_bar |
| 快照表 | code 单列 PK 或 (code, ...) 复合 PK | fox_stock_wide(code), fox_stock_master(code) |
| 事件表 | id BIGINT AUTO_INCREMENT 或 (code, dt) 复合 PK | em_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 NULL | trade_date/dt/code 等 PK 组成部分 |
created_at/updated_at | NULL 允许(DEFAULT CURRENT_TIMESTAMP) | 历史数据迁移兼容 |
| 业务数据列 | 允许 NULL(表示"未取到") | 与 DEFAULT 0 区分:0 是有效值,NULL 是缺失 |
| 全列 NULL | 禁止 | 若整列所有行均为 NULL,说明列定义错误或数据未写入,须修复 |
6.2 DEFAULT 值约定
| 数据类型 | 推荐 DEFAULT | 说明 |
|---|---|---|
BIGINT/INT (非 PK) | DEFAULT 0 或 DEFAULT NULL | 计数类用 0,度量类用 NULL |
DECIMAL | DEFAULT 0 或 DEFAULT NULL | 价格/金额用 NULL(0 是有效价格) |
VARCHAR | DEFAULT '' 或 DEFAULT NULL | 名称类用 '',可选属性用 NULL |
DATE/DATETIME | DEFAULT NULL 或 DEFAULT CURRENT_TIMESTAMP | 时间戳用 CURRENT_TIMESTAMP |
TEXT/JSON | DEFAULT NULL | 大文本/JSON 不设 DEFAULT |
6.3 全 NULL 列治理(分类处置,2026-10-07)
「整列全 NULL」不是一个问题而是四类,处置方式各自不同
(扫描:backend/tools/schema_full_audit.py data):
| 类别 | 识别特征 | 处置 |
|---|---|---|
| 时间戳列全 NULL | updated_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_selection | ddx / ddx_3d / ddx_5d / ddx_red_10d | 通达信专有算法,统一宽表无法复现 |
fox_unified_selection | popularity_rank / rank_change / browse_rank / concern_rank_7days / newfans_ratio / bigfans_ratio / pop_* | 股吧人气为东财平台独有 |
fox_unified_selection | mutual_netbuy_amt / hsgt_hold_ratio | 北向资金 2024-08 起停止披露 |
fox_unified_selection | is_issue_break / is_bps_break | 需发行价 / 每股净资产,当前无落库列 |
fox_unified_selection | win_market_5days / 10days / 20days | 需大盘基准列,融合器未 JOIN |
fox_unified_selection | nowinterst_ratio / nowinterst_ratio_5d | 需股东变动时序,当前无落库列 |
fox_unified_selection | listing_yield_year / listing_volatility_year | 需上市首日价,当前无落库列 |
fox_unified_selection | org_rating | 东财宽表恒为 0;真值见 fox_stock_wide.rating_* |
sse_stock_snapshot | bid1 ~ bid5 | 非交易时段采集,源侧无盘口 |
fox_stock_wide | 14 个财务列的 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/PB | DECIMAL(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 ORM | MySQL DDL | 说明 |
|---|---|---|
SmallInteger | SMALLINT | 不用 TINYINT(后者 ORM 无原生对应) |
Integer | INT | — |
BigInteger | BIGINT | — |
Numeric(p,s) | DECIMAL(p,s) | MySQL NUMERIC = DECIMAL(同义词) |
Float | DOUBLE / FLOAT | 金融列禁用(精度不可控) |
String(n) | VARCHAR(n) | — |
Text | TEXT | — |
Boolean | TINYINT(1) | MySQL 无原生 BOOLEAN,是 TINYINT 同义词 |
漂移类型分类(2026-10-07 实测 31 处,17 张表):
| 类别 | 典型漂移 | 根因 | 修复策略 |
|---|---|---|---|
| INT 家族 | TINYINT ↔ SMALLINT/INT | ORM 用 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 ↔ VARCHAR | VARCHAR(255) ↔ TEXT | ORM 改 Text(长文本),init_db.sql 仍 VARCHAR | 以 ORM 为准 |
| CHAR ↔ VARCHAR | CHAR(6) ↔ VARCHAR(6) | ORM 改 String,init_db.sql 仍 CHAR | 以 ORM 为准(VARCHAR 更灵活) |
检测工具:tools/audit_type_drift.py(逐表比对 ORM 声明 vs init_db.sql DDL,
输出漂移清单)。新建表 / 改 ORM 类型后必跑,0 漂移才算通过。
修复流程:
- 发现漂移 → 以 ORM 为准(ORM 是代码侧 source of truth)
- 改
init_db.sql(DDL 模板,全新环境建库用) - 写迁移脚本
database/fix_*.sql(存量环境ALTER TABLE,幂等 / 可重跑) - 重跑
audit_type_drift.py确认 0 漂移
实例(2026-10-07,31 处修复):
| 表 | 列 | 漂移 | 修复 |
|---|---|---|---|
app_smart_strategies | enabled / auto_run | TINYINT → INT | ORM 用 Integer |
cninfo_block_trade_stat | deal_amount / premium_rate | DOUBLE → DECIMAL | 金融列必须 DECIMAL |
fox_finance_indicator | gross_margin_pct 等 4 列 | DECIMAL(10,4) → DECIMAL(12,4) | ORM 扩精度 |
em_org_survey | receive_place | VARCHAR(255) → TEXT | 长文本 |
fox_factor_score_daily | code | CHAR(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 GB | SUM(DATA_LENGTH+INDEX_LENGTH) |
innodb_buffer_pool_size | 128 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 COMMENT | 124 张 | 0 | 已补齐 |
| 缺列 COMMENT | 多批 | 0 | 已补齐 |
缺 created_at | ~120 张 | 0 | 已补齐(默认值 CURRENT_TIMESTAMP) |
缺 updated_at | ~60 张 | 0 | 已补齐 |
| 时间戳默认值异常 | — | 0 | 已补齐 |
| 无主键表 | — | 0 | — |
| AUTO_INCREMENT 非 BIGINT | 8 张 | 0 | 已改 BIGINT |
排序规则非 utf8mb4_unicode_ci | — | 0 | 115 表 CONVERT 已完成 |
快照表缺 dt | 28 张 | 0 | dt 方案已补 + 存量回填(见 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 类型漂移 | — | 0 | 31 处已修复,真实库已执行迁移(见 §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 迁移原则
- 只加不删:新增列用
ALTER TABLE ADD COLUMN,不删除现有列 - 向后兼容:新增列允许 NULL 或设 DEFAULT,不影响现有写入逻辑
- 分批执行:每批 ≤ 20 张表,执行后验证 ORM 模型与 API 响应
- ORM 同步:ALTER 后必须同步更新
backend/app/models/db_models.py/fox_models.py/stg_models.py - 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)
| 批次 | 内容 | 留档 |
|---|---|---|
| 规范化 ALTER | created_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 已引用但存量库缺列,收藏接口调用即报 1054 | core/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类 时间戳列全 NULL | 11 列 | 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 加 default | models/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 / executemany | RAW 表大量走 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(中大表)