(高阶) Redis 7 第17讲 分布式锁 实战篇

本文详细探讨了Redis分布式锁的实现,从V1.0的Jmeter压测发现问题,到V3.0引入过期时间解决并发问题,深入分析了误删锁、可重入性等挑战,并提供了相应的解决策略,最后讨论了自动续期和CAP原则在分布式锁中的应用。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

🌹 以下分享 Redis 分布式锁,如有问题请指教。
 
🌹🌹 如你对技术也感兴趣,欢迎交流。
 
🌹🌹🌹  如有对阁下帮助,请👍点赞💖收藏🐱‍🏍分享😀

 Redis 除了做缓存,其他基于Redis的用法。(答案:数据共享,分布式session/分布式锁/全局ID/计算器、点赞/位统计/购物车/轻量级消息队列(list/stream)/抽奖/签到打卡/交差并算法/热点排行榜)

Redis 做分布式锁的时候需要注意的问题
是否使用setnx 命令实现分布式锁?合适吗?如何考虑分布式锁可重入问题
Redis 单点部署会有什么问题
Redis 集群下,比如主从模式,CAP方面有什么问题
简单介绍一下RedLock,Redisson
Redis 分布式锁如何续期?看门狗(watch Dog)了解吗

Redis 集群保证(AP高可用),Redis 单机(C: 一致性)

 锁的种类

单机锁,同一JVM虚拟机synchronized/Lock
分布式锁,多个不同的JVM虚拟机

分布式锁满足条件

独占性高可用防死锁不乱抢可重入
OnlyOne ,任何时刻只能有仅有一个线程持有

        若Redis集群环境下,不能因为某个节点下线而出现获取锁和释放锁失败的情况

        高并发情况下,依然性能俱佳

杜绝死锁,必须有超时控制机制或撤销操作,有个兜底跳出方案。防止张冠李戴,只能释放自己的锁同一节点的同一线程如果获得锁之后,它可以再次获得这个锁。

服务结构 

公共代码 

// 扣减库存
	private String excuteSale(String message) {
		// 查询库存
		String sales = redisTemplate.opsForValue().get(KEY_);
		//判断库存是否足够
		Integer restSale = StrUtil.isEmpty(sales) ? null : Integer.parseInt(sales);
		//扣减数量
		if (Objects.nonNull(restSale) && restSale > 0) {
			redisTemplate.opsForValue().set(KEY_, String.valueOf(--restSale));
			message = String.format("卖出商品,库存剩余 %s,服务端口%s", restSale, port);
		} else {
			message = String.format("商品已售罄,服务端口%s", port);
		}
		
		return message;
	}

案列V1.0

private final StringRedisTemplate redisTemplate;

	private Lock lock = new ReentrantLock();
	
	private final static String KEY_ = "sale:001";
	
	public String sale() {
		String message = "";
		lock.lock();
		try {
			message = excuteSale(message);
		} finally {
			lock.unlock();
		}
		System.out.println(message);
		return message;
	}

Jmeter压测

问题

8888和9999 同时卖出,出现超卖 

 原因

在单机环境下,可以使用synchronized或Lock来实现 ;

在分布式架构中,因竞争的线程在不同的节点上,需要一个让所有进程都能访问的锁来实现阻塞(比如redis 或者zookeeper)

 案列V2.0

引入Redis 分布式锁 

// v2.0
	public String sale() {
		String message = "";
		String redis_key = "rdl_sale_lock";
		String uuidVal = IdUtil.simpleUUID() + "_" + Thread.currentThread().getId();
		Boolean flag = redisTemplate.opsForValue().setIfAbsent(redis_key, uuidVal);
		// flag=false ,抢不到的线程继续重试
		if (!flag) {
			// 暂停 20毫秒,进行递归重试
			try {
				TimeUnit.MILLISECONDS.sleep(20);
			} catch (InterruptedException e) {
				e.printStackTrace();
			}
			sale();
		} else {
			// 抢到锁的线程,正常业务执行,扣减库存
			try {
				message = excuteSale(message);
			} finally {
				redisTemplate.delete(redis_key);
			}
			
		}
		
		System.out.println(message);
		return message;
	}
	

功能测试通过 

 

 潜在问题

递归使用易导致StackOverflowError问题,使用while 替换if;

 解决方法

使用自旋代替递归

自旋的案例

public String sale() {
		String message = "";
		String redis_key = "rdl_sale_lock";
		String uuidVal = IdUtil.simpleUUID() + "_" + Thread.currentThread().getId();
		//递归,高并发下容易出错,用自旋替代递归方法重试调用: 用while来替代
		while (!redisTemplate.opsForValue().setIfAbsent(redis_key, uuidVal)) {
			// 暂停 20毫秒,进行递归重试
			try {
				TimeUnit.MILLISECONDS.sleep(20);
			} catch (InterruptedException e) {
				e.printStackTrace();
			}
		}
		// 抢到锁的线程,正常业务执行,扣减库存
		try {
			message = excuteSale(message);
		} finally {
			redisTemplate.delete(redis_key);
		}
		System.out.println(message);
		return message;
	}
	

案例V3.0 

案例问题 

  锁未加过期时间

当服务突然中断时,锁一直不能被占用未被删除,其他服务无法使用

 加入过期时间(不完美)

潜在存在的问题

高并发下,设置key和过期时间分开,会造成未加过期时间同等问题

解决方法

必须要合并成一行具备原子性 

		//递归,高并发下容易出错,用自旋替代递归方法重试调用: 用while来替代; 加入过期时间
		while (!redisTemplate.opsForValue().setIfAbsent(redis_key, uuidVal,10L,TimeUnit.SECONDS)) {
			// 暂停 20毫秒,进行递归重试
			try {
				TimeUnit.MILLISECONDS.sleep(20);
			} catch (InterruptedException e) {
				e.printStackTrace();
			}
		}

 深层问题01

误删分布式锁

        A 先获取锁成功执行业务,正常情况下,锁过期时间30秒,业务执行完成,A释放锁;异常情况,A在执行业务过程中出现卡顿等情况,30秒未能完成业务执行;锁自动被释放;B获取到锁,开始执行业务,这时候A执行业务完成将B获取的锁释放,B业务完成后释放锁时,不能获取到自己的锁。

 

解决方法

//判断加锁与解锁是不是同一个客户端自己只能删除自己的锁,不误删他人的
if (redisTemplate.opsForValue().get(redis_key) == uuidVal) {
	redisTemplate.delete(redis_key);
}

 深层问题02

Distributed Locks with Redis | RedisA distributed lock pattern with Redisicon-default.png?t=N7T8https://redis.io/docs/manual/patterns/distributed-locks/

问题

 判断跟删除操作非原子操作,可能出现业务影响;用Lua 脚本保证原子性

if redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("del",KEYS[1])
else
    return 0
end

解决方法

	// 抢到锁的线程,正常业务执行,扣减库存
		try {
			message = excuteSale(message);
		} finally {
			//修改为Lua脚本
			String luaScript="if redis.call('get',KEYS[1])==ARGV[1] then " +
					"return redis.call('del',KEYS[1]) " +
					"else " +
						"return 0 " +
					"end";
			redisTemplate.execute(new DefaultRedisScript<>(luaScript,Long.class), ListUtil.list(false,redis_key),uuidVal);
		}

可重入性问题

一个线程中的多个流程可以获取同一把锁,持有这个同步锁可以再次进入。

自己可以获取自己的内部锁

setnx 不满足可重入,HSET 可实现可重入

使用Hset 的hincreby对锁进行计数

 将加锁和解锁的代码使用Lua 脚本封装成对应的lock和unlock方法,使其符合AQS规范

 加锁(lock)Lua

# 加锁
# v 1.0 
if redis.call('EXISTS','key')==0 then
	redis.call('HSET','key','uuid:threadid',1)
	redis.call('EXPIRE','key','30')
	return 1
elseif redis.call('HEXISTS','key','uuid:threadid')==1 then 
	redis.call('HINCRBY','key','uuid:threadid',1)
	redis.call('EXPIRE','key','30')
	return 1
else
	return 0
end

# v2.0
if redis.call('EXISTS','key')==0 or redis.call('HEXISTS','key','uuid:threadid')==1 then
	redis.call('HINCRBY','key','uuid:threadid',1)
	redis.call('EXPIRE','key','30')
	return 1
else
	return 0
end

# v3.0 换参数
EVAL "if redis.call('EXISTS',KEYS[1]) == 0 or redis.call('HEXISTS',KEYS[1],ARGV[1]) == 1 then redis.call('HINCRBY',KEYS[1],ARGV[1],1) redis.call('EXPIRE',KEYS[1],ARGV[2]) return 1 else return 0 end" 1 rdl_sale_lock 999:001 30

解锁(unlock)Lua

# 解锁
# v1.0
if redis.call('HEXISTS','key','uuid:threadid') == 0 then
	return nil
elseif redis.call('HINCRBY','key','uuid:threadid',-1) == 0 then 
	return redis.call('DEL','key')
else
	return 0
end

# v2.0
if redis.call('HEXISTS',KEYS[1],ARGV[1]) == 0 then
	return nil
elseif redis.call('HINCRBY',KEYS[1],ARGV[1],-1) == 0 then 
	return redis.call('DEL',KEYS[1])
else
	return 0
end
# v3.0
if redis.call('HEXISTS',KEYS[1],ARGV[1]) == 0 then return nil elseif redis.call('HINCRBY',KEYS[1],ARGV[1],-1) == 0 then  return redis.call('DEL',KEYS[1]) else return 0 end

EVAL "if redis.call('HEXISTS',KEYS[1],ARGV[1]) == 0 then return nil elseif redis.call('HINCRBY',KEYS[1],ARGV[1],-1) == 0 then  return redis.call('DEL',KEYS[1]) else return 0 end"

 微服务整合

private final static String REDIS_KEY = "rdl_sale_lock";
	private final static Long EXPIRE_ = 30L;
	private   Lock lock = new RedisDistributedLock(redisTemplate,REDIS_KEY,EXPIRE_);

	public String sale() {
		String message = "";
		lock.lock();
		try {
			message = excuteSale(message);
		} finally {
			lock.unlock();
		}
		System.out.println(message);
		return message;
	}


/**
 * 自研Redis 分布式锁
 *
 * @author :liao.wei
 * @date :2023/10/1 21:03
 * @package : com.mco.rdl.lock
 */
public class RedisDistributedLock implements Lock {
	
	private StringRedisTemplate stringRedisTemplate;
	/**
	 * 锁名 KEYS[1]
	 */
	private String lockKey;
	
	/**
	 * 锁值 ARGV[1]
	 */
	private String lockVal;
	
	/**
	 * 过期时间 ARGV[2]
	 */
	private long expireTime;
	
	public RedisDistributedLock(StringRedisTemplate stringRedisTemplate, String lockKey, String lockVal, long expireTime) {
		this.stringRedisTemplate = stringRedisTemplate;
		this.lockKey = lockKey;
		this.expireTime = expireTime;
		this.lockVal = lockVal + ":" + Thread.currentThread().getId();
	}
	
	@Override
	public void lock() {
		tryLock();
	}
	
	@Override
	public boolean tryLock() {
		try {
			return tryLock(-1, TimeUnit.SECONDS);
		} catch (InterruptedException e) {
			throw new RuntimeException(e);
		}
	}
	
	@Override
	public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
		if (time == -1L) {
			String script = "if redis.call('EXISTS',KEYS[1]) == 0 or redis.call('HEXISTS',KEYS[1],ARGV[1]) == 1 then " +
					"redis.call('HINCRBY',KEYS[1],ARGV[1],1) " +
					"redis.call('EXPIRE',KEYS[1],ARGV[2]) " +
					"return 1 " +
					"else " +
					"return 0 " +
					"end";
			System.out.println("lockname=" + lockKey + ",uuidval=" + lockVal);
			while (Boolean.FALSE.equals(stringRedisTemplate.execute(new DefaultRedisScript<>(script, Boolean.class), Collections.singletonList(lockKey), lockVal, StrUtil.toString(expireTime)))) {
				// 暂停 60毫秒,进行递归重试
				try {
					TimeUnit.MILLISECONDS.sleep(60);
				} catch (InterruptedException e) {
					Log.get().log(Level.ERROR, String.format("分布式锁(%s->%s)重试失败!", lockKey, lockVal));
				}
			}
			return true;
		}
		return false;
	}
	
	@Override
	public void unlock() {
		System.out.println("释放 lockname=" + lockKey + ",uuidval=" + lockVal);
		String script = "if redis.call('HEXISTS',KEYS[1],ARGV[1]) == 0 then " +
				"return nil " +
				"elseif redis.call('HINCRBY',KEYS[1],ARGV[1],-1) == 0 then  " +
				"return " +
				"redis.call('DEL',KEYS[1]) " +
				"else " +
				"return 0 " +
				"end";
		Long flag = stringRedisTemplate.execute(new DefaultRedisScript<>(script, Long.class), Collections.singletonList(lockKey), lockVal);
		if (Objects.isNull(flag)) {
			throw new RuntimeException(String.format("%s当前锁%s ===%s 不存在!/(ㄒoㄒ)/~~", flag, lockKey, lockVal));
		}
	}
	
	/**
	 * 下面两个暂时不实现
	 */
	
	@Override
	public void lockInterruptibly() throws InterruptedException {
	
	}
	
	@Override
	public Condition newCondition() {
		return null;
	}
}

 分布式锁引入工厂模式

/**
 * 分布式锁工厂类
 *
 * @author :liao.wei
 * @date :2023/10/1 22:11
 * @package : com.mco.rdl.lock
 */
@Component
@RequiredArgsConstructor
public class DistributedLockFactory {
	private final StringRedisTemplate template;
	
	public Lock getDistributeLock(String lockType, String lockKey, String uuid, Long expire) {
		if (StrUtil.isEmpty(lockType)) {
			return null;
		} else if (StrUtil.equalsIgnoreCase("REDIS", lockType)) {
			return new RedisDistributedLock(template, lockKey, uuid, expire);
		} else if (StrUtil.equalsIgnoreCase("ZOOKEEPER", lockType)) {
			// TODO zookeeper版本分布式锁
			return null;
		} else if (StrUtil.equalsIgnoreCase("MYSQL", lockType)) {
			// TODO MYSQL版本分布式锁
			return null;
		} else {
			return null;
		}
	}
}

测试

private String excuteSale(String uuid) {
		String message = "";
		// 查询库存
		String sales = redisTemplate.opsForValue().get(KEY_);
		//判断库存是否足够
		Integer restSale = StrUtil.isEmpty(sales) ? null : Integer.parseInt(sales);
		//扣减数量
		if (Objects.nonNull(restSale) && restSale > 0) {
			redisTemplate.opsForValue().set(KEY_, String.valueOf(--restSale));
			message = String.format("卖出商品,库存剩余 %s,服务端口%s", restSale, port);
			testEntry(uuid);
		} else {
			message = String.format("商品已售罄,服务端口%s", port);
		}
		return message;
	}
	
	private void testEntry(String uuid) {
		Lock lock = distributedLockFactory.getDistributeLock("Redis", REDIS_KEY, uuid, EXPIRE_);
		lock.lock();
		try {
			System.out.println("---------测试可重入锁------");
		} finally {
			lock.unlock();
		}
	}

 自动续期

业务执行时间大于过期时间

 CAP

Redis集群Zookeeper集群Eureka集群Nacos集群
APCPAPAP

Lua 脚本 
/**
	 * 启动定时任务进行自动续期
	 */
	private void reNewExpire() {
		String script = "if redis.call('HEXISTS',KEYS[1],ARGV[1]) == 1 then " +
				"return redis.call('EXPIRE',KEYS[1],ARGV[2]) " +
				"else " +
				"return 0 " +
				"end";
		new Timer().schedule(new TimerTask() {
			@Override
			public void run() {
				if (Boolean.TRUE.equals(stringRedisTemplate.execute(new DefaultRedisScript<>(script, Boolean.class), Collections.singletonList(lockKey), lockVal, StrUtil.toString(expireTime)))) {
					reNewExpire();
				}
			}
		}, this.expireTime * 1000 / 3);
	}
	

测试

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

一介布衣+

做好事,当好人

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值