Skip to content

Zeze 如何工作

读完这篇,你脑子里会有一张清晰的图:数据在 Zeze 里是怎么流动的、事务是怎么保护你的、分布式又是怎么拼上去的。理解了这套心智模型,后面的 API 文档就好读了。

Zeze 的运转可以拆成三层,从内到外分别是:存储过程(事务的执行单位)→ 乐观锁(并发的处理方式)→ 一致性缓存(内存与数据库的关系)。我们一层层拆开看。

第一层:存储过程 —— 事务的执行单位

Section titled “第一层:存储过程 —— 事务的执行单位”

存储过程(Procedure) 是 Zeze 里执行业务代码的基本单元。你在协议处理函数里写的逻辑,默认就跑在一个独立的存储过程里。框架会自动创建 Procedure 对象,你通常不用手动建。

一个存储过程的生命周期很简单:

执行业务代码 → 提交时加锁并检查冲突 → 成功则提交,冲突则重做

关键在于执行阶段:你写的代码正常读写数据,但写操作产生的变更不会直接改原始数据,而是以日志形式暂存。原始数据在执行阶段始终保持不变。

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

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

底层用**保存点(Savepoint)**实现:每进入一层就建一个保存点,记录这层的日志和回滚范围。对开发者而言嵌套是自然的代码组织方式,通常不用直接碰保存点。

第二层:乐观锁 —— 为什么不死锁

Section titled “第二层:乐观锁 —— 为什么不死锁”

传统悲观锁在读写前先加锁,直到事务结束才释放,并发高时容易等待和死锁。Zeze 的乐观锁反其道而行:执行过程完全不加锁,只在提交时才检查冲突

  1. 执行阶段:读数据时记录访问的记录及其时间戳(Timestamp);写操作记成日志,不改原始数据。全程不持任何锁
  2. 提交阶段:对访问过的所有记录按固定顺序加锁,逐一比对记录的当前时间戳与读取时的时间戳。
  3. 成功提交:所有时间戳没变 → 无冲突 → 应用日志、递增版本号、释放锁。
  4. 冲突重做:发现某条记录时间戳已变 → 丢弃日志和访问记录 → 从头重新执行整个存储过程

死锁的本质是循环等待。Zeze 从两个层面消除它:

  • 执行阶段无锁:多个事务能同时读写相同数据,互不阻塞。冲突不在这阶段解决,推到提交阶段处理。
  • 提交阶段按固定全局顺序加锁:所有记录按 TableKey 的固定顺序(TreeMap 自然排序)逐个锁定。所有事务都用同一套顺序请求锁,不可能形成环形等待

这是经典的资源有序分配策略。再加两条实现上的保证(初次加锁不中途返回、重做加锁不”回头”),乐观锁在任何阶段都不会形成环。

「乐观锁不会死锁」不是巧合,是它设计的本质决定的。

当并发冲突频繁时,事务可能被反复重做(最多 256 次)。但在大多数业务场景里(游戏、社交),不同用户操作的数据交集很小,冲突概率低,乐观锁的性能优势显著。

框架对重做做了优化:冲突重做时保持已得到的锁,这样第二次执行一般就能成功,不会陷入”一直冲突、永远完不成”的困境。

第三层:一致性缓存 —— 内存与数据库的关系

Section titled “第三层:一致性缓存 —— 内存与数据库的关系”

这一层是 Zeze 最精妙的设计,也是它高性能的来源。

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

事务提交后,被修改的记录标记为脏记录,版本号递增。「脏」意味着内存里的数据已更新,但还没写回数据库。Checkpoint 线程定期收集所有脏记录,把变更批量写入后端数据库,写完清除脏标记。

Checkpoint 的触发策略由 CheckpointMode 配置决定,目前实际可用的值是 Table(默认,按表维度批量刷写)Immediately(提交后立即写)在源码中标注为不可用。

记录由框架自动管理,对应用完全透明:

  1. 首次访问 → 按需从数据库加载到内存;
  2. 事务修改 → 提交后变成脏记录;
  3. Checkpoint → 写回数据库,清除脏标记;
  4. 不再使用 → 从缓存中移除;
  5. 提交时发现版本号不匹配 → 触发重做,从数据库重新加载最新数据。

应用代码不需要关心数据来自内存还是数据库——这就是透明的内存-数据库自动同步

理解了上面三层,你会发现 Zeze 的工作方式跟 CPU 访问内存惊人地相似:

CPU 体系结构Zeze 服务器
高速层CPU cache服务器内存缓存
慢速层主存(memory)数据库
速度差距1~2 个数量级(L1 vs 主存)数量级(内存 vs 数据库)
miss 时stall,等内存返回阻塞,等数据库返回
多核协调缓存一致性协议(Directory-based 等)GlobalCacheManager

这个类比非常有解释力:

  • 为什么能忍受偶尔的 cache miss——CPU 也忍,只要命中率够高(一般 >99%)。游戏里高频的移动、放技能不在 Zeze 框架下处理,Zeze 事务基本对应”玩家作为人的操作”,吞吐要求不会太高。
  • 为什么同步写 cache 也能高性能——内存 map 的读写是纳秒级、无网络往返、无磁盘 I/O,而 SQL 查询要经过网络、解析、执行、回表,两者差好几个数量级。Zeze 把热数据留在内存,只在 Checkpoint 时批量落库,摊薄了写库成本。
  • 现代 Java 的加持——Java 21 的虚拟线程(virtual thread)让锁等待和同步 I/O 不再占用操作系统载波线程,带来了跟异步方案同样的性能,又能用同步方式写代码。

前三层描述的是单实例的缓存机制。分布式部署时,同一个模块可以部署多个 Server 实例,每台都有自己的本地缓存。这时要协调多实例间的数据一致性。

每条记录除了版本号,还维护一个缓存状态(State)

  • Modify:当前实例有读写权限,能改数据;
  • Share:只读权限,能读不能改;
  • Invalid:不持有该记录的有效缓存。

核心规则:同一条记录在全局范围内,最多只有一个实例拥有 Modify 权限。这个权限由 GlobalCacheManager(GCM)统一分配。

实例 A 持有记录 R 的 Modify 权限时,如果实例 B 也要改 R:

  1. GCM 要求 A 把 R 降级——A 先把 R 的最新数据 Checkpoint 写库,再释放 Modify 权限;
  2. B 获得 Modify 权限,从数据库加载 R 的最新数据到本地缓存;
  3. 此后 B 可以安全地修改 R。

这个过程跟 CPU 的 Directory-based cache coherence 是一回事——GCM 就是那个 Directory。GCM 是单点?不,Zeze 用 Raft 让它高可用。

这一切你完全不用管。用 Zeze Table 读写数据时,不需要任何额外操作——事务会自动处理多实例间的数据共享和一致性保证。你只管写存储过程逻辑,框架负责:自动管理缓存状态和权限、提交时检测冲突并重做、协调多实例间的数据同步。

┌─────────────────────────────────┐
客户端请求 ───────────▶ │ 存储过程 (Procedure) │
│ ┌───────────────────────────┐ │
│ │ 执行业务:读写数据 │ │ ← 执行阶段不加锁
│ │ 读 → 记录访问+时间戳 │ │
│ │ 写 → 记日志,不动原始数据 │ │
│ └────────────┬──────────────┘ │
│ ▼ │
│ 提交:按固定顺序加锁,比对时间戳 │
│ │ │
│ 无冲突 │
│ ├──▶ 应用日志 → 脏记录 │
│ │ │ │
│ │ ▼ │
│ │ Checkpoint ──▶ 数据库 │
│ 有冲突 │
│ └──▶ 丢弃日志,重做 │
└─────────────────────────────────┘
分布式时:
GlobalCacheManager 协调多实例权限
(Modify/Share/Invalid) + Raft 高可用

记住三句话:

  1. 写数据先记日志,提交才生效,失败就回滚——这就是事务。
  2. 执行不加锁,提交按序加锁,冲突就重做——这就是乐观锁,所以不死锁。
  3. 数据在内存里跑,Checkpoint 自动落库——这就是一致性缓存,所以快。

Zeze 支持两种事务级别,用在不同的业务场景:

  • Serializable(默认):访问的所有记录(读或写)提交时都检查是否被改过。保证可串行化,适合需要精确一致性的业务(如余额校验)。
  • AllowDirtyWhenAllRead:当事务只有读、没有写时,跳过冲突检查直接返回成功。读到的是执行过程中的快照,不保证可串行化。适合统计、排行榜查询等允许近似值的场景,能显著提高吞吐。

细节见 事务参考

心智模型建立好了,接下来动手: