分布式锁(4)-基于Mysql实现

系列文章链接:

1.使用场景

在分布式系统里,我们有时执行定时任务,或者处理某些并发请求,需要确保多点系统里同时只有一个执行线程进行处理。

分布式锁就是在分布式系统里互斥访问资源的解决方案。

通常我们会更多地使用Redis分布式锁、Zookeeper分布式锁的解决方案。

本篇文章介绍的是基于MySQL实现的分布式锁方案,性能上肯定是不如Redis、Zookeeper

所以,基于Mysql实现分布式锁,适用于对性能要求不高,并且不希望因为要使用分布式锁而引入新组件

2.基于唯一索引(insert)实现

2.1 实现方式

  • 获取锁时在数据库中insert一条数据,包括id、方法名(唯一索引)、线程名(用于重入)、重入计数
  • 获取锁如果成功则返回true
  • 获取锁的动作放在while循环中,周期性尝试获取锁直到结束或者可以定义方法来限定时间内获取锁
  • 释放锁的时候,delete对应的数据

2.2 优点:

  • 实现简单、易于理解

2.3 缺点

  • 没有线程唤醒,获取失败就被丢掉了;
  • 没有超时保护,一旦解锁操作失败,就会导致锁记录一直在数据库中,其他线程无法再获得到锁;
  • 这把锁强依赖数据库的可用性,数据库是一个单点,一旦数据库挂掉,会导致业务系统不可用;
  • 并发量大的时候请求量大,获取锁的间隔,如果较小会给系统和数据库造成压力;
  • 这把锁只能是非阻塞的,因为数据的insert操作,一旦插入失败就会直接报错,没有获得锁的线程并不会进入排队队列,要想再次获得锁就要再次触发获得锁操作;
  • 这把锁是非重入的,同一个线程在没有释放锁之前无法再次获得该锁,因为数据中数据已经存在了;
  • 这把锁是非公平锁,所有等待锁的线程凭运气去争夺锁。

2.4 简单实现方案

新建一张表,用于存储锁的信息,需要加锁的时候就插入一条记录,释放锁的时候就删除这条记录

新建一张最简单的表

CREATE TABLE `t_lock` (
  `lock_key` varchar(64) NOT NULL COMMENT '锁的标识',
  PRIMARY KEY (`lock_key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='分布式锁'

根据插入sql返回受影响的行数,大于0表示成功占有锁

insert ignore into t_lock(lock_key) values(:lockKey)

释放锁的时候就删除记录

delete from t_lock where lock_key = :lockKey

2.5 完善实现方案

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

  • 这把锁强依赖数据库的可用性,数据库是一个单点,一旦数据库挂掉,会导致业务系统不可用。
  • 这把锁没有失效时间,一旦解锁操作失败,就会导致锁记录一直在数据库中,其他线程无法再获得到锁。
  • 这把锁只能是非阻塞的,因为数据的insert操作,一旦插入失败就会直接报错。没有获得锁的线程并不会进入排队队列,要想再次获得锁就要再次触发获得锁操作。
  • 这把锁是非重入的,同一个线程在没有释放锁之前无法再次获得该锁。因为数据中数据已经存在了。

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

  • 数据库是单点?搞两个数据库,数据之前双向同步。一旦挂掉快速切换到备库上。
  • 没有失效时间?只要做一个定时任务,每隔一定时间把数据库中的超时数据清理一遍。
  • 非阻塞的?搞一个while循环,直到insert成功再返回成功。
  • 非重入的?在数据库表中加个字段,记录当前获得锁的机器的主机信息和线程信息,那么下次再获取锁的时候先查询数据库,如果当前机器的主机信息和线程信息在数据库可以查到的话,直接把锁分配给他就可以了。

3.基于排他锁(for update)实现

3.1 实现方式

  • 获取锁可以通过,在select语句后增加for update,数据库会在查询过程中给数据库表增加排他锁。当某条记录被加上排他锁之后,其他线程无法再在该行记录上增加排他锁,我们可以认为获得排它锁的线程即可获得分布式锁;
  • 其余实现与使用唯一索引相同;
  • 释放锁通过connection.commit();操作,提交事务来实现。

for update具体可参考数据库-MySQL中for update的作用和用法一文。

3.2 优点

  • 实现简单、易于理解。

3.3 缺点

  • 排他锁会占用连接,产生连接爆满的问题;
  • 如果表不大,可能并不会使用行锁;
  • 同样存在单点问题、并发量问题。

3.4 伪代码

CREATE TABLE `methodLock` (
    `id` INT ( 11 ) NOT NULL AUTO_INCREMENT COMMENT '主键',
    `method_name` VARCHAR ( 64 ) NOT NULL DEFAULT '' COMMENT '锁定的方法名',
    `desc` VARCHAR ( 1024 ) NOT NULL DEFAULT '备注信息',
    `update_time` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '保存数据时间,自动生成',
    PRIMARY KEY ( `id` ),
    UNIQUE KEY `uidx_method_name` ( `method_name ` ) USING BTREE
) ENGINE = INNODB DEFAULT CHARSET = utf8 COMMENT = '锁定中的方法';
/**
 * 加锁
 */
public boolean lock() {
    // 开启事务
    connection.setAutoCommit(false);
    // 循环阻塞,等待获取锁
    while (true) {
        // 执行获取锁的sql
        result = select * from methodLock where method_name = xxx for update;
        // 结果非空,加锁成功
        if (result != null) {
            return true;
        }
    }
    // 加锁失败
    return false;
}

/**
 * 解锁
 */
public void unlock() {
    // 提交事务,解锁
    connection.commit();
}

4.乐观锁实现

一般是通过为数据库表添加一个 version 字段来实现读取出数据时,将此版本号一同读出.

之后更新时,对此版本号加1,在更新过程中,会对版本号进行比较,如果是一致的,没有发生改变,则会成功执行本次操作;如果版本号不一致,则会更新失败。

实际就是个CAS过程。

缺点:

  1. 这种操作方式,使原本一次的update操作,必须变为2次操作: select版本号一次;update一次。增加了数据库操作的次数。
  2. 如果业务场景中的一次业务流程中,多个资源都需要用保证数据一致性,那么如果全部使用基于数据库资源表的乐观锁,就要让每个资源都有一张资源表,这个在实际使用场景中肯定是无法满足的。而且这些都基于数据库操作,在高并发的要求下,对数据库连接的开销一定是无法忍受的。
  3. 乐观锁机制往往基于系统中的数据存储逻辑,因此可能会造成脏数据被更新到数据库中。

5.总结

数据库锁现在使用较多的就上面说的3种方式,排他锁(悲观锁),版本号(乐观锁),记录锁,各有优缺点。

注意点:

  • 使用mysql分布式锁,必须保证多个服务节点使用的是同一个mysql库。

优点

  • 直接借助DB简单易懂。
  • 方便快捷,因为基本每个服务都会连接数据库,但是不是每个服务都会使用redis或者zookeeper;
  • 如果客户端断线了会自动释放锁,不会造成锁一直被占用;
  • mysql分布式锁是可重入锁,对于旧代码的改造成本低;

缺点

  • 加锁直接打到数据库,增加了数据库的压力;
  • 加锁的线程会占用一个session,也就是一个连接数,如果并发量大可能会导致正常执行的sql语句获取不到连接;
  • 服务拆分后如果每个服务使用自己的数据库,则不合适;
  • 锁的可用性和数据库强关联,一旦数据库挂了,则整个分布式锁不可用;
  • 如果需要考虑极限情况,会有超时等各种问题,在解决问题的过程中会使整个方案变得越来越复杂;
  • 数据库的性能瓶颈相较于rediszk要低很多,当调用量大的时候,性能问题将成为关键;
  • 还需要考虑超时等问题。
03-05 22:10