mysql 分布式事务、分布式锁

上周近年来第一次面试,结果被事务虐的体无完肤(当然,不仅仅是事务),所以决定恶补一下事务知识。

一、事务的基本要素(ACID)

  1、原子性(Atomicity):事务开始后所有操作,要么全部做完,要么全部不做,不可能停滞在中间环节。事务执行过程中出错,会回滚到事务开始前的状态,所有的操作就像没有发生一样。也就是说事务是一个不可分割的整体,就像化学中学过的原子,是物质构成的基本单位。

   2、一致性(Consistency):事务开始前和结束后,数据库的完整性约束没有被破坏 。比如A向B转账,不可能A扣了钱,B却没收到。

   3、隔离性(Isolation):同一时间,只允许一个事务请求同一数据,不同的事务之间彼此没有任何干扰。比如A正在从一张银行卡中取钱,在A取钱的过程结束前,B不能向这张卡转账。

   4、持久性(Durability):事务完成后,事务对数据库的所有更新将被保存到数据库,不能回滚。

 

二、事务的并发问题

  1、脏读:事务A读取了事务B更新的数据,然后B回滚操作,那么A读取到的数据是脏数据

  2、不可重复读:事务 A 多次读取同一数据,事务 B 在事务A多次读取的过程中,对数据作了更新并提交,导致事务A多次读取同一数据时,结果 不一致。

  3、幻读:系统管理员A将数据库中所有学生的成绩从具体分数改为ABCDE等级,但是系统管理员B就在这个时候插入了一条具体分数的记录,当系统管理员A改结束后发现还有一条记录没有改过来,就好像发生了幻觉一样,这就叫幻读。

  小结:不可重复读的和幻读很容易混淆,不可重复读侧重于修改,幻读侧重于新增或删除。解决不可重复读的问题只需锁住满足条件的行,解决幻读需要锁表

 

三、MySQL事务隔离级别

事务隔离级别脏读不可重复读幻读
读未提交(read-uncommitted)
不可重复读(read-committed)
可重复读(repeatable-read)
串行化(serializable)

事务的ACID是通过InnoDB日志和锁来保证。事务的隔离性是通过数据库锁的机制实现的,持久性通过redo log(重做日志)来实现,原子性和一致性通过Undo log来实现。UndoLog的原理很简单,为了满足事务的原子性,在操作任何数据之前,首先将数据备份到一个地方(这个存储数据备份的地方称为UndoLog)。然后进行数据的修改。如果出现了错误或者用户执行了ROLLBACK语句,系统可以利用Undo Log中的备份将数据恢复到事务开始之前的状态。 和Undo Log相反,RedoLog记录的是新数据的备份。在事务提交前,只要将RedoLog持久化即可,不需要将数据持久化。当系统崩溃时,虽然数据没有持久化,但是RedoLog已经持久化。系统可以根据RedoLog的内容,将所有数据恢复到最新的状态。


现在,我们就来手动测试下事务吧。

首先,我们先把mysql的环境配置成最低级别。

环境一:我测试的表引擎是Innodb类型的,查询表属性,可以用这个sql查看:

show TABLE status like 'im_inventory'

 

环境二:事务级别也是最低的READ-UNCOMMITTED

 select @@tx_isolation;

这个sql是查询当前会话隔离级别,还有一个系统当前的隔离级别

select @@global.tx_isolation;

可以看到,我当前会话隔离级别和系统当前隔离级别 都是READ-UNCOMMITTED。

环境三:事务的自动提交,也是关闭的,可以用这个sql查看:

show session variables like 'autocommit';

要注意的是,上面的是查看会话系统变量,还有一个全局的系统变量:

show global variables like 'autocommit';

可以看到,我会话系统变量和全局系统变量都是OFF关闭的。

接下来,我们先测试下脏读。

现在查询库存id为40729的数量是5个,然后我们再开启个事务,对这个库存id的数量进行修改,但是不提交。这种情况下如果我们不提交的话,数量会修改,但是如果我们把当前会话关闭,事务会进行回滚。

这个时候我们再来查询一下数量

确实如上面所说,事务A读取了事务B更新的数据,如果B回滚操作,那么A读取到的数据是脏数据,这就是脏读。

关闭会话后,重新查询,这个时候数量就回滚成之前的5个了。

现在我们把数据库的事务级别改成不可重复读(read-committed),再来测试下

SET GLOBAL TRANSACTION ISOLATION LEVEL read committed ;

可以看到,已经修改成功了。

这个时候我们先查询下库存:

然后我们再执行修改

再来查询下库存数量有没有被改

可以看到数量确实没有变化,也就是说脏读的问题,确实解决了。

不过这个时候如果我再运行一次修改语句的话,

可以看到,数量减了1,我关闭重新连接再查后,发现也还是4个,

也就是说,事务提交了。在没有commit的情况下,重复执行相同命令,会提交

原来,这是由于隐式提交的原因,具体原因大家可以自行搜索,也可以参考这个网站:https://help.aliyun.com/document_detail/48656.html

大概意思就是说:对于连续 begin,若用户通过begin/start transaction/set autocommit = 0 开启第一个事务,稍后未进行提交而进行第二个begin/start transaction,那么分布式数据库将隐式地帮助用户 commit 上一个事务,这个 commit 的特性与普通 commit 相同,提交完成后,将自动进入下一个事务。

其实,在这里,我在一个事务里,两次读取前后结果不一致,一次是4,一次是5,这就是我们下一个测试的问题,不可重复读。

可能你会好奇,为什么就一个单单的select语句,也会开启一个事务?

这是因为在DML语句执行时或者在COMMIT或ROLLBACK语句之后执行第一条DML语句时,会自动开始一个新的事务,相关的资料,可以参考这个博客:https://blog.csdn.net/qq_43028054/article/details/94358394

现在我们把事务级别改成REPEATABLE-READ这个级别,再来试下:

执行修改sql

然后查询

可以看到无论查几次,数量都是一致的,也就是说可重复读(repeatable-read)这个事务级别,确实可以避免不可重复读的并发问题。

但是,这个级别的事务还是没法解决幻读的问题,幻读的问题通常通过MVCC来解决。

mvcc全称是multi version concurrent control(多版本并发控制)。mysql把每个操作都定义成一个事务,每开启一个事务,系统的事务版本号自动递增。每行记录都有两个隐藏列:创建版本号和删除版本号

select:事务每次只能读到创建版本号小于等于此次系统版本号的记录,同时行的删除版本号不存在或者大于当前事务的版本号。

update:插入一条新记录,并把当前系统版本号作为行记录的版本号,同时保存当前系统版本号到原有的行作为删除版本号。

delete:把当前系统版本号作为行记录的删除版本号

insert:把当前系统版本号作为行记录的版本号

这部分内容是摘自https://blog.csdn.net/J080624/article/details/53995591

到这里已经基本把mysql的事务级别以及问题过了一遍了,但是并没有达到我预期的效果。

因为项目中,我用到的是for update加排他锁解决并发问题,不过面试官被这个雷到了,当时我准备不够充分,现在我了解了一番过后,认为for update加锁,只要是行锁,并不是什么大不了的事,处理我们项目并发完全是够的。如果量很大,很密集的话,那么甚至就应该不直接跟关系数据库打交道,直接就用redis来完成。定时(空闲时)持久化到关系数据库或其他持久层咯。


既然事务可以解决以上问题,那么,事务又是怎么实现的呢

上文也说到,事务特性有ACID四种,实现了这四种特性,事务就自然而然的实现了,其中AD都是基于日志实现,I是用锁实现。

A原子性,原子性指要么都完成,要么都不完成,因为这个特性的结果分两种,都完成的情况下根本不需要做别的任何操作;都不完成的情况则需要回滚事务,所以这个特性的重点,在于回滚事务的实现。在Mysql中,undo log叫做回滚日志,用于记录数据被修改前的信息。undo log可以用来回滚数据的用于保障 未提交事务的原子性

C一致性,比如A向B转账,不可能A扣了钱,B却没收到。

上文是这样描述的,这样描述的话,可能大家会有点懵,因为A向B转账,A扣了钱,B没收到,这个跟原子性是有一些相似的。因为A扣了钱,B没收到的情况,就打破了原子性,关于一致性,我会再写一篇博客,这里就暂时跳过一致性。

I隔离性,隔离性是同一时间,只允许一个事务请求同一数据,隔离性的实现相对比较明确,使用读写锁实现

D持久性,持久性是通过 redo log 来实现的

详细的实现,大家可以参考这个链接:http://www.sohu.com/a/316482862_663371


接下来我们来看看分布式事务分布式锁

为什么要把分布式事务和分布式锁放在一起相提并论呢?因为我觉得分布式事务和分布式锁的产生原因都是因为分布式造成的。所以这两个其实本质是差不多的。

分布式事务,通俗点可以说就是分布式环境下的事务。

在上面也说到,在单机环境下,事务通过mysql的机制是可以保证的,但是在分布式环境下,同个事务中,可能会通过RPC去调用别的模块的业务,这个时候,如果发生异常,RPC所调用的业务是不会回滚的,这个时候事务就无法保证了,也就是分布式事务的问题。

伪代码可以这样

//转账方法
@transaction
public  void accounts()
{
    try{
        //a账户减去100
        dubbo1.invoker(reduction(a, 100));
        //b账户加上100
        dubbo2.invoker(add(b, 100));
    }catch(){
        ..........
    }
}

在伪代码中,a减去100和b减去100并不是一个事务,甚至于没有事务。一旦发生异常,数据就会错乱。

这个时候我们可以通过增加一个中间层来解决。

比如增加一个事务管理器,用来管理分布式事务。

当有需要用到分布式事务的时候,比如accounts()方法,可以先向事务管理器发送通知,告诉事务管理器dubbo1节点和dubbo2节点需要事务管理,然后事务管理器询问dubbo1和dubbo2能不能执行成功,dubbo1和dubbo2分别执行当前全部操作,并且写入日志,并将执行结果发送给事务管理器,当dubbo1和dubbo2都执行成功时,事务管理器会向dubbo1和dubbo2发送正式提交消息,如果有一方失败或者超时,则发送回滚消息,这个时候dubbo1和dubbo2就通过日志进行回滚。这个也可以称为XA,两阶段提交,当然,没有XA这么详细,大概思路是这样,通过增加中间层解决。

分布式事务就讲到这,详细的解决方案大家可以参考这篇文章:https://www.cnblogs.com/bigben0123/p/9453830.html

我们再来看看分布式锁。

说到分布式锁,我们先来看看分布式锁中的锁是什么。

比如秒杀,在内存中用个变量记录剩余数量(当然实际中这是不可取的),当有用户点击抢的时候,数量就会减掉,并且创建订单等等一系列操作。在单线程中,这样操作是没有什么大问题的,但是,现在都是多线程并发,这个时候就会有可能线程A获取到了数量,对数量进行-1操作,然后创建订单,还没执行完呢,线程B又来获取到了数量,对数量-1,。。。这个时候后执行完的线程就会把前一个执行完的结果覆盖,导致线程A抢到了,线程B也抢到了,但是数量只减了1,这个就是多线程并发情况下的问题,解决方法可以通过添加synchronized字段进行加锁操作。

现在业务扩大,秒杀模块拆成了两个缓解压力,这个时候synchronized字段就不管用了,因为synchronized只能保证单进程下的加锁,多进程下就没法弄了。

这个时候我们也可以通过增加中间层来解决。

比如redis,把数量放在redis中,在redis中再用一个变量标记为锁,同一时间只能有一个线程可以获取到锁,获取到锁的线程才能对数量进行操作。

这个redis中标记为锁的就是分布式锁。

当然不仅仅是用redis实现,zookeeper也一样能实现,只要是中间层,与模块无关,都可以作为分布式锁。

大家发现没有,分布式事务和分布式锁都可以通过增加一个中间层来解决的,产生的原因也都是因为分布式,所以我说这两个东西,本质差不多。


下面是分布式锁的一些解决方案,摘自参考这个博客https://www.cnblogs.com/seesun2012/p/9214653.html

### 我们需要怎样的分布式锁?

  • 可以保证在分布式部署的应用集群中,同一个方法在同一时间只能被一台机器-上的一个线程执行。
  • 这把锁要是一把可重入锁(避免死锁)
  • 这把锁最好是一把阻塞锁(根据业务需求考虑要不要这条)
  • 这把锁最好是一把公平锁(根据业务需求考虑要不要这条)
  • 有高可用的获取锁和释放锁功能
  • 获取锁和释放锁的性能要好

### 基于数据库做分布式锁

基于乐观锁

基于表主键唯一做分布式锁

思路:利用主键唯一的特性,如果有多个请求同时提交到数据库的话,数据库会保证只有一个操作可以成功,那么我们就可以认为操作成功的那个线程获得了该方法的锁,当方法执行完毕之后,想要释放锁的话,删除这条数据库记录即可。

上面这种简单的实现有以下几个问题:

  • 这把锁强依赖数据库的可用性,数据库是一个单点,一旦数据库挂掉,会导致业务系统不可用。
  • 这把锁没有失效时间,一旦解锁操作失败,就会导致锁记录一直在数据库中,其他线程无法再获得到锁。
  • 这把锁只能是非阻塞的,因为数据的 insert操作,一旦插入失败就会直接报错。没有获得锁的线程并不会进入排队队列,要想再次获得锁就要再次触发获得锁操作。
  • 这把锁是非重入的,同一个线程在没有释放锁之前无法再次获得该锁。因为数据中数据已经存在了。
  • 这把锁是非公平锁,所有等待锁的线程凭运气去争夺锁。
  • 在 MySQL 数据库中采用主键冲突防重,在大并发情况下有可能会造成锁表现象。

当然,我们也可以有其他方式解决上面的问题。

  • 数据库是单点?搞两个数据库,数据之前双向同步,一旦挂掉快速切换到备库上。
  • 没有失效时间?只要做一个定时任务,每隔一定时间把数据库中的超时数据清理一遍。
  • 非阻塞的?搞一个 while 循环,直到 insert 成功再返回成功。
  • 非重入的?在数据库表中加个字段,记录当前获得锁的机器的主机信息和线程信息,那么下次再获取锁的时候先查询数据库,如果当前机器的主机信息和线程信息在数据库可以查到的话,直接把锁分配给他就可以了。
  • 非公平的?再建一张中间表,将等待锁的线程全记录下来,并根据创建时间排序,只有最先创建的允许获取锁。
  • 比较好的办法是在程序中生产主键进行防重。

##### 基于表字段版本号做分布式锁

这个策略源于 mysql 的 mvcc 机制,使用这个策略其实本身没有什么问题,唯一的问题就是对数据表侵入较大,我们要为每个表设计一个版本号字段,然后写一条判断 sql 每次进行判断,增加了数据库操作的次数,在高并发的要求下,对数据库连接的开销也是无法忍受的。

基于悲观锁

##### 基于数据库排他锁做分布式锁

在查询语句后面增加for update,数据库会在查询过程中给数据库表增加排他锁 (注意: InnoDB 引擎在加锁的时候,只有通过索引进行检索的时候才会使用行级锁,否则会使用表级锁。这里我们希望使用行级锁,就要给要执行的方法字段名添加索引,值得注意的是,这个索引一定要创建成唯一索引,否则会出现多个重载方法之间无法同时被访问的问题。重载方法的话建议把参数类型也加上。)。当某条记录被加上排他锁之后,其他线程无法再在该行记录上增加排他锁。


我们可以认为获得排他锁的线程即可获得分布式锁,当获取到锁之后,可以执行方法的业务逻辑,执行完方法之后,通过connection.commit()操作来释放锁。


这种方法可以有效的解决上面提到的无法释放锁和阻塞锁的问题。

  • 阻塞锁? for update语句会在执行成功后立即返回,在执行失败时一直处于阻塞状态,直到成功。
  • 锁定之后服务宕机,无法释放?使用这种方式,服务宕机之后数据库会自己把锁释放掉。

但是还是无法直接解决数据库单点和可重入问题。

这里还可能存在另外一个问题,虽然我们对方法字段名使用了唯一索引,并且显示使用 for update 来使用行级锁。但是,MySQL 会对查询进行优化,即便在条件中使用了索引字段,但是否使用索引来检索数据是由 MySQL 通过判断不同执行计划的代价来决定的,如果 MySQL 认为全表扫效率更高,比如对一些很小的表,它就不会使用索引,这种情况下 InnoDB 将使用表锁,而不是行锁。如果发生这种情况就悲剧了。。。

还有一个问题,就是我们要使用排他锁来进行分布式锁的 lock,那么一个排他锁长时间不提交,就会占用数据库连接。一旦类似的连接变得多了,就可能把数据库连接池撑爆。

##### 优缺点

优点:简单,易于理解


缺点:会有各种各样的问题(操作数据库需要一定的开销,使用数据库的行级锁并不一定靠谱,性能不靠谱)

### 基于 Redis 做分布式锁

基于 REDIS 的 SETNX()、EXPIRE() 方法做分布式锁


setnx()


setnx 的含义就是 SET if Not Exists,其主要有两个参数 setnx(key, value)。该方法是原子的,如果 key 不存在,则设置当前 key 成功,返回 1;如果当前 key 已经存在,则设置当前 key 失败,返回 0。

expire()


expire 设置过期时间,要注意的是 setnx 命令不能设置 key 的超时时间,只能通过 expire() 来对 key 设置。

使用步骤


1、setnx(lockkey, 1) 如果返回 0,则说明占位失败;如果返回 1,则说明占位成功

2、expire() 命令对 lockkey 设置超时时间,为的是避免死锁问题。

3、执行完业务代码后,可以通过 delete 命令删除 key。

这个方案其实是可以解决日常工作中的需求的,但从技术方案的探讨上来说,可能还有一些可以完善的地方。比如,如果在第一步 setnx 执行成功后,在 expire() 命令执行成功前,发生了宕机的现象,那么就依然会出现死锁的问题,所以如果要对其进行完善的话,可以使用 redis 的 setnx()、get() 和 getset() 方法来实现分布式锁。

基于 REDIS 的 SETNX()、GET()、GETSET()方法做分布式锁


这个方案的背景主要是在 setnx() 和 expire() 的方案上针对可能存在的死锁问题,做了一些优化。

getset()


这个命令主要有两个参数 getset(key,newValue)。该方法是原子的,对 key 设置 newValue 这个值,并且返回 key 原来的旧值。假设 key 原来是不存在的,那么多次执行这个命令,会出现下边的效果:

  • getset(key, “value1”) 返回 null 此时 key 的值会被设置为 value1
  • getset(key, “value2”) 返回 value1 此时 key 的值会被设置为 value2
  • 依次类推!

使用步骤


  • setnx(lockkey, 当前时间+过期超时时间),如果返回 1,则获取锁成功;如果返回 0 则没有获取到锁,转向 2。
  • get(lockkey) 获取值 oldExpireTime ,并将这个 value 值与当前的系统时间进行比较,如果小于当前系统时间,则认为这个锁已经超时,可以允许别的请求重新获取,转向 3。
  • 计算 newExpireTime = 当前时间+过期超时时间,然后 getset(lockkey, newExpireTime) 会返回当前 lockkey 的值currentExpireTime。
  • 判断 currentExpireTime 与 oldExpireTime 是否相等,如果相等,说明当前 getset 设置成功,获取到了锁。如果不相等,说明这个锁又被别的请求获取走了,那么当前请求可以直接返回失败,或者继续重试。
  • 在获取到锁之后,当前线程可以开始自己的业务处理,当处理完毕后,比较自己的处理时间和对于锁设置的超时时间,如果小于锁设置的超时时间,则直接执行 delete 释放锁;如果大于锁设置的超时时间,则不需要再锁进行处理。

最后,https://blog.csdn.net/u010391342/article/details/84372342这个博客是针对分布式锁的优化

  • 1
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值