深入MYSQL之事务篇(一)

关于mysql的事务,大多数java开发都是非常熟悉的。但是近期面试了一批同学,发现很多同学对mysql事务的理解都是停留在spring的@Transactional上,问到为什么使用事务时往往会背书一样回答出ACID的一些概念。照本宣科其实并不是我想听到的答案,相信真正了解过,思考过mysql原理和设计思想的开发都应该有更深一步的体会。

那么,想要深入谈谈某件事情,那首先得回到“这件事情是做什么的,有什么用”这个方向上面。那在设计事务的初衷,它是用来做什么的呢?我个人觉得至少有以下几点作用,分别以具体的应用场景举例:

①某个业务操作是不可分割的,要么全部失败,要么全部成功。如电子记账系统里面,A给了B100块钱,要么交易失败,A和B的余额都不变,要么交易成功A的余额-100同时B的余额+100。

②开发人员为了性能和效率,采用反范式设计,即允许少量的冗余,通过空间换时间的方法来缩短查询业务的响应时间。

这里展开说明一下所谓范式设计的概念。通常情况下,范式设计包括三点:1NF确保原子性(Atomicity)原子性的粒度、原子性的价值——也就是把值当做单值用,建议不要把值拆开; 2NF检查对键的完全依赖价值在在于控制数据冗余和查询性能;3NF检查属性的独立性,不在多张表中存储相同意义的数据。虽然范式设计可以使得表结构设计变得简洁明了便于维护,但是缺点是,范式化的表,在查询的时候经常需要很多的关联,因为单独一个表内不存在冗余和重复数据。这导致多次的关联,增加查询代价,可能使一些索引策略无效。所以我们经常需要将一列常用属性重复存储到多张表里面,在这些列上面分别设置索引。

如:某个业务要对博文进行排序,排序规则为按照博主的用户等级倒序排列。根据3NF的范式设计规则,那么资源表是不应该存在博主相关的字段的。但是往往为了性能原因,要在博文对应的表中新增用户等级这个冗余字段,并在用户等级提升时做冗余更新。那么这种情况下的更新一样可能会遇到原子性或者一致性问题。同理的,有时候会遇到需要频繁计数的场景,由于innoDB下查询count(*)会遇到全索引扫描的问题,性能比较低,往往需要计数表或者计数缓存的方案,那么前者在更新是也会遇到原子性或者一致性问题。

有人会觉得用redis等缓存计数是一个不错的方案,但实际上,这种方案也有一定的问题。首先就是需要额外维护一个组件,缓存带来的更新丢失也是一个大问题。但实际上,将计数保存在缓存系统中的方式,还不只是丢失更新的问题。即使 Redis 正常工作,这个值还是逻辑上不精确的。我们可以看一下一下的时序图:

上图中,会话 A 是一个插入交易记录的逻辑,往数据表里插入一行 R,然后 Redis 计数加 1;会话 B 就是查询页面显示时需要的数据。在上图的这个时序里,在 T3 时刻会话 B 来查询的时候,会显示出新插入的 R 这个记录,但是 Redis 的计数还没加 1。这时候,就会出现我们说的数据不一致。你一定会说,这是因为我们执行新增记录逻辑时候,是先写数据表,再改 Redis 计数。而读的时候是先读 Redis,再读数据表,这个顺序是相反的。那么,如果保持顺序一样的话,是不是就没问题了?我们现在把会话 A 的更新顺序换一下,再看看执行结果:

你会发现,这时候反过来了,会话 B 在 T3 时刻查询的时候,Redis 计数加了 1 了,但还查不到新插入的 R 这一行,也是数据不一致的情况。

在并发系统里面,我们是无法精确控制不同线程的执行时刻的,因为存在图中的这种操作序列,所以,我们说即使 Redis 正常工作,这个计数值还是逻辑上不精确的。

③隔离性问题,正在处理的事务我不想被其他事务感知到,以免影响正常的业务逻辑。比如我有一个入库操作,会进行大量的数据插入,我还有一个定时任务用来做统计,会统计入库相关的大量指标。那么如果我在某个商品时入库就进行统计,有可能只统计到一半插入的数据,另一半的操作还在写入就没办法统计到。这种情况下我肯定是希望我能统计到相同一个时间版本的数据,这就涉及到事务的隔离性问题了。

实际上,mysql的默认引擎InnoDB 的行数据有多个版本,每个数据版本有自己的 row trx_id,每个事务或者语句有自己的一致性视图。普通查询语句是一致性读,一致性读会根据 row trx_id 和一致性视图确定数据版本的可见性。对于可重复读,查询只承认在事务启动前就已经提交完成的数据;对于读提交,查询只承认在语句启动前就已经提交完成的数据;更新数据都是先读后写的,而这个读,只能读当前的值,称为“当前读”(current read)。

也就是说,mysql的读分为两种,一种是版本读,一个是当前读。在版本读的时候,如果没有获取到该行数据的写锁,读到的很有可能不是最新提交的数据。比如最常见的更新丢失场景:我要读表t中某一行的a列(int类型),将其加一并更新到数据库里面,常用的做法有两种(假定是第一行,a初始值为1,假设有两个会话A和B,A先B后,mysql的隔离级别为默认的可重复读):

1.在代码里面先select a from t where id = 1,然后讲这个值赋予到内存变量x,然后在应用程序内存里面进行加一得到x = (1 + 1 = 2),然后 update a set a = 2 where id = 1。

2.使用手写的sql执行:update t set a = a + 1 where id = 1;

在第一种情况下,这段逻辑是由java应用程序+jdbc驱动+mysql数据库共同完成的,而且这段程序逻辑如果部署在服务器上运行,是很有可能有很多线程同时操作的。这个时候如果有两个线程同时需要进行做加法操作,则在高并发的时候可能出现AB线程同时读取到值为1,然后同时将该行数据update为2,那么,其中B线程就会出现“更新丢失”的现象。

这种情况其实是比较常见的,往往在长事务的情况下。A线程在+1操作后,应用程序还继续进行了其他sql操作,导致没有提交事务,在A线程的事务时间窗口内,B线程无论怎么读,读到的都是1这个旧制,导致计算的最终值有误,最终执行了update t set a = 2 where id = 1这条错误sql。这就是因为在事务的隔离机制下,存在着“版本读”这个效果。

而在第二种情况下,应用程序通过jdbc驱动同时向mysql server提交两个sql操作请求。这种情况下,Mysql会出现更新丢失的情况嘛?答案是不会的。因为在Mysql中,为了确保单条sql是原子性,无论是非声明事务开启与否,innoDB都会为单条sql开启事务,为其分配单独的事务id。那么A获取到id=1这条数据的行锁时,B对id=1的这行数据的更新和读取将会被阻塞,直到A更新完毕后B会话才能继续执行。而且,此时哪怕A会话所在的事务没有提交,B在更新时会采取“当前读”的策略,读到的总是最新的数据,不会出现更新丢失的情况。

④持久性问题,如我在写数据的时候,希望它像是合同一样,我在签字之前完全可以放弃提交(事务的回滚),但是我一旦签字,那么这东西的效用应该是持久的。这里提醒一下,不要过于依赖数据库的回滚功能,如果一个方法需要频繁回滚,那么这一定是有问题的。

以上就是数据库最基本的ACID问题,在实际的开发中,问题往往是这几种要素的排列组合。复杂的问题也往往导致了解决方案的不同,了解清楚mysql的原理将有助我们选择最优的方案,可以将数据控制的更精准,查询变得更迅捷。在第二讲中,我会结合spring的常见orm框架,进行进一步的讲解,欢迎各位朋友点赞收藏~有疑问的可以评论区一起讨论~

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值