【分散式鎖】通過MySQL資料庫的表來實現-V1

羅西施 發表於 2021-07-27
資料庫 MySQL

一、來源

  之所以要寫這篇文章是因為想對自己當前的分散式知識做一個歸納。今天就先推出一篇MySQL實現的分散式鎖,後續會繼續推出其他版本的分散式鎖,比如通過Zookeeper、Redis實現等。

 

二、正題

  要想通過MySQL來實現分散式鎖,那麼必定是需要一個唯一的特性才可以實現,比如主鍵、唯一索引這類。因為鎖是為了限制資源的同步訪問,也就是一個瞬間只能有一個執行緒去訪問該資源。分散式鎖就是為了解決這個資源的訪問競爭問題。

  那麼,主鍵這個方式是不建議使用的,因為我們的鎖有可能是各種字串,雖然字串也可以當作主鍵來使用,但是這樣會讓插入變得完全隨機,進而觸發頁分裂。所以站在效能角度,MySQL分散式鎖這一塊不使用主鍵來實現,而採用唯一索引的方式來實現。

  直接上資料庫指令碼:

DROP TABLE IF EXISTS `distribute_lock_info`;

CREATE TABLE `distribute_lock_info` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增ID',
  `lock_key` varchar(100) NOT NULL COMMENT '加鎖Key',
  `lock_value` varchar(100) NOT NULL COMMENT '加鎖Value',
  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '建立時間',
  `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新時間',
  `expire_time` datetime DEFAULT NULL COMMENT '過期時間',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uidx_lock_key` (`lock_key`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=21884 DEFAULT CHARSET=utf8mb4;

  這裡主要的是3個欄位:加鎖key(唯一索引),加鎖value,過期時間。id採用自增,保證順序性。兩個時間主要是作為一個補充資訊,非必需欄位。

  ok,那麼到這裡,一張基本的分散式鎖的表設計就已經完成了。這裡的唯一索引是實現互斥的一個重點。

  接著就開始程式碼的編寫了,先來一份依賴檔案。

    <dependencies>
        <!-- lombok -->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>false</optional>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <!--mybatis-plus-->
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
        </dependency>
        <!--mybatis-generator-->
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-generator</artifactId>
            <version>3.2.0</version>
        </dependency>

        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
        </dependency>
    </dependencies>

  分散式鎖有個過期時間,那麼就需要定時清除這些過期的鎖資訊,這也是預防死鎖的一個手段。所以,我們可以編寫一個清除的定時任務,來幫助我們清除過期的鎖資訊。程式碼如下:

package cn.lxw.task;

import cn.lxw.configdb.DistributeLockInfoMapper;
import cn.lxw.entity.DistributeLockInfo;
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.annotation.Scheduled;

import javax.annotation.Resource;
import java.time.LocalDateTime;

@Configuration
@EnableScheduling
public class LockCleanTask {

    @Resource
    private DistributeLockInfoMapper lockInfoMapper;

    /**
     * 功能描述: <br>
     * 〈Clean the lock which is expired.〉
     * @Param: []
     * @Return: {@link Void}
     * @Author: luoxw
     * @Date: 2021/7/26 20:13
     */
    @Scheduled(cron = "0/6 * * * * *")
    public void cleanExpireLock() {
        int deleteResult = lockInfoMapper.delete(new UpdateWrapper<DistributeLockInfo>() {
            {
                le("expire_time", LocalDateTime.now());
            }
        });
        System.out.println("[LockCleanTask]The count of expired lock is " + deleteResult + "!");
    }
}

  清除任務搞定,那麼我們可以開始建立資料庫的分散式鎖那張表對應的實體類,方便後面操作表資料。

package cn.lxw.entity;

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.io.Serializable;
import java.time.LocalDateTime;

/**
 * 功能描述: <br>
 * 〈The entity of ditribute_lock_info table in database.〉
 * @Param:
 * @Return: {@link }
 * @Author: luoxw
 * @Date: 2021/7/26 20:19
 */
@Data
@AllArgsConstructor
@NoArgsConstructor
@TableName("distribute_lock_info")
public class DistributeLockInfo implements Serializable {

    @TableId(value = "id", type = IdType.AUTO)
    private Long id;

    @TableField("lock_key")
    private String lockKey;

    @TableField("lock_value")
    private String lockValue;

    @TableField("create_time")
    private LocalDateTime createTime;

    @TableField("update_time")
    private LocalDateTime updateTime;

    @TableField("expire_time")
    private LocalDateTime expireTime;

    @Override
    public String toString() {
        return "DistributeLockInfo{" +
                "id=" + id +
                ", lockKey='" + lockKey + '\'' +
                ", lockValue='" + lockValue + '\'' +
                ", createTime=" + createTime +
                ", updateTime=" + updateTime +
                ", expireTime=" + expireTime +
                '}';
    }
}

  接著,就是編寫這張表對應的增刪改查操作了。這裡我採用的是Mybatis-Plus,這樣比較快捷一些。

package cn.lxw.configdb;


import cn.lxw.entity.DistributeLockInfo;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;

/**
 * 功能描述: <br>
 * 〈Judge by whether the record insert success or not to prove that lock-operation is whether success or not.So I need to define a method which can ignore insert when failed.〉
 * @Param:
 * @Return: {@link }
 * @Author: luoxw
 * @Date: 2021/7/26 20:19
 */
public interface DistributeLockInfoMapper extends BaseMapper<DistributeLockInfo> {

    int insertIgnore(DistributeLockInfo entity);

}

  這時,應該宣告一個分散式鎖相關的API操作,比如這些,加鎖,解鎖,獲取鎖資訊等。

package cn.lxw.service;

import cn.lxw.entity.DistributeLockInfo;

import java.util.concurrent.TimeUnit;

public interface ILockService {

    /**
     * 功能描述: <br>
     * 〈Lock until success!〉
     * @Param: [lockKey, lockValue]
     * @Return: {@link Void}
     * @Author: luoxw
     * @Date: 2021/7/26 20:14
     */
    void lock(String lockKey,String lockValue);

    /**
     * 功能描述: <br>
     * 〈Lock method, return the result immediately if failed .〉
     * @Param: [lockKey, lockValue]
     * @Return: {@link boolean}
     * @Author: luoxw
     * @Date: 2021/7/26 20:14
     */
    boolean tryLock(String lockKey,String lockValue);

    /**
     * 功能描述: <br>
     * 〈Lock with a timeout param, return the result immediately if failed.If lock success and it is expired,will be freed by LockCleanTask {@link cn.lxw.task.LockCleanTask}〉
     * @Param: [lockKey, lockValue, expireTime, unit]
     * @Return: {@link boolean}
     * @Author: luoxw
     * @Date: 2021/7/26 20:14
     */
    boolean tryLock(String lockKey,String lockValue,long expireTime, TimeUnit unit);

    /**
     * 功能描述: <br>
     * 〈Unlock with lockKey & lockValue.If doesn't matched,will be lock failed.〉
     * @Param: [lockKey, lockValue]
     * @Return: {@link boolean}
     * @Author: luoxw
     * @Date: 2021/7/26 20:14
     */
    boolean unlock(String lockKey,String lockValue);

    /**
     * 功能描述: <br>
     * 〈Get lock info by lockKey!〉
     * @Param: [lockKey]
     * @Return: {@link DistributeLockInfo}
     * @Author: luoxw
     * @Date: 2021/7/26 20:14
     */
    DistributeLockInfo getLock(String lockKey);
}

   接著就是通過資料庫的增刪改查操作去實現這些API。

package cn.lxw.service.impl;

import cn.lxw.configdb.DistributeLockInfoMapper;
import cn.lxw.entity.DistributeLockInfo;
import cn.lxw.service.ILockService;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
import org.springframework.context.annotation.Primary;
import org.springframework.stereotype.Service;

import javax.annotation.Resource;
import java.time.LocalDateTime;
import java.util.concurrent.TimeUnit;

@Service
@Primary
public class MysqlDistributeLockServiceImpl implements ILockService {

    @Resource
    private DistributeLockInfoMapper lockInfoMapper;

    /**
     * 功能描述: <br>
     * 〈Lock until success!〉
     * @Param: [lockKey, lockValue]
     * @Return: {@link Void}
     * @Author: luoxw
     * @Date: 2021/7/26 20:14
     */
    @Override
    public void lock(String lockKey, String lockValue) {
        int insertResult = 0;
        // trying until success
        while(insertResult < 1) {
            insertResult = lockInfoMapper.insertIgnore(new DistributeLockInfo() {
                {
                    setLockKey(lockKey);
                    setLockValue(lockValue);
                }
            });
        }
    }

    /**
     * 功能描述: <br>
     * 〈Lock method, return the result immediately if failed .〉
     * @Param: [lockKey, lockValue]
     * @Return: {@link boolean}
     * @Author: luoxw
     * @Date: 2021/7/26 20:14
     */
    @Override
    public boolean tryLock(String lockKey, String lockValue) {
        int insertResult = lockInfoMapper.insertIgnore(new DistributeLockInfo() {
            {
                setLockKey(lockKey);
                setLockValue(lockValue);
            }
        });
        return insertResult == 1;
    }

    /**
     * 功能描述: <br>
     * 〈Lock with a timeout param, return the result immediately if failed.If lock success and it is expired,will be freed by LockCleanTask {@link cn.lxw.task.LockCleanTask}〉
     * @Param: [lockKey, lockValue, expireTime, unit]
     * @Return: {@link boolean}
     * @Author: luoxw
     * @Date: 2021/7/26 20:14
     */
    @Override
    public boolean tryLock(String lockKey, String lockValue, long expireTime, TimeUnit unit) {
        long expireNanos = unit.toNanos(expireTime);
        LocalDateTime expireDateTime = LocalDateTime.now().plusNanos(expireNanos);
        int insertResult = lockInfoMapper.insertIgnore(new DistributeLockInfo() {
            {
                setLockKey(lockKey);
                setLockValue(lockValue);
                setExpireTime(expireDateTime);
            }
        });
        return insertResult == 1;
    }

    /**
     * 功能描述: <br>
     * 〈Unlock with lockKey & lockValue.If doesn't matched,will be lock failed.〉
     * @Param: [lockKey, lockValue]
     * @Return: {@link boolean}
     * @Author: luoxw
     * @Date: 2021/7/26 20:14
     */
    @Override
    public boolean unlock(String lockKey, String lockValue) {
        int deleteResult = lockInfoMapper.delete(new UpdateWrapper<DistributeLockInfo>() {
            {
                eq("lock_key", lockKey);
                eq("lock_value", lockValue);

            }
        });
        return deleteResult == 1;
    }

    /**
     * 功能描述: <br>
     * 〈Get lock info by lockKey!〉
     * @Param: [lockKey]
     * @Return: {@link DistributeLockInfo}
     * @Author: luoxw
     * @Date: 2021/7/26 20:14
     */
    @Override
    public DistributeLockInfo getLock(String lockKey) {
        return lockInfoMapper.selectOne(new QueryWrapper<DistributeLockInfo>(){
            {
                eq("lock_key",lockKey);
            }
        });
    }
}

  理解起來沒有那麼困難,【加鎖】實際就是新增一條記錄,【解鎖】就是刪除這條記錄,【獲取鎖資訊】就是查詢出這條記錄,【加過期時間的鎖】就是新增記錄的時候多設定一個過期時間。

  這樣的話,就可以進行測試工作了。測試之前,需要先準備一個鎖資訊的生成工具類,幫助我們生成統一格式的鎖資訊。主要的鎖資訊有:IP+節點ID+執行緒ID+執行緒名稱+時間戳。這個鎖資訊一方面是為了解鎖的時候是唯一值,不會誤解掉別人的鎖,還有一方面是可以提供有效的資訊幫助你排查問題。

package cn.lxw.util;

/**
 * 功能描述: <br>
 * 〈A string util of lock.〉
 * @Param:
 * @Return: {@link }
 * @Author: luoxw
 * @Date: 2021/7/26 20:09
 */
public class LockInfoUtil {

    private static final String LOCAL_IP = "192.168.8.8";
    private static final String NODE_ID = "node1";
    private static final String STR_SPILT = "-";
    private static final String STR_LEFT = "[";
    private static final String STR_RIGHT = "]";
    
    /**
     * 功能描述: <br>
     * 〈Return the unique String value of lock info.〉
     * "[192.168.8.8]-[node1]-[37]-[pool-1-thread-1]-[1627301265325]"
     * @Param: []
     * @Return: {@link String}
     * @Author: luoxw
     * @Date: 2021/7/26 20:08
     */
    public static String createLockValue(){
        StringBuilder stringBuilder = new StringBuilder();
        stringBuilder
                .append(STR_LEFT)
                .append(LOCAL_IP)
                .append(STR_RIGHT)
                .append(STR_SPILT)
                .append(STR_LEFT)
                .append(NODE_ID)
                .append(STR_RIGHT)
                .append(STR_SPILT)
                .append(STR_LEFT)
                .append(Thread.currentThread().getId())
                .append(STR_RIGHT)
                .append(STR_SPILT)
                .append(STR_LEFT)
                .append(Thread.currentThread().getName())
                .append(STR_RIGHT)
                .append(STR_SPILT)
                .append(STR_LEFT)
                .append(System.currentTimeMillis())
                .append(STR_RIGHT);
        return stringBuilder.toString();
    }
}

  測試開始,我這邊直接通過main函式進行測試工作。大家有覺得不妥的,可以寫個test類,效果是一樣的。

package cn.lxw;

import cn.lxw.entity.DistributeLockInfo;
import cn.lxw.service.ILockService;
import cn.lxw.util.LockInfoUtil;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.transaction.annotation.EnableTransactionManagement;

import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;

@SpringBootApplication
@EnableTransactionManagement
@MapperScan("cn.lxw")
@EnableScheduling
public class MainApp {

    /**
     * 功能描述: <br>
     * 〈DistributeLock testing start here!〉
     *
     * @Param: [args]
     * @Return: {@link Void}
     * @Author: luoxw
     * @Date: 2021/7/26 18:20
     */
    public static void main(String[] args) {
        // run the springboot app
        ConfigurableApplicationContext context = SpringApplication.run(MainApp.class, args);
        // define some lock infomation
        final String lockKey = "lock_test";
        ILockService lockService = context.getBean(ILockService.class);
        // create a ThreadPoolExecutor
        ThreadPoolExecutor tpe = new ThreadPoolExecutor(5,
                5,
                60,
                TimeUnit.SECONDS,
                new LinkedBlockingQueue<>(10));
        // execute the simulator
        for (int i = 0; i < 3; i++) {
            tpe.execute(() -> {
                while (true) {
                    // get the unique lock value of current thread
                    String lockValue = LockInfoUtil.createLockValue();
                    // start lock the lockKey
                    boolean tryLockResult = lockService.tryLock(lockKey, lockValue, 10L, TimeUnit.SECONDS);
                    // get the most new lock info with lockKey
                    DistributeLockInfo currentLockInfo = lockService.getLock(lockKey);
                    System.out.println("[LockThread]Thread[" + Thread.currentThread().getId() + "] lock result:" + tryLockResult + ",current lock info:" + (currentLockInfo==null?"null":currentLockInfo.toString()));
                    // here do some business opearation
                    try {
                        TimeUnit.SECONDS.sleep((int) (Math.random() * 10));
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    // continue to fight for lock if failed
                    if(!tryLockResult){
                        continue;
                    }
                    // start unlock the lockKey with lockKey & lockValue
                    lockService.unlock(lockKey, lockValue);
                }
            });
        }
    }
}

 檢視日誌,是否滿足分散式鎖的要求:同一個瞬間,必然只有一個執行緒可以爭搶到對應資源的鎖。

2021-07-27 20:33:40.764  INFO 14128 --- [pool-1-thread-1] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Starting...
2021-07-27 20:33:40.972  INFO 14128 --- [pool-1-thread-1] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Start completed.
[LockThread]Thread[39] lock result:false,current lock info:DistributeLockInfo{id=22354, lockKey='lock_test', lockValue='[192.168.8.8]-[node1]-[38]-[pool-1-thread-2]-[1627389195666]', createTime=2021-07-27T20:33:15, updateTime=2021-07-27T20:33:15, expireTime=2021-07-27T20:33:26}
[LockThread]Thread[37] lock result:false,current lock info:DistributeLockInfo{id=22354, lockKey='lock_test', lockValue='[192.168.8.8]-[node1]-[38]-[pool-1-thread-2]-[1627389195666]', createTime=2021-07-27T20:33:15, updateTime=2021-07-27T20:33:15, expireTime=2021-07-27T20:33:26}
[LockThread]Thread[38] lock result:false,current lock info:DistributeLockInfo{id=22354, lockKey='lock_test', lockValue='[192.168.8.8]-[node1]-[38]-[pool-1-thread-2]-[1627389195666]', createTime=2021-07-27T20:33:15, updateTime=2021-07-27T20:33:15, expireTime=2021-07-27T20:33:26}
[LockThread]Thread[38] lock result:false,current lock info:DistributeLockInfo{id=22354, lockKey='lock_test', lockValue='[192.168.8.8]-[node1]-[38]-[pool-1-thread-2]-[1627389195666]', createTime=2021-07-27T20:33:15, updateTime=2021-07-27T20:33:15, expireTime=2021-07-27T20:33:26}
[LockCleanTask]The count of expired lock is 1!
[LockThread]Thread[37] lock result:true,current lock info:DistributeLockInfo{id=22362, lockKey='lock_test', lockValue='[192.168.8.8]-[node1]-[37]-[pool-1-thread-1]-[1627389224033]', createTime=2021-07-27T20:33:44, updateTime=2021-07-27T20:33:44, expireTime=2021-07-27T20:33:54}
[LockThread]Thread[38] lock result:false,current lock info:DistributeLockInfo{id=22362, lockKey='lock_test', lockValue='[192.168.8.8]-[node1]-[37]-[pool-1-thread-1]-[1627389224033]', createTime=2021-07-27T20:33:44, updateTime=2021-07-27T20:33:44, expireTime=2021-07-27T20:33:54}
[LockThread]Thread[37] lock result:true,current lock info:DistributeLockInfo{id=22364, lockKey='lock_test', lockValue='[192.168.8.8]-[node1]-[37]-[pool-1-thread-1]-[1627389224067]', createTime=2021-07-27T20:33:44, updateTime=2021-07-27T20:33:44, expireTime=2021-07-27T20:33:54}
[LockThread]Thread[37] lock result:true,current lock info:DistributeLockInfo{id=22365, lockKey='lock_test', lockValue='[192.168.8.8]-[node1]-[37]-[pool-1-thread-1]-[1627389225085]', createTime=2021-07-27T20:33:45, updateTime=2021-07-27T20:33:45, expireTime=2021-07-27T20:33:55}
[LockThread]Thread[39] lock result:false,current lock info:DistributeLockInfo{id=22365, lockKey='lock_test', lockValue='[192.168.8.8]-[node1]-[37]-[pool-1-thread-1]-[1627389225085]', createTime=2021-07-27T20:33:45, updateTime=2021-07-27T20:33:45, expireTime=2021-07-27T20:33:55}
[LockCleanTask]The count of expired lock is 0!
[LockThread]Thread[38] lock result:false,current lock info:DistributeLockInfo{id=22365, lockKey='lock_test', lockValue='[192.168.8.8]-[node1]-[37]-[pool-1-thread-1]-[1627389225085]', createTime=2021-07-27T20:33:45, updateTime=2021-07-27T20:33:45, expireTime=2021-07-27T20:33:55}
[LockThread]Thread[38] lock result:false,current lock info:DistributeLockInfo{id=22365, lockKey='lock_test', lockValue='[192.168.8.8]-[node1]-[37]-[pool-1-thread-1]-[1627389225085]', createTime=2021-07-27T20:33:45, updateTime=2021-07-27T20:33:45, expireTime=2021-07-27T20:33:55}
[LockThread]Thread[39] lock result:false,current lock info:DistributeLockInfo{id=22365, lockKey='lock_test', lockValue='[192.168.8.8]-[node1]-[37]-[pool-1-thread-1]-[1627389225085]', createTime=2021-07-27T20:33:45, updateTime=2021-07-27T20:33:45, expireTime=2021-07-27T20:33:55}
[LockThread]Thread[37] lock result:true,current lock info:DistributeLockInfo{id=22370, lockKey='lock_test', lockValue='[192.168.8.8]-[node1]-[37]-[pool-1-thread-1]-[1627389232106]', createTime=2021-07-27T20:33:52, updateTime=2021-07-27T20:33:52, expireTime=2021-07-27T20:34:02}
[LockCleanTask]The count of expired lock is 0!
[LockThread]Thread[38] lock result:false,current lock info:DistributeLockInfo{id=22370, lockKey='lock_test', lockValue='[192.168.8.8]-[node1]-[37]-[pool-1-thread-1]-[1627389232106]', createTime=2021-07-27T20:33:52, updateTime=2021-07-27T20:33:52, expireTime=2021-07-27T20:34:02}
[LockThread]Thread[38] lock result:false,current lock info:DistributeLockInfo{id=22370, lockKey='lock_test', lockValue='[192.168.8.8]-[node1]-[37]-[pool-1-thread-1]-[1627389232106]', createTime=2021-07-27T20:33:52, updateTime=2021-07-27T20:33:52, expireTime=2021-07-27T20:34:02}
[LockThread]Thread[38] lock result:false,current lock info:DistributeLockInfo{id=22370, lockKey='lock_test', lockValue='[192.168.8.8]-[node1]-[37]-[pool-1-thread-1]-[1627389232106]', createTime=2021-07-27T20:33:52, updateTime=2021-07-27T20:33:52, expireTime=2021-07-27T20:34:02}

  大家可以自己按照這個思路去調整一下程式碼驗證一下。

三、結論

  結論是通過MySQL我們是可以實現分散式鎖的,而且十分簡單,一張表,一點程式碼就可以搞定。但是,它的本質是通過資料庫的鎖來實現的,所以這麼做會增加資料庫的負擔。而且資料庫實現的鎖效能是有瓶頸的,不能滿足效能高的業務場景。所以,效能低的業務下玩玩是可以的。

  

  OK,本篇MySQL實現分散式鎖介紹結束,歡迎關注下一篇分散式鎖V2的實現。

 

  專案地址:https://github.com/telephone6/java-collection/tree/main/distribute/distribute-lock/mysql-lock