[seata]脏写,脏读问题是否可以通过规范避免

2024-03-28 729 views
4

在官网中关于seata是什么这一章节详细的描述了 seata AT模式 是如何实现 写隔离读隔离 的。 1、写隔离的问题在于:全局事务中,rollback导致的脏写问题是靠全局锁和本地锁死锁超时解决的。这种做法并发场景下很不友好。 2、读隔离的问题在于:全局事务中,读的行为被降级成了读未提交的隔离级别,如果要变成读已提交则需要加 for update; 而且是事务内所有语句加上for update 。mysql串行化隔离级别也才在读操作时使用 S 锁。加上for update 后读操作全是 X 锁。这效率比串行化还低。

而且,几乎所有事务都必须是全局事务,如果一部分是全局事务,一部分是普通事务,还是会出现脏写问题。这就导致这个效率问题得覆盖整个业务场景。

是不是可以通过规范来避免这种情况?比如要求项目中不同的微服务必须使用不同的库。如果做到这一点,是不是就能避免如下情况: 1、写隔离不会再遇到需要死锁超时来解决rollback脏写问题的情况,毕竟不同微服务已经不会操作相同的表。 2、因为不同微服务不操作相同的表,逻辑上其实就已经进行了隔离,也不需要去额外实现读隔离。

不知以上分析是否正确,有没有大佬给个肯定的回答,万分感谢。

回答

7

1.首先理解回滚就理解错了,假设全局锁a被事务1持有,事务1已释放本地锁,且决议为回滚时,事务2进来想持有了全局锁a这条记录的本地锁,并想竞争全局锁,在事务二竞争全局锁时会发现锁被事务1持有且状态为回滚,此时事务2会回滚当前本地事务释放本地锁,事务1就正常回滚,而事务2通过重试机制也成功获取全局锁并提交事务,不存在你说的什么死锁超时,只不过竞争全局锁本身有超时时间,本地锁在数据库侧也有超时时间,即便业务侧上的分布式锁也有超时时间,这很正常 2.你说的s锁是快照读,即便你开启了本地事务,你用快照读读到一个叫张三的人,张三改成李四的中间时刻,另一个本地事务把张三改成王五,是完全可以介入到前者事务中的。本地事务的隔离级别是本地,全局事务为读未提交是全局事务,分布式下数据的隔离级别为读未提交,为什么为未提交呢?因为select本身就是快照读,快照读的数据在读取后本身就存在可变性,而at的一阶段是提交的(99.9%的业务稳定性要求下,回滚的事务几率非常小),由于快照读会读取到一阶段提交的事务,而这个事务可能过一会会决议为回滚,那么这个数据就变脏读了,所以一阶段的数据是不可信的,怎么确认一阶段数据可信?for update持有本地锁,此时没有任何地方能修改这个数据,再加globallock注解,此时会去checklock查看全局锁是否被其他事务持有,如果被持有那么说明要回滚重试for update和检查全局锁,直到这个数据没被任何事物持有全局锁,且本地事务也持有这个数据的本地锁,那么再分布式下这个数据就是一致的,已提交的终态数据,这就叫分布式事务的读已提交,你把select的条件加上索引,让其每次查询都能命中索引,理论上加个for update开销也很低,特别是本身很多业务,比如扣库存,这种update本身就会去持有本地锁 3.跟不同服务操作不同表没任何关系,你能保证每条数据操作都没有竞争?这个问题说明你还不理解at的特性 4.同上

4

再者就是脏写在seata-at来说,就是绕过了全局锁对二阶段还未决议时对一阶段尚不明确结果的数据,进行了修改,导致回滚的时候通不过镜像校验导致的,只要你所有写数据的接口上有globaltransational或者globallock就没这个问题

2

首先,感谢您的回答,我对seata的细节还不了解,所以针对您的说的第一点我会再仔细看看代码。关于第二点,我感觉您跟我的理解有不同。我希望能够继续跟您讨论这个问题,关于第二点我的理解是这样的:

  1. MySQL本地事务中, 读未提交读已提交可重复读。 此三者经过测试发现,这三个隔离级别在进行读操作时,查询data_locks 的确是没有锁的。因此,我认为此三者确实是镜像读。只是它们会读取 undo log 链表上不同的版本。

  2. MySQL本地事务中,串行化 隔离级别在读取操作时,通过查询发现确实使用了 S 锁。S锁 跟 S锁不互斥,因此,多个事务之间可以同时读同一行数据。如果使用 select for update; 通过查询发现确实使用的是 X 锁。当使用的是 X 锁时,其他事务是不能读的。因此,我才认为 如果本地事务中使用 select for update,确实是比串行化效率更低的。

  3. 我纠结于 seata AT 会将读操作在全局事务的逻辑上变成 读未提交,这是官方文档上也确认的说法。而我们平时使用的隔离级别都不会是读未提交, 因为读未提交会引发脏读问题,是多数场景都不能接受的问题,这也与seata官方文档说对于读隔离的说法一致。本地事务中 oralce默认是 读已提交,MySQL默认是可重复读。 您有一点说的是对的,读未提交确实是读镜像。但它读取的是 undo log 链表上最新的版本。而读已提交读取的是 undo log 链表上最新的已提交版本,可重复读则是读取的 undo log 链表上事务开始前的最后一个已提交版本。因为 读已提交可重复读 都是读取的已提交版本,因此不会有脏读问题。而这一点在我们的业务中非常重要。因此,我认可您说的 select 查询的都是镜像 (除串行化外) . 但镜像和镜像是不一样的。

关于您提到的第三点,我的想法是资源的竞争总是存在的,但数据库不可能任由竞争的后果变大,数据库使用 MVCC就是为了让锁的竞争尽量减少。我们常说串行化不能用,正是因为只有这个隔离级别的读操作会加锁。加锁就会有竞争。加上事务的锁释放要等到事务结束,这种竞争的粒度会进一步扩大。从seata的官方文档上也能看到官方的建议。它们也不建议使用select for update。从官方建议的是 select for update 而不是 lock in share mode 来看,官方确实是希望帮开发者找到一个能够解决读隔离的办法,否则如果select都是镜像读为啥不用影响更小的 lock in share mode 呢?

其实,我还想说,只要加锁了,就会把镜像读强制变成当前读。因为锁都是锁当前值,而不是镜像。但这个我只是从帖子上看到的,书上的知识已经忘记了。我也没办法找到一个好的办法验证这个想法。希望还有大佬能帮忙继续做出解释。

1

想太多,不要想到什么串行化去,用select做at的前镜像怎么保证做完前镜像还没updatesql发出就被另一个事务改了数据,导致前镜像不准的问题?如果就用slelect+globallock怎么保证全局锁不存在的时候,这条select的数据是准的?快照读本身就不是准的,很有可能这个select的sql发出后再检查全局锁时,其值已经改写了,本地事务的读已提交之前每次查,根据mvcc版本链读到已提交的结果事务,而可重复读就不行只能读到发出sql后那个已提交事务的结果,随后即便再次查也是读之前那个快照结果,所以不加for update和globallock就无法确定这个数据是分布式事务下已提交的

5

你说的是正确的。确实,你说的情况数据库不能保证一致性。但这个问题的根本原因在于使用了中间变量,如果直接使用 update amount=amount+10 这样就一致性问题的。毕竟写的时候都会加X锁。又因为加锁会强制触发当前读,所以amount是当前最新提交的值(已测试证明),因此是一致的。我们多数业务有一致性问题不是数据库本身的问题,是因为我们使用了中间变量,加减操作是在java代码里面完成的,无法触发当前读。因此我们才使用乐观锁 或者 select for update 来解决问题。

但需要注意的是,我们多数情况下只会给修改额度等操作加 for update. 因为 for update 解决的是无法当前读的问题。而seata AT中描述的问题是需要select for update 是来解决脏读问题,也就是读未提交问题。这两者虽然都是用 for update,但它们解决的问题是不一样的。也因为后者是解决脏读问题,for update 操作必须应用在所有的读操作上。而不是某一个或多个读操作。这是有本质差异的。效率也是完全不同。一个只是部分使用,一个是全方位覆盖。

我分析这个问题的思路是这样的。我们的单体应用本身也会负载均衡,也是会出现多个应用实例访问同一个数据库实例。 多个应用都在同一个数据库实例中进行事务的隔离,隔离性靠 单个数据库实例维护,没问题。那么,我们进一步分析 负载均衡 + 分库分表 时的情况,此时是多个app实例和多个数据实例。这里只以纵向分表来说明

(这里假设 Global 是全局事务, APP是应用, Tx是不同数据库的事务,d是库,t 是表) 其实这个情况下就已经出现了分布式事务问题了。之所以我们没有感知是因为事务呈现是这样的 Global { App1 -> Tx1 (d1.t1, d1.t2, d1.t3) -> App2 -> Tx2 (d2.t4, d2.t5, d2.t6)} 如上,每个应用实例 开启的事务访问到的数据库实例都是隔离的。因此Tx1 和Tx2 只需要保证同时提交,同时回滚就行。业务上没有影响。这个多阶段提交的问题是一样的。也很好理解。

seata描述的脏写和脏读问题体现出来的是这样: Global { app1 -> tx1 (d1.t1, d1.t2, d2.t3) -> tx2(d2.t3, d2.t4, d2.t5) } 这个情况下,本地事务 tx1 涉及到 d1, d2个数据库实例, tx2本地事务 也涉及到 d1 和 d2 数据库实例。 tx1 和 tx2 已经不能仅靠本地数据库实例来维护事务,而需要整个全局事务来协调,这样就会遇到 脏读,脏写问题。而seata协调这个问题就会出现我最初描述的那两个问题。我认为这样的协调效果不太理想。因此,我才提到是不是可以建立规范。不同微服务必须使用不同数据库。这样就能保证分布式事务其实和分库分表遇到的问题是等价的。都只需要解决一个全局事务中的多个本地事务能否同时提交或者同时回滚,不需要关注多个本地事务时间会不会有冲突。

简单总结一下就是我个人认为:不同微服务必须使用不同库可以绕开 一个全局事务中的多个本地事务需要进行读写隔离。因为只要做到这一点,一个全局事务中的多个本地事务本身就是相互隔离的。不需要seata AT 去帮助进行隔离。seataAT 要做的事情仅仅是保证一个全局事务中的多个本地事务同时提交或者回滚 这一件事。

我不知道您是否能理解我这个说法。其实,我觉得您一直认为我说的这个问题是错误的,是因为seata描述的脏写和脏读问题其实很少遇到。我们项目中其实大多都正好是遵循我说的这个规范的。

1

数据本身就存在并发,所以肯定要有全局锁保证隔离,你说的保证提交和回滚而已的事务模式是xa和lcn

1

我的意思是,如果业务数据的并发是严格隔离在单个数据库实例内的,那么仅通过本地事务就能保证隔离性。剩下的只是怎么一起提交和回滚。多数情况下,一个有隔离性的全局事务 == 多个相互隔离的本地事务 + 共同提交或回滚。其实,只有代码设计到很不合理的情况下,才会出现多个本地事务之间没有做到逻辑隔离。如果真发生这种情况,seataAT 是能解决的,也就是seataAT 描述 写隔离读隔离 的部分。seata官方也明确说这样的解决方案并不好。那么,我们要做的就是不要让这种事情发生,不需要seata AT为我们做这部分事情。所以,需要建立这样的规范。

我只是不确定,这样的规范我有没有想漏什么。

7

哪来的本地事务就能保证隔离性,我a调b延迟3ms,你确定a调完b再响应到a时,a不会有其他线程也在做相同的事?b响应给a的时候早就提交了本地事务释放全局锁了,哪来只靠本地事务本地锁就可以隔离?恰好第一个操作id为1的商品扣1库存,第二个也去扣id为1的商品库存,第一个出现rpc timeout比如网络抖动,此时决议id为1进行回滚,假设第一个事务操作前库存为100,由于按你的说法不需要全局锁,那么第二个事务直接再扣1库存变成99,此时事务1需要回滚到101(未扣除之前)现在数据是99,请问要怎么处理?如果直接回滚到101,那么就导致库存数据脏了,多了一个1库存

4

我在纸上又画了一下,发现你是对的。我还想问一下,在你们使用seata时,会关心脏读脏写问题吗?毕竟本地事务+乐观锁 没这两个问题。分布式事务这两个问题你们在实际业务中有什么办法去解决吗?

7

99.9%的事务基本上都会提交,所以脏读写造成的影响本身就小,再者纯更新场景本身还是可以用乐观锁+本地事务,因为自动会变成乐观锁+本地事务+全局锁,乐观锁本身也就是重试,本身dml就会占据本地锁,先select for update,或者是我说到的那种扣库存,update...where 库存>=0即可,无需在select查数据的情况是没有脏读写问题的

6

感谢您的建议,我会尝试应用它。