从数据混乱到丝滑管理:我在Python项目中重构SQLite数据库的实战记录

2 阅读1分钟

背景:一团乱麻的数据层

上个月,我接手维护一个公司内部用的数据采集与分析工具。这个工具用Python写成,核心功能是把从不同API抓取到的设备状态数据存起来,供一个简单的Web面板查询。数据量不大,每天也就几千条记录,所以当初的开发者选用了SQLite,这本身没问题。问题出在实现上。

我刚拿到代码时,差点没背过气去。整个项目就一个database.py文件,里面密密麻麻全是这样的代码:

def insert_device_data(device_id, status, timestamp):
    conn = sqlite3.connect('data.db')
    cursor = conn.cursor()
    # 注意看这里,字符串直接拼接!
    sql = f"INSERT INTO device_log (device_id, status, log_time) VALUES ('{device_id}', '{status}', '{timestamp}')"
    cursor.execute(sql)
    conn.commit()
    conn.close()

这简直是“教科书式”的错误示范:手动管理连接、字符串拼接SQL(SQL注入的邀请函)、没有错误处理、表结构变更靠人肉在DB Browser里点点点。更头疼的是,由于没有数据模型定义,我根本不知道device_log表到底有哪些字段,类型是什么。项目运行半年,已经出现了几次因为数据格式问题导致的插入失败,以及一次轻微的数据混乱。

我的任务很明确:在不影响现有数据的前提下,重构这个数据层,让它变得可维护、可扩展、更安全。核心诉求就三点:1. 用ORM替代裸SQL;2. 引入数据库迁移工具;3. 保持向后兼容,平滑过渡。

问题分析:ORM选型与迁移策略

我的第一反应是上SQLAlchemy,这是Python生态里最强大的ORM,没有之一。但紧接着就面临两个具体问题:

  1. 如何兼容现有混乱的数据? 直接让SQLAlchemy的declarative_base根据现有表结构自动生成模型(automap)是一个选择,但自动生成的模型可能无法完美反映一些业务约束,而且表名和字段名如果很随意,生成的类名也会很丑。
  2. 如何管理表结构变更? 我需要一个类似Django Migrations的工具。Alembic是SQLAlchemy官方的迁移工具,但它通常需要从模型生成迁移脚本。而我现在的情况是:数据库已经存在,模型还没定义。这是一个“先有鸡还是先有蛋”的问题。

我最初的思路是分两步走:先用sqlite3命令行或工具把现有表结构导出来,手动写成SQLAlchemy模型。然后,把这个初始模型当作基准,让Alembic生成一个“初始迁移”脚本,但这个脚本应该是空的(因为表已经存在)。我查了Alembic文档,发现它确实支持这种场景,通过--autogenerate检测模型与数据库的差异,如果模型定义与当前数据库状态一致,生成的迁移脚本就是空的。然后,我未来的所有变更都可以从这个一致的状态开始,通过Alembic来管理。

排查现有数据库时,我又发现一个坑:原表里有些字段是TEXT类型,但存储的其实是JSON字符串。在旧代码里,每次查询出来都要用json.loads()解析。这应该在模型层就处理好。SQLAlchemy提供了JSON类型(配合sqlite3json1扩展)或者用自定义类型,这给了我优化数据结构的机会。

核心实现第一步:定义数据模型

我决定不完全依赖automap,而是结合数据库现状和业务逻辑,手动定义模型。这样虽然初期工作量稍大,但模型更清晰、更可控。

首先,我使用sqlite3命令行查看了表结构:

.schema device_log

输出大概是:

CREATE TABLE device_log (
    id INTEGER PRIMARY KEY,
    device_id TEXT,
    status TEXT,
    log_time TEXT,
    raw_data TEXT
);

我发现log_time存的是文本,raw_data里是JSON字符串。我新建了一个models.py文件来定义模型。

这里有个坑:SQLite默认不强制数据类型(Type Affinity),你甚至可以把字符串存到声明为INTEGER的列里。但SQLAlchemy的模型定义是强类型的,为了兼容,我决定在定义模型时,严格按照当前实际存储的数据类型来定义,避免初始化时就出错。对于log_time,我暂时还是用String,后续再考虑转为DateTime。对于raw_data,我决定使用SQLAlchemy的JSON类型,这需要SQLite支持json1扩展(现代Python版本基本都支持)。

# models.py
from sqlalchemy import Column, Integer, String, JSON, DateTime
from sqlalchemy.ext.declarative import declarative_base
import json

Base = declarative_base()

class DeviceLog(Base):
    __tablename__ = 'device_log'

    id = Column(Integer, primary_key=True)
    device_id = Column(String(64), nullable=False, index=True)  # 加了索引,因为常按设备查询
    status = Column(String(32), nullable=False)
    log_time = Column(String(32))  # 暂时保持String,兼容旧数据
    raw_data = Column(JSON)  # 使用JSON类型,SQLAlchemy会自动处理序列化/反序列化

    # 添加一个属性,方便访问解析后的raw_data
    @property
    def data(self):
        # 如果raw_data已经是dict(用了JSON类型后),直接返回,否则尝试解析
        if isinstance(self.raw_data, dict):
            return self.raw_data
        try:
            return json.loads(self.raw_data) if self.raw_data else {}
        except json.JSONDecodeError:
            return {}

注意,我特意为device_id添加了index=True。因为在业务查询中,WHERE device_id = ?是非常频繁的操作,原表没有索引,这在数据增长后会是性能瓶颈。这个索引的添加,我会通过Alembic迁移来完成,而不是手动去数据库创建。

核心实现第二步:初始化Alembic与空迁移

接下来是解决“先有数据库,后有模型”的迁移初始化问题。我按照Alembic官方文档操作:

  1. 安装必要包:pip install sqlalchemy alembic
  2. 在项目根目录初始化Alembic环境:alembic init alembic
  3. 修改alembic.ini中的数据库连接字符串,指向我的data.db
  4. 修改alembic/env.py,导入我的Base元数据,并设置target_metadata = Base.metadata

关键步骤来了:生成初始迁移脚本。我运行:

alembic revision --autogenerate -m "Initial migration based on existing schema"

Alembic会比较我的模型定义(Base.metadata)和当前数据库(data.db)的差异。因为我刚才定义模型时,刻意保持了与现有表结构的一致(除了raw_data用了JSON类型,但SQLite底层存储还是TEXT,Alembic的检测可能忽略这种同质变化),所以生成的迁移脚本upgrade函数很可能是空的。这正是我想要的——一个代表当前状态的基准点。

我打开生成的迁移文件(在alembic/versions/下),确认upgrade()downgrade()函数确实为空,或者只包含一些无关紧要的操作。然后,我必须执行这个迁移,以在Alembic的特殊表alembic_version中记录这个版本,这样Alembic才知道当前数据库处于这个“初始”状态。

alembic upgrade head

执行后,用数据库工具查看,会发现多了一个alembic_version表,里面有一条记录,版本号对应刚才生成的迁移脚本。

核心实现第三步:通过迁移添加索引与优化

现在,Alembic已经接管了数据库版本。我可以开始改进模型,并通过迁移来应用这些改进了。

首先,我实现之前计划的优化:为device_id添加索引,以及将log_timeString改为DateTime(这需要数据转换)。

  1. 修改模型 (models.py):

    class DeviceLog(Base):
        __tablename__ = 'device_log'
        # ... 其他字段不变
        log_time = Column(DateTime)  # 改为DateTime类型
        # device_id的索引已经在定义中,无需修改
    

    注意,这里直接改了log_time的类型。Alembic会发现这个变化。

  2. 生成并检查迁移脚本

    alembic revision --autogenerate -m "Add index on device_id and change log_time to DateTime"
    

    打开新生成的迁移文件,我看到了类似以下的内容:

    def upgrade():
        # ### commands auto generated by Alembic - please adjust! ###
        op.create_index(op.f('ix_device_log_device_id'), 'device_log', ['device_id'], unique=False)
        # 注意这里!Alembic检测到类型变化,生成了alter_column操作
        op.alter_column('device_log', 'log_time',
                       existing_type=sa.TEXT(),
                       type_=sa.DateTime(),
                       existing_nullable=True)
        # ### end Alembic commands ###
    

    这里有个大坑! op.alter_column对于SQLite是有限支持的。SQLite本身不支持直接修改列类型(ALTER TABLE ... ALTER COLUMN)。Alembic会通过一个复杂的过程(创建新表、复制数据、删除旧表、重命名)来模拟这个操作。这对于有数据的表是危险的,尤其是类型转换(TEXT -> DateTime)可能失败。

  3. 手动编辑迁移脚本,安全处理数据转换。我不能完全依赖自动生成的脚本。我需要修改这个迁移,确保数据转换是安全可控的。

    import sqlalchemy as sa
    from alembic import op
    import sqlite3
    from datetime import datetime
    
    # revision identifiers, used by Alembic.
    revision = 'xxxxxx'
    down_revision = 'yyyyyy'
    
    def upgrade():
        # 1. 先添加索引,这个操作SQLite原生支持,是安全的
        op.create_index(op.f('ix_device_log_device_id'), 'device_log', ['device_id'], unique=False)
    
        # 2. 处理log_time列类型转换。我们需要自定义逻辑。
        conn = op.get_bind()
    
        # 首先,读取所有现有的id和log_time文本
        rows = conn.execute(sa.text("SELECT id, log_time FROM device_log WHERE log_time IS NOT NULL")).fetchall()
    
        # 创建一个临时字典,存储解析后的时间。如果解析失败,则置为None
        updates = []
        for row_id, time_str in rows:
            dt = None
            # 尝试解析旧数据中可能的时间格式
            for fmt in ('%Y-%m-%d %H:%M:%S', '%Y/%m/%d %H:%M:%S', '%Y-%m-%dT%H:%M:%S'):
                try:
                    dt = datetime.strptime(time_str, fmt)
                    break
                except ValueError:
                    continue
            updates.append((row_id, dt))
    
        # SQLite的ALTER COLUMN不支持类型变更,Alembic会模拟。我们让它执行。
        # 但为了数据正确,我们在Alembic操作后,手动更新数据。
        # 注意:Alembic的模拟操作会创建新表、复制数据,但复制时log_time列的数据还是文本。
        # 所以我们需要在Alembic的alter_column操作之后,再执行我们的更新。
    
        # 实际上,更安全的做法是分步迁移,这里为了示例,我们采用一个策略:
        # 先让Alembic执行它的模拟alter操作(结构变更),然后我们手动更新数据。
        # 但Alembic的op.alter_column在SQLite环境下是一个大事务,我们不能在中间插入。
        # 因此,我选择另一种方法:禁用Alembic的自动alter,完全手动处理。
    
        # 注释掉自动生成的alter_column行
        # op.alter_column('device_log', 'log_time',
        #                existing_type=sa.TEXT(),
        #                type_=sa.DateTime(),
        #                existing_nullable=True)
    
        # 手动执行SQLite的列变更流程(简化版,假设没有复杂约束)
        # 步骤略复杂,在实际项目中,我可能会选择保持TEXT类型,在应用层进行解析。
        # 鉴于篇幅和示例的清晰性,我决定在本文中采取一个折中方案:
        # 保留log_time为String,但添加一个DateTime的属性方法,并确保新数据按ISO格式存储。
        # 这是一个重要的实战决策:当数据迁移风险过高时,在应用层兼容是更稳妥的选择。
    
        # 所以,我回滚了模型中对log_time的修改,仍然使用String。
        # 并在DeviceLog类中添加一个方法:
        # @property
        # def log_datetime(self):
        #     # ... 解析self.log_time字符串为datetime对象,提供None安全
        pass
    
    def downgrade():
        op.drop_index(op.f('ix_device_log_device_id'), table_name='device_log')
        # 类型改回操作也相应移除
    

    经过这番思考,我意识到在现有数据质量不明的情况下,强行改变底层列类型风险太大。我调整了方案:保持数据库列类型不变,在ORM层提供类型化的访问接口。这是很多遗留系统重构的实用技巧。我修改了模型,放弃直接修改Column类型,而是通过hybrid_property或普通属性方法来提供datetime对象。

    最终,我重新生成了一个只包含创建索引的迁移,并执行它。

    alembic upgrade head
    

    这样,性能索引加上了,数据结构的风险也避免了。

完整代码示例

以下是核心模块的最终版本代码,可以直接运行(需要先安装sqlalchemyalembic,并有一个名为data.db的SQLite数据库,其中包含device_log表)。

models.py:

from sqlalchemy import Column, Integer, String, JSON, DateTime, create_engine, Index
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
import json
from datetime import datetime

Base = declarative_base()

class DeviceLog(Base):
    __tablename__ = 'device_log'

    id = Column(Integer, primary_key=True)
    device_id = Column(String(64), nullable=False)
    status = Column(String(32), nullable=False)
    log_time = Column(String(32))  # 保持文本存储,兼容性第一
    raw_data = Column(JSON, default=dict)  # 使用JSON类型,默认空字典

    # 定义索引,注意这里只是元数据,实际创建需要Alembic迁移
    __table_args__ = (Index('ix_device_log_device_id', 'device_id'), )

    @property
    def data(self):
        """获取解析后的raw_data"""
        if isinstance(self.raw_data, dict):
            return self.raw_data
        try:
            return json.loads(self.raw_data) if self.raw_data else {}
        except json.JSONDecodeError:
            return {}

    @property
    def log_datetime(self):
        """将log_time字符串转换为datetime对象,安全版本"""
        if not self.log_time:
            return None
        # 尝试常见格式
        for fmt in ('%Y-%m-%d %H:%M:%S', '%Y/%m/%d %H:%M:%S', '%Y-%m-%dT%H:%M:%S', '%Y-%m-%d %H:%M:%S.%f'):
            try:
                return datetime.strptime(self.log_time, fmt)
            except ValueError:
                continue
        # 如果都无法解析,记录警告或返回None
        # import logging
        # logging.warning(f"无法解析时间字符串: {self.log_time}")
        return None

    @log_datetime.setter
    def log_datetime(self, dt: datetime):
        """设置datetime,统一存储为ISO格式字符串"""
        if dt is None:
            self.log_time = None
        else:
            # 存储为ISO格式,便于解析和排序
            self.log_time = dt.isoformat(sep=' ', timespec='seconds')  # 例如 '2023-10-27 14:30:00'

# 数据库连接和会话工厂
engine = create_engine('sqlite:///data.db', echo=False)  # echo=True可查看SQL日志
SessionLocal = sessionmaker(bind=engine)

def get_db():
    """依赖注入使用的会话获取函数"""
    db = SessionLocal()
    try:
        yield db
    finally:
        db.close()

使用示例 (example_usage.py):

from models import DeviceLog, get_db
from datetime import datetime

# 插入新数据
with next(get_db()) as db:
    new_log = DeviceLog(
        device_id="device_001",
        status="online",
        log_datetime=datetime.now(),  # 使用setter
        raw_data={"temperature": 23.5, "humidity": 60}  # 直接传字典
    )
    db.add(new_log)
    db.commit()
    print(f"插入记录ID: {new_log.id}")

# 查询数据
with next(get_db()) as db:
    logs = db.query(DeviceLog).filter(DeviceLog.device_id == "device_001").order_by(DeviceLog.log_time).all()
    for log in logs:
        print(f"ID: {log.id}, Status: {log.status}")
        print(f"  时间对象: {log.log_datetime}")  # 使用property获取datetime
        print(f"  原始数据: {log.data}")  # 使用property获取解析后的dict
        print(f"  存储的时间文本: {log.log_time}")

Alembic迁移目录 (alembic/) 是运行alembic init alembic命令生成的,需要根据项目配置env.pyalembic.ini

踩坑记录

  1. Alembic空迁移执行失败:第一次执行alembic upgrade head时,报错Target database is not up to date。这是因为我没有理解alembic_version表的作用。在已有数据库上初始化时,必须先“标记”当前状态。解决方案就是生成一个与当前状态一致的模型,创建一个空迁移,并执行它,让Alembic记录这个基准版本。

  2. SQLite的ALTER COLUMN陷阱:如正文所述,Alembic为SQLite生成的alter_column操作是模拟的,涉及表重建。对于有重要数据且列类型转换复杂的场景,这是高风险操作。我踩的坑是盲目信任自动生成的脚本,差点导致数据丢失。解决方法:仔细审查针对SQLite生成的迁移脚本,对于复杂的数据转换,要么手动编写安全的数据迁移逻辑,要么像我一样,选择保持数据库列类型不变,在应用层进行适配。

  3. JSON字段的默认值:在模型中将raw_data = Column(JSON)后,插入新记录时如果没提供raw_data,默认会是None。但在业务逻辑中,我希望它默认是空字典{}。直接设置default={}会导致所有实例共享同一个字典引用(可变默认值陷阱)。解决方法:使用default=dict,或者使用default=lambda: {},这样每次创建新实例时都会生成一个新的空字典。

  4. 连接未关闭导致数据库锁:在旧代码和我的新代码早期版本中,如果异常发生,连接可能没有正确关闭。在Web应用或多线程环境下,这容易导致sqlite3.OperationalError: database is locked解决方法:使用上下文管理器(with语句)或类似FastAPI的Depends(get_db)模式来确保会话在任何情况下都能正确关闭。我在get_db函数中使用了try...finally来保证。

小结

这次重构让我深刻体会到,即使是简单的SQLite,在项目规模增长后,也需要用ORM和迁移工具这样的“重型装备”来管理。核心收获是:用ORM定义清晰的数据模型,用迁移工具记录每一次结构变更,两者结合是维护数据库健康度的最佳实践。下一步,我可以探索SQLAlchemy更高级的特性,如异步IO(asyncpg/aiosqlite)、复杂查询优化,并将这套模式应用到更复杂的PostgreSQL项目中。