事务设计
事务 Transaction 是数据库系统中的基础机制,Transaction 用于保证数据操作时的正确性与可靠性而提出的一组核心约束。事务必须具备的 ACID 四个核心特性,ACID 的缩写缩写源自于:原子性 Atomicity、一致性 Consistency、隔离性 Isolation 和持久性 Durability。ACID 约束确保了一组数据库操作,即使在运算故障时也能保持数据的一致性和可靠性,实现数据从一个合法状态向另一个合法状态的转换,使其执行运算过程中没有异常状态的产生。
目前工业级主流数据库产品 MySQL、PostgreSQL、Redis、MongoDB、DynamoDB 都有对 Transaction 特性的支持,但具体实现细节有着天壤之别。同样按照数据库类型分类:关系型数据库如 PostgreSQL 的事务实现通常基于 SQL 标准并完整支持 ACID 特性;而 NoSQL 数据库如 Redis、DynamoDB 的事务机制则没有统一标准,通常根据系统设计在原子性、隔离性等方面提供不同级别的支持,部分系统甚至仅支持单操作原子性。
关系型数据库的 SQL 语言表达能力强大,SQL 可以通过复杂的 JOIN、子查询、聚合函数、窗口函数等表达复杂的业务逻辑,一条 SQL 语句就能完成多表关联、分组统计、排序分页等操作。使用者就需要花费大量的时间来学习和掌握使用 SQL 语法包括复杂的子查询、窗口函数、索引原理、执行计划分析、事务隔离级别、锁机制、MVCC 原理、查询优化技巧、性能调优、高可用架构等。
Important
关系型 SQL 数据库在技术实现层面同样存在较高门槛,其核心组件如 SQL 解析器、查询优化器和执行引擎的设计与实现复杂度极高,不仅需要长期的工程投入,还伴随着持续的维护成本,这种复杂性需要多年经验和多学科知识才能胜任,对于小规模团队或独立开发者而言,通常是不现实的选择。
NoSQL 相较于关系型 SQL 数据库在实现层面通常更加简化,通过放弃通用 SQL 标准,转而采用自定义 API 来定义数据访问与事务语义。NoSQL 系统无需实现 SQL 解析器、查询优化器以及执行引擎等复杂核心组件,从而显著降低了系统实现的复杂度,这使得 NoSQL 数据库对于小规模团队或独立开发者而言更加可行。
Important
NoSQL 也有相应的学习成本,每换一款新的 NoSQL 数据库,使用者就得重新学习对应的新 API 来操作数据库。例如 Redis 和 MongoDB 数据库产品的设计还需要使用者去学习 Lua、JavaScript 语言,引入特定语言的设计会为使用者增加额外负担和学习成本。
🛡️ 安全性
本节将以数据库 “安全性” 为切入点,对比 UrnaDB 与其他竞品数据库在相同安全问题下的设计思路与解决方案。传统 SQL 关系型数据库在生产环境中,安全层面的风险是不可忽略的要素,其中 SQL 注入作为一种典型的应用层安全漏洞,长期以来是开发者需要重点防范的问题之一,由于缺乏内建防护机制,使用者需要在应用层额外处理,例如在 Java 中通过 Prepared Statement 对 SQL 语句进行预编译。同样 NoSQL 数据库也存在安全层面的风险,例如 Redis 通过内置 Lua VM 虚拟机来实现事务逻辑或扩展能力如脚本执行、存储过程等,虽然增强了灵活性,但也带来了额外的复杂性和潜在的安全风险。
Important
Redis 在运行期间支持通过 SDK 或 API 动态加载并执行用户自定义的 Lua 脚本,这依赖于内置 Lua VM 提供脚本执行能力。该设计在增强可编程性的同时,也对执行环境的隔离性与资源控制提出了更高的要求,如果宿主机(这里是指 Redis 数据库)缺乏严格的沙箱机制与执行限制,可能引发诸如沙箱逃逸、长时间阻塞、如死循环,以及内存滥用等问题。
下文将通过具体恶意代码示例,分析这些潜在的安全风险,如下:
-- 死循环攻击
while true do
redis.call('GET', 'key')
end
-- 内存炸弹
local t = {}
for i=1,10000000 do
t[i] = string.rep("x", 1000000)
end
-- DoS 攻击
for i=1,1000000 do
redis.call('KEYS', '*')
end上述任意代码,如果被恶意用户提交到 Redis 的 Lua VM 中执行,一定会导致系统行为不可预测,从而影响整体稳定性与调试难度,例如脚本中的异常逻辑如死循环或大量内存分配会耗尽数据库硬件资源,使 Lua VM 长时间被占用,进而阻塞其他脚本或相关操作的正常执行。
例如在 Redis 配置文件中的参数,可以设置 Lua VM 执行自定义脚本的超时时间:
lua-time-limit 5000 # 默认 5 秒Warning
Redis 并未提供真正的执行隔离,而是借助单线程模型、执行时间限制和受控 API,将 Lua 脚本带来的风险由不可控的系统级崩溃,转化为可干预的阻塞行为。Redis 受限于早期架构,引入了内置 Lua VM 以增强事务处理的灵活性;而 UrnaDB 参考 Redis 这些问题之后,在设计之初即放弃此类执行模型,直接放弃内置 VM 执行方案,从源头减少可受攻击面,将相关风险降至最低。
另外一个问题是 Redis Lua 原子事务无回滚机制,在一组事务计算过程中出现意外中断,无法正常回滚到这组数据原始状态,一个实际案例如下:
-- 场景:转账操作
EVAL "
redis.call('DECRBY', 'account:A', 100) -- ✅ 成功:A 扣款
-- 这里发生错误(比如 B 账户不存在)
redis.call('INCRBY', 'account:B', 100) -- ❌ 失败
return 'done'
" 2 account:A account:B事务计算过程中异常中断之后,数据出现不一致性:
A 账户:-100. ✅ 已执行 => B 账户:失败. ❌ 未执行Warning
上面案例最终结果是数据不一致,A 账户的钱凭空消失了,B 账户却没有增加金额!按正常的业务逻辑 SQL 事务设计,事务计算是要符合 ACID 原则的,一组数据操作要么成功要么失败,不会出现其他状态;Redis Lua 只适合 可以容忍部分失败 的场景,不适合金融转账这种严格要求 ACID 的场景。
下面是一组 UrnaDB 与 Redis 事务完备性的数据对比表:
| 操作类型 | 是否幂等 | Redis Lua | UrnaDB |
|---|---|---|---|
| SET | ✅ 是 | ✅ 安全 | ✅ 安全 |
| DEL | ✅ 是 | ✅ 安全 | ✅ 安全 |
| Lua | ❌ 否 | ❌ 危险 | ✅ 安全 |
| INCR | ❌ 否 | ❌ 危险 | ✅ 安全 |
| LPUSH | ❌ 否 | ❌ 危险 | ✅ 安全 |
通过上述数据对比可以看出,UrnaDB 在事务设计上优于 Redis 的设计。
⏱️ 高性能
首先需要明确这里的 “高性能” 并非泛指 UrnaDB 的整体性能,而是聚焦于其在高并发场景下处理海量事务时的吞吐能力。数据库事务的处理性能不仅受磁盘 I/O、CPU 和 RAM 等硬件因素影响,还与数据库系统本身内部的设计密切相关,本节将忽略外部环境因素,重点从 UrnaDB 的数据内结构设计以及事务管理器、和锁机制出发,探讨其对 UrnaDB 事务吞吐能力的影响。
Important
事务的执行时间具有不可预测性,而多个事务在并发执行过程中,可能对同一热点数据产生竞争访问。由于执行顺序的不确定性,系统无法依赖物理时间来保证结果的一致性。因此数据库必须引入并发控制机制,在异步执行环境下对数据访问进行协调,以确保最终结果满足可串行化等一致性约束。
数据库通常通过内置的锁管理器来协调并发事务对共享数据的访问,以避免数据竞争并保证一致性,UrnaDB 数据库系统中多个事务在并发数据竞争示意图:
Warning
数据库的事务锁的粒度如行级、页级或表级是影响事务处理性能的关键因素,它在并发度与锁管理开销之间形成权衡:粒度越细,并发性越高但管理成本越大;粒度越粗,管理成本较低但冲突概率更高。所以影响数据库的事务并发吞吐量关键要素是锁的持有时间,锁的持有时间与其访问的数据规模和执行复杂度密切相关,长事务会延长锁的占用周期,导致其他事务在竞争相同资源时发生阻塞,在高并发负载下,锁竞争会形成放大效应,使系统吞吐量下降,并成为限制事务处理能力的关键瓶颈。
UrnaDB 中的批量原子事务操作是针对的 Table 命名空间中的数据记录所设计。在之前的 API 交互 文档中有提到 Table 命名空间在初始化完成之后会自动分配一个全局锁。在默认单个原子事务请求下,UrnaDB 内部的事务处理器会使用本次事务请求中所涉及到的 Table 对应锁进行并发数据竞争的保护,单个事务处理的示意图如下:
Important
在该数据结构与锁管理策略下,即使 Table 命名空间规模达到百万级,系统仍具备良好的并发扩展能力,由于事务仅对访问的 Table 加锁,而活跃事务数通常远小于 Table 空间规模,因此锁竞争概率较低,从而提升整体吞吐性能。但是在当事务涉及多个 Table 命名空间的交集运算时,基于 Table 的细粒度锁机制不再具备设计之初的并发特性和高事务吞吐量。
但在涉及到多个 Table 批量事务处理时候上述的设计就不适用了,跨多 Table 事务在执行过程中,需要同时获取并持有多个 Table 命名空间对应的锁,从而扩大锁作用域,锁范围的扩展会提高锁冲突概率,降低并发执行效率,并最终影响系统整体吞吐性能,多事务锁冲突示意图如下:
Warning
并发冲突的本质在于事务之间访问数据集的重叠程度,由此可以推导出数据库系统的并发性能在很大程度上取决于事务访问数据集的重叠程度,而减少访问范围能够有效降低冲突概率。基于这一原则,主流数据库系统在并发控制策略上进行了大量优化,例如 MySQL 和 PostgreSQL 采用 MVCC 以降低读写之间的互斥,而 Redis 则通过 Compare-And-Swap 乐观并发机制减少锁竞争,从而提升整体吞吐量。
UrnaDB 的批量事务操作也参考了传统 SQL 数据库的 MVCC 的设计,在默认批量事务请求处理下也是使用的多个数据版本快照的方式供事务执行运算操作。多个并行处理的事务之间操作的数据都是 Snapshot 副本,每个事务的运算都是基于 Snapshot 快照副本,从而实现了无事务锁干预来提升数据库整体事务吞吐量,多个事务 MVCC 处理示意图如下:
Important
UrnaDB 的事务处理器基于 MVCC 管理并发事务访问的数据版本,对于同时执行的多个事务,当其访问的数据存在重叠时,系统会在提交阶段根据事务的提交顺序进行冲突检测,并决定是合并变更还是回滚冲突事务。并发优化的核心在于降低事务之间的数据访问重叠,让多个事务之间 “尽量少碰同一批数据” ,MVCC 通过为每个事务维护独立的数据版本,使事务能够在各自的版本视图上执行操作,从而实现读写解耦,减少锁竞争并提升系统整体并发性能。当数据规模 N 远大于并发事务数量 T 时,即数学公式为 𝑁 ≫ 𝑇 成立时,事务访问集合之间的交集概率显著降低,系统冲突率趋于较低水平,在此条件下 MVCC 模型能够有效提升系统并发能力,从而提高整体事务吞吐量。
需要注意的是在 MVCC 模式下,事务处理并不能保证每个事务都能够成功提交,当多个事务对同一数据记录产生写冲突时,系统会在提交阶段进行冲突检测并回滚冲突事务。在需要强一致性的批量事务场景下,UrnaDB 提供类似传统关系型数据库 Serialization 模式中两阶段锁 2PL 的事务处理模式,事务串行化示意图如下:
Warning
Serialization 适用于对批量原子事务具有强一致性要求的场景,然而由于其执行过程中会放大锁粒度,不建议在单个事务中操作过多 Table 命名空间,实践中通常将数量控制在 5 个以内,否则将显著增加锁竞争与阻塞时间,降低系统整体并发性能。
⚛️ 原子事务
前文从安全性与性能角度对不同类型数据库的设计优缺点进行了分析,在此基础上本节将进一步介绍 UrnaDB 的 Transaction 事务功能及其使用方法。对于用户而言,只需掌握相关 API 的使用方式,无需关注底层事务机制的实现细节,与其他事务相关 API 一致,UrnaDB 的 Transaction 事务操作同样基于 PQL 协议 实现。
Tip
在 UrnaDB 中批量原子事务操作被抽象为一组 Mutations 结构,由多个 Mutation 组成。每个 Mutations 用于定义本次事务需要操作的数据集合,其操作类型包括 INSERT、UPDATE 和 REMOVE 类型,这些类型分别对应着单个原子事务的操作类型。
例如下面展示的是一组 Mutations 结构请求的 JSON Body 抽象表示:
{
"mutations": [
{
"name": "users",
"operation": "INSERT",
"values": {
"id": 1,
"name": "Leon Ding",
"age": 25
}
},
{
"name": "users",
"operation": "UPDATE",
"where": {
"id": 1
},
"values": {
"age": 26
}
}
],
"serialization": false
}Important
Mutations 通过对多个原子操作进行封装与组合,对外提供批量原子事务的抽象,使多个操作在逻辑上表现为一个整体事务。
使用 UrnaDB 批量原子事务时,客户端只需向 https://.../txns 端点发送 HTTP 协议的 POST 请求,服务器端收到事务请求之后即可执行批量事务操作,需要注意的是事务中涉及的 Table 命名空间必须已提前创建;下面是使用 HTTP 客户端软件发送批量原子事务请求的示例:
curl -X POST "http://192.168.101.252:2668/txns" \
-H "Auth-Token: yv2PH82JXfm2UpScAQK37iJVI" \
-H "Content-Type: application/json" \
-d '{
"mutations": [
{
"name": "users",
"operation": "INSERT",
"values": {
"id": 1,
"name": "Leon Ding",
"age": 25
}
},
{
"name": "users",
"operation": "UPDATE",
"where": {
"id": 1
},
"values": {
"age": 26
}
}
],
"serialization": false
}'Warning
上述请求会对名为 users 的 Table 命名空间执行批量原子操作,类似于关系数据库中的 INSERT 和 UPDATE 组合事务语句,需要注意的是 serialization 字段的值,如果不传入 serialization 字段,UrnaDB 事务处理器默认会以值为 false 进行处理。此处的 serialization 对应前文提到的关系型数据库中的事务串行化模式,但在使用方式上更加灵活,关系型数据库通常需要在配置中统一设置事务隔离级别,而 UrnaDB 则通过在每次事务请求中指定 serialization 字段,动态决定是否启用串行化模式。
批量原子事务操作 JSON Body 请求中的 operation 字段用于标识当前 Mutation 的操作类型,不同类型对应不同的数据结构变更需求,客户端必须根据操作类型提供相应字段,否则事务请求将被拒绝;不同 Operation 类型说明表格如下:
| Operation | 描述 | 必填字段 | 说明 |
|---|---|---|---|
| INSERT | 插入数据 | name, values |
向指定 Table 写入一条或多条记录 |
| UPDATE | 更新数据 | name, where, values |
根据条件更新符合条件的数据 |
| REMOVE | 删除数据 | name, where |
根据条件删除数据 |
目前批量原子事务仅支持上述表格中列出的 Operation 类型,后续将逐步扩展更多复杂条件数据筛选能力,例如范围查询以及基于 <= 、>= 、!= 的条件过滤。
🆚 产品差异性
| 功能维度 | 传统 SQL 数据库 | UrnaDB |
|---|---|---|
| SQL 注入 | ⚠️ 高风险 | ✅ 无风险 |
| 性能可预测 | ❌ 不可预测 | ✅ 可预测 |
| 查询优化 | ⚠️ 复杂 | ✅ 无需优化,一级索引和可选择二级索引 |
| 事务隔离 | ⚠️ 4 种级别,复杂 | ✅ MVCC 简单,基于版本的冲突检测 |
| 解析开销 | ⚠️ 10-70ms | ✅ ≈ 0.3ms,简单的 JSON 解析器 |
| 锁竞争 | ⚠️ 严重 | ✅ MVCC 无锁读,可 2PL 锁升级串行化 |
| Schema 变更 | ⚠️ 锁表 | ✅ 无 Schema,动态修改 Schema 结构 |
| 维护成本 | ⚠️ 需要 DBA 团队 | ✅ 低维护量,一个人就能胜任 |
| 扩展性 | ⚠️ 困难 | ✅ 简单,按需通过配置启用插件功能 |
| 学习曲线 | ⚠️ 陡峭 | ✅ 平缓,导入对应语言 SDK 即可使用 |
| 功能维度 | UrnaDB | Redis |
|---|---|---|
| ACID 支持 | ✅ 完整支持 | ❌ 不支持,Lua 事务有被攻击风险 |
| 原子性 | ✅ 真正的原子性 | ❌ 无回滚机制 |
| 隔离性 | ✅ MVCC + 可选 2PL 模式 | ❌ 单线程串行 |
| 事务持久性 | ✅ WAL + .txn 文件 | ⚠️ 依赖 AOF + RDB |
| 事务回滚机制 | ✅ 自动回滚 | ❌ 无回滚 |
| 事务并发控制 | ✅ MVCC 乐观锁 | ❌ 单线程无并发 |
| 事务版本检测 | ✅ 自动版本检测和回滚 | ⚠️ 手动 WATCH 丢弃执行 |
| 事务快照隔离 | ✅ 自动快照隔离 | ❌ 需手动读取 |
| 跨多 Key 事务 | ✅ 支持 | ⚠️ Lua 脚本异常会导致数据不一致 |
| 事务实现 | ✅ 真正的事务 | ⚠️ 串行执行 |
| 数据压缩 | ✅ 支持 | ⚠️ 需配置 |
| 数据加密 | ✅ 静态加密 | ❌ 需第三方 |
| 垃圾回收 | ✅ 自动磁盘 GC | ⚠️ 内存淘汰策略 |
| 分布式锁 | ✅ 原生支持 | ⚠️ 需 Redlock 算法 |
| 强一致性 | ✅ ACID 保证 | ❌ 不适合 |
| 订单系统 | ✅ 事务支持 | ❌ 不适合 |
| 持久化方式 | ✅ 基于 LogStructuredFS 实时 | ⚠️ RDB + AOF |
| 默认持久化 | ✅ 始终持久化 | ❌ 默认仅内存 |
| 数据安全性 | ✅ 高,每次写入持久化 | ⚠️ 中,可能丢失数据 |