目前公司几乎所有的项目都使用Spanner,在我们部署的项目中发现偶尔会有Transaction was aborted
的情况,报错如下:
很多同学可能以为是死锁才会导致Transaction被中止,其实并不是,是一个比死锁更宽泛的情况——事务读写冲突,读锁被aborted,因此事务被aborted。
下面解释读锁为什么会被aborted。
阅读本文之前最好能够理解事务隔离级别,下面不会详细讲解隔离级别的知识。
Spanner事务隔离
Spanner的事务是可串行化的(Serializable),可串行化是最高隔离级别,每个事务看起来像是串行执行的,也就是每个事务从外部看起来是有顺序的,这就是可串行化。并且外部观察到的执行顺序与每个事务的commit timestamp顺序相同,这就是Spanner所说的外部一致性(External Consistency)
。
如何保证外部一致性
外部一致性 = 可串行化 + commit timestamp决定事务顺序
先说说可串行化,大家熟悉的InnoDB是使用加悲观锁的方式实现可串行化,读操作加读锁,写操作加写锁,事务A如果要写已经被事务B加上锁的数据,则需要等待事务B释放锁,MySQL对锁100%是采取等待的方式,这也是为什么会出现死锁,因为双方互相等待,InnoDB中事务的读写冲突本身不会导致事务被中止。
那么Spanner有什么不同呢?
Spanner不是100%采取等待的方式,它可能会abort别的事务的锁,锁被aborted的事务就会中止。这就是Spanner文档中所说的伤停等待(wound-wait)
,abort锁导致事务中止就是伤停
。我们知道读锁和读锁是不冲突的,因此只有读锁和写锁才是冲突的,那么说明是一方读另一方写时可能会造成某一方被aborted。
是哪一方会被aborted呢?
是年轻的事务
。
怎么区分年轻和年老?
越早启动的事务越年老,越晚启动的事务越年轻,什么叫启动?每个事务第一次进行读写操作时,Spanner会为其生成一个start timestamp,即为启动时间。这里注意,是进行第一次读写时的,而不是begin一个Transaction时。
在Spanner中,只有Read操作会被马上执行并获取锁,Write操作都会被缓存在client本地,并没有真的Write,也不会获取锁,只有commit被调用后才会一次性发送到server,尝试执行并获取锁,因此一个pending的事务是没有写锁的,只有读锁。那么读写冲突的产生就一定是在一个事务pending另一个事务commit时,pending的事务持有读锁,而commit的事务想要获取写锁,此时:
- 如果commit事务比pending事务年轻,那么它需要等待pending事务主动释放读锁,才能获取写锁,此时采用等待策略。
- 如果commit事务比pending事务年老,那么它会直接abort掉pending事务的读锁,成功获取写锁并提交, 此时采用伤停策略,pending事务被aborted。
举个栗子
等待策略(年轻事务等待年老事务释放锁)
先说一下我们最熟悉的等待策略,也是InnoDB的锁策略。
- 首先我们begin两个事务,注意:此时并不会给事务生成start timestamp,因此begin的顺序是不影响结果的。
- 在左边的事务(下称事务A)中select * from ID为0的数据,此时事务A进行了第一个读操作,Spanner为其生成start timestamp。
- 紧接着在右边的事务(下称事务B)中select * from 同一行数据,此时事务B进行了第一个读操作,生成start timestamp,那么这个timestamp一定是晚于事务A的,因此事务A更年老、事务B更年轻。
- 然后事务B立即更新同一行数据的LastName列,并且commit。
-
由于事务B更年轻,因此其commit将不会返回成功,而是一直等待,需要等待年老的事务A释放锁。
伤停策略(年轻事务被aborted)
- 首先我们begin两个事务,注意:此时并不会给事务生成start timestamp,因此begin的顺序是不影响结果的。
- 在左边的事务(下称事务A)中select * from ID为0的数据,此时事务A进行了第一个读操作,Spanner为其生成start timestamp
- 紧接着在右边的事务(下称事务B)中select * from 同一行数据,此时事务B进行了第一个读操作,生成start timestamp,那么这个timestamp一定是晚于事务A的,因此事务A更年老、事务B更年轻。
- 事务A更新这一行的LastName
- 事务A commit,此时A会获取LastName列的写锁,而发现B已经占有读锁,对比timestamp发现B更年轻,因此直接abrot B事务的读锁,最后成功提交
-
事务B在A提交后也进行update,发现自己已被aborted,结束。
需要注意的是,Spanner获取锁的粒度是列,不是行
,因此冲突是在列上,报错将会是
conflict on keys in range (xxx), column LastName in table Singers
总结
可以看出,如果同一个包含读写冲突的事务代码在短时间内被执行两次,且先执行的先commit了,就会出现后执行的那个事务被aborted的情况,这也是开头讲到的,我们会收到Transaction was aborted
的原因。
或者是,有两个不同的事务代码,紧接着被执行,且它们有读写冲突,年老的事务先commit,就会造成年轻事务aborted。
Spanner的SDK都有提供事务重试,根据Spanner文档,重试的事务将会以旧的timestamp重启,因此事务不会出现饿死的现象,最终一定有机会被执行成功。
更佳实践
但是我们还是应该思考,这些冲突的事务是真的需要每一个都得到执行,还是只是不小心被重复调用,只执行其中一个就能满足业务?如果执行一次就能满足业务,那么其他的重复事务会造成Spanner的资源浪费,因此被aborted的事务会重试,如果多个事务一起重试,还是可能会出现aborted,然后再次重试,因此需要尽量避免短时间内的无意义的重复调用。