
做零到全栈项目最容易在哪个阶段卡住很多人会遇到一个相似的路口功能已经能跑起来了数据却留不住。早期为了快速验证想法大家通常把用户、文章、配置全部塞在内存列表、全局字典甚至 JSON 文件里。程序一重启数据清零所有操作只能从头再来。这个问题在演示阶段不致命但它是一个非常典型的信号项目到了需要做一次结构性重构的时候了。这节「6.5 重构在项目中使用 SQLite」要解决的就是这个转折点。我们会做一次范围可控的重构把原来用内存数组或 JSON 文件维护的数据迁移到 SQLite 数据库里。SQLite 是嵌入式单文件关系型数据库不需要安装服务端没有账号密码一个文件就是完整数据库非常适合零到全栈项目在本地持久化阶段使用。这篇文章会先讲清楚重构的边界再解释 SQLite 为什么值得选然后从建库、建表、CRUD 写到数据访问层最后给出验证方法和常见坑。读完你至少能完成一次“换存储不动接口”的重构并且理解为什么这种重构比继续堆功能更值得先做。1. 这篇文章真正要解决的问题1.1 “能跑”和“能用”之间隔着一个持久化在零到全栈的学习过程中前几节通常重点解决“功能能不能跑通”接口能不能返回数据、页面能不能展示列表、按钮能不能触发逻辑。这时候用内存存储是完全合理的因为它最快、最直接没有任何额外依赖。但项目一旦从“演示”走向“自用”问题就暴露了。最常见的现象就是页面刷新后数据还在因为前端状态还在但后端进程一重启所有用户、记录、配置全部丢失。再严重一点如果用了 JSON 文件存储还要处理多人同时写入、文件损坏、格式校验等问题。这些问题的本质是业务逻辑已经在内存里运行但数据没有找到合适的落点。解决它不是一个功能需求而是一个架构需求。这也是为什么这节不叫“SQLite 教程”而叫“重构在项目中使用 SQLite”——重点不是学会 SQLite 的语法而是知道在什么时机、用什么方式把存储层换掉。1.2 为什么选择 SQLite 而不是 MySQL很多新手的第一反应是既然要用数据库为什么不直接上 MySQL这个问题问得很有价值但答案是场景决定的。MySQL 适合服务端有独立数据库进程、需要多机部署、多人并发访问的场景。而零到全栈项目在这个阶段通常是单机运行、单用户或少量用户使用SQLite 反而更合适。SQLite 不需要单独安装数据库服务不需要配置端口、账号、权限不需要维护数据目录。应用代码直接链接到数据库文件读写都在本地完成。对学习项目来说这意味着可以把注意力集中在“数据模型怎么设计”和“SQL 怎么写”上而不是被数据库运维分散精力。从后续发展角度看先学会 SQLite 也不亏。SQL 语法在地层上是通用的今天用 SQLite 写的建表语句、查询语句将来切换到 MySQL 或 PostgreSQL 时核心概念和大部分语法都能平移。选 SQLite 不是因为它是“玩具”而是因为它的学习成本与现实需求在本阶段最匹配。1.3 什么样的读者应该读这篇文章如果你正在做自己的全栈练习项目数据还是存在内存或 JSON 文件里且已经感受到“重启就丢数据”的麻烦这篇文章就是为你准备的。如果你是刚开始接触数据库想找一个门槛最低的方式理解建表、外键、事务、参数化查询SQLite 也是最好的起点。这篇文章的示例以 Python 为主因为 Python 标准库自带sqlite3模块不需要额外安装任何依赖。同时文中也会给出 Node.js 的对应示例如果你用的是 JavaScript 技术栈可以对照迁移。无论哪种语言重构的思路和 SQL 设计都是通用的。2. SQLite 的核心特性与适用场景2.1 什么是 SQLiteSQLite 是一个嵌入式关系型数据库管理系统所谓“嵌入式”就是它不是一个独立的服务进程而是以库的形式链接到应用程序中。你的程序通过调用 SQLite 的接口读写数据库数据库内容保存在一个普通文件里例如app.db。和 MySQL、PostgreSQL 这种 C/S 架构的数据库相比SQLite 有几个非常直观的特点零配置不需要初始化数据目录、创建用户、设置密码。单文件整个数据库就是一个文件备份就是复制文件。无网络端口不监听任何端口也就没有远程连接的安全风险。标准 SQL支持大部分 SQL-92 标准支持事务、外键、索引。跨平台同一个数据库文件可以在不同操作系统之间复制使用。这些特点让 SQLite 成为移动端应用、桌面软件、嵌入式设备、本地工具链中最常用的数据库。2.2 SQLite 与 MySQL、PostgreSQL 的对比很多人在选择数据库时会纠结这里用一张表把关键差异列出来维度SQLiteMySQL / PostgreSQL架构嵌入式库无独立服务独立服务进程部署成本极低随应用分发即可需要安装、配置、运维数据存储单个文件数据目录多个文件并发能力写操作全局串行适合低并发行级锁适合高并发网络访问不支持只能本机访问支持远程连接使用场景本地存储、移动端、桌面软件、原型服务端业务系统、Web 应用学习曲线低中到高这里需要特别说明一个常见误区很多人觉得 SQLite 只能做练习用不能上生产。实际上 iOS、Android 系统内置的数据库就是 SQLite大量桌面软件如浏览器、聊天工具也在用 SQLite 做本地存储。它适合的场景是“单机、低并发、需要持久化”不适合的场景是“多实例部署、高并发写入、复杂权限控制”。2.3 在零到全栈项目中SQLite 能解决什么问题零到全栈项目的特点是功能越来越多但基础设施相对简单。这时候用 SQLite可以解决三个具体问题。第一重启不丢数据。数据落盘后程序进程退出再启动之前写入的内容依然存在。第二让数据查询变得规范。内存列表只能通过遍历查找而 SQLite 可以用WHERE、JOIN、ORDER BY完成复杂查询代码量更少表达力更强。第三为后续升级打基础。当你在项目里建立了数据访问层将来切换 MySQL 时只需要替换数据访问层的实现业务层基本不用动。还有一个隐性问题值得注意如果一直不引入数据库项目的数据操作会越来越散每个功能各自操作自己的内存变量时间长了很难维护。SQLite 的引入本质上是在项目里建立了一个统一的“数据出口”。3. 重构前的项目现状与重构目标3.1 一个典型的“内存存储”实现假设我们的项目已经实现了一个简单的用户管理功能。在重构之前数据可能是这样存储的# 文件路径store/memory_store.py重构前的旧实现 class MemoryUserStore: def __init__(self): self._users [] self._next_id 1 def create(self, name, email): user { id: self._next_id, name: name, email: email, } self._next_id 1 self._users.append(user) return user[id] def find_by_id(self, user_id): for user in self._users: if user[id] user_id: return user return None def list_all(self): return self._users这段代码的问题不是说它写得不对而是它把全部数据保存在进程内存中。进程一结束列表清空所有用户数据归零。如果项目还需要保存文章、评论、任务等更多实体这种内存数组会越来越多数据之间的关系也会越来越难处理。3.2 当前实现存在的主要问题用内存存储或者 JSON 文件存储在数据量小的时候确实够用但项目继续推进会遇到四个问题。第一是数据生命周期太短。进程重启、电脑关机数据就没了。如果项目是用来记录真实信息比如个人笔记、学习进度、任务清单这种丢失是无法接受的。第二是查询逻辑越来越复杂。当数据实体变多比如用户下面有任务任务下面有标签用纯 Python 列表去维护关系代码里会出现大量嵌套循环和条件判断阅读成本急剧上升。第三是写入并发问题。如果使用 JSON 文件存储多个写入请求同时操作文件很容易出现数据覆盖或者文件损坏。JavaScript 的事件循环虽然是单线程但异步写入依然可能造成竞争条件。第四是数据完整性不受保障。没有唯一约束邮箱可能重复没有外键约束任务可能指向不存在的用户没有事务一次批量操作只写完一半程序就崩了数据就停留在半成品状态。这些问题的共同根源是缺少一个真正能持久化、能约束数据关系的存储层。3.3 重构的目标边界说到重构首先要明确边界。这节的重构不是推翻重来而是保持对外行为基本不变的前提下把内部存储机制换掉。具体目标有三个持久化数据写入后程序重启依然存在。接口复用之前调用create、find_by_id的代码尽量少改动。数据关系清晰用外键和唯一约束把数据规则固化到数据库层。重构最怕的是边重构边加新功能。那样出了问题你很难判断是重构引入的 bug还是新功能的 bug。所以本文的实践原则是先只换存储不增加业务功能验证跑通后再继续演进。4. 环境准备与 SQLite 工具选择4.1 运行环境本文示例使用 Python 3 编写充分利用标准库自带的sqlite3模块。你不需要手动安装任何数据库程序只要本机有 Python 3 即可。如果你想用 Node.js 复现代码需要准备 Node.js 环境并通过 npm 安装better-sqlite3依赖。需要特别说明的是SQLite 的版本由运行环境的动态库决定。Python 内置的sqlite3模块会附带一个较新版本的 SQLite常用功能完全够用。如果你需要查看当前 SQLite 版本可以执行python -c import sqlite3; print(sqlite3.sqlite_version)4.2 官方命令行工具虽然 Python 自带驱动但验证数据库内容时命令行工具会更直观。在 macOS 和大部分 Linux 发行版中系统自带sqlite3命令。Windows 用户需要从 SQLite 官网下载命令行工具或者通过包管理器安装。进入数据库文件后可以用.tables查看表列表.schema查看建表语句SELECT查询数据sqlite3 app.db.tables .schema users SELECT * FROM users;这个工具很适合在开发阶段快速确认数据是否正确写入。4.3 图形化工具DB Browser for SQLite如果你不习惯命令行推荐使用开源免费的 DB Browser for SQLite。它支持 Windows、macOS、Linux可视化查看表结构、执行 SQL、导入导出数据。对于零到全栈项目来说它比商业数据库客户端更轻量也没有授权问题。实际开发中我喜欢用这样的分工代码里写好建表和 CRUD命令行用来快速验证遇到复杂查询先用 DB Browser 试 SQL确认无误后复制回代码。这个流程可以减少很多试错成本。5. 在项目中引入 SQLite建库、建表与基础 CRUD5.1 项目目录结构设计在动手写代码之前先规划一下目录结构。这里采用了数据访问层的分离思路把数据库连接、建表、数据操作分别放在不同模块中project/ ├── app.py # 业务入口 ├── db/ │ ├── __init__.py │ ├── database.py # 连接管理与建表 │ └── user_repository.py # 用户数据访问 └── scripts/ └── demo.py # 手动验证脚本这样的好处是业务层不需要知道数据库是怎么连接的只需要调用UserRepository的方法。未来如果切换数据库只需要重写db包内部实现。5.2 初始化数据库与建表第一步是建立连接管理模块。这里设置row_factory sqlite3.Row让查询结果可以通过字段名访问然后开启外键约束确保任务表引用用户表时不会出现“悬空引用”。# 文件路径db/database.py import sqlite3 from pathlib import Path DB_PATH Path(__file__).resolve().parent.parent / app.db def get_connection(): conn sqlite3.connect(DB_PATH) conn.row_factory sqlite3.Row conn.execute(PRAGMA foreign_keys ON) return conn def init_db(): conn get_connection() conn.executescript( CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, email TEXT NOT NULL UNIQUE, created_at TEXT NOT NULL DEFAULT (datetime(now)) ); CREATE TABLE IF NOT EXISTS tasks ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, title TEXT NOT NULL, done INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime(now)), FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); ) conn.commit() conn.close()这段 SQL 做了几件重要的事情。users表的id使用INTEGER PRIMARY KEY AUTOINCREMENTSQLite 会自动生成递增的主键email加了UNIQUE约束从数据库层面保证邮箱不重复tasks表通过FOREIGN KEY (user_id) REFERENCES users(id)建立外键关系ON DELETE CASCADE表示删除用户时自动删除其任务。5.3 数据访问层基础 CRUD建表完成后开始写最核心的UserRepository类。这里的关键是参数化查询用?占位符替代字符串拼接既能防止 SQL 注入也让 SQL 语句更清晰。# 文件路径db/user_repository.py from db.database import get_connection class UserRepository: def create(self, name, email): with get_connection() as conn: cursor conn.execute( INSERT INTO users (name, email) VALUES (?, ?), (name, email) ) return cursor.lastrowid def find_by_id(self, user_id): with get_connection() as conn: row conn.execute( SELECT * FROM users WHERE id ?, (user_id,) ).fetchone() return dict(row) if row else None def list_all(self): with get_connection() as conn: rows conn.execute( SELECT id, name, email FROM users ORDER BY id DESC ).fetchall() return [dict(row) for row in rows] def update_email(self, user_id, new_email): with get_connection() as conn: cursor conn.execute( UPDATE users SET email ? WHERE id ?, (new_email, user_id) ) return cursor.rowcount def delete(self, user_id): with get_connection() as conn: cursor conn.execute( DELETE FROM users WHERE id ?, (user_id,) ) return cursor.rowcountwith get_connection() as conn这段代码利用sqlite3连接对象的上下文管理器在操作成功后自动提交事务在发生异常时自动回滚。lastrowid可以拿到刚插入记录的自增主键rowcount则是受影响的行数。5.4 多表查询JOIN 示例当数据进入关系型数据库后最典型的受益场景就是 JOIN 多表查询。比如查出某个用户的任务列表不用写循环去内存里匹配一句 SQL 就能完成SELECT users.name, tasks.title, tasks.done FROM users JOIN tasks ON tasks.user_id users.id WHERE users.id ?;在 Python 中调用这段 SQL 时同样使用参数化查询。先通过UserRepository.find_by_id拿到用户再执行 JOIN 查询返回的每一行都包含用户姓名和任务标题数据关系一目了然。5.5 Node.js 技术栈的对应实现如果你的项目基于 Node.js推荐使用better-sqlite3它是同步 API性能好且代码直观。安装命令如下npm install better-sqlite3对应的建表和插入示例// 文件路径db/index.js const Database require(better-sqlite3); const db new Database(app.db); db.exec( CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, email TEXT NOT NULL UNIQUE ); ); const insert db.prepare(INSERT INTO users (name, email) VALUES (?, ?)); const info insert.run(李四, lisiexample.com); console.log(insert id:, info.lastInsertRowid); const row db.prepare(SELECT * FROM users WHERE id ?).get(info.lastInsertRowid); console.log(row:, row);Node.js 版的思路和 Python 版完全一致先建立连接再执行 SQL最后获取结果。掌握了一边的思路另一边的代码很容易迁移。6. 核心重构把内存存储替换为数据访问层6.1 重构第一步让业务层面向接口编程在替换存储之前我们先做一件重要的事情让业务层只依赖UserRepository而不是直接依赖内存列表或者数据库连接。也就是说业务层调用的是“仓库”而不是“列表”或者“连接”。这样做的好处是替换存储方案时业务层的代码不需要动。之前业务层可能会这样写# 重构前业务代码直接操作内存存储 store MemoryUserStore() user_id store.create(张三, zhangsanexample.com)重构后只需要换成# 重构后业务代码使用统一的数据访问层 repo UserRepository() user_id repo.create(张三, zhangsanexample.com)从业务层的视角看create方法签名没有变化变化的只是底层实现。这就是重构中常说的“保持接口稳定替换内部实现”。6.2 重构第二步把查询逻辑改造成 SQL内存存储时代查找一个用户需要遍历整个列表。数据量小的时候无所谓但一旦数据量增长每次遍历都是 O(n) 的时间复杂度而且代码里会塞进大量循环。改成 SQL 之后查找逻辑变成一句话def find_by_email(self, email): with get_connection() as conn: row conn.execute( SELECT * FROM users WHERE email ?, (email,) ).fetchone() return dict(row) if row else None这里体现了关系型数据库的核心价值数据操作交给 SQL 引擎处理应用的职责是组织数据和呈现结果。6.3 重构第三步引入事务保证数据一致性如果一个业务操作需要同时写多张表比如“创建用户并给该用户分配三个默认任务”一次性写多个 INSERT 就必须考虑事务。事务的四个特性 ACID 在这里最有用要么全部成功要么全部回滚。def create_user_with_tasks(repo, task_repo, name, email, tasks): with get_connection() as conn: try: conn.execute(BEGIN) cursor conn.execute( INSERT INTO users (name, email) VALUES (?, ?), (name, email) ) user_id cursor.lastrowid for title in tasks: conn.execute( INSERT INTO tasks (user_id, title) VALUES (?, ?), (user_id, title) ) conn.commit() return user_id except Exception: conn.rollback() raise如果中途某个 INSERT 失败rollback()会把前面的写入全部撤销数据库不会出现“用户已创建但任务没创建成功”的中间状态。事务是在重构中最容易被忽略、但影响最大的一部分。6.4 重构的验收标准重构完成不代表“改完了”还要对照几个验收标准确认没有破坏原有功能原有业务逻辑的入口函数签名是否保持不变。创建、查询、更新、删除的结果是否与重构前一致。程序重启后数据是否仍然存在。顶层接口能否在测试环境中跑通。如果以上四项都满足这次重构就达到了目标。7. 运行结果与效果验证7.1 编写验证脚本为了验证重构效果写一个简单的手动验证脚本依次执行初始化、创建、查询、更新、删除等操作# 文件路径scripts/demo.py from db.database import init_db from db.user_repository import UserRepository if __name__ __main__: init_db() repo UserRepository() uid repo.create(张三, zhangsanexample.com) print(create user id:, uid) user repo.find_by_id(uid) print(find by id:, user) repo.update_email(uid, zhangsan_newexample.com) print(after update:, repo.find_by_id(uid)) users repo.list_all() print(all users:, users) repo.delete(uid) print(after delete:, repo.find_by_id(uid))运行脚本python scripts/demo.py7.2 预期输出如果一切正常输出大致如下create user id: 1 find by id: {id: 1, name: 张三, email: zhangsanexample.com, created_at: 2025-01-01 12:00:00} after update: {id: 1, name: 张三, email: zhangsan_newexample.com, created_at: 2025-01-01 12:00:00} all users: [{id: 1, name: 张三, email: zhangsan_newexample.com, created_at: 2025-01-01 12:00:00}] after delete: Nonecreated_at的具体时间取决于运行时刻。到这里CRUD 基本验证通过。7.3 验证持久化效果CRUD 验证通过后最关键的验证是持久化先运行一次脚本创建一个用户但不删除然后关闭程序重新运行一次查询脚本确认用户仍然存在。python scripts/create_user.py python scripts/list_users.py如果第二次运行时用户数据还能查询出来说明数据已经成功落盘。这时候再打开项目目录会看到一个app.db文件可以用命令行工具直接查看sqlite3 app.db SELECT * FROM users;如果看到刚刚创建的用户记录持久化验证就成功了。如果数据不存在优先检查DB_PATH指向的文件路径确认程序读写的是同一个数据库文件。8. 常见问题与排查思路从内存存储切到 SQLite过程中会遇到一些典型问题。下面列出最常出现的几种。问题现象可能原因排查方式解决方案程序启动后提示 no such table: users建表语句未执行或init_db()未调用检查入口是否调用了init_db()在应用启动入口显式调用init_db()插入数据显示 database is locked其他连接持有写锁未释放检查是否有长事务未提交缩短事务时间开启 WAL 模式数据写入成功但查询不到连接未提交事务确认是否使用with语句或显式commit()统一使用上下文管理器管理连接程序一重启数据就消失DB_PATH指向临时目录或相对路径漂移打印DB_PATH确认路径使用基于项目根目录的绝对路径插入数据报 UNIQUE constraint failed唯一约束字段重复查询已存在的数据在应用层做前置校验或使用 ON CONFLICT 语句中文数据乱码终端编码问题数据库本身没问题用 DB Browser 查看确认设置终端 UTF-8 编码更新返回值总是 0更新的数据与原数据相同或主键不存在先查询记录是否存在根据业务需要区分“无记录”和“值相同”在这些问题中database is locked是新手最常遇到也最容易慌的。它通常发生在多线程或多次连接同时写入的场景。解决思路是确保每次写入操作尽快提交不要在一个事务里做耗时操作。SQLite 也支持 WAL 模式可以显著提升并发读写体验这个后面会讲。9. 最佳实践与工程建议9.1 数据文件路径要稳定数据文件路径是新手最容易忽略的坑。如果使用相对路径比如sqlite3.connect(app.db)数据库文件会落在“当前工作目录”。从项目根目录运行和从scripts目录运行生成的文件位置完全不同。建议统一使用基于项目根目录的绝对路径。Python 中可以通过Path(__file__).resolve().parent.parent / app.db计算这样无论从哪里启动程序数据库都固定在同一个位置。9.2 开启外键和 WAL 模式PRAGMA foreign_keys ON必须放在每次连接建立后执行因为 SQLite 的外键约束默认是关闭的。如果忘记开启外键虽然定义在表结构里但不会生效。对于本地单机项目WALWrite-Ahead Logging模式值得开启。它会把写操作先追加到独立的-wal文件中读操作不会被写操作阻塞适合读写并存的场景。开启方式是在连接后执行PRAGMA journal_mode WAL;注意 WAL 模式会产生额外的app.db-wal和app.db-shm文件备份数据库时这三个文件要一起处理或者先执行一次 checkpoint 合并。9.3 参数化查询是底线永远不要用字符串格式化拼 SQL。这不仅是为了防注入更是为了代码可维护性和执行计划复用。下面这种写法必须避免# 错误示例不要用 f-string 拼接 SQL cursor conn.execute(fSELECT * FROM users WHERE email {email})正确的做法是使用?占位符把参数作为元组传给execute。Python 的sqlite3模块以及 Node.js 的better-sqlite3都支持这种方式。9.4 建表变更要用迁移方案随着项目迭代你一定会遇到需要给表加字段的情况。直接在旧表上执行ALTER TABLE可以解决简单需求比如加一列ALTER TABLE users ADD COLUMN nickname TEXT;但如果涉及改字段类型、拆分表、合并表就需要设计迁移流程。零到全栈阶段不需要引入复杂的迁移工具但可以在项目里预留一个migrations目录按时间或版本保存 SQL 文件db/migrations/ ├── 001_create_users.sql ├── 002_create_tasks.sql └── 003_add_nickname.sql每次启动时读取已执行过的迁移记录没有执行过的按顺序执行。这样表结构的演进可以得到记录和回溯。9.5 备份与恢复SQLite 的备份非常简单本质上就是复制文件。但要保证数据一致性最好在执行备份时避免写入操作。Python 的sqlite3模块提供了备份 APIimport sqlite3 source sqlite3.connect(app.db) target sqlite3.connect(backup.db) source.backup(target) target.close() source.close()这个 API 会生成一个一致性快照比直接cp文件更安全。对零到全栈项目来说每天定时备份或者手动备份一次就够了。9.6 什么时候不该用 SQLite虽然 SQLite 很好但也要知道它的边界。如果你预计项目会部署到服务器上多进程访问且写入并发很高SQLite 就不太合适了。写操作全局串行意味着同一时间只能有一个连接写入高并发场景下会成为瓶颈。另外如果项目需要跨设备、跨地区共享数据SQLite 作为单机文件数据库也无法满足需求。这时候需要换成服务端数据库并把数据访问逻辑抽象成 API。好在本文做重构时已经把数据访问收拢到了Repository层将来切换不会伤筋动骨。10. 总结与后续学习方向这一节的核心不是“SQLite 的语法大全”而是一次完整的最小重构理解内存存储的局限选型 SQLite建立数据访问层用事务保证一致性再通过验证脚本证明重构没有破坏功能。整个过程下来你的项目第一次拥有了真正可靠的数据落点。接下来值得深入的方向有三个。第一是学习更多 SQL 查询技巧比如分组聚合、子查询、窗口函数这些在后续报表类功能中非常有用。第二是研究索引原理理解为什么某些字段加索引后查询速度大幅提升为什么有些场景加索引反而没效果。第三是考虑把数据访问层进一步抽象成通用模块为将来接入 MySQL 或 PostgreSQL 做准备。如果在动手实践时遇到问题建议先把报错信息完整读一遍再去定位连接管理和查询语句的部分。数据库问题大多不是玄学多数情况下错误信息已经把原因写得很清楚了。建议把这篇文章收藏备用搭好骨架后后续为项目添加实体、字段、查询都会顺很多。