跳到主要内容

分布式锁实现

在分布式系统中,分布式锁是解决并发控制和数据一致性问题的关键技术。它确保了在多进程或多线程环境下,对共享资源的访问是互斥的,从而避免了数据冲突和不一致性。本文将深入探讨分布式锁的概念、常见实现方式。

分布式锁概念

分布式锁是满足分布式系统或集群模式下多进程可见并且互斥的锁。其核心思想是让所有进程使用同一把锁,通过锁来控制线程的执行,使程序在并发环境下能够串行执行,从而保证数据的安全性和一致性。

1653374296906.png

常见的分布式锁

常见的分布式锁有三种:Mysql、Redis 和 Zookeeper。

  1. Mysql:Mysql 本身带有锁机制,但由于其性能一般,在分布式锁的应用中相对较少。
  2. Redis:Redis 作为分布式锁是非常常见的一种方式,在企业级开发中广泛应用。它利用setnx命令来实现锁的获取,如果插入键成功,则表示获取到锁,否则表示获取锁失败。此外,还可以通过设置过期时间来自动释放锁,避免死锁的发生。
  3. Zookeeper:Zookeeper 也是企业级开发中实现分布式锁的较好方案,它提供了强大的分布式协调能力,能够确保锁的可靠性和一致性。

1653382219377.png

实现思路

  1. 获取锁:
    • 互斥:确保在同一时刻只能有一个线程获取到锁,其他线程必须等待。
    • 非阻塞:尝试获取锁一次,如果成功则返回true,否则返回false,避免线程阻塞等待。
  2. 释放锁:
    • 手动释放:线程在完成任务后,应及时手动释放锁,以便其他线程能够获取锁。
    • 超时释放:在获取锁时设置一个超时时间,如果线程在超时时间内未完成任务,锁将自动释放,避免死锁的发生。

为了确保锁的操作原子性,通常使用一些原子性操作或脚本,如 Redis 中的 Lua 脚本,来保证获取锁和设置过期时间的操作是一个原子操作,避免出现部分成功的情况。例如

setnx lock thread1
expire lock 5

有可能在执行完第一句话的时候,服务器挂了,过期时间就无法执行,造成死锁的情况,无法保证原子性,因此我们想要这两个操作同时执行,可以使用 下面这种方式,过期时间ex 为10s:

set lock thread ex 10 nx

分布式锁实现

锁的基本接口:

public interface ILock {

/**
* 尝试获取锁
* @param timeoutSec 超时时间,单位秒 过期自动释放锁
* @return true 获取成功,false 获取失败
*/
boolean tryLock(long timeoutSec);

/**
* 释放锁
*/
void unlock();
}

实现类:

public class SimpleRedisLock implements ILock {

private String name;
private StringRedisTemplate stringRedisTemplate;

public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
this.name = name;
this.stringRedisTemplate = stringRedisTemplate;
}

public static final String KEY_PREFIX = "lock:";

@Override
public boolean tryLock(long timeoutSec) {
//获取当前线程的id
long threadId = Thread.currentThread().getId();
Boolean success = stringRedisTemplate.opsForValue()
.setIfAbsent(KEY_PREFIX + name, String.valueOf(threadId),
timeoutSec, TimeUnit.SECONDS);
return BooleanUtil.isTrue(success);
}

@Override
public void unlock() {
stringRedisTemplate.delete(KEY_PREFIX + name);
}
}

修改业务代码: 原来有问题的代码:

        Long userId = UserHolder.getUser().getId();
synchronized (userId.toString().intern()) {
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
}

修改如下 :

        Long userId = UserHolder.getUser().getId();

SimpleRedisLock lock = new SimpleRedisLock("order:" + userId, stringRedisTemplate);
boolean isLock = lock.tryLock(5);
if (!isLock) {
//获取失败,返回错误或者 重试
return Result.fail("服务器繁忙");
}
try {
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
} finally {
lock.unlock();
}

##Redis 分布式锁误删问题

误删问题描述:持有锁的线程在锁的内部出现阻塞,导致锁自动释放,此时其他线程(如线程 2)尝试获取锁并成功获得。然后线程 2 在持有锁执行过程中,线程 1 反应过来继续执行,当线程 1 走到删除锁逻辑时,会误将本应该属于线程 2 的锁删除,这就是误删别人锁的情况。

1653385920025.png

解决方案:在获取锁时存入线程标示(可以用 UUID 表示),在释放锁时先获取锁中的线程标示,判断是否与当前线程标示一致。如果一致,则释放锁;如果不一致,则不进行锁的删除。

1653387398820.png

具体实现代码如下:

public static final String KEY_PREFIX = "lock:";
public static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";

@Override
public boolean tryLock(long timeoutSec) {
// 获取当前线程的id
String threadId = ID_PREFIX + Thread.currentThread().getId();
Boolean success = stringRedisTemplate.opsForValue()
.setIfAbsent(KEY_PREFIX + name, String.valueOf(threadId),
timeoutSec, TimeUnit.SECONDS);
return BooleanUtil.isTrue(success);
}

@Override
public void unlock() {
// 获取当前线程的id
String threadId = ID_PREFIX + Thread.currentThread().getId();
// 获取锁的值
String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
if (threadId.equals(id)) {
// 一致,说明是当前线程的锁,删除
stringRedisTemplate.delete(KEY_PREFIX + name);
}
}

分布式锁原子性问题

问题描述:线程 1 持有锁后,在执行业务逻辑过程中准备删除锁,并且已经走到了条件判断的过程,即确认当前锁属于自己,正准备删除锁时,锁到期了。此时线程 2 进入,而线程 1 在卡顿结束后会继续执行删除锁的代码,导致条件判断没有起到作用,这就是删锁时的原子性问题。出现这个问题的原因是线程 1 的拿锁、判断锁、删锁操作并不是原子性的。

1653387764938.png

解决方案:使用 Lua 脚本解决多条命令的原子性问题。Lua 脚本可以编写多条 Redis 命令,确保这些命令执行时的原子性。

Lua脚本解决多条命令原子性问题

lua脚本可以编写多条redis命令,确保多条命令执行时的原子性. 调用函数

redis.call("命令名称 ","key","其他参数 ")

例如:

-- 执行set name jack  
redis.call("set","name","jack")

先执行set name Rose,再执行get name,则脚本如下:

--先执行set name Rose,再执行get name,则脚本如下:
redis.call("set","name","Rose")
local name = redis.call("get", "name")
return name

例如,释放锁的业务流程如下:

  • 获取锁中的线程标示。
  • 判断是否与指定的标示(当前线程标示)一致。
  • 如果一致则释放锁(删除)。
  • 如果不一致则什么都不做。

对应的Lua脚本为:

if (redis.call('GET', KEYS[1]) == ARGV[1]) then  
-- 一致,则删除锁
return redis.call('DEL', KEYS[1])
end
-- 不一致,则直接返回
return 0

修改代码:

private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;

static {
UNLOCK_SCRIPT = new DefaultRedisScript<>();
UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
UNLOCK_SCRIPT.setResultType(Long.class);
}

@Override
public void unlock() {
//调用lua脚本
stringRedisTemplate.execute(
UNLOCK_SCRIPT,
Collections.singletonList(KEY_PREFIX + name),
ID_PREFIX + Thread.currentThread().getId());
}