数据库存储的基本原理:数据是如何写入磁盘的

数据库存储的基本原理:数据是如何写入磁盘的

数据库存储的基本原理:数据是如何写入磁盘的

执行一条 INSERT,数据看起来立刻就出现在表里了。但在数据库内部,这条记录通常不会直接以“追加一行文本”的方式写进磁盘文件。数据库要解析语句、检查约束、组织事务、更新索引和数据页,再通过操作系统与存储设备的多层缓存完成持久化。

理解这条路径,能帮助我们回答几个实际问题:为什么提交成功仍可能丢数据?为什么数据库要写日志?为什么批量写入比逐条提交快?数据库文件能不能直接复制?本文先解释通用原理,再用 Python 自带的 SQLite 演示可运行的写入流程,并说明适用场景和关键边界。

一、先区分“写入成功”的几种含义

在讨论磁盘之前,要先明确“写入成功”可能指不同的事情:

  • 语句执行成功:数据库接受了这条语句,没有发现语法、类型或约束错误。
  • 事务提交成功:数据库确认事务完成,其他事务可以按隔离规则观察到它的结果。
  • 数据具有持久性:即使进程退出、操作系统重启或机器断电,已提交的数据仍能恢复。

前两项不必然意味着数据已经到达非易失介质。数据可能还在数据库进程的内存、操作系统的页缓存、磁盘控制器缓存,甚至设备自身的易失缓存中。数据库会通过日志、同步调用和硬件写缓存策略来建立持久性,但最终保证取决于数据库配置、操作系统、文件系统、驱动和存储设备是否正确实现并履行刷新请求。

因此,应用收到“提交成功”意味着什么,需要结合数据库及其持久化配置判断,不能把“API 返回成功”简单等同于“每个字节已写进闪存单元”。

二、一条写入经过哪些层

不同数据库的内部细节不一样,但常见的写入路径可以概括为:

  1. 应用发送语句。客户端向数据库进程发送 SQL,或者通过嵌入式数据库库直接调用数据库引擎。
  2. 解析与执行。数据库检查语句、权限、类型、约束和索引,确定需要修改的数据。
  3. 形成事务变更。数据库在内存中维护页、行或其他数据结构的变更,并记录事务状态。
  4. 写入恢复日志。许多数据库先写事务日志,再将更新后的数据页写回数据文件。这样崩溃后可以用日志恢复已提交事务或撤销未完成事务。
  5. 提交事务。数据库按配置把必要的日志内容写出,并在要求持久性的模式下请求同步到稳定存储。
  6. 稍后整理数据页。脏页可能由后台线程、检查点或缓存淘汰过程写入数据文件;日志与数据页不一定同时写入。

这也解释了为什么“提交”不一定等于“数据页已经更新”:数据库可以先让日志承担恢复责任,稍后再把数据页写回。只要日志和恢复机制满足规则,数据库仍能在崩溃后重建正确状态。

三、页、索引与文件:表不是简单的文本文件

关系型数据库通常把表和索引组织为页(page)或块。页有固定或受控的大小,里面存放记录、槽位、指针、空闲空间等信息。数据库可以把相关页放入内存缓冲池,查询和修改优先操作内存中的页。

例如,向一张有主键和二级索引的表插入一行,数据库可能需要修改表所在的数据页、主键索引页、一个或多个二级索引页,以及事务日志。若索引页已满,还可能发生页分裂或树结构调整。因此写入成本并不只取决于记录本身的字节数。

页的组织方式是实现细节,不同引擎差异很大。数据库文件通常还含有页头、元数据、空闲空间和恢复信息,不适合用文本编辑器修改,也不能按“每行一条记录”的假设自行解析。数据库提供的 SQL、备份和恢复工具才是正确的访问方式。

四、为什么要先写日志

如果数据库直接覆盖数据页,断电可能只写入一部分,导致页内结构不完整。即使单次写操作在某些设备上看起来是原子的,多个页之间的更新也通常不是一个不可分割的动作。事务日志提供了恢复依据。

常见的日志思路包括:

  • 预写日志(WAL,Write-Ahead Logging):先把描述变更的信息写入日志,再允许相应数据页写回。提交时通常需要确保提交记录及其之前的日志达到规定的持久化状态。
  • 撤销与重做:恢复时可能需要撤销未提交事务的影响,并重做已提交但尚未写入数据页的更新。具体协议由数据库实现决定。
  • 检查点:把部分已提交变更对应的数据页写回,并标记恢复可以从较新的位置开始,避免每次启动都扫描过长的日志。

日志不只是“备份副本”。日志格式、记录顺序、校验、事务标识和回收规则都属于数据库内部协议。手动删除或编辑日志文件可能造成数据库无法恢复或丢失数据。

五、操作系统缓存与持久化边界

数据库通常通过系统调用读写文件。一次普通文件写入可能只是把数据交给操作系统缓存,稍后再由内核写到设备。数据库需要在特定时机调用相应的同步机制,要求操作系统把数据和必要的元数据向下刷新。

但同步调用也有边界:它依赖操作系统、文件系统、驱动和设备正确处理刷新请求。一些带电池保护的控制器可以安全地缓存写入;不可靠的设备或错误配置可能谎报完成。远程文件系统、虚拟化存储和云盘还有各自的持久性语义。因此,对高价值数据,除了配置数据库,还应验证实际部署环境,并有可恢复的备份。

还要区分持久性和可见性。事务隔离决定其他事务何时能看到变更;日志同步影响断电后是否保留变更。它们相关但不是同一件事。

六、用 Python 和 SQLite 演示事务写入

SQLite 是嵌入式关系型数据库,适合本地应用、工具、小型服务和原型。它不需要单独运行数据库服务器,数据通常保存在一个数据库文件中。以下示例使用 Python 标准库 sqlite3,无需安装第三方包。

把代码保存为 write_demo.py,运行 python write_demo.py。脚本会在当前目录创建 demo.db。

import sqlite3

DB_PATH = "demo.db"

with sqlite3.connect(DB_PATH) as conn:
    conn.execute("""
        CREATE TABLE IF NOT EXISTS accounts (
            id INTEGER PRIMARY KEY,
            name TEXT NOT NULL UNIQUE,
            balance INTEGER NOT NULL CHECK (balance >= 0)
        )
    """)

    # 同一事务中的两条写入:全部成功,或因异常回滚。
    with conn:
        conn.execute(
            "INSERT OR IGNORE INTO accounts (name, balance) VALUES (?, ?)",
            ("alice", 1000),
        )
        conn.execute(
            "INSERT OR IGNORE INTO accounts (name, balance) VALUES (?, ?)",
            ("bob", 500),
        )

    rows = conn.execute(
        "SELECT id, name, balance FROM accounts ORDER BY id"
    ).fetchall()

    for row in rows:
        print(row)

with sqlite3.connect(...) 管理连接资源;内层 with conn 管理事务:正常退出时提交,发生异常时回滚。参数占位符 ? 将值作为数据绑定,而不是把字符串拼接成 SQL。这样既能正确处理引号等字符,也能防止把用户输入当作 SQL 代码执行。

INSERT OR IGNORE 会忽略某些约束冲突,因此重复运行时不会因唯一键冲突而中断。它不适合所有业务:如果必须知道插入是否发生,或要处理不同类型的约束错误,应显式检查结果或使用数据库支持的冲突处理语法。不要用“忽略所有错误”的方式掩盖数据问题。

七、SQLite 的 WAL 模式与同步级别

SQLite 默认使用回滚日志模式,也支持 WAL 模式。WAL 模式下,修改先追加到 -wal 文件;读取时,SQLite 会结合主数据库文件和 WAL 内容得到当前状态。检查点会把 WAL 中的内容合并回主数据库文件。活跃读事务可能影响检查点能够推进到的位置,所以 WAL 文件不一定在每次提交后立即变小。

下面的代码启用 WAL,并设置同步级别。它可以放在连接建立后、开始业务事务前运行:

import sqlite3

conn = sqlite3.connect("demo.db")

mode = conn.execute("PRAGMA journal_mode=WAL").fetchone()[0]
conn.execute("PRAGMA synchronous=FULL")

print("journal mode:", mode)
print("synchronous:", conn.execute("PRAGMA synchronous").fetchone()[0])

conn.close()

journal_mode=WAL 是持久化的数据库模式设置,执行后返回实际启用的模式;如果环境不支持或设置失败,应检查返回值,不能只假设成功。WAL 通常适合读写并发较多、读操作需要与写操作并行的本地数据库场景,但 SQLite 仍只有一个写入者同时执行写事务。WAL 也有使用边界,例如需要谨慎处理网络文件系统、数据库相关文件的复制,以及多进程访问的部署条件。

synchronous=FULL 要求 SQLite 在提交时进行更严格的同步操作,通常以较高写入延迟换取更强的断电持久性要求。NORMAL 在 WAL 模式下仍保持数据库一致性,但在操作系统崩溃或断电等故障中,最近提交的数据可能丢失;它通常不是“数据库文件必然损坏”的意思。实际效果仍受底层存储是否可靠履行同步请求影响。选择哪一种,应根据可接受的数据丢失窗口和性能目标决定,而不是把某个值当作所有系统的通用最佳值。

连接的 timeout 参数用于等待数据库锁的时间,单位是秒,并不是写入超时或持久化保证。例如 sqlite3.connect("demo.db", timeout=10) 表示遇到锁竞争时最多等待一段时间;超时后仍可能抛出锁相关异常。它不会让 SQLite 支持多个并行写入者。

八、检查点与数据库文件复制

在 WAL 模式中,不能只复制主数据库文件就认为备份完整:最近提交的数据可能仍在 WAL 文件中。复制数据库时应使用 SQLite 的备份接口,或使用能正确协调读写和 WAL 状态的受支持工具。应用运行期间直接复制文件,可能得到不一致的副本。

SQLite 提供在线备份 API。下面的例子将一个数据库备份到另一个文件:

import sqlite3

source = sqlite3.connect("demo.db")
target = sqlite3.connect("demo-backup.db")

try:
    source.backup(target)
finally:
    target.close()
    source.close()

备份文件还应定期执行恢复演练。备份任务显示成功,不代表备份一定可用;验证方式是从备份恢复到隔离位置,并检查表、关键记录和应用行为。复制数据库文件不是加密,也不自动保护文件免受误删、勒索软件或账号泄露。

九、事务为什么能提高可靠性和性能

事务把多个相关操作组合成一个逻辑单元。例如转账需要扣除一个账户余额并增加另一个账户余额。如果两条语句分别提交,程序在中间崩溃就可能只完成一半;把它们放在同一个事务里,可以让数据库按事务规则提交或回滚。

import sqlite3

conn = sqlite3.connect("demo.db")

try:
    with conn:
        conn.execute(
            "UPDATE accounts SET balance = balance - ? "
            "WHERE name = ? AND balance >= ?",
            (100, "alice", 100),
        )
        if conn.execute("SELECT changes()").fetchone()[0] != 1:
            raise ValueError("账户不存在或余额不足")

        conn.execute(
            "UPDATE accounts SET balance = balance + ? WHERE name = ?",
            (100, "bob"),
        )
        if conn.execute("SELECT changes()").fetchone()[0] != 1:
            raise ValueError("收款账户不存在")
finally:
    conn.close()

这个例子演示事务边界和参数绑定,但完整的资金系统还需要定义并处理并发、幂等、货币精度、审计记录、重试策略和业务授权。不能把示例直接当作金融生产代码。尤其要注意:数据库回滚不会撤销事务之外的副作用,例如已发送的邮件、已调用的外部支付接口或已发布的消息。这类流程通常需要事务性发件箱、幂等键或补偿机制。

事务还能减少提交次数。每次提交可能涉及日志同步,逐行提交会反复承担固定开销。对大量导入,通常可以将一批相关写入放在一个事务中。但事务不宜无限增大:长事务会占用锁或快照,增加回滚成本,也可能让 WAL 检查点受阻。合理的批次大小要结合内存、锁竞争、恢复时间和业务原子性测试。

十、应用层常见错误

1. 把 SQL 和用户输入拼接

# 不安全:输入可以改变 SQL 语义
sql = "SELECT * FROM users WHERE name = '" + user_name + "'"

# 使用参数绑定
row = conn.execute(
    "SELECT * FROM users WHERE name = ?",
    (user_name,),
).fetchone()

参数绑定适用于值,不适用于表名、列名或排序方向等 SQL 结构。若要动态选择列名,应从程序内固定的白名单中选择,再构造 SQL;不能把任意用户字符串直接拼入语句。参数绑定可以降低 SQL 注入风险,但不替代身份验证、权限控制和输入校验。

2. 忘记事务边界

把每条语句都当成独立操作,可能留下半完成业务状态;反过来,把大量无关工作放进一个很长的事务,也会引发锁竞争和恢复成本。事务边界应对应业务中必须一起成功或失败的操作。

3. 以为进程退出就等于安全落盘

正常关闭连接有助于释放资源、完成必要的数据库处理,但不能替代正确的同步配置和可靠的存储硬件。突然断电、内核崩溃、磁盘故障与进程崩溃是不同故障模型,应分别考虑。

4. 手动删除日志或临时文件

WAL、回滚日志、共享内存文件可能是数据库正常运行或恢复所必需的部分。不要在数据库运行时手动删除它们。若遇到残留文件或空间异常,先使用数据库提供的诊断、检查点和备份恢复手段,再查明原因。

5. 把超时设置当成并发方案

延长锁等待时间只能让请求等待更久,不能提升数据库写入并行度。若单机 SQLite 的写入竞争已经影响业务,应检查事务是否过长、是否存在不必要的频繁写入,并评估是否需要客户端—服务器数据库。换数据库不能自动解决事务设计不当的问题。

十一、如何判断某种存储方案是否合适

SQLite 适合本地数据、桌面软件、移动应用、命令行工具、测试环境,以及写入并发需求有限的小型服务。它部署简单、无需单独维护服务器,事务和 SQL 能力也足以覆盖不少应用。

如果有大量客户端同时写入、需要集中权限管理、跨机器访问、细致的在线备份与复制、复杂的高可用需求,通常应评估 PostgreSQL、MySQL 等客户端—服务器数据库。选择时应比较数据规模、并发、故障恢复目标、运维能力和部署环境,而不是仅凭单条基准测试的吞吐量。

任何数据库都不能替代备份、监控和恢复演练。应至少明确:允许丢失多少数据(RPO)、故障后多久恢复(RTO)、如何检测磁盘空间耗尽和写入错误、备份保留多久,以及谁有权限读取或恢复数据。

结语

数据库写入不是“把一行文字塞进文件”,而是事务、页结构、日志、内存缓存、操作系统和存储设备共同完成的一套过程。日志使数据库能够在数据页尚未全部写回时恢复事务;提交与持久化则由同步策略和底层存储能力共同决定。

实践中,优先做好三件事:用事务表达真正的业务边界;用参数绑定处理数据值;明确并验证持久化、备份和恢复策略。理解这些边界后,才能根据业务风险在性能、并发和可靠性之间作出有依据的选择。

© 版权声明

相关文章