不懂多线程也能写安全多线程程序
读完这篇,你会明白为什么在 Zeze 里写业务逻辑可以完全不考虑锁与死锁,也能知道在哪些少数场景下仍需要补一点多线程知识。
不懂多线程也能写安全多线程程序
Section titled “不懂多线程也能写安全多线程程序”多线程并发编程向来是后端开发里最容易踩坑的部分:锁加少了会数据错乱,加多了会死锁,加错顺序还是死锁。然而 Zeze 给出的核心承诺是——你可以完全不懂多线程,照样写出安全的多线程程序。
这一篇就来把这个承诺讲透:它凭什么成立,以及它在什么边界下会”失灵”。
核心承诺:业务逻辑只管写,并发安全自动来
Section titled “核心承诺:业务逻辑只管写,并发安全自动来”Zeze 本身是完全多线程的:线程之间确实通过锁来访问共享数据。但关键是,你通过 Zeze 定义的数据(Bean / Table),其读写都天然处在事务保护之下。这意味着你在写业务逻辑时,体验和写单进程单线程程序几乎一样——数据访问自动安全,完全不需要多线程知识。
举一个最常见的例子:转账。先看传统写法。
// 传统多线程写法:手动加锁,处处小心死锁public void transfer(Account from, Account to, long amount) { // 必须按固定顺序加锁,否则两个转账以相反顺序加锁就会死锁 Account first = from.getId() < to.getId() ? from : to; Account second = from.getId() < to.getId() ? to : from;
synchronized (first) { synchronized (second) { from.setMoney(from.getMoney() - amount); to.setMoney(to.getMoney() + amount); } } // 一不小心锁顺序写反,或者忘加某一处锁,线上就是事故。}在 Zeze 里,同样的事情你只要把”逻辑”写出来:
// Zeze 写法:数据访问自动在事务保护下,无需关心锁与死锁public void transfer(BAccount from, BAccount to, long amount) { from.setMoney(from.getMoney() - amount); to.setMoney(to.getMoney() + amount); // 提交时由框架统一加锁、检查冲突,死锁从原理上不可能发生。}没有 synchronized,没有锁顺序,没有心智负担。下面解释这一切为什么成立。
为什么能这样:乐观锁 + 固定顺序 + 自动重做
Section titled “为什么能这样:乐观锁 + 固定顺序 + 自动重做”Zeze 的事务采用乐观锁策略,整套机制可以归结为几条原则:
- 执行时不加锁。事务跑的时候,谁都不阻塞谁,各自读写自己的数据副本。
- 提交时按固定顺序加锁检查冲突。框架对所有数据的加锁顺序是固定不变的,不存在”A 等 B、B 等 A”的循环依赖,因此死锁从原理上就不可能发生。
- 冲突时自动重做。如果提交时发现数据被别的事务改过,框架会自动重做整个事务(最多 256 次)。重做时会保持已经拿到的锁,所以第二次基本都能成功。
- 内存数据自动与数据库同步。你只管读写内存里的数据,持久化由框架的 Checkpoint 机制负责,开发者不必操心。
把这几条合起来看,结论就很清晰:写业务逻辑时就像在写单进程单线程程序,而数据访问的并发安全是框架免费送你的。
乐观锁 vs 悲观锁:两种直觉
Section titled “乐观锁 vs 悲观锁:两种直觉”理解乐观锁,可以用一个生活化的类比:
- 悲观锁像「先占座再办事」——拿到锁才开始干活,别人想用只能干等。
- 乐观锁像「先办事,结账时发现冲突就重来一遍」——干活时不拦着任何人,最后提交时统一结算。
在冲突很少的场景里(而这恰恰是绝大多数业务——比如游戏里不同玩家各自操作自己的角色、背包、好友列表,彼此数据几乎不打架),乐观锁几乎不会被重做,吞吐量极高;而悲观锁即使没有冲突也要互相等待。这正是 Zeze 选乐观锁的原因。
那么,什么时候才需要懂一点多线程?
Section titled “那么,什么时候才需要懂一点多线程?”核心结论先放这里:只要你的数据是通过 Zeze 定义的,就完全不用懂多线程。 需要补一点多线程知识的,基本只发生在你要写某些”高级功能”——也就是那些需要自己管理 Zeze 体系之外的数据的时候。
下面几种情况要特别留意。
1. 操作自定义(非 Zeze 管理)的内存数据
Section titled “1. 操作自定义(非 Zeze 管理)的内存数据”当你在 Zeze 之外自己 new 出来的普通对象上做”读—改—写”时,框架无法替你保护它。这时并发安全需要你自己负责。Zeze 推荐的安全写法是这样一个固定模式:
随意读取 → 把要用的值算出来存进局部变量 → 在
whileCommit中修改自定义数据。
// 推荐模式:自定义数据的修改收敛到提交回调里Transaction.whileCommit(() -> { myCustomCache.put(key, computedValue); // 自定义数据修改放这里});whileCommit 的语义是「事务真正提交成功时,执行一次」。把所有对自定义数据的修改集中到这里,既保证了只在事务成功时生效,也规避了事务重做导致的副作用重复问题。
2. 提交任务给线程池、调用第三方系统
Section titled “2. 提交任务给线程池、调用第三方系统”当你需要把一段工作丢给线程池异步执行,或者去操作 Zeze 之外的第三方系统(发消息、写外部缓存、调外部接口)时,要注意事务可能被重做:如果第三方调用直接写在事务体里,重做时它会被执行多次,造成重复调用。正确做法同样是用 whileCommit 把这类”副作用”包起来,确保它只在事务提交成功时发生一次。
3. 自定义日志等需要直接操作事务内部的高级功能
Section titled “3. 自定义日志等需要直接操作事务内部的高级功能”如果你要写自定义日志(通过继承 Zeze.Transaction.Log),需要遵守一条硬规则:commit() 必须成功,否则会触发 halt(停服)。这是因为提交阶段已经无法再回滚重做,任何失败都被视为不可恢复。commit() 里通常只做纯内存的、确定能成功的操作(比如把数据交换进数据结构),绝不放可能失败的逻辑。
事务机制本身的细节(乐观锁如何加锁、重做上限、
whileCommit/whileRollback的完整语义),可以参考 transaction;线程模型与上述高级场景的深入说明,参考 advanced-threads;与第三方系统交互的注意事项,参考 third-party-interactions。
线程模型速览:心里有个概念就好
Section titled “线程模型速览:心里有个概念就好”你不需要记住每个线程池的细节,但有个整体概念会更踏实。Zeze 内部大致有这样几类线程:
- WorkerThreads:普通业务线程池,绝大多数协议处理和事务执行都在这里(实际大小取
max(配置, availableProcessors * 30))。 - ScheduledThreads:调度线程池,负责定时任务(实际大小取
max(配置, availableProcessors))。 - Checkpoint 线程:负责把内存数据批量持久化到数据库。
- IO 线程(Netty):处理网络数据的收发。
- Raft 线程:负责分布式一致性协议相关的工作。
对于协议处理,你可以通过 @DispatchMode 控制它在哪种线程上执行:
Normal(默认):派发到普通线程池。Critical:派发到重要线程池(独立于普通池,避免互相阻塞)。Direct:直接在调用者线程上执行。
这些通常你按默认值用就好。完整说明见 advanced-threads。
Java 21 虚拟线程:同步写法,异步性能
Section titled “Java 21 虚拟线程:同步写法,异步性能”如果你在 Java 21 上运行,还会额外获得一个红利:虚拟线程。
传统同步代码的一个痛点是,当线程被锁阻塞、或在做同步 IO 时,它会一直占着一个昂贵的操作系统载波线程。Java 21 的虚拟线程改变了这一点——当 lock 被其他线程持有、或遇到同步 IO 时,虚拟线程会让出载波线程,不白白占着它。
这带来的效果是:你能拿到跟异步方案(回调、CompletableFuture、响应式)同样的性能,却依然可以用最直白的同步方式写代码。 从”同步”到”异步”再到借助虚拟线程”回归同步”,这正是编程模型螺旋式进步的最新一环。你不需要为此改写任何业务代码,框架已经替你接好了。
把这篇压缩成一句话:放心地写业务逻辑,数据访问自动安全;只有当你写自定义数据管理这类高级功能时,才需要懂一点多线程,而那也只限于”把副作用收进 whileCommit”这一件事。 唯一需要你主动留意的,是事务可能重做——凡是有副作用的操作(第三方调用、自定义数据修改),一律用 whileCommit 包起来。
写完逻辑准备上线了?接下来看 上线清单。