Skip to content

理论基础

本章阐述 Zeze 事务框架的内部原理:为什么需要事务、乐观锁如何工作、为什么不会死锁、事务级别的语义差异,以及缓存一致性和分布式事务的实现思路。

当一个业务操作需要修改多项数据时,如果中途发生异常,已完成的修改会导致内存数据处于不一致状态。考虑一次转账操作,需要同时修改两个账户的余额:

accountA.balance -= 100 // 第1步:成功
accountB.balance += 100 // 第2步:抛出异常

第 1 步成功执行后,第 2 步发生了异常。此时账户 A 已经扣款,但账户 B 没有入账。100 元凭空消失了——数据不再一致。这类问题在复杂系统中更加隐蔽:一个业务流程可能调用多个模块,每个模块修改不同数据,任何一步失败都会破坏数据的完整性。

最合理的处理方式是放弃所有修改,恢复到操作前的状态。这就是事务”要么全部成功,要么全部失败”的语义。

Zeze 通过存储过程(Procedure)封装事务逻辑。存储过程是执行业务代码的基本单元——框架保证其中的所有操作要么全部生效,要么全部回滚。执行阶段,写操作产生的变更不会直接修改原始数据,而是以日志形式暂存。

存储过程的执行模型:

  • 执行阶段:代码正常读写数据,写操作产生的变更以日志暂存,原始数据不变
  • 提交:存储过程返回成功后,日志应用到原始数据,变更生效
  • 回滚:存储过程返回错误或抛出异常时,日志被丢弃,数据恢复原状

存储过程支持嵌套调用——一个存储过程内部可以调用另一个存储过程,形成外层和内层的关系。内层拥有独立的回滚范围:内层失败只回滚内层的修改,外层不受影响,可以根据返回值决定是否继续。内层成功时,其变更合并到外层,最终由最外层统一提交。

底层通过保存点(Savepoint)实现嵌套——每进入一层存储过程创建一个保存点,记录该层的日志和回滚范围。对开发者而言,嵌套是自然的代码组织方式,通常无需直接操作保存点。

传统悲观锁在读写数据前先加锁,直到事务结束才释放。这种方式在并发度高时容易产生等待和死锁。Zeze 采用乐观锁策略:执行过程完全不加锁,只在提交阶段才进行冲突检查。核心流程如下:

  1. 执行阶段:事务读取数据时,记录访问的记录(Record)及其时间戳(Timestamp)。写入操作记录为日志,不修改原始数据。整个执行过程中不持有任何锁
  2. 提交阶段:对事务访问过的所有记录按固定顺序加锁,逐一比对记录的当前时间戳与事务读取时的时间戳
  3. 成功提交:如果所有时间戳均未变化,说明没有冲突,应用日志、递增版本号、释放锁
  4. 冲突重做:如果发现任何记录的时间戳已变化,说明其他事务已修改该数据,当前事务重做——清除日志和访问记录,重新执行整个存储过程

这一机制体现在 Transaction.perform() 方法中。该方法包含两层循环:

  • 外层循环在释放所有锁后短暂等待(20-100 毫秒随机),然后重新尝试,最多 256 次
  • 内层循环在保持锁的情况下重试,只有当 _check_ 返回 RedoAndReleaseLock 时才跳出内层,释放锁后进入外层重新开始

每次重做时,reuseTransactionForRedo() 会清除保存点、访问记录和日志,但保留存储过程栈底的根 Procedure,使其可以从头重新执行。

重做是乐观锁的核心代价:当并发冲突频繁时,事务可能被反复重做。但在大多数业务场景中(如游戏、社交应用),不同用户操作的数据交集较小,冲突概率低,乐观锁的性能优势显著。

死锁的本质是一个循环等待:线程 A 持有锁 1 并等待锁 2,线程 B 持有锁 2 并等待锁 1,双方互相阻塞,永远无法推进。Zeze 的乐观锁从两个层面消除了这种可能:

事务运行期间不持有任何锁。多个事务可以同时读写相同的数据而不互相阻塞。冲突不在执行阶段解决,而是推迟到提交阶段处理。

当需要加锁校验时,所有记录按 TableKey 的固定顺序(TreeMap 自然排序)逐个锁定。由于所有事务都以相同的全局顺序请求锁,不可能形成环形等待——这是经典的资源有序分配策略。

源码中,事务的 accessedRecords 使用 TreeMap<TableKey, RecordAccessed> 存储,天然按 TableKey 全局排序。提交阶段加锁时,lockAndCheck() 方法严格按此顺序逐条锁定。这里有两层保证:

初次加锁不跳过:即使某条记录的校验发现冲突,方法也不会中途返回——而是标记冲突后继续锁定剩余记录,直到全部处理完毕。这确保了无论成功还是冲突,锁的获取顺序始终一致。

重做加锁不回头:事务重做时仍持有上次的锁。如果重做过程中访问了新的记录,可能出现新记录的锁序位于已持有锁之前的情况。此时方法会先释放从该位置起的所有已持有锁,再按正确顺序重新获取——绝不出现”回头加锁”。

通过这两条规则,乐观锁在任何阶段都不会形成环形等待。

乐观锁不会死锁——这不是巧合,而是其设计本质决定的。

Zeze 支持两种事务隔离级别,通过 TransactionLevel 枚举定义。不同的级别在提交阶段的冲突检查策略不同,适用于不同的业务场景。

事务访问的所有记录(无论读或写)都必须在提交时检查是否被修改。只有全部记录的时间戳未变,事务才提交成功。这保证了可串行化(Serializable)的隔离语义:事务的执行效果等价于某种串行执行顺序。

当事务仅包含读操作、没有任何写操作时,跳过提交阶段的冲突检查,直接返回成功。此时读到的是事务执行过程中各记录当时的值,不保证与其他并发事务可串行化。

在源码中,lockAndCheck() 方法会扫描所有日志判断事务是否存在写操作(dirty 标志)。当 allRead == true 且事务级别为 AllowDirtyWhenAllRead 时,直接返回 CheckResult.Success,跳过加锁和校验。

假设账户 A 和账户 B 初始余额各为 0,系统并发执行两种事务:

// 转账事务
A.balance -= 100
B.balance += 100
// 统计事务
sum = A.balance + B.balance

Serializable 级别下,统计事务提交时会检查 A 和 B 的时间戳。如果转账事务先完成提交,统计事务会发现 A 或 B 的时间戳已变,触发重做。重做后读到转账后的值(A = -100, B = 100),sum = 0。在任何并发调度下,sum 始终为 0。

AllowDirtyWhenAllRead 级别下,统计事务没有写操作,直接跳过冲突检查。此时可能出现这样的交错执行:

统计事务读到 A = 0 // 转账前
转账事务提交:A = -100, B = 100
统计事务读到 B = 100 // 转账后
sum = 0 + 100 = 100 // 不为 0

sum 不再恒为 0。对于统计报表、排行榜查询等允许近似值的场景,这种级别可以避免不必要的重做,显著提高吞吐量。但对于需要精确一致性的业务(如余额校验),必须使用 Serializable

Zeze 将数据库中的数据以记录(Record)为单位缓存到内存。每条记录维护一个版本号(Timestamp),用于乐观锁的冲突检测。

事务提交后,被修改的记录标记为脏记录(dirty),其版本号递增。脏记录意味着内存中的数据已更新,但尚未写入数据库。Checkpoint 线程定期收集所有脏记录,将变更写入后端存储(支持 MySQL、PostgreSQL、MongoDB、Redis 等),写入完成后清除脏标记。

Checkpoint 的触发策略由配置决定:立即持久化(Immediately)、定期批量刷写(Period,默认)、按表维度刷写(Table)。

记录由框架自动管理:首次访问时按需从数据库加载;事务修改后成为脏记录;Checkpoint 时写回数据库并清除脏标记;不再使用时从缓存中移除。事务在提交检查中发现版本号不匹配时,触发重做并从数据库重新加载最新数据。应用代码无需关心数据来自内存还是数据库,实现了透明的内存-数据库自动同步

上一节描述了单实例的缓存机制。在分布式部署中,同一个模块(Module)可以部署多个 Server 实例,每台实例拥有各自的本地缓存。此时需要协调多实例间的数据一致性。

为了协调多实例间的访问权限,每条记录除了版本号外,还维护一个缓存状态(State):

  • Modify:当前实例拥有读写权限,可以修改数据
  • Share:当前实例拥有只读权限,可以读取但不能修改
  • Invalid:当前实例不持有该记录的有效缓存

缓存状态由 GlobalCacheManager 在全局范围内统一分配。核心规则是:同一条记录在全局范围内最多只有一个实例拥有 Modify 权限。实例需要修改记录时向 GlobalCacheManager 申请 Modify 权限,只需读取时申请 Share 权限即可。

当实例 A 持有记录 R 的 Modify 权限时,若实例 B 也需要修改 R,GlobalCacheManager 会要求 A 将 R 降级。这个过程分为两步:

  1. 实例 A 将 R 的最新数据写入数据库(Checkpoint),然后释放 Modify 权限
  2. 实例 B 获取 Modify 权限,从数据库加载 R 的最新数据到本地缓存

此后实例 B 可以在其本地事务中安全地修改 R。实例 A 如果再次访问 R,会重新申请权限并加载最新数据。

对于应用开发者而言,这一切完全透明。使用 Zeze Table 读写数据时,不需要任何额外操作——事务会自动处理多实例间的数据共享和一致性保证。开发者只需编写存储过程逻辑,框架负责:

  • 自动管理记录的缓存状态和权限
  • 在提交阶段检测冲突并触发重做
  • 协调多实例间的数据同步

更多架构细节参见 Arch