本文档详细说明了将项目从 PostgreSQL 迁移到 SQLite 的完整改动流程和关键注意点。

迁移缘由:

  • 客户暂无网站服务器,首选本地化部署

  • 项目需打包为单一 exe,嵌入 SQLite 便于随包分发

  • 降低额外运维成本(无需独立 DB 服务)

一、改动概述

1.1 主要变化

  • 数据库驱动:从 asyncpg/psycopg2 改为 aiosqlite

  • 连接字符串格式:从 postgresql+asyncpg://... 改为 sqlite+aiosqlite:///...

  • 数据库位置:从远程 PostgreSQL 服务器改为本地 SQLite 文件

  • 时间戳处理:从数据库默认值改为 Python 默认值

  • 并发处理:需要特别注意 SQLite 的锁定问题

1.2 文件结构变化

新增/修改的文件:
├── src/utils/sqlite_util.py          # SQLite 工具函数(数据库路径管理)
├── src/conf.py                       # 配置改为 SQLite
├── src/database.py                   # 数据库配置改为 SQLite
├── alembic/versions/2025-12-24_*.py  # SQLite 时间戳修复迁移
└── src/business_report/router.py     # DDL 操作改为使用当前连接

二、详细改动步骤

2.1 依赖项更改 (pyproject.toml)

dependencies = [
    "aiosqlite>=0.20.0",  # ✅ 新增:SQLite 异步驱动
    "asyncpg>=0.29.0",    # ⚠️ 保留:测试环境仍可能使用 PostgreSQL
    "psycopg2-binary>=2.9.11",  # ⚠️ 保留:同步操作可能需要
]

注意点:

  • aiosqlite 是 SQLite 的异步驱动,必须添加

  • 建议保留 PostgreSQL 相关依赖,方便后续切换回 PostgreSQL 或用于测试

2.2 配置更改 (src/conf.py)

from src.utils.sqlite_util import get_app_db_path

class Settings(BaseSettings):
    # ❌ 旧配置(PostgreSQL)
    # DATABASE_URL: str = "postgresql+asyncpg://user:password@host:5432/dbname"
    # SYNC_DATABASE_URL: str = "postgresql+psycopg2://user:password@host:5432/dbname"
    
    # ✅ 新配置(SQLite)
    _db_path = get_app_db_path("tianlu_spc", "tianlu_database.db")
    DATABASE_URL: str = f"sqlite+aiosqlite:///{_db_path.as_posix()}"
    SYNC_DATABASE_URL: str = f"sqlite:///{_db_path.as_posix()}"

注意点:

  1. 路径格式:SQLite 使用文件路径,Windows 路径需要使用 .as_posix() 转换为 POSIX 格式

  2. 数据库位置:使用 get_app_db_path() 将数据库存储在用户 APPDATA 目录,确保:

    • Windows: %APPDATA%\tianlu_spc\tianlu_database.db

    • 便于用户数据管理和备份

    • 避免权限问题

2.3 数据库引擎配置 (src/database.py)

2.3.1 异步引擎配置

connect_args = {}
if settings.DATABASE_URL.startswith("sqlite"):
    connect_args = {"check_same_thread": False}  # ✅ SQLite 必需

async_engine = create_async_engine(
    settings.DATABASE_URL,
    echo=settings.DEBUG,
    future=True,
    connect_args=connect_args,
    pool_pre_ping=not settings.DATABASE_URL.startswith("sqlite"),  # ✅ SQLite 不需要
)

关键注意点:

  1. check_same_thread=False:SQLite 默认不允许跨线程访问,必须设置此参数

  2. pool_pre_ping=False:SQLite 不需要连接池预检,应该禁用

2.3.2 同步引擎配置

sync_connect_args = {}
if settings.SYNC_DATABASE_URL.startswith("sqlite"):
    sync_connect_args = {"check_same_thread": False}  # ✅ SQLite 必需

engine = create_engine(
    settings.SYNC_DATABASE_URL,
    echo=settings.DEBUG,
    connect_args=sync_connect_args,
    pool_pre_ping=not settings.SYNC_DATABASE_URL.startswith("sqlite"),  # ✅ SQLite 不需要
)

2.4 时间戳字段处理 (src/database.py)

SQLite 不支持 now() 函数,必须使用 Python 默认值:

class TimestampTableMixin:
    _is_sqlite = settings.DATABASE_URL.startswith("sqlite")
    
    if _is_sqlite:
        # ✅ SQLite: 使用 Python 默认值
	    created_at: Mapped[datetime] = mapped_column(
	        DateTime(timezone=True),
	        server_default=text("(CURRENT_TIMESTAMP)"),
	        nullable=False,
	    )
	    updated_at: Mapped[datetime] = mapped_column(
	        DateTime(timezone=True),
	        server_default=text("(CURRENT_TIMESTAMP)"),
	        onupdate=text("(CURRENT_TIMESTAMP)"),
	        nullable=False,
	    )
    else:
        # PostgreSQL: 使用数据库默认值
        created_at: Mapped[datetime] = mapped_column(
            DateTime(timezone=True),
            server_default=func.now(),  # 数据库默认值
            nullable=False,
        )
        # ...

注意点:

  1. 动态判断:使用 _is_sqlite 标志在运行时判断数据库类型

  2. Python 默认值:SQLite 必须在 Python 层面设置时间戳,而不是数据库层面

  3. 迁移影响:如果从 PostgreSQL 迁移已有数据,需要创建迁移脚本修复时间戳字段

  4. mapped_column传参:server_default参数是数据库自动添加的,而default是python内置自动添加的

2.5 DDL 操作处理(关键改动)

2.5.1 问题背景

SQLite 对并发写入有严格限制。当会话已有活跃事务时,使用不同连接执行 DDL 会导致 “database is locked” 错误。

2.5.2 错误示例

# ❌ 错误:使用全局引擎会创建新连接
table.create(engine, checkfirst=True)  # 会导致锁定

# ❌ 错误:使用 get_bind() 可能仍会使用连接池
table.create(db.get_bind(), checkfirst=True)  # 仍可能锁定

2.5.3 正确做法

# ✅ 正确:使用当前会话的连接
table.create(db.connection(), checkfirst=True)
table.drop(db.connection(), checkfirst=True)

注意点:

  1. 使用 db.connection():确保 DDL 操作使用与当前事务相同的连接

  2. 同一事务中:所有操作必须在同一个事务和连接中完成

  3. 应用范围:所有动态创建/删除表的操作都需要修改

2.5.4 实际代码示例

# src/business_report/router.py

# 创建动态表
table = business_report.to_sqlalchemy_table(include_system_column=True)
table.create(db.connection(), checkfirst=True)  # ✅ 使用当前连接

# 删除动态表
table = report.to_sqlalchemy_table(include_system_column=True)
table.drop(db.connection(), checkfirst=True)  # ✅ 使用当前连接

2.6 数据库迁移(Alembic)

2.6.1 迁移脚本调整

alembic/env.py 无需特殊修改,会自动使用 settings.DATABASE_URL

2.6.2 时间戳字段迁移

如果需要从 PostgreSQL 迁移已有数据,需要创建迁移脚本修复时间戳字段的默认值:

# alembic/versions/2025-12-24_19e269aa0a4e_fix_sqlite_timestamp_defaults.py

def upgrade() -> None:
    # SQLite 不支持 ALTER TABLE ... SET DEFAULT
    # 如果之前使用了 server_default,需要手动处理
    pass  # 新项目不需要,旧项目需要数据迁移逻辑

def downgrade() -> None:
    pass

2.7 SQLite 工具函数 (src/utils/sqlite_util.py)

新增的工具函数用于管理 SQLite 数据库路径:

def get_app_db_path(app_name: str, db_name: str) -> Path:
    """获取应用数据目录下的数据库路径"""
    # Windows: %APPDATA%\app_name\db_name
    # Linux/Mac: $HOME/.config/app_name/db_name
    ...

def get_sqlite_connection_string(app_name: str, db_name: str) -> str:
    """生成 SQLite 连接字符串"""
    ...

三、关键注意点总结

3.1 连接配置

配置项

PostgreSQL

SQLite

说明

异步驱动

asyncpg

aiosqlite

必须匹配

同步驱动

psycopg2

内置

SQLite 使用内置驱动

check_same_thread

✅ 必需

SQLite 跨线程访问必需

pool_pre_ping

✅ 推荐

❌ 不需要

SQLite 不需要连接池

3.2 DDL 操作

操作

PostgreSQL

SQLite

注意点

创建表

支持并发

❌ 必须同连接

使用 db.connection()

删除表

支持并发

❌ 必须同连接

使用 db.connection()

ALTER TABLE

支持

有限支持

SQLite 3.25+ 支持 RENAME COLUMN

3.3 数据类型差异

类型

PostgreSQL

SQLite

处理方式

时间戳默认值

now()

❌ 不支持

使用 Python default

布尔类型

BOOLEAN

INTEGER

SQLAlchemy 自动处理

字符串长度

有约束

无实际约束

建议保留长度定义

3.4 并发限制

SQLite 的并发限制比较严格:

  1. 同一时间只能有一个写操作

  2. 读操作可以并发

  3. DDL 操作必须在同一连接中执行

  4. 长时间事务会阻塞其他操作

建议:

  • 尽量缩短事务时间

  • DDL 操作使用 db.connection() 确保同一连接

  • 考虑启用 WAL 模式(如果需要更高并发)

3.5 性能考虑

  1. 连接池:SQLite 不需要连接池,但 sessionmaker 仍可正常使用

  2. 索引:SQLite 的索引性能良好,建议保持合理的索引设计

  3. 文件大小:SQLite 数据库是单个文件,注意备份和管理

  4. 并发写入:如果有多进程写入需求,考虑使用文件锁或消息队列

3.6 测试环境

测试代码(tests/conftest.py)仍使用 PostgreSQL,因为:

  • PostgreSQL 测试容器更方便

  • 测试环境需要隔离

  • 生产环境使用 SQLite 是独立的决策

四、常见问题排查

4.1 “database is locked” 错误

原因:

  • DDL 操作使用了不同的连接

  • 有长时间未提交的事务

  • 有其他进程/线程正在写入

解决:

  1. 确保 DDL 使用 db.connection()

  2. 检查是否有未提交的事务

  3. 确保没有其他程序打开数据库文件

4.2 时间戳字段为 NULL

原因:

  • 迁移前使用了 server_default=func.now()

  • SQLite 不支持数据库层面的默认值函数

解决:

  1. 确保模型使用 Python default

  2. 如果是迁移的数据,需要手动更新 NULL 值

4.3 路径问题(Windows)

原因:

  • Windows 路径格式 C:\path\to\db.db 需要特殊处理

解决:

  • 使用 Path.as_posix() 转换为 POSIX 格式

  • 连接字符串格式:sqlite+aiosqlite:///C:/path/to/db.db

五、回滚到 PostgreSQL

如果需要回滚到 PostgreSQL:

  1. 修改 src/conf.py 中的 DATABASE_URLSYNC_DATABASE_URL

  2. 确保已安装 asyncpgpsycopg2-binary

  3. 时间戳字段会自动使用数据库默认值(TimestampTableMixin 已处理)

  4. DDL 操作可以改回使用 db.get_bind()(PostgreSQL 支持并发 DDL)

六、最佳实践建议

  1. 数据库位置:使用 APPDATA 目录,便于用户管理

  2. 连接管理:DDL 操作始终使用 db.connection()

  3. 事务管理:尽量缩短事务时间,及时提交

  4. 错误处理:捕获并处理 “database is locked” 错误

  5. 备份策略:定期备份 SQLite 数据库文件

  6. 性能监控:关注长时间运行的查询和事务

附录:迁移涉及的源码汇总

配置:src/conf.py

from src.utils.sqlite_util import get_app_db_path


class Settings(BaseSettings):
    _db_path = get_app_db_path("tianlu_spc", "tianlu_database.db")
    DATABASE_URL: str = f"sqlite+aiosqlite:///{_db_path.as_posix()}"
    SYNC_DATABASE_URL: str = f"sqlite:///{_db_path.as_posix()}"

数据库引擎:src/database.py

# 异步引擎
connect_args = {}
if settings.DATABASE_URL.startswith("sqlite"):
    connect_args = {"check_same_thread": False}

async_engine = create_async_engine(
    settings.DATABASE_URL,
    echo=settings.DEBUG,
    future=True,
    connect_args=connect_args,
    pool_pre_ping=not settings.DATABASE_URL.startswith("sqlite"),
)

# 同步引擎
sync_connect_args = {}
if settings.SYNC_DATABASE_URL.startswith("sqlite"):
    sync_connect_args = {"check_same_thread": False}

engine = create_engine(
    settings.SYNC_DATABASE_URL,
    echo=settings.DEBUG,
    connect_args=sync_connect_args,
    pool_pre_ping=not settings.SYNC_DATABASE_URL.startswith("sqlite"),
)

时间戳字段:src/database.py

class TimestampTableMixin:
    _is_sqlite = settings.DATABASE_URL.startswith("sqlite")

    if _is_sqlite:
	    created_at: Mapped[datetime] = mapped_column(
	        DateTime(timezone=True),
	        server_default=text("(CURRENT_TIMESTAMP)"),
	        nullable=False,
	    )
	    updated_at: Mapped[datetime] = mapped_column(
	        DateTime(timezone=True),
	        server_default=text("(CURRENT_TIMESTAMP)"),
	        onupdate=text("(CURRENT_TIMESTAMP)"),
	        nullable=False,
	    )
    else:
        created_at: Mapped[datetime] = mapped_column(
            DateTime(timezone=True),
            server_default=func.now(),
            nullable=False,
        )
        updated_at: Mapped[datetime] = mapped_column(
            DateTime(timezone=True),
            server_default=func.now(),
            onupdate=func.now(),
            nullable=False,
        )

DDL 操作示例:src/business_report/router.py

# 创建动态表
table = business_report.to_sqlalchemy_table(include_system_column=True)
table.create(db.connection(), checkfirst=True)

# 删除动态表
table = report.to_sqlalchemy_table(include_system_column=True)
table.drop(db.connection(), checkfirst=True)