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.
 

14 KiB

Alembic 数据库迁移指南

概述

Jointo 项目使用 Alembic 进行数据库版本化迁移管理。Alembic 是 SQLAlchemy 官方推荐的迁移工具,支持自动生成迁移脚本、版本管理和回滚操作。

快速开始

首次部署(推荐)

# 进入 server 目录
cd server

# 启动容器(自动执行数据库迁移)
./start_docker.sh --clean --build

脚本会自动完成:

  1. 清理旧容器和卷
  2. 构建 Docker 镜像
  3. 启动所有服务
  4. 自动执行数据库迁移(创建所有表和 UUID v7 函数)
  5. 创建迁移标记文件

后续部署

# 快速重启(不执行迁移)
./start_docker.sh

# 如果更新了数据库模型,手动执行迁移
./migrate_db.sh

0. 全新环境初始化

首次部署时直接执行迁移即可

# 启动容器(UUID v7 函数会自动创建)
./start_docker.sh --clean --build

⚠️ 注意

  • 使用 ./start_docker.sh 启动容器时,首次部署会自动执行迁移
  • 初始化迁移脚本(3a3a2a1417de)会自动创建 UUID v7 函数
  • 后续部署需要手动执行迁移:./migrate_db.sh

1. 创建新迁移

自动生成迁移(推荐)

# 方式 1:使用便捷脚本
python scripts/db_migrate.py create "添加用户表" --autogenerate

# 方式 2:使用 Shell 脚本
./migrate_db.sh create "添加用户表"

# 方式 3:直接使用 Alembic
cd server
alembic revision --autogenerate -m "添加用户表"

手动创建空白迁移

python scripts/db_migrate.py create "自定义迁移"

2. 执行迁移

# 升级到最新版本
python scripts/db_migrate.py upgrade
# 或
./migrate_db.sh

# 升级一个版本
python scripts/db_migrate.py upgrade +1

# 升级到指定版本
python scripts/db_migrate.py upgrade <版本号>

3. 回滚迁移

# 回滚一个版本
python scripts/db_migrate.py downgrade
# 或
./migrate_db.sh downgrade

# 回滚两个版本
python scripts/db_migrate.py downgrade -2

# 回滚到指定版本
python scripts/db_migrate.py downgrade <版本号>

# 回滚所有迁移
python scripts/db_migrate.py downgrade base

4. 查看状态

# 查看当前版本
python scripts/db_migrate.py current

# 查看迁移历史
python scripts/db_migrate.py history

# 查看最新版本
python scripts/db_migrate.py heads

工作流程

典型开发流程

  1. 修改模型

    # server/app/models/user.py
    class User(SQLModel, table=True):
        __tablename__ = "users"
    
        user_id: UUID = Field(default_factory=uuid7, primary_key=True)
        username: str = Field(max_length=255, unique=True)
        email: Optional[str] = Field(default=None, max_length=255)
        # 新增字段
        phone: Optional[str] = Field(default=None, max_length=20)
    
  2. 生成迁移脚本

    python scripts/db_migrate.py create "添加用户手机号字段" --autogenerate
    
  3. 检查生成的迁移文件

    # server/alembic/versions/20260127_1430_abc123_添加用户手机号字段.py
    def upgrade() -> None:
        op.add_column('users', sa.Column('phone', sa.String(length=20), nullable=True))
    
    def downgrade() -> None:
        op.drop_column('users', 'phone')
    
  4. 执行迁移

    python scripts/db_migrate.py upgrade
    
  5. 测试回滚

    # 开发环境测试回滚
    python scripts/db_migrate.py downgrade
    python scripts/db_migrate.py upgrade
    
  6. 提交代码

    git add server/alembic/versions/
    git commit -m "feat: 添加用户手机号字段"
    

迁移文件结构

文件命名

Alembic 自动生成的文件名格式:

YYYYMMDD_HHMM_<revision>_<slug>.py

示例:

20260127_1430_abc123def456_添加用户手机号字段.py

文件内容

"""添加用户手机号字段

Revision ID: abc123def456
Revises: xyz789
Create Date: 2026-01-27 14:30:00.000000

"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import sqlmodel

# revision identifiers, used by Alembic.
revision: str = 'abc123def456'
down_revision: Union[str, None] = 'xyz789'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
    """升级数据库"""
    op.add_column('users', sa.Column('phone', sa.String(length=20), nullable=True))
    op.create_index('ix_users_phone', 'users', ['phone'])


def downgrade() -> None:
    """回滚数据库"""
    op.drop_index('ix_users_phone', table_name='users')
    op.drop_column('users', 'phone')

常见操作

添加表

def upgrade() -> None:
    op.create_table(
        'products',
        sa.Column('id', sa.UUID(), nullable=False),
        sa.Column('name', sa.String(length=255), nullable=False),
        sa.Column('price', sa.Numeric(10, 2), nullable=False),
        sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
        sa.PrimaryKeyConstraint('id')
    )
    op.create_index('ix_products_name', 'products', ['name'])

def downgrade() -> None:
    op.drop_index('ix_products_name', table_name='products')
    op.drop_table('products')

添加列

def upgrade() -> None:
    op.add_column('users', sa.Column('bio', sa.Text(), nullable=True))

def downgrade() -> None:
    op.drop_column('users', 'bio')

修改列

def upgrade() -> None:
    # 修改列类型
    op.alter_column('users', 'username',
                    existing_type=sa.String(length=100),
                    type_=sa.String(length=255),
                    existing_nullable=False)
    
    # 修改列默认值
    op.alter_column('users', 'is_active',
                    server_default=sa.text('true'))

def downgrade() -> None:
    op.alter_column('users', 'username',
                    existing_type=sa.String(length=255),
                    type_=sa.String(length=100),
                    existing_nullable=False)
    
    op.alter_column('users', 'is_active',
                    server_default=None)

重命名列

def upgrade() -> None:
    op.alter_column('users', 'old_name', new_column_name='new_name')

def downgrade() -> None:
    op.alter_column('users', 'new_name', new_column_name='old_name')

添加索引

def upgrade() -> None:
    op.create_index('ix_users_email', 'users', ['email'])
    
    # 唯一索引
    op.create_index('ix_users_username', 'users', ['username'], unique=True)
    
    # 复合索引
    op.create_index('ix_users_phone_country', 'users', ['phone', 'country_code'])

def downgrade() -> None:
    op.drop_index('ix_users_phone_country', table_name='users')
    op.drop_index('ix_users_username', table_name='users')
    op.drop_index('ix_users_email', table_name='users')

数据迁移

from sqlalchemy import text

def upgrade() -> None:
    # 添加新列
    op.add_column('users', sa.Column('full_name', sa.String(length=255), nullable=True))
    
    # 数据迁移
    connection = op.get_bind()
    connection.execute(
        text("UPDATE users SET full_name = CONCAT(first_name, ' ', last_name)")
    )
    
    # 设置为非空
    op.alter_column('users', 'full_name', nullable=False)

def downgrade() -> None:
    op.drop_column('users', 'full_name')

Jointo 项目规范

1. 禁止外键约束

根据 Jointo 技术栈规范,禁止在数据库层创建物理外键

错误示例

def upgrade() -> None:
    op.create_table(
        'projects',
        sa.Column('id', sa.UUID(), nullable=False),
        sa.Column('user_id', sa.UUID(), nullable=False),
        sa.ForeignKeyConstraint(['user_id'], ['users.user_id']),  # ❌ 禁止
        sa.PrimaryKeyConstraint('id')
    )

正确示例

def upgrade() -> None:
    op.create_table(
        'projects',
        sa.Column('id', sa.UUID(), nullable=False),
        sa.Column('user_id', sa.UUID(), nullable=False),  # 仅字段,无外键约束
        sa.PrimaryKeyConstraint('id')
    )
    # 创建索引以提高查询性能
    op.create_index('ix_projects_user_id', 'projects', ['user_id'])

2. 枚举类型使用 SMALLINT

错误示例

def upgrade() -> None:
    op.create_table(
        'projects',
        sa.Column('status', sa.Enum('active', 'archived', name='project_status'), nullable=False)  # ❌
    )

正确示例

def upgrade() -> None:
    op.create_table(
        'projects',
        sa.Column('status', sa.SmallInteger(), nullable=False),  # ✅ 使用 SMALLINT
        sa.PrimaryKeyConstraint('id')
    )
    # 在 Python 模型中使用 IntEnum 映射

3. UUID v7 主键

def upgrade() -> None:
    op.create_table(
        'users',
        sa.Column('user_id', sa.UUID(), server_default=sa.text('gen_uuid_v7()'), nullable=False),
        sa.PrimaryKeyConstraint('user_id')
    )

4. 时间戳字段

def upgrade() -> None:
    op.create_table(
        'users',
        sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
        sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
        sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),  # 软删除
        sa.PrimaryKeyConstraint('user_id')
    )
    
    # 添加 updated_at 自动更新触发器
    op.execute("""
        CREATE TRIGGER update_users_updated_at
            BEFORE UPDATE ON users
            FOR EACH ROW
            EXECUTE FUNCTION update_updated_at_column();
    """)

def downgrade() -> None:
    op.execute("DROP TRIGGER IF EXISTS update_users_updated_at ON users")
    op.drop_table('users')

高级用法

条件迁移

from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect

def upgrade() -> None:
    conn = op.get_bind()
    inspector = inspect(conn)
    
    # 检查列是否存在
    if 'phone' not in [c['name'] for c in inspector.get_columns('users')]:
        op.add_column('users', sa.Column('phone', sa.String(length=20), nullable=True))

批量操作

def upgrade() -> None:
    with op.batch_alter_table('users') as batch_op:
        batch_op.add_column(sa.Column('phone', sa.String(length=20), nullable=True))
        batch_op.add_column(sa.Column('bio', sa.Text(), nullable=True))
        batch_op.create_index('ix_users_phone', ['phone'])

执行原始 SQL

def upgrade() -> None:
    op.execute("""
        CREATE OR REPLACE FUNCTION gen_uuid_v7()
        RETURNS UUID AS $$
        BEGIN
            RETURN uuid_generate_v7();
        END;
        $$ LANGUAGE plpgsql;
    """)

def downgrade() -> None:
    op.execute("DROP FUNCTION IF EXISTS gen_uuid_v7()")

故障排查

问题 1:迁移冲突

症状:多个分支同时创建迁移,导致版本冲突

解决

# 查看当前头部
alembic heads

# 合并分支
alembic merge <revision1> <revision2> -m "合并迁移"

问题 2:迁移失败

症状:执行迁移时出错,数据库处于不一致状态

解决

# 1. 查看当前版本
python scripts/db_migrate.py current

# 2. 手动修复数据库

# 3. 标记为已迁移(不执行)
python scripts/db_migrate.py stamp <revision>

问题 3:自动生成不准确

症状:autogenerate 生成的迁移不符合预期

解决

  1. 检查所有模型是否已导入到 alembic/env.py
  2. 手动编辑生成的迁移文件
  3. 测试 upgrade 和 downgrade

最佳实践

1. 迁移原则

  • 每次模型变更立即创建迁移
  • 迁移描述清晰明确
  • 提交前测试 upgrade 和 downgrade
  • 生产环境部署前备份数据库
  • 使用事务确保原子性

2. 命名规范

  • 使用动词开头:添加修改删除
  • 包含表名和字段名
  • 示例:
    • 添加用户手机号字段
    • 修改项目状态枚举
    • 删除旧的会话表

3. 代码审查

迁移文件必须经过代码审查:

  • 检查是否符合 Jointo 规范
  • 验证 downgrade 逻辑正确
  • 确认数据迁移安全

4. 生产环境部署

# 1. 备份数据库
pg_dump -U postgres jointo > backup_$(date +%Y%m%d_%H%M%S).sql

# 2. 查看待执行的迁移
python scripts/db_migrate.py history

# 3. 执行迁移
python scripts/db_migrate.py upgrade

# 4. 验证结果
python scripts/db_migrate.py current

全新环境部署

首次部署(空数据库)

全新环境可以直接执行迁移,自动创建所有表结构:

# 1. 启动 Docker 容器
docker compose up -d

# 2. 执行迁移(自动创建所有表)
./migrate_db.sh
# 或
docker exec jointo-server-app alembic upgrade head

# 3. 验证
docker exec jointo-server-app alembic current
# 输出:c496e41f07ff (head)

现有环境(表已存在)

如果数据库已经有表结构,需要标记为已应用:

# 标记为已应用最新迁移(不执行建表)
docker exec jointo-server-app alembic stamp head

# 验证
docker exec jointo-server-app alembic current
# 输出:c496e41f07ff (head)

从旧迁移系统迁移

如果你的数据库使用旧的迁移系统(app/migrations/),已经废弃,请使用 Alembic:

步骤 1:标记当前状态

# 标记为已应用最新迁移(不执行)
docker exec jointo-server-app alembic stamp head

步骤 2:验证

# 查看当前版本
docker exec jointo-server-app alembic current

# 查看历史
docker exec jointo-server-app alembic history

步骤 3:归档旧迁移

旧迁移文件已移至 server/app/migrations/ 并标记为废弃,无需手动操作。

参考资料

常见命令速查

# 创建迁移
python scripts/db_migrate.py create "描述" --autogenerate

# 执行迁移
python scripts/db_migrate.py upgrade

# 回滚迁移
python scripts/db_migrate.py downgrade

# 查看状态
python scripts/db_migrate.py current
python scripts/db_migrate.py history

# 标记版本
python scripts/db_migrate.py stamp head