learn-tech/专栏/Java并发编程实战/43软件事务内存:借鉴数据库的并发经验.md
2024-10-16 06:37:41 +08:00

11 KiB
Raw Blame History

                        因收到Google相关通知网站将会择期关闭。相关通知内容
                        
                        
                        43 软件事务内存:借鉴数据库的并发经验
                        很多同学反馈说工作了挺长时间但是没有机会接触并发编程实际上我们天天都在写并发程序只不过并发相关的问题都被类似Tomcat这样的Web服务器以及MySQL这样的数据库解决了。尤其是数据库在解决并发问题方面可谓成绩斐然它的事务机制非常简单易用能甩Java里面的锁、原子类十条街。技术无边界很显然要借鉴一下。

其实很多编程语言都有从数据库的事务管理中获得灵感并且总结出了一个新的并发解决方案软件事务内存Software Transactional Memory简称STM。传统的数据库事务支持4个特性原子性Atomicity、一致性Consistency、隔离性Isolation和持久性Durability也就是大家常说的ACIDSTM由于不涉及到持久化所以只支持ACI。

STM的使用很简单下面我们以经典的转账操作为例看看用STM该如何实现。

用STM实现转账

我们曾经在《05 | 一不小心就死锁了,怎么办?》这篇文章中,讲到了并发转账的例子,示例代码如下。简单地使用 synchronized 将 transfer() 方法变成同步方法并不能解决并发问题,因为还存在死锁问题。

class UnsafeAccount { //余额 private long balance; //构造函数 public UnsafeAccount(long balance) { this.balance = balance; } //转账 void transfer(UnsafeAccount target, long amt){ if (this.balance > amt) { this.balance -= amt; target.balance += amt; } } }

该转账操作若使用数据库事务就会非常简单如下面的示例代码所示。如果所有SQL都正常执行则通过 commit() 方法提交事务如果SQL在执行过程中有异常则通过 rollback() 方法回滚事务。数据库保证在并发情况下不会有死锁而且还能保证前面我们说的原子性、一致性、隔离性和持久性也就是ACID。

Connection conn = null; try{ //获取数据库连接 conn = DriverManager.getConnection(); //设置手动提交事务 conn.setAutoCommit(false); //执行转账SQL ...... //提交事务 conn.commit(); } catch (Exception e) { //出现异常回滚事务 conn.rollback(); }

那如果用STM又该如何实现呢Java语言并不支持STM不过可以借助第三方的类库来支持Multiverse就是个不错的选择。下面的示例代码就是借助Multiverse实现了线程安全的转账操作相比较上面线程不安全的UnsafeAccount其改动并不大仅仅是将余额的类型从 long 变成了 TxnLong ,将转账的操作放到了 atomic(()->{}) 中。

class Account{ //余额 private TxnLong balance; //构造函数 public Account(long balance){ this.balance = StmUtils.newTxnLong(balance); } //转账 public void transfer(Account to, int amt){ //原子化操作 atomic(()->{ if (this.balance.get() > amt) { this.balance.decrement(amt); to.balance.increment(amt); } }); } }

一个关键的atomic()方法就把并发问题解决了这个方案看上去比传统的方案的确简单了很多那它是如何实现的呢数据库事务发展了几十年了目前被广泛使用的是MVCC全称是Multi-Version Concurrency Control也就是多版本并发控制。

MVCC可以简单地理解为数据库事务在开启的时候会给数据库打一个快照以后所有的读写都是基于这个快照的。当提交事务的时候如果所有读写过的数据在该事务执行期间没有发生过变化那么就可以提交如果发生了变化说明该事务和有其他事务读写的数据冲突了这个时候是不可以提交的。

为了记录数据是否发生了变化可以给每条数据增加一个版本号这样每次成功修改数据都会增加版本号的值。MVCC的工作原理和我们曾经在《18 | StampedLock有没有比读写锁更快的锁》中提到的乐观锁非常相似。有不少STM的实现方案都是基于MVCC的例如知名的Clojure STM。

下面我们就用最简单的代码基于MVCC实现一个简版的STM这样你会对STM以及MVCC的工作原理有更深入的认识。

自己实现STM

我们首先要做的就是让Java中的对象有版本号在下面的示例代码中VersionedRef这个类的作用就是将对象value包装成带版本号的对象。按照MVCC理论数据的每一次修改都对应着一个唯一的版本号所以不存在仅仅改变value或者version的情况用不变性模式就可以很好地解决这个问题所以VersionedRef这个类被我们设计成了不可变的。

所有对数据的读写操作一定是在一个事务里面TxnRef这个类负责完成事务内的读写操作读写操作委托给了接口TxnTxn代表的是读写操作所在的当前事务 内部持有的curRef代表的是系统中的最新值。

//带版本号的对象引用 public final class VersionedRef { final T value; final long version; //构造方法 public VersionedRef(T value, long version) { this.value = value; this.version = version; } } //支持事务的引用 public class TxnRef { //当前数据,带版本号 volatile VersionedRef curRef; //构造方法 public TxnRef(T value) { this.curRef = new VersionedRef(value, 0L); } //获取当前事务中的数据 public T getValue(Txn txn) { return txn.get(this); } //在当前事务中设置数据 public void setValue(T value, Txn txn) { txn.set(this, value); } }

STMTxn是Txn最关键的一个实现类事务内对于数据的读写都是通过它来完成的。STMTxn内部有两个MapinTxnMap用于保存当前事务中所有读写的数据的快照writeMap用于保存当前事务需要写入的数据。每个事务都有一个唯一的事务ID txnId这个txnId是全局递增的。

STMTxn有三个核心方法分别是读数据的get()方法、写数据的set()方法和提交事务的commit()方法。其中get()方法将要读取数据作为快照放入inTxnMap同时保证每次读取的数据都是一个版本。set()方法会将要写入的数据放入writeMap但如果写入的数据没被读取过也会将其放入 inTxnMap。

至于commit()方法我们为了简化实现使用了互斥锁所以事务的提交是串行的。commit()方法的实现很简单首先检查inTxnMap中的数据是否发生过变化如果没有发生变化那么就将writeMap中的数据写入这里的写入其实就是TxnRef内部持有的curRef如果发生过变化那么就不能将writeMap中的数据写入了。

//事务接口 public interface Txn { T get(TxnRef ref); void set(TxnRef ref, T value); } //STM事务实现类 public final class STMTxn implements Txn { //事务ID生成器 private static AtomicLong txnSeq = new AtomicLong(0);

//当前事务所有的相关数据 private Map<TxnRef, VersionedRef> inTxnMap = new HashMap<>(); //当前事务所有需要修改的数据 private Map<TxnRef, Object> writeMap = new HashMap<>(); //当前事务ID private long txnId; //构造函数自动生成当前事务ID STMTxn() { txnId = txnSeq.incrementAndGet(); }

//获取当前事务中的数据 @Override public T get(TxnRef ref) { //将需要读取的数据加入inTxnMap if (!inTxnMap.containsKey(ref)) { inTxnMap.put(ref, ref.curRef); } return (T) inTxnMap.get(ref).value; } //在当前事务中修改数据 @Override public void set(TxnRef ref, T value) { //将需要修改的数据加入inTxnMap if (!inTxnMap.containsKey(ref)) { inTxnMap.put(ref, ref.curRef); } writeMap.put(ref, value); } //提交事务 boolean commit() { synchronized (STM.commitLock) { //是否校验通过 boolean isValid = true; //校验所有读过的数据是否发生过变化 for(Map.Entry<TxnRef, VersionedRef> entry : inTxnMap.entrySet()){ VersionedRef curRef = entry.getKey().curRef; VersionedRef readRef = entry.getValue(); //通过版本号来验证数据是否发生过变化 if (curRef.version != readRef.version) { isValid = false; break; } } //如果校验通过,则所有更改生效 if (isValid) { writeMap.forEach((k, v) -> { k.curRef = new VersionedRef(v, txnId); }); } return isValid; } }

下面我们来模拟实现Multiverse中的原子化操作atomic()。atomic()方法中使用了类似于CAS的操作如果事务提交失败那么就重新创建一个新的事务重新执行。

@FunctionalInterface public interface TxnRunnable { void run(Txn txn); } //STM public final class STM { //私有化构造方法 private STM() { //提交数据需要用到的全局锁
static final Object commitLock = new Object(); //原子化提交方法 public static void atomic(TxnRunnable action) { boolean committed = false; //如果没有提交成功,则一直重试 while (!committed) { //创建新的事务 STMTxn txn = new STMTxn(); //执行业务逻辑 action.run(txn); //提交事务 committed = txn.commit(); } } }}

就这样我们自己实现了STM并完成了线程安全的转账操作使用方法和Multiverse差不多这里就不赘述了具体代码如下面所示。

class Account { //余额 private TxnRef balance; //构造方法 public Account(int balance) { this.balance = new TxnRef(balance); } //转账操作 public void transfer(Account target, int amt){ STM.atomic((txn)->{ Integer from = balance.getValue(txn); balance.setValue(from-amt, txn); Integer to = target.balance.getValue(txn); target.balance.setValue(to+amt, txn); }); } }

总结

STM借鉴的是数据库的经验数据库虽然复杂但仅仅存储数据而编程语言除了有共享变量之外还会执行各种I/O操作很显然I/O操作是很难支持回滚的。所以STM也不是万能的。目前支持STM的编程语言主要是函数式语言函数式语言里的数据天生具备不可变性利用这种不可变性实现STM相对来说更简单。

另外需要说明的是文中的“自己实现STM”部分我参考了Software Transactional Memory in Scala这篇博文以及一个GitHub项目目前还很粗糙并不是一个完备的MVCC。如果你对这方面感兴趣可以参考Improving the STM: Multi-Version Concurrency Control 这篇博文,里面讲到了如何优化,你可以尝试学习下。

欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。