MySQL悲观锁与乐观锁

什么是悲观锁

悲观锁的特点是先获取锁,再进行业务操作,即 “悲观” 的认为获取锁是非常有可能失败的,因此要先确保获取锁成功再进行业务操作。通常所说的 “一锁二查三更新” 即指的是使用悲观锁。

通常来讲在数据库上的悲观锁需要数据库本身提供支持,即通过常用的 select … for update 操作来实现悲观锁。 当数据库执行 select for update 时会获取被 select 中的数据行的行锁,因此其他并发执行的 select for update 如果试图选中同一行则会发生排斥(需要等待行锁被释放),因此达到锁的效果。select for update 获取的行锁会在当前事务结束时自动释放,因此必须在事务中使用。

mysql 还有个问题是 select… for update 语句执行中,如果数据表没有添加索引或主键,所有扫描过的行都会被锁上,这一点很容易造成问题。因此如果在 mysql 中用悲观锁务必要确定走了索引,而不是全表扫描。

悲观锁应用举例

商品 goods 表中有一个字段 status,status 为 1 代表商品未被下单,status 为 2 代表商品已经被下单,那么我们对某个商品下单时必须确保该商品 status 为 1。假设商品的 id 为 1。如果不采用锁,那么操作方法如下:

# 1.查询出商品信息 select status from t_goods where id=1; # 2.根据商品信息生成订单 insert into t_orders (id,goods_id) values (null,1); # 3.修改商品status为2 update t_goods set status=2;

上面这种场景在高并发访问的情况下很可能会出现问题。前面已经提到,只有当 goods status 为 1 时才能对该商品下单,上面第一步操作中,查询出来的商品 status 为 1。但是当我们执行第三步 Update 操作的时候,有可能出现其他人先一步对商品下单把 goods status 修改为 2 了,但是我们并不知道数据已经被修改了,这样就可能造成同一个商品被下单 2 次,使得数据不一致。所以说这种方式是不安全的。

使用悲观锁来实现,在上面的场景中,商品信息从查询出来到修改,中间有一个处理订单的过程,使用悲观锁的原理就是,当我们在查询出 goods 信息后就把当前的数据锁定,直到我们修改完毕后再解锁。那么在这个过程中,因为 goods 被锁定了,就不会出现有第三者来对其进行修改了。(注:要使用悲观锁,我们必须关闭 mysql 数据库的自动提交属性,因为 MySQL 默认使用 autocommit 模式,也就是说,当你执行一个更新操作后,MySQL 会立刻将结果进行提交。)

我们可以使用命令设置 MySQL 为非 autocommit 模式:

set autocommit=0;

设置完 autocommit 后,我们就可以执行我们的正常业务了。具体如下:

# 0.开始事务 begin;/begin work;/start transaction; (三者选一就可以) # 1.查询出商品信息 select status from t_goods where id=1 for update; # 2.根据商品信息生成订单 insert into t_orders (id,goods_id) values (null,1); # 3.修改商品status为2 update t_goods set status=2; # 4.提交事务 commit;/commit work;

注:上面的 begin/commit 为事务的开始和结束,因为在前一步我们关闭了 mysql 的 autocommit,所以需要手动控制事务的提交,在这里就不细表了。上面的第一步我们执行了一次查询操作:

select status from t_goods where id=1 for update;

与普通查询不一样的是,我们使用了 select…for update 的方式,这样就通过数据库实现了悲观锁。此时在 t_goods 表中,id 为 1 的 那条数据就被我们锁定了,其它的事务必须等本次事务提交之后才能执行。这样我们可以保证当前的数据不会被其它事务修改。

注:需要注意的是,在事务中,只有 SELECT … FOR UPDATE 或 LOCK IN SHARE MODE 同一笔数据时会等待其它事务结束后才执行,一般 SELECT … 则不受此影响。拿上面的实例来说,当我执行 select status from t_goods where id=1 for update; 后。我在另外的事务中如果再次执行 select status from t_goods where id=1 for update; 则第二个事务会一直等待第一个事务的提交,此时第二个查询处于阻塞的状态,但是如果我是在第二个事务中执行 select status from t_goods where id=1; 则能正常查询出数据,不会受第一个事务的影响。

上面我们提到,使用 select…for update 会把数据给锁住,不过我们需要注意一些锁的级别,MySQL InnoDB 默认 Row-Level Lock,所以只有明确地指定主键,MySQL 才会执行 Row lock (只锁住被选取的数据) ,否则 MySQL 将会执行 Table Lock (将整个数据表单给锁住)。

Mysql的乐观锁

乐观锁的特点先进行业务操作,不到万不得已不去拿锁。即“乐观”的认为拿锁多半是会成功的,因此在进行完业务操作需要实际更新数据的最后一步再去拿一下锁就好。

乐观锁在数据库上的实现完全是逻辑的,不需要数据库提供特殊的支持。一般的做法是在需要锁的数据上增加一个版本号,或者时间戳。

乐观锁的两种实现方式

  1. 使用数据版本(Version)记录机制实现,这是乐观锁最常用的一种实现方式。何谓数据版本?即为数据增加一个版本标识,一般是通过为数据库表增加一个数字类型的 “version” 字段来实现。当读取数据时,将 version 字段的值一同读出,数据每更新一次,对此 version 值加一。当我们提交更新的时候,判断数据库表对应记录的当前版本信息与第一次取出来的 version 值进行比对,如果数据库表当前版本号与第一次取出来的 version 值相等,则予以更新,否则认为是过期数据。
  2. 乐观锁定的第二种实现方式和第一种差不多,同样是在需要乐观锁控制的table中增加一个字段,名称无所谓,字段类型使用时间戳(timestamp),和上面的 version 类似,也是在更新提交的时候检查当前数据库中数据的时间戳和自己更新前取到的时间戳进行对比,如果一致则 OK,否则就是版本冲突。

乐观锁的应用举例

还是拿之前的实例来举:商品 goods 表中有一个字段 status,status 为 1 代表商品未被下单,status 为 2 代表商品已经被下单,那么我们对某个商品下单时必须确保该商品 status 为 1。假设商品的 id 为 1。下单操作包括 3 步骤:

# 1.查询出商品信息 select (status,status,version) from t_goods where id=#{id} # 2.根据商品信息生成订单 insert into t_orders (id,goods_id) values (null,1); # 3.修改商品status为2 update t_goods set status=2,version=version+1 where id=#{id} and version=#{version};

乐观锁与悲观锁的总结

悲观锁优缺点:

  1. 悲观锁适用于可靠的持续性连接,诸如 C/S 应用。 对于 Web 应用的 HTTP 连接,先天不适用。
  2. 锁的使用意味着性能的损耗,在高并发、锁定持续时间长的情况下,尤其严重。 Web 应用的性能瓶颈多在数据库处,使用悲观锁,进一步收紧了瓶颈。
  3. 非正常中止情况下的解锁机制,设计和实现起来很麻烦,成本还很高。
  4. 不够严谨的设计下,可能产生莫名其妙的,不易被发现的的死锁问题。

乐观锁的优缺点:

  1. 乐观锁机制避免了长事务中的数据库加锁开销,大大提升了大并发量下的系统整体性能表现。
  2. 乐观锁机制往往基于系统中的数据存储逻辑,因此也具备一定的局限性,如在上例中,由于乐观锁机制是在我们的系统中实现,来自外部系统的更新操作不受我们系统的控制,因此可能会造成脏数据被更新到数据库中。在系统设计阶段,应该充分考虑到这些情况出现的可能性,并进行相应调整(如将乐观锁策略在数据库存储过程中实现,对外只开放基于此存储过程的数据更新途径,而不是将数据库表直接对外公开)。