You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 

9.3 KiB

ADR 007: 数据库迁移最佳实践

状态: 已采纳
日期: 2026-01-30
决策者: 开发团队
相关问题: AI Service 迁移幂等性问题

背景

在实施 AI Service 数据库迁移时,遇到了以下问题:

  1. JSONB GIN 索引创建失败: 错误使用 USING gin (column) 而非 USING gin (column jsonb_path_ops)
  2. Model 与数据库不一致: SQLModel 自动创建的表与迁移脚本定义不一致
  3. 非幂等迁移: CREATE TABLE IF NOT EXISTS 后续的索引和注释语句未做条件检查
  4. 列类型不匹配: 数据库中为 json 类型,Model 定义为 JSONB 类型

这些问题导致迁移脚本无法重复执行,影响开发和部署流程。

决策

制定以下数据库迁移规范,所有迁移脚本必须遵守:

1. 迁移脚本必须完全幂等

原则: 迁移脚本可以安全地重复执行多次,不会产生错误或副作用。

1.1 表创建

# ✅ 正确 - 使用 IF NOT EXISTS
op.execute("""
    CREATE TABLE IF NOT EXISTS table_name (
        id UUID PRIMARY KEY,
        name TEXT NOT NULL,
        data JSONB NOT NULL DEFAULT '{}'
    );
""")

# ❌ 错误 - 直接创建
op.create_table('table_name', ...)

1.2 列添加

# ✅ 正确 - 检查列是否存在
op.execute("""
    DO $$
    BEGIN
        IF NOT EXISTS (
            SELECT 1 FROM information_schema.columns 
            WHERE table_name = 'table_name' 
            AND column_name = 'new_column'
        ) THEN
            ALTER TABLE table_name ADD COLUMN new_column TEXT;
        END IF;
    END $$;
""")

# ❌ 错误 - 直接添加
op.add_column('table_name', sa.Column('new_column', sa.Text))

1.3 索引创建

# ✅ 正确 - 使用 IF NOT EXISTS 并检查列存在
op.execute("""
    DO $$
    BEGIN
        IF EXISTS (
            SELECT 1 FROM information_schema.columns 
            WHERE table_name = 'table_name' 
            AND column_name = 'column_name'
        ) THEN
            CREATE INDEX IF NOT EXISTS idx_name 
            ON table_name (column_name);
        END IF;
    END $$;
""")

# ❌ 错误 - 直接创建
op.create_index('idx_name', 'table_name', ['column_name'])

1.4 约束添加

# ✅ 正确 - 检查约束是否存在
op.execute("""
    DO $$
    BEGIN
        IF NOT EXISTS (
            SELECT 1 FROM pg_constraint 
            WHERE conname = 'constraint_name'
        ) THEN
            ALTER TABLE table_name 
            ADD CONSTRAINT constraint_name CHECK (condition);
        END IF;
    END $$;
""")

1.5 触发器创建

# ✅ 正确 - 检查触发器是否存在
op.execute("""
    DO $$
    BEGIN
        IF NOT EXISTS (
            SELECT 1 FROM pg_trigger 
            WHERE tgname = 'trigger_name'
        ) THEN
            CREATE TRIGGER trigger_name
                BEFORE UPDATE ON table_name
                FOR EACH ROW
                EXECUTE FUNCTION function_name();
        END IF;
    END $$;
""")

2. JSONB 列和索引规范

2.1 列定义

# ✅ 正确 - 明确使用 JSONB 类型
op.execute("""
    CREATE TABLE IF NOT EXISTS table_name (
        id UUID PRIMARY KEY,
        config JSONB NOT NULL DEFAULT '{}',
        metadata JSONB
    );
""")

# ❌ 错误 - 使用 JSON 类型
config JSON NOT NULL DEFAULT '{}'

2.2 GIN 索引

# ✅ 正确 - 使用 jsonb_path_ops 操作符类
op.execute("""
    CREATE INDEX IF NOT EXISTS idx_table_config_gin 
    ON table_name USING gin (config jsonb_path_ops);
""")

# ❌ 错误 - 缺少操作符类
CREATE INDEX idx_table_config_gin 
ON table_name USING gin (config);

操作符类说明

  • jsonb_path_ops: 仅支持 @> 操作符,索引更小,性能更好(推荐)
  • jsonb_ops: 支持所有 JSONB 操作符,索引更大

2.3 类型转换

如果需要将 json 转换为 jsonb

op.execute("""
    DO $$
    BEGIN
        IF EXISTS (
            SELECT 1 FROM information_schema.columns 
            WHERE table_name = 'table_name' 
            AND column_name = 'config' 
            AND data_type = 'json'
        ) THEN
            ALTER TABLE table_name 
            ALTER COLUMN config TYPE JSONB USING config::jsonb;
        END IF;
    END $$;
""")

3. 禁止 SQLModel 自动创建表

问题: SQLModel 的 create_all() 会在应用启动时自动创建表,可能导致:

  • 表结构与迁移脚本不一致
  • 列名、类型不匹配
  • 缺少索引、约束、注释

解决方案

3.1 移除自动创建代码

# ❌ 禁止使用
from sqlmodel import SQLModel, create_engine

engine = create_engine(DATABASE_URL)
SQLModel.metadata.create_all(engine)  # 禁止!

3.2 仅依赖 Alembic

所有表结构变更必须通过 Alembic 迁移脚本管理:

# 创建迁移
alembic revision --autogenerate -m "description"

# 执行迁移
alembic upgrade head

# 回滚迁移
alembic downgrade -1

4. Model 定义与迁移脚本一致性

4.1 字段类型映射

Python 类型 SQLModel 类型 PostgreSQL 类型 说明
dict JSONB JSONB 推荐
dict JSON JSON 避免使用
int SmallInteger SMALLINT 枚举类型
datetime TIMESTAMP(timezone=True) TIMESTAMPTZ 时间戳
UUID PG_UUID(as_uuid=True) UUID 主键/外键

4.2 字段命名一致性

# ✅ 正确 - Model 字段名与数据库列名一致
class AIUsageLog(SQLModel, table=True):
    extra_metadata: dict = Field(
        default_factory=dict,
        sa_column=Column(JSONB, ...)
    )

# ❌ 错误 - 字段名不一致
class AIUsageLog(SQLModel, table=True):
    extra_data: dict = Field(...)  # 数据库列名是 extra_metadata

5. 迁移脚本命名规范

YYYYMMDD_HHMM_description.py

示例:

  • 20260130_1800_create_ai_service_tables.py
  • 20260130_1900_add_user_avatar_column.py
  • 20260130_2000_fix_timestamp_timezone.py

6. 注释和文档

6.1 表注释

op.execute("COMMENT ON TABLE table_name IS '表描述';")

6.2 列注释

op.execute("COMMENT ON COLUMN table_name.column_name IS '列描述';")

6.3 迁移脚本注释

"""create ai service tables

Revision ID: 20260129_1800
Revises: 20260129_1700
Create Date: 2026-01-29 18:00:00.000000

说明:
- 创建 AI Service 相关的 4 个表
- 使用 JSONB 类型存储配置和元数据
- 所有外键关系在应用层维护
- 完全幂等,可重复执行
"""

7. 迁移测试

7.1 本地测试

# 1. 执行迁移
alembic upgrade head

# 2. 验证表结构
psql -U user -d db -c "\d table_name"

# 3. 回滚测试
alembic downgrade -1

# 4. 重新执行(幂等性测试)
alembic upgrade head
alembic upgrade head  # 第二次执行不应报错

7.2 CI/CD 测试

在 CI/CD 流程中添加迁移测试:

test-migrations:
  script:
    - docker-compose up -d postgres
    - alembic upgrade head
    - alembic upgrade head  # 幂等性测试
    - alembic downgrade base
    - alembic upgrade head  # 完整流程测试

8. 常见错误和解决方案

8.1 GIN 索引错误

错误: operator class "jsonb_path_ops" does not accept data type json

原因: 列类型是 json 而非 jsonb

解决:

ALTER TABLE table_name 
ALTER COLUMN column_name TYPE JSONB USING column_name::jsonb;

8.2 列不存在错误

错误: column "column_name" does not exist

原因: 在索引或注释语句中引用了不存在的列

解决: 添加列存在性检查

8.3 约束冲突

错误: constraint "constraint_name" already exists

原因: 重复创建约束

解决: 使用 IF NOT EXISTS 检查

后果

优点

  1. 迁移脚本完全幂等,可安全重复执行
  2. 避免 SQLModel 自动创建表导致的不一致
  3. JSONB 列和索引使用规范,性能最优
  4. Model 定义与数据库结构完全一致
  5. 迁移过程可追溯、可回滚

缺点

  1. ⚠️ 迁移脚本编写更复杂,需要更多条件检查
  2. ⚠️ 禁用 SQLModel 自动创建后,开发时需手动执行迁移

风险

  1. 开发人员可能忘记遵守规范
  2. 需要在 Code Review 中严格检查迁移脚本

实施计划

阶段 1: 规范制定(已完成)

  • 创建 ADR 文档
  • 定义迁移脚本规范
  • 定义 JSONB 使用规范

阶段 2: 现有代码修复

  • 修复 AI Service 迁移脚本
  • 审查所有现有迁移脚本
  • 修复不符合规范的迁移

阶段 3: 工具和流程

  • 创建迁移脚本模板
  • 添加 pre-commit hook 检查
  • 更新 CI/CD 流程
  • 编写迁移测试工具

阶段 4: 团队培训

  • 团队分享会
  • 更新开发文档
  • Code Review Checklist

参考资料

相关 ADR

  • ADR 001: UUID v7 迁移
  • ADR 002: PostgreSQL 17 UUID v7
  • ADR 006: TIMESTAMPTZ 时间戳标准

变更历史

  • 2026-01-30: 初始版本,基于 AI Service 迁移问题总结