06 | 把 meta_config 同步进 MySQL(生成阶段)

28 阅读13分钟

06 | 把 meta_config 同步进 MySQL(生成阶段)

项目地址:github.com/frontzhm/n2…

每一步对应的完整代码都在仓库里,跟着文档卡住了就去翻源码。

这是一篇系列文,请按顺序阅读。

本文目标

执行 NL2SQL 元数据召回前,要先把业务元数据灌进各个存储:

  1. MySQL meta 库:结构化元数据(表、字段、指标、关联)
  2. Qdrant:字段与指标的语义向量(后续)
  3. Elasticsearch:维度值全文检索(后续)

本文完成第 1 步:把 conf/meta_config.yaml 同步到 MySQL meta 库的四张表。

数据源头是 YAML,不是直接扫描业务库;业务库 dw 只用来补充字段类型和少量样例值。

1. 整体流程

meta_config.yaml
       │
       ▼
  conf/sync_db.py
       │
       ├─ validate_meta_config()
       ├─ sync_table_info()
       ├─ sync_column_info()   ← 读取 dw,补 type / examples
       ├─ sync_metric_info()
       └─ sync_column_metric()
              │
              ▼
     Service → Repository → Mapper → Model
              │
              ▼
         MySQL meta 库

调用链可以概括为:脚本校验并解析 YAML,组装 Entity,经 Service 和 Repository 调用 Mapper 转为 Model,最后由 SQLAlchemy 写入 MySQL。

四类元数据共用一个 meta Session,并在全部写入成功后统一提交。任何一步失败,写入事务都会回滚。

2. meta 库存什么

表名作用关键字段
table_info业务表信息id / name / role / description
column_info业务字段信息id / name / type / role / description / alias / examples / table_id
metric_info指标定义id / name / description / alias / relevant_columns
column_metric字段与指标关联column_id / metric_id

逻辑关系如下:

table_info 1 ── N column_info
column_info N ── N metric_info(通过 column_metric)

当前 Model 通过 ID 约定维护这些逻辑关系,暂未声明 MySQL ForeignKey。数据库本身不会阻止无效的 table_idcolumn_idmetric_id,因此同步前要先校验配置。

配置上做了拆分:

  • tables 只放业务表(dim / fact)及其 columns
  • metric_info 放在顶级 key,不塞进 tables

这样可以避免把指标误写入 table_info

3. 为什么分层

YAML 格式与数据库表结构不同,不适合读完后直接执行 INSERT:

层级路径职责
Entityapp/entities/纯业务对象(dataclass)
Modelapp/models/ORM 映射,对应物理表
Mapperapp/mappers/Entity 与 Model 相互转换
Repositoryapp/repositories/数据持久化与 flush
Serviceapp/services/业务能力封装
Scriptconf/sync_db.py校验配置、装配依赖、控制事务

相关目录:

.
├── .env
├── app/
│   ├── dbs/
│   ├── entities/
│   ├── mappers/
│   ├── models/
│   ├── repositories/
│   └── services/
└── conf/
    ├── app_config.yaml
    ├── get_config.py
    ├── meta_config.yaml
    └── sync_db.py

4. 配置:密码放在 .env

.env

MYSQL_USER=yan
MYSQL_PASSWORD=你的密码
LLM_API_KEY=...
LLM_BASE_URL=...

app_config.yaml

meta_db:
  host: localhost
  port: 3306
  user: "${MYSQL_USER}"
  password: "${MYSQL_PASSWORD}"
  database: meta

dw_db:
  host: localhost
  port: 3306
  user: "${MYSQL_USER}"
  password: "${MYSQL_PASSWORD}"
  database: dw

get_config.py 会加载 .env、展开 ${VAR_NAME},并在缺少变量时立即报错。

数据库模块使用 URL.create() 构造连接地址,不直接拼接字符串。这样密码含有 @:/ 等字符时也不会破坏 URL:

from sqlalchemy import URL

DB_URL = URL.create(
    drivername="mysql+asyncmy",
    username=user,
    password=password,
    host=host,
    port=port,
    database=database,
)

依赖:

uv add pyyaml python-dotenv sqlalchemy asyncmy

5. table_info 的分层实现

Entity 是不依赖 SQLAlchemy 的业务对象:

@dataclass
class TableInfo:
    id: str
    name: str
    role: str
    description: str

配置没有单独的表 ID,因此使用物理表名(例如 dim_region)作为稳定主键。

Model 负责 ORM 映射:

class TableInfoModel(Base):
    __tablename__ = "table_info"

    id: Mapped[str] = mapped_column(String(64), primary_key=True)
    name: Mapped[str | None] = mapped_column(String(128))
    role: Mapped[str | None] = mapped_column(String(32))
    description: Mapped[str | None] = mapped_column(Text)

Mapper 显式展开 ORM 字段,避免把 SQLAlchemy 内部状态带入 Entity:

class TableInfoMapper:
    def to_entity(self, model: TableInfoModel) -> TableInfo:
        return TableInfo(
            id=model.id,
            name=model.name,
            role=model.role,
            description=model.description,
        )

    def to_model(self, entity: TableInfo) -> TableInfoModel:
        return TableInfoModel(**entity.__dict__)

Repository 使用一次 add_all() 和一次 flush() 把一类对象加入当前事务。四类数据全部处理完后,由同步脚本统一提交;不要在循环里逐条提交。

6. 校验、重建与事务

同步入口先校验以下内容:

  • tablesmetric_infocolumns 的类型
  • 表、字段、指标的必填属性
  • 重复的表名、字段 ID 和指标名
  • relevant_columns 是否引用了真实配置字段

校验必须放在重建表之前,避免配置错误时先清空数据库。

开发阶段会执行:

async def reset_meta_tables() -> None:
    async with async_engine.begin() as conn:
        await conn.run_sync(Base.metadata.drop_all)
        await conn.run_sync(Base.metadata.create_all)

⚠️ reset_meta_tables() 会删除 Base.metadata 中已注册的全部表,只适用于本地开发。不要直接用于测试、预发布或生产数据库。生产环境应改用 migration 和 upsert。

重建属于 DDL,不在后续数据写入事务的回滚保护范围内。数据写入共用一个 Session:

validate_meta_config(config)
await reset_meta_tables()

async with MetaAsyncSessionLocal() as session, session.begin():
    table_infos = await sync_table_info(config, session)
    column_infos = await sync_column_info(config, session)
    metric_infos = await sync_metric_info(config, session)
    column_metrics = await sync_column_metric(config, session)

7. 同步 column_info

字段配置示例:

columns:
  - name: province
    role: dimension
    description: 订单所属的省份名称。
    alias: [省份, , 所在省份]
    sync: true

映射规则:

  • id{table_name}.{column_name}
  • name / role / description / alias:来自 YAML
  • table_id:所属物理表名
  • type:从 dw 库的 information_schema 读取
  • examplessync: true 时最多读取 20 个去重样例,否则为空列表

examples 只存少量样例,避免 SELECT DISTINCT 全量加载导致慢查询、内存增长和 JSON 字段膨胀。后续 Elasticsearch 同步需要全量维度值时,应使用独立的分页读取流程。

字段类型只查询一次:Repository 从 information_schema.COLUMNS 读取当前 dw 库的全部字段类型,组成以 (表名, 字段名) 为键的字典。同步字段时直接查字典,避免每个字段都请求一次 MySQL 形成 N 次重复查询。

column_types = await dw_service.get_all_column_types()
column_type = column_types.get((table_name, column_name))
async def get_column_examples(
    self,
    table_name: str,
    column_name: str,
    limit: int = 20,
) -> list[str]:
    sql = text(
        f"SELECT DISTINCT `{column_name}` "
        f"FROM `{table_name}` "
        f"WHERE `{column_name}` IS NOT NULL "
        "LIMIT :limit"
    )
    result = await self.session.execute(sql, {"limit": limit})
    return [str(row[0]) for row in result.all()]

如果 YAML 中的字段在 dw 库不存在,同步会报告具体的 表名.字段名,而不是等到写入 NULL type 时才失败。

8. 同步 metric_info 与 column_metric

metric_info:
  - name: GMV
    description: 全称 Gross Merchandise Value,表示所有订单的成交金额总和。
    alias: [成交总额, 订单总额]
    relevant_columns:
      - fact_order.order_amount

  - name: AOV
    description: 全称 Average Order Value,表示所有订单的成交金额平均值。
    alias: [平均单价, 平均订单金额]
    relevant_columns:
      - fact_order.order_amount

metric_info.id 使用指标名。column_metric 不单独配置,而是从 relevant_columns 展开:

column_id = fact_order.order_amount
metric_id = GMV

本阶段只同步指标语义和相关字段,还不能仅依靠这些配置生成精确的指标 SQL。聚合表达式、统计粒度和过滤规则需要在后续指标模型中继续补充。

9. 执行与验证

确认 MySQL 已启动:

docker ps --filter name=mysql

在项目根目录执行:

uv run python conf/sync_db.py

查询验证:

set -a && source .env && set +a
docker exec -e MYSQL_PWD="$MYSQL_PASSWORD" mysql \
  mysql --default-character-set=utf8mb4 -u"$MYSQL_USER" -D meta \
  -e "SELECT * FROM table_info;"

--default-character-set=utf8mb4 用于避免客户端查询时中文显示异常;数据库连接和表字段也应统一使用 utf8mb4

10. 常见问题

现象原因处理
No module named 'app'工作目录或 sys.path 不对在项目根执行;脚本也会加入项目根路径
No module named 'asyncmy'缺少异步驱动执行 uv add asyncmy
Access denied ... using password: NO.env 没有加载或变量未展开检查 .env${MYSQL_PASSWORD}
KeyError 或“缺少必填字段”YAML 配置不完整根据错误中的配置路径补字段
“dw 库中不存在字段”YAML 与业务库表结构不一致核对 dw 连接、表名和字段名
Duplicate entry主键重复检查配置重复项;生产同步改用 upsert
中文显示异常客户端字符集不匹配查询时指定 utf8mb4,并检查库表字符集

11. 下一步

MySQL meta 四张表同步完成后,继续实现:

  1. Qdrant:向量化字段和指标的描述、别名,支持语义召回
  2. Elasticsearch:分页同步 sync: true 的维度值,支持值匹配

入口已经预留:

sync_to_qdrant(meta_config)          # TODO
sync_to_elasticsearch(meta_config)   # TODO

科普 MySQL

MySQL 是什么

MySQL 是一个关系型数据库管理系统。可以先把它想象成一个加强版的 Excel 文件管理器:数据也按行和列组织,但它更适合处理大量数据、多人同时读写、权限控制、关联查询和事务。

几个容易混淆的词:

名词含义本项目示例
MySQL管理数据库的软件Docker 中运行的 MySQL 服务
Database / Schema一组表的命名空间metadw
Table同类数据的集合table_infofact_order
Column一项数据属性namedescription
Row一条具体记录GMV 这一条指标记录
SQL操作关系型数据库的语言SELECT * FROM table_info

本项目里有两个数据库:

  • dw 是业务数仓,保存订单、商品、地区等真实业务数据
  • meta 是元数据库,保存“业务库里有哪些表、字段和指标”等描述信息

元数据可以理解成“描述数据的数据”。例如,fact_order.order_amount 里的订单金额是业务数据;“这个字段叫订单金额、类型是 decimal、别名是销售额”则是元数据。

表、主键和关系

一张表通常要有能够唯一识别一行数据的字段,这个字段称为主键(Primary Key)

CREATE TABLE table_info (
    id VARCHAR(64) PRIMARY KEY,
    name VARCHAR(128),
    description TEXT
);

主键不能重复。因此重复插入相同的 id 会出现 Duplicate entry

一张表也可以通过**外键(Foreign Key)**引用另一张表,从而让数据库检查数据关系。当前项目暂时只通过 table_idcolumn_id 等字段维护逻辑关系,没有声明 MySQL 外键,所以关系正确性由同步前的配置校验保证。

最常用的 SQL 操作

SQL 关键字通常大写只是为了便于阅读,并非语法强制要求。

查询数据:

SELECT id, name
FROM table_info
WHERE role = 'fact';

插入数据:

INSERT INTO table_info (id, name, role)
VALUES ('fact_order', 'fact_order', 'fact');

更新数据:

UPDATE table_info
SET description = '订单事实表'
WHERE id = 'fact_order';

删除数据:

DELETE FROM table_info
WHERE id = 'fact_order';

建表、改表、删表一类语句称为 DDL;查询和增删改数据的语句通常归为 DML。本文中的 create_all()drop_all() 最终执行的是 DDL,数据同步执行的是 DML。

事务为什么重要

**事务(Transaction)**把多次数据库写操作视为一个整体:

开始事务
  ├─ 写 table_info
  ├─ 写 column_info
  ├─ 写 metric_info
  └─ 写 column_metric
全部成功 → COMMIT
任一步失败 → ROLLBACK
  • COMMIT:确认修改,让事务中的数据正式生效
  • ROLLBACK:撤销本次事务中尚未提交的修改

这就是常说的“要么全部成功,要么全部失败”。如果四张表各自提交,第三张表失败时,前两张表已经无法随当前事务回滚,meta 库就可能处于不完整状态。

不过 MySQL 的 DDL 有特殊的提交行为,因此本文的 drop_all() / create_all() 不在后续数据写入事务的回滚保护范围内。

新手要特别注意什么

  • DROP TABLE 会删除表结构和数据,执行前一定确认环境
  • DELETEUPDATE 如果漏写 WHERE,可能影响整张表
  • SQL 字符串不要直接拼接用户输入,否则可能产生 SQL 注入
  • 数据库连接、客户端和表字段应统一使用 utf8mb4
  • 主键保证唯一性,索引能加快查询,但索引过多也会增加写入成本
  • 开发阶段可以重建表;生产环境通常使用 migration 渐进修改表结构

科普 SQLAlchemy

SQLAlchemy 是什么

SQLAlchemy 是 Python 生态里常用的数据库工具。它可以帮我们:

  1. 创建和管理数据库连接
  2. 用 Python 对象描述数据库表
  3. 生成并执行 SQL
  4. 管理事务和对象状态

不使用 SQLAlchemy 时,我们可能直接写 SQL:

INSERT INTO table_info (id, name, role)
VALUES ('fact_order', 'fact_order', 'fact');

使用 SQLAlchemy ORM 后,可以先创建 Python 对象:

model = TableInfoModel(
    id="fact_order",
    name="fact_order",
    role="fact",
)
session.add(model)

SQLAlchemy 最终仍然会把操作转换成 SQL。ORM 不是数据库,也没有消灭 SQL;它是在 Python 对象和关系表之间做映射。

ORM 是什么

ORM 全称是 Object-Relational Mapping,即“对象关系映射”:

Python 类      ↔ MySQL 表
Python 对象    ↔ 表中的一行
对象属性       ↔ 表中的一列

例如:

class TableInfoModel(Base):
    __tablename__ = "table_info"

    id: Mapped[str] = mapped_column(String(64), primary_key=True)
    name: Mapped[str] = mapped_column(String(128))

这里的 TableInfoModel 对应 table_info 表,id 属性对应 id 列。

Engine、Session 和 Model 各负责什么

对象可以怎样理解职责
Engine数据库连接总入口保存连接配置、管理连接池、执行底层数据库通信
Session一次工作单元跟踪对象、组织 SQL、控制事务
Model表的 Python 映射声明表名、字段类型、主键等结构
Mapper项目自己的转换器在业务 Entity 和数据库 Model 之间转换

Engine 通常在进程中创建一次,不要每查一条数据就创建一个 Engine。Session 的生命周期应更短,通常一次请求或一次完整业务操作使用一个 Session。

add、flush、commit、refresh 有什么区别

这几个操作很容易混淆:

session.add(model)
await session.flush()
await session.commit()
await session.refresh(model)
  • add():把对象加入 Session 管理,通常还没有立即执行 INSERT
  • flush():把待执行修改发送给数据库,但事务还没有提交,仍然可以回滚
  • commit():提交当前事务,让修改正式生效
  • rollback():回滚当前事务
  • refresh():重新从数据库读取这一行,更新对象中的数据库生成值

所以 flush() 不等于 commit()。本项目的 Repository 负责 add_all()flush(),最外层同步流程负责统一 commit(),这样四类数据才能处于同一个事务。

为什么使用异步 SQLAlchemy

本文使用:

create_async_engine(...)
async_sessionmaker(...)
await session.execute(...)

数据库访问属于 I/O 操作。异步代码等待 MySQL 返回结果时,可以把执行机会交给其他任务,更适合 FastAPI 这类需要同时处理多个请求的服务。

异步不代表单条 SQL 会自动变快,也不代表可以无限并发查询。它主要改善等待期间的资源利用率,数据库连接池大小和 SQL 性能仍然需要合理控制。

ORM 之外为什么还会写原生 SQL

ORM 适合常规增删改查,但有些查询直接写 SQL 更清楚,例如读取 information_schema 或动态查询业务字段:

result = await session.execute(
    text("SELECT COLUMN_TYPE FROM information_schema.COLUMNS ..."),
    {"table_name": table_name},
)

普通数据值应使用 :table_name 这样的绑定参数,不能通过字符串拼接用户输入。表名和列名通常不能作为普通值参数绑定,因此本项目会先校验标识符,再拼接经过校验的表名、字段名。

科普领域驱动设计

领域驱动设计是什么

领域驱动设计通常简称 DDD(Domain-Driven Design)。它的核心不是“必须创建很多文件夹”,而是让代码围绕业务概念组织,并让业务规则尽量不依赖数据库、Web 框架等技术细节。

这里的“领域”就是软件要解决的业务问题。在本项目中,表元数据、字段元数据、指标、字段与指标的关系,都是领域概念。

一个重要理念是:代码里的名字应该尽量使用业务人员和开发人员都能理解的统一语言。例如统一使用 MetricInfo 表示指标信息,而不是在不同位置混用 IndexDataMeasureConfig 等含义模糊的名字。这通常称为统一语言(Ubiquitous Language)

本项目各层怎样协作

同步脚本
   ↓ 负责流程和事务
Service
   ↓ 表达业务能力
Repository
   ↓ 隔离持久化操作
Mapper
   ↓ 转换业务对象和 ORM 对象
Entity              Model
业务概念             数据库映射

各部分的意义:

  • Entity:表达业务数据和业务身份,不关心 MySQL、SQLAlchemy
  • Model:描述数据怎样落到数据库表中
  • Mapper:防止 Entity 与 Model 强耦合
  • Repository:为上层提供“保存元数据”一类接口,隐藏持久化细节
  • Service:组合领域能力或应用流程
  • 同步脚本:读取配置、装配依赖、安排执行顺序和事务边界

例如,TableInfo 是业务对象,TableInfoModel 是数据库对象。虽然它们目前字段很像,但职责不同:将来数据库字段变化时,不一定要让业务对象跟着完全变化。

为什么不直接在脚本里 INSERT

小脚本直接写 SQL 并没有错,但项目逐渐变大后,经常会出现:

  • YAML 解析、业务校验和 SQL 混在一个函数里
  • 换数据库或增加 API 后重复实现相同业务规则
  • 测试业务逻辑时必须连接真实数据库
  • 一个字段修改后,不知道会影响哪些流程

分层的价值是隔离变化。例如修改表结构主要影响 Model 和 Mapper;修改业务校验主要影响 Entity 或 Service;更换数据读取方式主要影响 Repository。

DDD 不是层数越多越好

DDD 也有成本:文件更多、调用链更长、简单功能可能显得繁琐。新手尤其容易把“分文件”误认为“完成了 DDD”。判断一层是否有价值,可以问:

  • 它有没有清晰且独立的职责?
  • 它是否隔离了容易变化的部分?
  • 它是否让业务规则更容易理解和测试?

当前项目里的部分 Service 还是薄封装,这是生成阶段可以接受的起点。随着校验、查询和召回规则增加,Service 才会逐渐承载更多业务逻辑。不要为了套模式而人为制造复杂度。

科普 .env 文件

.env 是什么

.env 是一种常见的本地环境变量配置文件,通常写成 KEY=VALUE

MYSQL_USER=yan
MYSQL_PASSWORD=your_password

它不是 Python 或操作系统强制规定的特殊文件,也不会天然自动生效。项目需要使用 python-dotenv 等工具读取它:

from dotenv import load_dotenv

load_dotenv(".env")

加载后,程序可以通过环境变量读取:

import os

password = os.getenv("MYSQL_PASSWORD")

本文的 get_config.py 先加载 .env,再把 YAML 中的 ${MYSQL_PASSWORD} 替换成真正的环境变量值。

为什么不把密码直接写进 YAML

代码和普通配置经常需要提交到 Git。如果把密码、API Key 直接写进去,密钥可能进入 Git 历史、代码托管平台、日志或截图。

常见做法是:

.env                 保存本机真实值,不提交 Git
.env.example         只保留变量名和示例,允许提交 Git
app_config.yaml      使用 ${VAR_NAME} 占位符

.gitignore 中应包含:

.env

但要注意:加入 .gitignore 只能防止今后误提交。如果密钥已经提交过,仅删除文件并不够,因为 Git 历史里仍可能存在;此时应立即轮换密钥,并按需要清理历史。

环境变量覆盖规则

项目使用:

load_dotenv(ENV_PATH)

默认情况下,操作系统中已经存在的同名环境变量优先,.env 不会覆盖它。这样部署环境可以注入生产配置,而不用修改项目文件。

例如终端中临时设置:

export MYSQL_USER=yan
export MYSQL_PASSWORD='your_password'
uv run python conf/sync_db.py

这只对当前 Shell 及其启动的子进程生效。关闭终端后,临时设置通常就不存在了。

.env 使用注意事项

  • .env 只适合本地开发和简单部署,不是专业的密钥管理系统
  • 生产环境优先使用部署平台的 Secret、云密钥服务或容器 Secret
  • 不要在日志里打印完整配置、密码、Token 或数据库 URL
  • 含空格、#$ 等字符的值建议加引号,并确认加载库的解析规则
  • 修改 .env 后,已经启动的进程通常不会自动更新,需要重启程序
  • 给新人提供 .env.example,避免让大家猜需要哪些环境变量