理论基础
本章阐述 Zeze 事务框架的内部原理:为什么需要事务、乐观锁如何工作、为什么不会死锁、事务级别的语义差异,以及缓存一致性和分布式事务的实现思路。
为什么需要事务
Section titled “为什么需要事务”异常导致的数据不一致
Section titled “异常导致的数据不一致”当一个业务操作需要修改多项数据时,如果中途发生异常,已完成的修改会导致内存数据处于不一致状态。考虑一次转账操作,需要同时修改两个账户的余额:
accountA.balance -= 100 // 第1步:成功accountB.balance += 100 // 第2步:抛出异常第 1 步成功执行后,第 2 步发生了异常。此时账户 A 已经扣款,但账户 B 没有入账。100 元凭空消失了——数据不再一致。这类问题在复杂系统中更加隐蔽:一个业务流程可能调用多个模块,每个模块修改不同数据,任何一步失败都会破坏数据的完整性。
最合理的处理方式是放弃所有修改,恢复到操作前的状态。这就是事务”要么全部成功,要么全部失败”的语义。
存储过程与日志机制
Section titled “存储过程与日志机制”Zeze 通过存储过程(Procedure)封装事务逻辑。存储过程是执行业务代码的基本单元——框架保证其中的所有操作要么全部生效,要么全部回滚。执行阶段,写操作产生的变更不会直接修改原始数据,而是以日志形式暂存。
存储过程的执行模型:
- 执行阶段:代码正常读写数据,写操作产生的变更以日志暂存,原始数据不变
- 提交:存储过程返回成功后,日志应用到原始数据,变更生效
- 回滚:存储过程返回错误或抛出异常时,日志被丢弃,数据恢复原状
嵌套存储过程
Section titled “嵌套存储过程”存储过程支持嵌套调用——一个存储过程内部可以调用另一个存储过程,形成外层和内层的关系。内层拥有独立的回滚范围:内层失败只回滚内层的修改,外层不受影响,可以根据返回值决定是否继续。内层成功时,其变更合并到外层,最终由最外层统一提交。
底层通过保存点(Savepoint)实现嵌套——每进入一层存储过程创建一个保存点,记录该层的日志和回滚范围。对开发者而言,嵌套是自然的代码组织方式,通常无需直接操作保存点。
执行与提交的两阶段设计
Section titled “执行与提交的两阶段设计”传统悲观锁在读写数据前先加锁,直到事务结束才释放。这种方式在并发度高时容易产生等待和死锁。Zeze 采用乐观锁策略:执行过程完全不加锁,只在提交阶段才进行冲突检查。核心流程如下:
- 执行阶段:事务读取数据时,记录访问的记录(Record)及其时间戳(Timestamp)。写入操作记录为日志,不修改原始数据。整个执行过程中不持有任何锁
- 提交阶段:对事务访问过的所有记录按固定顺序加锁,逐一比对记录的当前时间戳与事务读取时的时间戳
- 成功提交:如果所有时间戳均未变化,说明没有冲突,应用日志、递增版本号、释放锁
- 冲突重做:如果发现任何记录的时间戳已变化,说明其他事务已修改该数据,当前事务重做——清除日志和访问记录,重新执行整个存储过程
这一机制体现在 Transaction.perform() 方法中。该方法包含两层循环:
- 外层循环在释放所有锁后短暂等待(20-100 毫秒随机),然后重新尝试,最多 256 次
- 内层循环在保持锁的情况下重试,只有当
_check_返回RedoAndReleaseLock时才跳出内层,释放锁后进入外层重新开始
每次重做时,reuseTransactionForRedo() 会清除保存点、访问记录和日志,但保留存储过程栈底的根 Procedure,使其可以从头重新执行。
重做是乐观锁的核心代价:当并发冲突频繁时,事务可能被反复重做。但在大多数业务场景中(如游戏、社交应用),不同用户操作的数据交集较小,冲突概率低,乐观锁的性能优势显著。
为什么不会死锁
Section titled “为什么不会死锁”死锁的本质是一个循环等待:线程 A 持有锁 1 并等待锁 2,线程 B 持有锁 2 并等待锁 1,双方互相阻塞,永远无法推进。Zeze 的乐观锁从两个层面消除了这种可能:
执行阶段无锁
Section titled “执行阶段无锁”事务运行期间不持有任何锁。多个事务可以同时读写相同的数据而不互相阻塞。冲突不在执行阶段解决,而是推迟到提交阶段处理。
提交阶段按序加锁
Section titled “提交阶段按序加锁”当需要加锁校验时,所有记录按 TableKey 的固定顺序(TreeMap 自然排序)逐个锁定。由于所有事务都以相同的全局顺序请求锁,不可能形成环形等待——这是经典的资源有序分配策略。
源码中,事务的 accessedRecords 使用 TreeMap<TableKey, RecordAccessed> 存储,天然按 TableKey 全局排序。提交阶段加锁时,lockAndCheck() 方法严格按此顺序逐条锁定。这里有两层保证:
初次加锁不跳过:即使某条记录的校验发现冲突,方法也不会中途返回——而是标记冲突后继续锁定剩余记录,直到全部处理完毕。这确保了无论成功还是冲突,锁的获取顺序始终一致。
重做加锁不回头:事务重做时仍持有上次的锁。如果重做过程中访问了新的记录,可能出现新记录的锁序位于已持有锁之前的情况。此时方法会先释放从该位置起的所有已持有锁,再按正确顺序重新获取——绝不出现”回头加锁”。
通过这两条规则,乐观锁在任何阶段都不会形成环形等待。
乐观锁不会死锁——这不是巧合,而是其设计本质决定的。
Zeze 支持两种事务隔离级别,通过 TransactionLevel 枚举定义。不同的级别在提交阶段的冲突检查策略不同,适用于不同的业务场景。
Serializable(默认)
Section titled “Serializable(默认)”事务访问的所有记录(无论读或写)都必须在提交时检查是否被修改。只有全部记录的时间戳未变,事务才提交成功。这保证了可串行化(Serializable)的隔离语义:事务的执行效果等价于某种串行执行顺序。
AllowDirtyWhenAllRead
Section titled “AllowDirtyWhenAllRead”当事务仅包含读操作、没有任何写操作时,跳过提交阶段的冲突检查,直接返回成功。此时读到的是事务执行过程中各记录当时的值,不保证与其他并发事务可串行化。
在源码中,lockAndCheck() 方法会扫描所有日志判断事务是否存在写操作(dirty 标志)。当 allRead == true 且事务级别为 AllowDirtyWhenAllRead 时,直接返回 CheckResult.Success,跳过加锁和校验。
用转账和统计的例子说明
Section titled “用转账和统计的例子说明”假设账户 A 和账户 B 初始余额各为 0,系统并发执行两种事务:
// 转账事务A.balance -= 100B.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 // 不为 0sum 不再恒为 0。对于统计报表、排行榜查询等允许近似值的场景,这种级别可以避免不必要的重做,显著提高吞吐量。但对于需要精确一致性的业务(如余额校验),必须使用 Serializable。
缓存一致性概述
Section titled “缓存一致性概述”Zeze 将数据库中的数据以记录(Record)为单位缓存到内存。每条记录维护一个版本号(Timestamp),用于乐观锁的冲突检测。
脏记录与 Checkpoint
Section titled “脏记录与 Checkpoint”事务提交后,被修改的记录标记为脏记录(dirty),其版本号递增。脏记录意味着内存中的数据已更新,但尚未写入数据库。Checkpoint 线程定期收集所有脏记录,将变更写入后端存储(支持 MySQL、PostgreSQL、MongoDB、Redis 等),写入完成后清除脏标记。
Checkpoint 的触发策略由配置决定:立即持久化(Immediately)、定期批量刷写(Period,默认)、按表维度刷写(Table)。
记录生命周期
Section titled “记录生命周期”记录由框架自动管理:首次访问时按需从数据库加载;事务修改后成为脏记录;Checkpoint 时写回数据库并清除脏标记;不再使用时从缓存中移除。事务在提交检查中发现版本号不匹配时,触发重做并从数据库重新加载最新数据。应用代码无需关心数据来自内存还是数据库,实现了透明的内存-数据库自动同步。
上一节描述了单实例的缓存机制。在分布式部署中,同一个模块(Module)可以部署多个 Server 实例,每台实例拥有各自的本地缓存。此时需要协调多实例间的数据一致性。
缓存状态与全局协调
Section titled “缓存状态与全局协调”为了协调多实例间的访问权限,每条记录除了版本号外,还维护一个缓存状态(State):
- Modify:当前实例拥有读写权限,可以修改数据
- Share:当前实例拥有只读权限,可以读取但不能修改
- Invalid:当前实例不持有该记录的有效缓存
缓存状态由 GlobalCacheManager 在全局范围内统一分配。核心规则是:同一条记录在全局范围内最多只有一个实例拥有 Modify 权限。实例需要修改记录时向 GlobalCacheManager 申请 Modify 权限,只需读取时申请 Share 权限即可。
权限降级与数据同步
Section titled “权限降级与数据同步”当实例 A 持有记录 R 的 Modify 权限时,若实例 B 也需要修改 R,GlobalCacheManager 会要求 A 将 R 降级。这个过程分为两步:
- 实例 A 将 R 的最新数据写入数据库(Checkpoint),然后释放 Modify 权限
- 实例 B 获取 Modify 权限,从数据库加载 R 的最新数据到本地缓存
此后实例 B 可以在其本地事务中安全地修改 R。实例 A 如果再次访问 R,会重新申请权限并加载最新数据。
对开发者透明
Section titled “对开发者透明”对于应用开发者而言,这一切完全透明。使用 Zeze Table 读写数据时,不需要任何额外操作——事务会自动处理多实例间的数据共享和一致性保证。开发者只需编写存储过程逻辑,框架负责:
- 自动管理记录的缓存状态和权限
- 在提交阶段检测冲突并触发重做
- 协调多实例间的数据同步
更多架构细节参见 Arch。