
做全栈项目到一定程度你会很快遇到一个很现实的问题数据到底存在哪里在本地练手阶段把数据写进 JSON 文件、CSV 文件甚至直接放在内存列表里好像都能跑。但一旦数据多起来体验会直线下降——整个文件要被一次性读进内存查找一条记录要靠 for 循环逐个比对稍有不慎还会把整个文件写坏。很多同学卡在这里并不是不会写代码而是“数据存储”这一层的认知还停留在文件读写上。这次的内容就是带你把一个典型的 JSON 文件存储方案重构为 SQLite 数据库方案。它不需要你重新学一门高深的数据库理论却能让你真切体会到“数据库”和“文件”的差别事务、结构化查询、索引、持久化这些概念不再停留在书本上而是直接出现在你的代码里。从一个全栈学习者的角度看这次重构是一个分水岭。你会意识到真正的项目并不是把数据放进文件就完事而是要选择一个合适的数据存储方案并且把它稳定、安全、可查询地管起来。SQLite 可能不是你未来生产环境里的唯一选择但它一定是让你建立数据库思维的最好工具。1. 为什么要在项目里做这次重构先别急着写代码。这次重构的价值在于解决真实问题而不是为了展示新技术。1.1 原来的方案JSON 文件存储在前面的章节里我们做了一个非常常见的待办清单Todo List功能。为了保证数据不丢我们把数据写进了本地 JSON 文件结构大概是[ { id: 1, title: 学习 SQLite, done: false, created_at: 2025-01-10 10:00:00 }, { id: 2, title: 完成 JSON 数据迁移, done: false, created_at: 2025-01-10 10:05:00 } ]这个方案的问题在于代码执行时会把整个文件读入内存修改后再整体写回磁盘。当数据只有几十条时毫无压力但数据量逐步增长后你会开始遇到查找一条记录需要遍历整个数组无法按条件高效过滤。每次保存都要把整个文件重写一遍频繁写入时性能很差。没有事务保护程序中途崩溃可能导致文件内容不完整。多条数据同时修改时容易出现互相覆盖的问题。这里真正让人焦虑的不是“数据量变大”而是“数据状态不可控”。你辛苦做的功能却不知道文件在某个时刻是否是完整的、一致的。1.2 重构的边界不是推翻重写而是替换存储层很多人听说要重构第一反应是“全部推倒重来”。但好的重构恰恰相反业务逻辑大概率不用改尤其是用户界面的部分。我们要做的是把最底层的数据访问方式从“读写 JSON 文件”替换为“操作 SQLite 数据库”然后通过封装好的函数对外提供同样的能力。这个思路在工程里叫“存储层替换”。只要你原先的代码不是把所有文件操作都散落在页面里而是抽成了一个专门的模块那么这次重构的成本就非常低。如果你原来的代码是分散写的这次正好顺手把数据访问收敛到一个模块里。1.3 从 JSON 到 SQLite 带来的核心变化维度JSON 文件存储SQLite 数据库数据读取范围全量读入内存按需读取支持 WHERE 条件过滤查找效率线性遍历数据越多越慢有索引支持查询效率高数据一致性无法保证中途崩溃可能损坏文件事务机制提交成功才生效并发能力多个进程同时写易冲突单写多读支持 WAL 模式优化数据类型检查完全靠代码自行校验建表时定义字段类型和约束可维护性数据越多越难维护SQL 语句统一管理结构清晰这张表不是让你立刻掌握所有概念而是要你明白重构的价值不是把“文件”换成“数据库文件”这么简单而是换了一套数据管理的方式。你的心智模型要从“数组里的对象”切换到“表里的记录”。2. SQLite 到底是什么嵌入式数据库与适用边界在动手之前我们需要把 SQLite 这个概念讲清楚。很多初学者以为 SQLite 是 MySQL 的廉价替代品这个理解并不准确。2.1 嵌入式数据库的核心特点SQLite 是一个嵌入式关系型数据库。所谓“嵌入式”一个重要特点是它不需要独立的服务器进程。传统数据库如 MySQL通常要先启动一个数据库服务你的应用通过网络协议连接到这个服务再去读写数据。而 SQLite 是直接把数据库引擎作为程序的一部分使用操作的是一个本地文件。你可以这样理解MySQL 像一个专门的仓库有管理员、有门禁需要通过网络把货物送进去。SQLite 则像你自己房间里的储物柜打开门就能存取但也就意味着储物柜的存取规则由你自己负责。这个设计带来的直接结果是零配置不需要安装服务端不需要配置端口。数据库就是单个.db文件复制文件就等于备份整个数据库。非常适合桌面应用、移动端应用、爬虫、数据分析工具和中小型 Web 应用。Python 自带的sqlite3模块就是标准库的一部分不用额外安装依赖。2.2 为什么全栈项目里可以先从 SQLite 开始在零到全栈这条路线上学习节奏很重要。如果你一上来就直接学习 MySQL会牵扯到服务器安装、账号权限、远程连接、数据库调优等问题这些对初学者来说很有挫败感。SQLite 把“数据库学习”的门槛降到最低你只需要一个 Python 文件、一个本地数据库文件就能完整体验建表、增删改查、事务和索引。更重要的是SQL 语言是通用的。你在 SQLite 里写的 SQL 语句到 MySQL、PostgreSQL 上依然适用差异非常小。把 SQLite 作为数据库启蒙工具性价比很高。2.3 SQLite 与 MySQL、PostgreSQL 的选型边界对比项SQLiteMySQL / PostgreSQL部署方式嵌入式库随应用集成独立服务需安装与运维适用规模中小型应用、本地应用、移动端高并发、大数据量、多用户系统并发写入同一时刻通常只允许一个写者支持更强的并发控制数据迁移直接复制.db文件即可需要备份恢复机制流程更复杂上手成本极低中等需要理解服务生命周期所以这篇文章的判断很明确作为全栈学习者你完全应该在本地项目里先使用 SQLite等真正面临部署到云服务器、需要多人高并发访问时再考虑迁移到 MySQL。而迁移时你之前学习的表和 SQL 经验并不会白费。3. 环境准备Python sqlite3、命令行与图形化工具这次实操基本不需要复杂的安装步骤。下面列出需要用到的环境。3.1 Python 内置 sqlite3 模块Python 官方解释器自带的sqlite3模块就是这次重构的主角。它的文档在官方标准库中可以找到因此没有“安装第三方库”的步骤。python --version运行上面的命令确认你的 Python 版本。理论上只要 Python 3 系列即可本文的写法也不依赖很新的特性。进入 Python 交互环境试一下import sqlite3 print(sqlite3.sqlite_version)能输出 SQLite 版本号就说明环境没有问题。3.2 命令行工具 sqlite3如果你是在 Windows 上开发Python 安装目录下的Scripts或Tools等位置不一定自带sqlite3.exe最简单的方式是去 SQLite 官网下载对应平台的命令行工具压缩包。如果你在 Linux 或 macOS 上通常已经内置了sqlite3命令。命令行工具适合快速查看表结构和执行简单的 SQL配合教程练习非常方便。sqlite3 todos.db .schema3.3 图形化工具DB Browser for SQLite对于全栈学习者我强烈建议安装一个图形化数据库管理工具。热词里出现了 Navicat for SQLite、SQLite Expert这两个都是不错的商业工具但作者不推荐去网上搜破解密钥既不稳定也不安全。我更推荐开源的 DB Browser for SQLite。它免费、跨平台支持 Windows、macOS、Linux安装后可以非常直观地看到表结构、浏览数据、执行 SQL。很多正式团队在本地调试 SQLite 时也会用它。安装完成后打开工具选择“打开数据库”指向你的todos.db文件就能看到表、字段和数据的可视化界面。3.4 项目目录结构假设你的项目目前结构是todo-project/ ├── app.py ├── todos.json └── storage.py重构完成后我们希望变成todo-project/ ├── app.py ├── db.py ├── todo_repo.py ├── migrate_json_to_sqlite.py ├── verify.py ├── todos.db └── todos.backup.jsondb.py负责数据库连接todo_repo.py负责待办事项的增删改查migrate_json_to_sqlite.py负责把旧的 JSON 数据迁移到 SQLiteverify.py用于验证功能是否正常。4. 重构前从 JSON 数据结构设计 SQLite 表数据从 JSON 迁移到 SQLite不是简单地把 JSON 字符串存进一个字段而是要对数据做结构化设计。这一步也是“重构”里最有含金量的部分。4.1 梳理原有数据字段回到最初的 JSON 数据结构{ id: 1, title: 学习 SQLite, done: false, created_at: 2025-01-10 10:00:00 }我们一共有四个字段id唯一标识用于定位某一条记录。title待办内容。done是否完成布尔类型。created_at创建时间。这四个字段在 SQLite 中可以直接映射为一张表。设计表时要注意几点id应该设置为主键并尽量使用自增。title不允许为空。done使用整数 0/1 表示布尔值SQLite 本身没有独立的布尔类型。created_at在插入时可以由数据库自动生成减少应用层代码的负担。4.2 设计 todos 表表结构如下字段名类型约束含义idINTEGERPRIMARY KEY AUTOINCREMENT主键自增titleTEXTNOT NULL待办内容doneINTEGERNOT NULL DEFAULT 0是否完成0 否/1 是created_atTEXTNOT NULL DEFAULT (datetime(now,localtime))创建时间这里解释一下两个常见点。PRIMARY KEY AUTOINCREMENT表示主键自增每次插入不需要显式传 id。AUTOINCREMENT 会保证主键不会重复使用已经删除的 id。对于待办清单这种应用用自增是合理的。DEFAULT (datetime(now,localtime))会在插入时自动生成当前时间效果是“应用层不用写时间数据库帮你记”。这样即使调用方忘记传时间数据也不会残缺。4.3 SQL 建表语句下面是标准的建表语句CREATE TABLE IF NOT EXISTS todos ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, done INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime(now, localtime)) );IF NOT EXISTS很关键。它表示“如果表不存在才创建”避免重复执行建表语句时报错。这一点在重构脚本中尤其重要因为迁移脚本可能会被重复运行。datetime(now, localtime)是 SQLite 的内置函数作用是记录当前的本地时间。如果不加localtimeSQLite 默认记录的是 UTC 时间会造成 8 小时偏差。这个细节虽然小但在实际项目里影响很大。5. 重构核心连接、建表与基础 CRUD环境准备好后开始写核心代码。为了让代码不散落各文件我们先建立数据库基础模块。5.1 旧版 JSON 存储的问题为了等一下能看出变化我们先看一眼旧的存储实现# 文件路径storage.py重构前的实现仅用于对比 import json import os FILE todos.json def load(): if not os.path.exists(FILE): return [] with open(FILE, r, encodingutf-8) as f: return json.load(f) def save(todos): with open(FILE, w, encodingutf-8) as f: json.dump(todos, f, ensure_asciiFalse, indent2) def add_todo(title): todos load() todos.append({id: len(todos) 1, title: title, done: False}) save(todos)这里有一个隐藏的坑id用len(todos) 1生成一旦删除一条数据再新增数据时id就可能重复或覆盖旧记录。这不是小事在某些业务里会导致界面显示错乱也会给后续更新、删除带来隐患。JSON 方案下这些问题需要额外写大量代码去规避而数据库主键天然解决了它。5.2 SQLite 版基础模块 db.py# 文件路径db.py import sqlite3 DB_PATH todos.db def get_connection(): conn sqlite3.connect(DB_PATH) # 让查询结果可按字段名访问方便后续转换为 JSON conn.row_factory sqlite3.Row return conn def init_db(): sql CREATE TABLE IF NOT EXISTS todos ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, done INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime(now, localtime)) ); with get_connection() as conn: conn.execute(sql)这段代码里的两个细节值得注意。第一conn.row_factory sqlite3.Row。它让查询结果变成可以按列名访问的对象非常接近字典的用法。框架的后续环节中我们经常需要把数据库记录转成 JSON 返回给前端这个设置会让转换非常顺畅。第二with get_connection() as conn。sqlite3的Connection对象支持上下文管理器进入with块后如果正常执行完毕事务会提交如果中途发生异常事务会回滚。这样我们就不用到处手动调用commit()和rollback()代码干净得多。5.3 基础 CRUD 模块 todo_repo.py接下来编写待办事项的增删改查也就是 CRUD。# 文件路径todo_repo.py from db import get_connection def add_todo(title): with get_connection() as conn: cur conn.execute( INSERT INTO todos (title) VALUES (?), (title,), ) return cur.lastrowid def get_todo(todo_id): with get_connection() as conn: row conn.execute( SELECT id, title, done, created_at FROM todos WHERE id ?, (todo_id,), ).fetchone() return dict(row) if row else None def list_todos(doneNone): sql SELECT id, title, done, created_at FROM todos params () if done is not None: sql WHERE done ? params (int(done),) sql ORDER BY done ASC, id DESC with get_connection() as conn: rows conn.execute(sql, params).fetchall() return [dict(row) for row in rows] def update_todo(todo_id, titleNone, doneNone): with get_connection() as conn: if title is not None: conn.execute( UPDATE todos SET title ? WHERE id ?, (title, todo_id), ) if done is not None: conn.execute( UPDATE todos SET done ? WHERE id ?, (int(done), todo_id), ) def delete_todo(todo_id): with get_connection() as conn: conn.execute( DELETE FROM todos WHERE id ?, (todo_id,), )这段代码里有一个很重要的编码习惯所有 SQL 语句都使用?占位符再把参数作为元组传给execute()。这是对 SQL 注入的标准防御手段。所谓 SQL 注入是指用户输入的字符串被拼接到 SQL 语句中后改变了 SQL 的语义。用参数化查询之后驱动会自动处理字符串转义安全性高很多。另外list_todos(doneNone)支持按状态筛选。传done0时只查未完成传done1时只查已完成不传时查全部。这种通过条件拼接 SQL 的方式在真实项目里非常常见。6. 数据迁移把 JSON 旧数据完整搬到 SQLite在项目已经跑过一段时间的情况下数据是不能直接丢掉的。真实的工程重构不会只改代码还要做历史数据迁移。6.1 迁移脚本实现# 文件路径migrate_json_to_sqlite.py import json import os import shutil from db import get_connection, init_db JSON_FILE todos.json BACKUP_FILE todos.backup.json def load_old_data(file_path): if not os.path.exists(file_path): return [] with open(file_path, r, encodingutf-8) as f: return json.load(f) def migrate(): old_items load_old_data(JSON_FILE) if not old_items: print(没有需要迁移的数据) return init_db() inserted 0 with get_connection() as conn: for item in old_items: title item.get(title) or item.get(text) or done 1 if item.get(done) else 0 created_at item.get(created_at) if not title: print(f跳过空标题数据: {item}) continue if created_at: conn.execute( INSERT INTO todos (title, done, created_at) VALUES (?, ?, ?), (title, done, created_at), ) else: conn.execute( INSERT INTO todos (title, done) VALUES (?, ?), (title, done), ) inserted 1 # 保留一份备份再删除原文件防止迁移出错后无法恢复 if os.path.exists(JSON_FILE): shutil.copy2(JSON_FILE, BACKUP_FILE) os.remove(JSON_FILE) print(f迁移完成共写入 {inserted} 条数据原文件已备份为 {BACKUP_FILE}) if __name__ __main__: migrate()6.2 迁移过程的关键设计这个脚本体现了几个非常实战的细节。第一兼容了字段名差异。如果旧数据里某个字段叫title直接用如果旧代码里曾经叫过text也能兼容。真实项目里的历史数据往往不像教科书那样规范很容易出现字段名不一致提前做兼容能省很多事。第二对空数据做了保护。造数据时如果title为空整条记录实际上没有意义直接跳过避免脏数据污染新表。第三插入时保留了旧的created_at。如果数据库默认生成时间那么所有旧数据的创建时间都会变成迁移那一刻相当于丢失了历史信息。我们显式插入created_at能最大程度保留原始记录的完整状态。第四迁移成功后并没有直接删掉旧 JSON 文件而是先复制为todos.backup.json再删除原文件。这是一个典型的“先备份再操作”流程。如果迁移后发现数据有问题你还有机会从备份文件恢复。这也是生产环境操作数据库时非常重要的原则。6.3 调用方代码的改造数据访问层从“JSON 数组”变成了“数据库查询”调用方的变化比很多人想象中少很多。比如原来的添加操作是from storage import add_todo add_todo(学习 SQLite)重构后from todo_repo import add_todo add_todo(学习 SQLite)函数名、参数几乎没有变化依然是一个函数完成一件事。变化最大的是内部实现再也不用load()读全部数据、save()写回整个文件了。这就是封装的意义所在。不过查询逻辑会变化。以前想要“未完成的待办”你得这样写todos load() undone [t for t in todos if not t[done]]现在改成from todo_repo import list_todos undone list_todos(done0)这看起来只是减少了几行代码但底层差别很大。JSON 的列表推导式会把所有数据读进内存再过滤SQLite 只返回符合条件的记录当数据量变大时性能差异会非常明显。7. 运行结果与效果验证代码写完之后最重要的就是验证。不能只看“没有报错”还要确认数据真的对、逻辑真的对。7.1 执行迁移假设项目里已经有旧数据todos.json运行python migrate_json_to_sqlite.py预期输出迁移完成共写入 2 条数据原文件已备份为 todos.backup.json如果还没有旧数据脚本会提示“没有需要迁移的数据”这不代表失败只是没有数据可迁移。7.2 命令行验证表结构与数据打开命令行进入项目目录sqlite3 todos.db .schema预期输出CREATE TABLE todos ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, done INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime(now, localtime)) );再查所有数据sqlite3 todos.db SELECT id, title, done, created_at FROM todos;如果能看到迁移进去的记录说明建表和写入都成功了。同样的结果也可以在 DB Browser for SQLite 中查看。打开表后可以直接看到字段和记录的表格化展示。7.3 自检脚本 verify.py为了让验证可以反复执行我建议写一个自检脚本把每个功能点都跑一遍# 文件路径verify.py from db import init_db from todo_repo import add_todo, list_todos, update_todo, delete_todo, get_todo init_db() # 1. 插入 id1 add_todo(学习 SQLite 基础) id2 add_todo(完成 JSON 数据迁移) print(新增两条待办:, id1, id2) # 2. 查询全部 print(当前全部待办:) for item in list_todos(): print(item) # 3. 更新状态 update_todo(id1, done1) print(完成第一项后:, get_todo(id1)) # 4. 查询未完成列表 print(未完成待办:) for item in list_todos(done0): print(item) # 5. 删除 delete_todo(id2) print(删除第二项后剩余条数:, len(list_todos()))运行python verify.py执行成功后你会在输出里看到新增、查询、更新、再次查询、删除的完整流程。如果其中任何一步抛异常可以先看它是在哪一步报错再回到对应函数排查。7.4 失败时第一步看哪里如果运行迁移脚本时报错最常见的几类情况sqlite3.OperationalError: table todos already exists建表语句里没写IF NOT EXISTS或者 init_db 没有被正确执行。sqlite3.OperationalError: no such column: xxxSQL 语句里的列名和建表语句不一致重点检查大小写和拼写。中文乱码数据库本身存储的是 UTF-8 文本问题通常出在终端编码。Windows 下可以先执行chcp 65001切换代码页再运行命令。8. 常见问题与排查思路问题现象可能原因排查方式解决方案运行时报table todos already exists建表语句未加IF NOT EXISTS查看建表 SQL统一使用CREATE TABLE IF NOT EXISTS中文数据显示乱码终端编码不是 UTF-8检查系统代码页Windows 执行chcp 65001查询结果为空但文件确实有数据表结构或字段名不匹配用.schema查看建表语句对比 SQL 中的列名与建表语句多线程/多进程同时写入时报 database is lockedSQLite 同一时间只允许一个写者查看并发写入代码路径合并短事务设置timeout考虑 WAL 模式重新运行程序时旧数据丢失调用顺序问题或迁移脚本被重复执行检查执行日志迁移前备份文件并判断表是否已有数据手写 SQL 拼接参数后报语法错误字符串拼接导致引号或转义问题打印实际 SQL 语句改为参数化查询?占位符数据库文件过大或碎片化频繁增删改产生空洞查看文件大小周期性执行VACUUM压缩整理关于database is locked这一点值得多说一句。SQLite 的并发模型是“单写多读”同一时刻通常只能有一个连接执行写操作。在本地单用户项目里几乎不会遇到但如果你把程序部署成 Web 服务多线程同时请求时就要注意。最简单的规避方式是让每个连接尽快完成操作并退出with块不要长连接持有写事务。9. 工程化建议与后续学习方向这次重构把项目的数据层升级到了 SQLite但距离“工程化”还有几个值得继续深入的地方。9.1 使用事务保障数据一致性with get_connection() as conn已经提供了事务的基本保护。正常情况下块内所有 SQL 要么全部成功提交要么全部回滚。如果你有“先插入再更新如果有问题全部撤销”的多步操作尽量放在同一个事务里。with get_connection() as conn: conn.execute(INSERT INTO todos (title) VALUES (?), (事务测试,)) conn.execute(UPDATE todos SET done 1 WHERE id ?, (1,))当第二个 SQL 失败时第一个 SQL 的插入也会被回滚数据不会处于半完成状态。9.2 为查询频率高的字段加索引如果待办清单的数据量很大而且经常按done筛选可以给该字段加索引CREATE INDEX idx_todos_done ON todos(done);索引能显著提高查询效率但也会占用一定空间并降低一点写入速度。不要给所有字段都加索引只给高频过滤字段和关联字段加。9.3 数据库文件的安全与备份SQLite 的备份非常简单直接把.db文件复制走就行但复制前要确保没有正在写事务。生产中更稳妥的方式是用 SQLite 自带的备份接口而不是直接在文件层复制。在本地项目阶段养成“迁移前备份、操作后验证”的习惯比任何具体命令都重要。9.4 避免使用破解工具选择开源方案热词里出现的 Navicat for SQLite、SQLite Expert 等商业工具虽然功能强大但我不建议去网上寻找所谓“密钥”或“破解版”。一方面安全无法保证另一方面也踩了合规风险。对于学习阶段DB Browser for SQLite 完全够用它是开源软件跨平台功能也符合绝大多数开发调试需求。9.5 从 SQLite 迈向真正的全栈这次重构只是一个起点。当你掌握了 SQLite 的表设计、CRUD、事务和索引之后再看其他数据库会轻松很多。比如 uni-app 这类跨端框架中本地数据也可以使用 SQLite 存储不同框架的驱动 API 有差异但 SQL 语句是通用的C、Node.js 等语言也都有各自的 SQLite 驱动。将来部署到线上环境需要高并发写入时再平滑迁移到 MySQL 或 PostgreSQL 即可。后续你可以继续学习ORM 框架通过对象映射简化数据库操作比如 Python 中的 SQLAlchemy。数据库设计主键、外键、一对一关联、一对多关联。事务隔离级别理解不同事务之间的可见性规则。索引原理B-Tree、覆盖索引、最左前缀法则。这次重构最重要的意义是让你意识到“存储”不是随手一放的文件而是一个可以精心设计的工程环节。当你开始为一堆 JSON 文件没有安全感的时候说明你已经准备好进入数据库的世界了。前面写的那些 CRUD 函数请务必亲手跑一遍再试着把项目里的待办清单完整切到 SQLite 上你会发现全栈的视野一下子宽了很多。