手写Redis分布式锁需注意的问题-实践

实现普通Redis分布式锁过程会遇到的问题

问题1:使用Syschronize或者ReentrantLock,在分布式环境下会出现超买的情况。

上redis分布式锁setnx:

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.UUID;

/**
 * @author Jujuxiaer
 */
@RestController
public class GoodsController {

	private Logger logger = LoggerFactory.getLogger(GoodsController.class);

	@Autowired
	private StringRedisTemplate redisTemplate;

	private static final String GOODS_KEY = "goods:001";

	private static final String REDIS_LOCK = "redis_lock";

	@Value("${server.port}")
	private String serverPort;

	@GetMapping("/buyGoods")
	public String buyGoods() {
		String value = UUID.randomUUID() + Thread.currentThread().getName();
		// 相当于 setnx命令
		Boolean flag = redisTemplate.opsForValue().setIfAbsent(REDIS_LOCK, value);
		if (Boolean.FALSE.equals(flag)) {
			return "抢锁失败,请重试。";
		}
		String result = redisTemplate.opsForValue().get(GOODS_KEY);
		int goodsNumber = (result == null ? 0 : Integer.parseInt(result));
		String ret;
		if (goodsNumber > 0) {
			int realGoodNumber = goodsNumber - 1;
			redisTemplate.opsForValue().set(GOODS_KEY, String.valueOf(realGoodNumber));
			ret = "成功买到商品,库存还剩" + realGoodNumber + "件。服务提供方端口:" + serverPort;
			logger.info(ret);
			// 解锁
			redisTemplate.delete(REDIS_LOCK);
			return ret;
		}
		ret = "商品已经售完或者活动结束或者调用超时,欢迎下次光临。服务提供方端口:" + serverPort;
		logger.info(ret);
		return ret;
	}
}

有什么问题呢?

问题2:出异常的话,可能无法释放锁,必须在代码层面finally释放锁。

解决方式,代码如下:

@GetMapping("/buyGoods")
public String buyGoods() {
    String value = UUID.randomUUID() + Thread.currentThread().getName();

    try {
        // 相当于 setnx命令
        Boolean flag = redisTemplate.opsForValue().setIfAbsent(REDIS_LOCK, value);
        if (Boolean.FALSE.equals(flag)) {
            return "抢锁失败,请重试。";
        }
        String result = redisTemplate.opsForValue().get(GOODS_KEY);
        int goodsNumber = (result == null ? 0 : Integer.parseInt(result));
        String ret;
        if (goodsNumber > 0) {
            int realGoodNumber = goodsNumber - 1;
            redisTemplate.opsForValue().set(GOODS_KEY, String.valueOf(realGoodNumber));
            ret = "成功买到商品,库存还剩" + realGoodNumber + "件。服务提供方端口:" + serverPort;
            logger.info(ret);
            return ret;
        }
        ret = "商品已经售完或者活动结束或者调用超时,欢迎下次光临。服务提供方端口:" + serverPort;
        logger.info(ret);
        return ret;
    } finally {
        // 解锁
        redisTemplate.delete(REDIS_LOCK);
    }
}

上面代码的问题:

问题3:如果宕机了,部署了微服务的jar包的机器挂了,代码层面根本没有走到finally这块,没办法保证解锁,这个key没有被删除,需要加入一个过期时间限定的key。

解决方式:

需要对key设置过期时间。加行设置redis的key过期时间的代码 redisTemplate.expire(REDIS_LOCK,10, TimeUnit.SECONDS);

@GetMapping("/buyGoods")
public String buyGoods() {
    String value = UUID.randomUUID() + Thread.currentThread().getName();

    try {
        // 相当于 setnx命令
        Boolean flag = redisTemplate.opsForValue().setIfAbsent(REDIS_LOCK, value);
        redisTemplate.expire(REDIS_LOCK,10, TimeUnit.SECONDS);
        if (Boolean.FALSE.equals(flag)) {
            return "抢锁失败,请重试。";
        }
        String result = redisTemplate.opsForValue().get(GOODS_KEY);
        int goodsNumber = (result == null ? 0 : Integer.parseInt(result));
        String ret;
        if (goodsNumber > 0) {
            int realGoodNumber = goodsNumber - 1;
            redisTemplate.opsForValue().set(GOODS_KEY, String.valueOf(realGoodNumber));
            ret = "成功买到商品,库存还剩" + realGoodNumber + "件。服务提供方端口:" + serverPort;
            logger.info(ret);
            return ret;
        }
        ret = "商品已经售完或者活动结束或者调用超时,欢迎下次光临。服务提供方端口:" + serverPort;
        logger.info(ret);
        return ret;
    } finally {
        // 解锁
        redisTemplate.delete(REDIS_LOCK);
    }
}

上面代码的问题:

Boolean flag = redisTemplate.opsForValue().setIfAbsent(REDIS_LOCK, value); // 相当于 setnx命令
redisTemplate.expire(REDIS_LOCK,10, TimeUnit.SECONDS);

问题4:这两行代码不能保证原子性,有可能代码执行完第一行代码后,就宕机了,也就设置不了Redis的key过期时间。

解决方式:

使用setIfAbsent另一个参数列表的方法,自带设置过期时间。能保证原子性:

Boolean flag = redisTemplate.opsForValue().setIfAbsent(REDIS_LOCK, value, 10, TimeUnit.SECONDS);

上面代码的问题:

问题5:设置固定的过期时间会带来另一个问题,A线程错删了B线程的锁。

比如说A线程 设置了redisTemplate.opsForValue().setIfAbsent(REDIS_LOCK, value, 10, TimeUnit.SECONDS);这行代码,设置了锁,切过期时间为10秒,如果A线程执行业务时间超过了10秒还未完成,A线程加的锁过期了,B线程就会进来加上同样key值的redis锁,接着A线程继续后面的业务,最后执行解锁操作,也就是finally代码块中的redisTemplate.delete(REDIS_LOCK),此时删除的锁是B线程加的锁。

image-20210916223918000

解决方式:

只能删除自己的锁,不能动别人的锁。 删锁前判断当前要删的锁是不是自己加的锁。关键代码如下:

// 判断加锁和解锁是不是同一个客户端
if (redisTemplate.opsForValue().get(REDIS_LOCK).equalsIgnoreCase(value)) {
    // 解锁
    redisTemplate.delete(REDIS_LOCK);
}

完整代码如下:

@GetMapping("/buyGoods")
public String buyGoods() {
    String value = UUID.randomUUID() + Thread.currentThread().getName();

    try {
        Boolean flag = redisTemplate.opsForValue().setIfAbsent(REDIS_LOCK, value, 10, TimeUnit.SECONDS);
        if (Boolean.FALSE.equals(flag)) {
            return "抢锁失败,请重试。";
        }
        String result = redisTemplate.opsForValue().get(GOODS_KEY);
        int goodsNumber = (result == null ? 0 : Integer.parseInt(result));
        String ret;
        if (goodsNumber > 0) {
            int realGoodNumber = goodsNumber - 1;
            redisTemplate.opsForValue().set(GOODS_KEY, String.valueOf(realGoodNumber));
            ret = "成功买到商品,库存还剩" + realGoodNumber + "件。服务提供方端口:" + serverPort;
            logger.info(ret);
            return ret;
        }
        ret = "商品已经售完或者活动结束或者调用超时,欢迎下次光临。服务提供方端口:" + serverPort;
        logger.info(ret);
        return ret;
    } finally {
        // 判断加锁和解锁是不是同一个客户端
        if (redisTemplate.opsForValue().get(REDIS_LOCK).equalsIgnoreCase(value)) {
            // 解锁
            redisTemplate.delete(REDIS_LOCK);
        }
    }
}

上述代码的问题:

问题6:finally代码块中判断是否是自己加的锁和解锁操作不能保证原子性

解决方式:

  • 第一种方式:用Redis自带的事务解决(Redis是事务介绍见下文)
@GetMapping("/buyGoods")
public String buyGoods() {
    String value = UUID.randomUUID() + Thread.currentThread().getName();

    try {
        Boolean flag = redisTemplate.opsForValue().setIfAbsent(REDIS_LOCK, value, 10, TimeUnit.SECONDS);
        if (Boolean.FALSE.equals(flag)) {
            return "抢锁失败,请重试。";
        }
        String result = redisTemplate.opsForValue().get(GOODS_KEY);
        int goodsNumber = (result == null ? 0 : Integer.parseInt(result));
        String ret;
        if (goodsNumber > 0) {
            int realGoodNumber = goodsNumber - 1;
            redisTemplate.opsForValue().set(GOODS_KEY, String.valueOf(realGoodNumber));
            ret = "成功买到商品,库存还剩" + realGoodNumber + "件。服务提供方端口:" + serverPort;
            logger.info(ret);
            return ret;
        }
        ret = "商品已经售完或者活动结束或者调用超时,欢迎下次光临。服务提供方端口:" + serverPort;
        logger.info(ret);
        return ret;
    } finally {
        while (true) {
            redisTemplate.watch(REDIS_LOCK);
            // 判断加锁和解锁是不是同一个客户端
            if (redisTemplate.opsForValue().get(REDIS_LOCK).equalsIgnoreCase(value)) {
                redisTemplate.setEnableTransactionSupport(true);
                redisTemplate.multi();
                // 解锁
                redisTemplate.delete(REDIS_LOCK);
                List<Object> list = redisTemplate.exec();
                if (list == null) {
                    continue;
                }
            }
            redisTemplate.unwatch();
            break;
        }
    }
}
  • 第二种方式:用Lua脚本解决
/**
 * @author Jujuxiaer
 * @date 2021-09-12 17:54
 */
@RestController
public class GoodsController {

	private Logger logger = LoggerFactory.getLogger(GoodsController.class);

	@Autowired
	private StringRedisTemplate redisTemplate;

	private static final String GOODS_KEY = "goods:001";

	private static final String REDIS_LOCK = "redis_lock";

	/**
	 * 来源于redis官网:https://redis.io/commands/set 页面最下方
	 * 删除锁的时候,找到 key 对应的 value,跟自己传过去的 value 做比较,如果是一样的才删除。
	 */
	private static final String LUA_SCRIPT = "if redis.call(\"get\",KEYS[1]) == ARGV[1]\n" +
			"then\n" +
			"    return redis.call(\"del\",KEYS[1])\n" +
			"else\n" +
			"    return 0\n" +
			"end";

	@Value("${server.port}")
	private String serverPort;

	@GetMapping("/buyGoods")
	public String buyGoods() throws Exception {
		String value = UUID.randomUUID() + Thread.currentThread().getName();

		try {
			// 相当于 setnx命令
			Boolean flag = redisTemplate.opsForValue().setIfAbsent(REDIS_LOCK, value, 10, TimeUnit.SECONDS);
			if (Boolean.FALSE.equals(flag)) {
				return "抢锁失败,请重试。";
			}
			String result = redisTemplate.opsForValue().get(GOODS_KEY);
			int goodsNumber = (result == null ? 0 : Integer.parseInt(result));
			String ret;
			if (goodsNumber > 0) {
				int realGoodNumber = goodsNumber - 1;
				redisTemplate.opsForValue().set(GOODS_KEY, String.valueOf(realGoodNumber));
				ret = "成功买到商品,库存还剩" + realGoodNumber + "件。服务提供方端口:" + serverPort;
				logger.info(ret);
				return ret;
			}
			ret = "商品已经售完或者活动结束或者调用超时,欢迎下次光临。服务提供方端口:" + serverPort;
			logger.info(ret);
			return ret;
		} finally {
			Jedis jedis = RedisUtils.getJedis();
			try {
				Object ret = jedis.eval(LUA_SCRIPT, Collections.singletonList(REDIS_LOCK), Collections.singletonList(value));
				if ("1".equals(ret)) {
					logger.info("delete redis lock ok");
				} else {
					logger.error("delete redis lock error");
				}
			} finally {
				if (jedis != null) {
					jedis.close();
				}
			}
		}
	}
}

上面代码还存在问题:

问题7:不能确保redis过期时间大于业务执行时间的问题。那么会引出来的问题,Redis分布式锁如何续期?

问题8:在使用 Redis 时,一般会采用主从集群 + 哨兵的模式部署,这样做的好处在于,当主库异常宕机时,哨兵可以实现「故障自动切换」,把从库提升为主库,继续提供服务,以此保证可用性。那当「主从发生切换」时,这个分布锁会依旧安全吗?

试想这样的场景:

  1. 客户端 1 在主库上执行 SET 命令,加锁成功
  2. 此时,主库异常宕机,SET 命令还未同步到从库上(主从复制是异步的)
  3. 从库被哨兵提升为新主库,这个锁在新的主库上,丢失了!

image-20211108171009938

可见,当引入 Redis 副本后,分布锁还是可能会受到影响。

怎么解决这个问题?

为此,Redis 的作者提出一种解决方案,就是我们经常听到的 Redlock(分布式锁的简称)。

**解决方式:**Redisson

示例流程图:

image-20211108173937186

示例主要代码:

package com.jujuxiaer.bootredis.controller;

import org.redisson.Redisson;
import org.redisson.api.RLock;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

/**
 * @author Jujuxiaer
 * @date 2021-09-12 17:54
 */
@RestController
public class Redisson_GoodsController {

	private final Logger logger = LoggerFactory.getLogger(Redisson_GoodsController.class);
	private static final String GOODS_KEY = "goods:001";
	private static final String REDIS_LOCK = "redis_lock";

	@Autowired
	private StringRedisTemplate redisTemplate;

	@Autowired
	private Redisson redisson;

	@Value("${server.port}")
	private String serverPort;

	@GetMapping("/buyGoods")
	public String buyGoods() throws Exception {
		RLock redissonLock = redisson.getLock(REDIS_LOCK);
		redissonLock.lock();
		try {
			String result = redisTemplate.opsForValue().get(GOODS_KEY);
			int goodsNumber = (result == null ? 0 : Integer.parseInt(result));
			String ret;
			if (goodsNumber > 0) {
				int realGoodNumber = goodsNumber - 1;	
				redisTemplate.opsForValue().set(GOODS_KEY, String.valueOf(realGoodNumber));
				ret = "成功买到商品,库存还剩" + realGoodNumber + "件。服务提供方端口:" + serverPort;
				logger.info(ret);
				return ret;
			}
			ret = "商品已经售完或者活动结束或者调用超时,欢迎下次光临。服务提供方端口:" + serverPort;
			logger.info(ret);
			return ret;
		} finally {
			redissonLock.unlock();
		}
	}
}

可能会出现下面的异常:

java.lang.IllegalMonitorStateException: attempt to unlock lock, 
not locked by current thread by node id: 1f24378c-5456-4321-827a-bc0a7515ec5d thread-id: 227
at org.redisson.RedissonLock.unlock(RedissonLock.java:366) ~[redisson-2.10.5.jar:na]

解决办法,解锁时需要判断解锁,是不是锁住的并且当前线程持有了这把锁:

finally {
  if (redissonLock.isLocked() && redissonLock.isHeldByCurrentThread()) {
     redissonLock.unlock();
  }
}

Redis事务

事务介绍

  • Redis的事务是通过MULTI、EXEC、DISCARD、WATCH这四个命令来完成。
  • Redis的单个命令都是原子性的,所以这里确保事务性的对象是命令集合
  • Redis将命令集合序列化并确保处于一事务的命令集合连续且不被打断的执行。
  • Redis不支持回滚的操作

相关命令

  • MULTI

    注:用于标记事务块的开始

    Redis会将后续的命令逐个放入队列中,然后使用EXEC命令原子化地执行命令序列。

    语法:MULTI

  • EXEC

    在一个事务中执行所有先前放入队列的命令,然后恢复正常的连接状态。

    语法:EXEC

  • DISCARD

    清除所有先前在一个事务中放入队列的命令,然后恢复正常的连接状态。

    语法:DISCARD

  • WATCH

    当某个事务需要按条件执行时,就要使用这个命令将给定的键设置为受监控状态

    语法:WATHC key [key …]

    注:该命令可以实现Redis的乐观锁

  • UNWATCH

    清除所有先前为一个事务监控的键。

    语法:UNWATCH

测试如下:

用MULTI开启一个事务,让set k1 v1命令和set k2 v2命令放在队列中一起执行。

127.0.0.1:6379> MULTI
OK
127.0.0.1:6379(TX)> set k1 v1
QUEUED
127.0.0.1:6379(TX)> set k2 v2
QUEUED
127.0.0.1:6379(TX)> EXEC
1) OK
2) OK
127.0.0.1:6379> WATCH k1
OK
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379(TX)> set k1 11
QUEUED
127.0.0.1:6379(TX)> set k2 22
QUEUED
127.0.0.1:6379(TX)> EXEC
(nil)
    
当执行到  set k2 22 命令时,另一个终端又设置了k1的值如下:
127.0.0.1:6379> set k1 AAA
OK
    
最后执行EXEC命令失败,因为在WATCH k1的时候 k1的值是"v1"而不是中间被另一个终端改成的值"AAA",
相当于被人动过了,违背了执行命令前的初衷,所以最后EXEC命令就执行失败了。
  • 1
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值