NoSQL 的抽象
数据库系统的核心作用在于高效地存储和管理数据,并支持从海量信息中快速检索所需记录。传统的 SQL 关系型数据库在数据规模持续增长的情况下,容易遭遇单机性能瓶颈。目前提升性能的主流做法多依赖垂直扩展增强单机硬件能力,而非水平扩展增加节点以线性扩容。由于其架构限制,关系型数据库在弹性扩展性方面存在障碍,难以充分适应多节点分布式环境。同时为满足 ACID 事务特性,这类系统在支持分布式事务处理时也面临一定的技术挑战。
相比之下,NoSQL 数据库更适用于大规模数据存储和高吞吐、低延迟的应用场景。由于 NoSQL 不受固定数据结构和关系的限制,具有更灵活的扩展能力,并且支持节点的动态扩展,从而更好地适应分布式环境。无论是关系型 SQL 数据库还是 NoSQL 数据库,它们都有一个共同的核心组件存储引擎,这是数据库设计与实现的关键核心部分。
目前数据库存储引擎可以分为两大类,一类是基于内存的 (In-Memory)实现,例如 Redis 和 Memcached 这类的将数据全部存储在内存中;另外一种则是类似于 MySQL 的 InnoDB 和 LevelDB 这种将数据存储在磁盘中。两者都有各自的优势和劣势,基于内存的存储引擎通常存储的数据量较少,但其访问速度往往比基于磁盘的存储引擎快好几个数量级,而基于磁盘的存储引擎访问速度较慢,但存储数据量要比内存引擎多几个数量级。通常内存的价格要比磁盘的贵很多,这也是在选择存储硬件时要考虑的成本问题。
存储引擎的开发者需要权衡读写性能和硬件成本的同时,重点在于如何高效存储数据和检索数据。数据库的存储引擎相当于在操作系统文件系统之上,建立的一套用于数据组织、存储与查询的逻辑管理层,最终实现是依赖于操作系统的文件系统 API 接口的,最终数据是被持久化到磁盘文件中保存,这个就对应着 ACID 中的 Durability 数据持久性。
持久性(Durability)是存储引擎最为至关重要的功能实现,目前很多基于内存版本的 NoSQL 数据库例如 Memcached 和 Redis 在这方面做的就比较差,在 NoSQL 服务器运行过程中突然崩溃断电就会导致数据没有被持久化存储的情况,从而导致数据丢失影响到上层的业务程序。
存储是现代计算机最核心的功能之一,计算机中的程序和文件以数据块的形式保存在各种存储介质中,主要可分为持久化存储和非持久化存储两类。非持久化存储通常指内存(如 RAM),其特点是在断电或关机后,数据会被清除。尽管容量通常小于持久化存储,但其高速读写性能使其广泛应用于程序运行过程中的数据处理和临时缓存。相比之下,持久化存储如硬盘、固态硬盘、DVD、磁带等,能够在断电后保留数据,适用于长期保存程序和文件信息。
鉴于数据可能存储在多种物理介质(如磁盘、SSD、光盘等)上,主流的操作系统需要提供一致的抽象接口,以屏蔽底层硬件差异,使用户能够高效、统一地进行文件的读写、创建与删除等操作。当前主流操作系统均实现了完整的文件系统,开发者可以通过如 POSIX 这样的标准 API 接口来访问和管理存储设备,而无需关心具体设备的物理实现方式。尽管操作系统的文件子系统通过标准如 POSIX 提供了对存储设备的访问能力但仍存在一些限制;例如存储性能受限于设备的物理读写速度,同时文件系统通常采用页缓存 Page Cache 或写缓冲机制,这意味着数据写入操作并不会立即持久化到磁盘,一旦系统发生故障或意外断电,可能会导致数据丢失,影响系统的可靠性。
基础架构
综上所述,针对这些场景的问题 UrnaDB 采用了基于 Log-Structured Megre Tree 日志结构化文件系统的存储引擎实现,存储引擎会以 Append-Only Log 的方式将所有的数据操作写入到数据文件中。同时 UrnaDB 为了高速查询检索数据记录,存储引擎会将数据记录索引信息全部保存在内存中,从而实现高效快速的查询目标数据记录。这样的设计的好处是能以磁盘最大写入性能进行写入数据,并且还能减少读取磁盘索引所需要的时间,通过一次索引定位来读取数据记录,写入和查询流程图:

其核心持久化机制基于预写日志 Write-Ahead Logging 简称 WAL ,在对数据执行任何操作前,都会先将操作记录写入 WAL 日志文件。WAL 文件不仅承担持久化的角色,也作为主要的数据存储载体。在数据库进程崩溃后,只需从 WAL 日志中顺序读取各条 Segment 记录,即可高效恢复内存中的索引结构。
在 UrnaDB 中对这些 WAL 数据文件有一个统称为叫 Region 文件,这些文件有单个固定大小限制,当一个数据文件写满之后就会被关闭,会重新创建一个新的 Active 活跃的 Region 文件进行数据记录的进行写入。被关闭的 Region 文件会被视为冷数据文件,随着数据库不间断长时间运行 Region 文件会不断递增逐渐占用磁盘空间。此时数据库进程就需要对旧的 Region 文件执行压缩 Compaction 和定期清理,以降低存储压力并提高查崩溃数据恢复时的启动效率,压缩流程原理图:

目前工业级的存储系统中,RaikDB 是一个采用类似模型实现的数据库产品,RaikDB 是基于 Amazon Dynamo 论文的设计理念构建而成。其 RaikDB 底层也是采用的顺序写入的日志式存储引擎,并将其命名为 Bitcask 存储模型,以提升写入性能和读写效率。
该模型与 UrnaDB 所采用的存储引擎在设计理念上颇为相似。Bitcask 存储引擎在执行数据压缩 Compaction 时会生成 Hint 文件,其作用是在存储引擎重启时,辅助快速重建内存索引,从而避免每次启动都需全量扫描数据文件,显著提升系统的启动效率。然而 Hint 文件仅在 Compaction 过程中生成,所记录的只是当时内存索引的快照,无法反映实时状态。
针对这一问题 UrnaDB 在存储引擎中进行了改进,将 Hint 文件机制替换为 Checkpoint 功能,系统会在固定时间间隔内,定期将当前内存索引持久化为 checkpoint 文件写入磁盘。当服务器进程发生异常崩溃,也可通过读取 checkpoint 快照快速恢复内存索引状态。在实际恢复流程中,UrnaDB 会优先加载最近一次的 checkpoint 快照,并从该时间点之后的 Region 文件中继续回放增量数据。通过这种方式,有效提升了系统启动恢复的速度,减少了启动耗时,同时兼顾了数据一致性与运行效率。
在该存储引擎设计的基础上,UrnaDB 构建了内置的 HTTP 服务器,并引入了多种抽象化的数据结构层。整体架构如下图所示:

系统采用模块化设计,各模块以包结构组织并协同工作,覆盖底层存储、核心逻辑与服务接口,构成一个完整的单机版 NoSQL 数据库,实现了分层解耦与一体化集成。
检查点技术
由于 UrnaDB 数据库存储引擎采用将所有索引数据存储在内存中的设计,一旦数据库进程意外崩溃,内存中的索引信息将会丢失,未能持久化到磁盘。这样在下一次启动时,系统需要重新扫描所有数据文件,以重建内存中的索引结构,如果数据文件体量较大,将导致启动过程变得缓慢,启动耗时较长。

Checkpoint 功能机制会通过定期将内存中的索引状态保存到磁盘,在发生异常重启时即可快速加载 checkpoint 文件,从而大幅缩短数据库的启动时间。开启 checkpoint 功能可以在数据库崩溃后显著加快启动恢复的速度。然而 checkpoint 文件的生成时间间隔设置同样需要谨慎权衡。如果生成间隔过短,将导致 checkpoint 文件过于频繁地写入磁盘,占用大量 I/O 资源,可能影响数据库的整体性能;而如果间隔设置过长,崩溃恢复时仍需扫描大量 Region 数据记录,反而无法发挥 checkpoint 的优势,导致启动时间较长。因此合理配置 checkpoint 的生成频率,需在性能开销与数据恢复效率之间做出平衡,建议根据实际业务的数据写入频率和系统负载,动态调整该参数以获得最佳效果。
磁盘垃圾回收器
由于 UrnaDB 存储引擎采用顺序日志写入结构,随着系统运行时间的增长,每个 Region 区域对应的磁盘数据文件会不断增大。默认情况下 Region 文件中的每条数据记录未经过压缩,导致磁盘空间占用随数据量增加而持续上升。为了解决这一问题 UrnaDB 存储引擎内置了数据压缩算法,启用该压缩器功能后,可以显著减少磁盘文件的存储体积,从而有效降低存储成本。
在配置 Region 区域的垃圾回收功能时,需要综合考虑多种因素。若不启用该功能,随着系统长时间运行,磁盘空间占用将持续增长,甚至可能过高,影响系统稳定性。若启用垃圾回收,则必须合理设定其执行周期:
- 周期过长:会导致空间回收不及时,磁盘占用上升,同时在压缩 Region 时耗时较长。
- 周期过短:会导致垃圾回收过于频繁,增加系统资源开销,进而影响存储引擎的整体性能。
在 UrnaDB 中垃圾回收器的执行周期同样依赖于 Time 触发,并且其执行过程会占用一定的系统资源。如果采用并行执行机制,这段时间对外部应用基本无感,系统可持续对外提供服务。这种设计理念与许多现代编程语言的垃圾回收器类似,UrnaDB 的 Region 区域划分为 Active Region 和 Read Region 区,其架构与 Java 虚拟机(JVM)中的垃圾回收机制具有相似性。当垃圾回收开始运行时,它不会对 Active Region 中的活跃数据进行操作,而是仅针对旧的 Read Region 区域执行垃圾分析与清理,从而避免对当前读写路径造成干扰,不会出现类似于编程语言虚拟机中的 Stop-The-World 世界时钟停止问题 (这里指明一点 JVM 即使分代进行垃圾回收,因为存在循环引用问题,还是会出现两个区域的对象相互关联),减少压缩磁盘空间的抖动,保障数据库稳定性。
时间就是一个问题
在整个 NoSQL 数据库的设计过程中,许多复杂问题的根源其实都与物理世界中的 Time 有关。当前的 UrnaDB 实现为单机版本,因此主要面临的是本地磁盘 I/O 和线程调度带来的延迟问题。然而一旦在未来引入多个存储节点,发展为分布式架构,就必须应对网络延迟、时钟同步、事务一致性等与时间密切相关的挑战。因此无论是当前的 NoSQL 单机实现,还是面向未来的分布式扩展,如何合理建模和管理时间 Time 维度,始终是数据库架构设计中的核心研究问题之一。
在单机版本中还面临另一个挑战如何为数据记录提供过期时间(Time-To-Live)支持,尽管 TTL 是常见的需求,但其实现往往较为复杂,目前主流 NoSQL 数据库普遍采用惰性删除机制,即仅在数据被访问时才判断其是否过期并删除,这种方式依赖于外部读取请求的触发,可能导致过期数据长期滞留在存储中。
数据文件采用 WAL(Write-Ahead Logging)机制实现持久化,但 WAL 本身仍需通过操作系统文件系统 API 写入磁盘。现代操作系统通常基于 Page Cache 和写缓冲进行 I/O 加速,因此普通的 write 操作并不会立即落盘,而是先进入内核缓存,若在此期间发生掉电,仍可能丢失尚未刷新的数据。
UrnaDB 运行在用户态无法从根本上绕过操作系统的 Page Cache 机制,只能通过 fsync/O_DSYNC 等接口请求内核将数据强制写入物理持久介质中。在通过一些代码测试中不同持久化策略下写入性能存在很大差异性,差异对比表格:
| 写入模式 | 性能 (ops/sec) | 性能下降 | 数据安全性 | 刷盘依赖 |
|---|---|---|---|---|
| 普通写入 | ~100,000 | 基准 | 依赖 OS 缓存 | OS 自动 |
| 定期sync | ~50,000 | 50% | 中等 | 应用控制 |
| O_DSYNC | ~1,000 | 99% | 最高 | 每次写入 |
| mmap | ~100,000 | 基准 | 依赖 OS 缓存 | OS 自动 |
| mmap+msync | ~1,000 | 99% | 最高 | 应用控制 |
所以无论是 write() 还是 mmap 内存文件映射,只要不主动调用同步操作sync/msync 都是依赖 OS 的页面缓存刷盘策略。对于 UrnaDB 用户而言,更推荐的优化方式是从操作系统层面调整相关参数,这才是更为根本和可靠的解决方案。
通过调整 Page Cache 的刷写策略,可降低数据在内核缓存中滞留的时间,从而减少断电或崩溃场景下的数据丢失风险,以下为关键的操作系统可调优的参数示例:
# 减少脏页比例,更频繁地触发后台刷盘
echo 5 > /proc/sys/vm/dirty_background_ratio # 默认10%,改为5%
echo 10 > /proc/sys/vm/dirty_ratio # 默认20%,改为10%
# 缩短脏页存活时间
echo 500 > /proc/sys/vm/dirty_expire_centisecs # 默认3000(30秒),改为5秒
echo 100 > /proc/sys/vm/dirty_writeback_centisecs # 默认500(5秒),改为1秒在 UrnaDB 的运行过程中,如果发生异常宕机,可能会导致部分数据处于未完成写入的状态,从而出现数据记录的不一致性。在某些极端情况下会出现检查点和索引文件同时损毁情况,在这种情况下如果从这些半损毁文件中恢复数据,可能导致记录不一致甚至造成二次损坏。
索引文件和检查点文件损毁的原因较为多样,其中一种典型场景出现在 Docker 容器化部署 UrnaDB 时,Docker 在正常关闭容器时会启用默认的超时机制,当 UrnaDB 的内存索引规模较大、持久化耗时较长时,进程可能无法在超时时间内完成数据落盘。此时 Docker 会强制终止容器,导致内存中的索引与检查点数据未能完整写入磁盘,进而引发文件损毁或数据不一致。
fix-dbfs 工具在启动之前对数据目录中文件进行损坏数据整理,经过 fix-dbfs 修复后的数据文件才被视为安全且一致的有效数据文件,再让 UrnaDB 正常加载启动即可恢复宕机之前的数据状态。
如何合理权衡 Checkpoint 的执行频率与垃圾回收的触发时机,是 UrnaDB 数据库系统优化中的关键问题。若在数据高频读写期间执行垃圾回收(如磁盘空间压缩),可能引入短暂的读写延迟;同时在数据迁移与压缩过程中,存储引擎还可能导致磁盘空间的临时占用上升,从而影响整体性能与资源利用率。
为此 UrnaDB 在未来版本中将引入一种主动回收机制:结合运行时监控指标与 Unix 的 Signal 机制,通过 kill 命令向 UrnaDB 进程发送信号,实时唤醒后台垃圾回收线程执行过期数据清理任务。该方案能够更精准地控制存储空间占用,提升系统的可预测性与运行稳定性。