事务与锁的爱恨情仇-msyql更新丢失问题的解决方案

问题背景

一个砍价助力功能,用户下完单后可以邀请好友砍价。

问题描述

在好友砍价过程中记录好友砍价人数,是先从数据库查询已砍人数然后加一再更新数据库的方式实现的。如果有并发砍价情况出现,后一个事务更新的已砍人数便会覆盖前一个事务更新的已砍人数(更新丢失问题)。

问题分析

在mysql默认的事务隔离级别(可重复读)中事务查询数据是不加锁的,只有更新数据才加排他锁。并发情况下,倘若出现两个事务都查询了数据后,其中一个事务才去获取这条数据的排它锁去更新数据的情况,就会出现更新丢失。如下:
事务100和事务101同时开启并同时查询了同一条数据,然后再这条数据的某个值上+1再去获取这条数据的X锁去更新数据,其中事务100抢占到了锁,事务100先将数据的这个值更改,然后事务100提交后释放了这条数据的锁,此时事务101经过等待获取了这条数据的X锁,然后再去更新这条数据的那个值,更新完成后我们发现,事务101的更新覆盖了事务100的更新,这就是更新丢失问题。

我本以为如果使用事务的话,每个事务都会一一获取事务中相关记录所有的锁后才开始执行事务的,这样如果并发请求的话第一个事务优先获取要更新记录的x锁,第二个事务虽然先查询但也无法获取s锁。显然不是这样的,(倘若是这样的,岂不是变成了顺序执行)。这说明我对数据库锁在事务中的运用理解还不深刻。以mysql为例,在可重复读的隔离级别下,一个事务中的查询并不会获取该记录的共享锁(S锁),而是采用mvcc实现了类似乐观锁的机制。所以还是会出现更新丢失。

解决方法

虽然用上了事务,但是更新丢失的问题依然还没解决(除非数据库事务隔离级别设置成序列化,但是严重影响性能,相当于顺序执行,没法并发,显然这样是不行的)。所以我们这种情况经常用前两种解决方案

  • 悲观锁实现 查询时加锁(select .....for update),这种方案使用悲观锁,预设每次更新都会有别的事务在更新此数据,查询时就上锁,这种方案固然能解决问题且容易实现。但是其实大部分情况下都不会发生并发,所以这种方法无疑浪费了资源。但是在高争用条件下还是值得推荐的。
  • 乐观锁实现: 数据记录加版本标记,每次更新版本加1,更新时where条件后跟上查询出的版本号,这样如果此时版本号被别的事务更新过,那么更新条数就会返回0,根据这个更新条数来判断后续操作。这样做增加了程序的复杂性,但是提高了并发效率,在资源低争用条件下下值得推荐。
  • 隔离级别实现: 此外很多网上的文章介绍可以将数据库事务隔离级别设置为REPEATABLE-READ来避免更新丢失。但是这种解决方案大多数数据库下是不生效的,至少在mysql里是不生效的,标准的REPEATABLE-READ只保证在同一事务中的可重复读,并没有保证更新不丢失。或许有些许数据库或某些版本的数据库实现可重复读的方案是在select 上也加共享锁(S锁)直到事务结束释放,这样确实可以避免更新丢失。但是mysql的实现方案是mvvc并不能避免更新丢失。mysql可以将事务隔离级别设置为Serializable可避免更新丢失,但是这样数据库的效率变得很低。

引申 事务隔离性的实现原理

本节主要引申讨论事务的隔离性实现,重点讨论锁在事务中的运用。首先有几个基本概念需要先理解下:
数据库的锁一般有两种维度的定义,按独享和共享分为以下类型

  • Exclusive Locks(排它锁/X锁)顾名思义这种锁加在数据上,别的事务不能再次加别的任何锁了。
  • Shared Locks(共享锁/S锁) 此种锁可以加在另外的已有s锁的数据上

按锁的粒度分为以下锁:

  • table locks(表锁) 此锁是锁在表级别的使用这种锁会极大的降低数据库的并发量。
  • Record Locks(行锁) 此锁是锁在索引行上的,注意是索引行。
  • Gap Locks(间隙锁) 此种锁是加载一个范围上的 如 where id>1 and id <10 for update
  • Next-Key Locks(间隙锁) 另外一种间隙锁,是Gap 锁和行锁的结合,where id>=1 and id<=10 for update就是使用此种间隙锁。
  • 读未提交 READ UNCOMMITTED隔离级别下, 读不会加任何锁。而写会加X锁,直到事务结束释放X锁。既然写会加排他锁,那为何别的事务依然可以读呢,因为这个隔离级别下,读不加任何锁。
  • 读已提交 READ COMMITTED顾名思义,事务之间可以读取彼此已提交的数据。我们当然可以在读上加s锁(读完就可以释放s锁,不必等到事务结束),写加X锁直到事务结束来达到读已提交的隔离级别,但是,如果锁冲突很频繁的情况下读写不能同时进行会降低数据库的并发度。mysql采用mvcc的方案来实现这种隔离级别。mvcc机制可以实现读写并发执行,稍后重点再说。
  • 可重复读 REPEATABLE READ在这种隔离级别下,同一事务中读同一条数据的结果是相同的。我们当然也可以使用锁来实现这个特性。select时加s锁,并到事务结束释放,写加X锁直到事务结束释放。这样就可以实现可重复读了。但是mysql还是使用了mvcc来实现的,原因和上一个一样,锁冲突较多的情况下并发性的问题。
  • 序列化 SERIALISABLE此种隔离级别消除了幻读。但是代价也是最大的。几乎是串行执行事务了,同时只能有一个事务在执行。这种方案对于互联网高并发的业务来说几乎不可接受。但是mysql在 REPEATABLE READ级别已经解决了部分情况的幻读问题(innodb引擎),我们稍后再看。

再引申 mvcc多版本并发控制

mvcc是为了提高事务的并发性能而提供的一种解决方案,使用mvcc可以在读的时候不加锁就能实现可重复读。mvcc的实现需要借助两个隐藏列一个是trx_id表示的是这行的数据版本,实际这个列存储的是更新这行数据的事务id,另外一个rollback_pointer是指向上版本数据记录的指针,mvcc下数据被更新时会copy一份老数据到undo日志,然后在修改这行数据并在rollback_pointer里加个指向老版本数据的指针。
假如有条数据初始化如下:

id name trx_id rollback_pointer
1 小明 90

同时开启两个事务100 101

1 .事务100执行了查询语句

select * from user where id =1

首先这个事务会建立一个readview,这个readview包含当前活动的事务id [100,101]
然后判断数据行中trx_id与readview中事务id比较

  • 如果当前数据行中trx_id比readview中所有事务id都小则说明更新这条数据的事务早已提交,随意当前数据对查询事务可见。
  • 如果trx_id大于readview中最小事务id 且小于readview中最大事务id则但不包含在readview中,则说明更新这行数据的事务不活跃已提交,当前数据对查询数据可见。
  • 如果trx_id大于所有readview中事务id,则说明更新数据的事务开启时间在当前事务后,所以此时数据对当前事务不可见
  • 如果trx_id在readview中且trx_id不等于当前事务id,这说明更新这条数据的事务还未提交,则此数据不可见。

上述如果数据对当事务不可见的情况,会根据roollback_pointer找到上版本的数据,再次做上述判断,以此类推,知道找到可见版本的数据为止。

那再来看事务100查的数据trx_id此时为90,那对事务100的查询是可见的。查询出的数据就是当前数据。

2.事务101执行了sql

update user set name='小白' where id = 1

那么此时数据便成了这样:

id name trx_id rollback_pointer
1 小白 101 指向undo旧版本数据的指针

undo日志中记录的旧版本数据

id name trx_id rollback_pointer
1 小明 90

3.然后事务100如果再次执行查询的话,(readview在可重复读的隔离级别下首次执行select创建,在读已提交的隔离级别下,每次select都会创建新的readview)
判断 当前数据trx_id(101)在readview中且不等于当前事务id:100,所以该数据不可见,根据rollback_pointer找到undo中旧版本数据在此做比较 trx_id(90)<当前事务id100且不在readview中,所以undo中旧版本数据对事务100是可见的。这样就实现了可重复读。

你可能感兴趣的:(事务与锁的爱恨情仇-msyql更新丢失问题的解决方案)