MelonBlog

redis setnx加锁的各种问题

redis setnx可以用作一个分布式锁来用,但是回遇到各种各样的问题。

不仅仅setnx,所有依赖原子get set操作来实现加锁的方案都有同样的问题,因为大家用setnx实际上就是使用它的原子get set操作。

下面来看看一下有哪些问题。


问题1:宕机导致死锁

假如对某一个商品做扣库存操作

public void deduct(String skuCode,int quantity) {
	Boolean locked = redisClient.setNx(skuCode, "");
	if (locked) {
		try {
			doBiz();
		} finally {
			redisClient.del(skuCode);
		}
	} else {
		sleep(3000); // 等待3秒
		deduct(skuCode, quantity); //等待完成后继续执行操作
	}
}

假如2个线程(T1和T2)同时执行deduct方法,T1先setNx成功,然后T1执行到doBiz()的时候机器断电了,这样T2会永远被阻塞,加锁的key永远不会被delete。

解决方案:可以通过对锁加一个过期时间来解决

public void deduct(String skuCode,int quantity) {
	Boolean locked = redisClient.setNx(skuCode,"", 10, TimeUnit.SECONDS);
	if (locked) {
		try {
			doBiz();
		} finally {
			redisClient.del(skuCode);
		}
	} else {
		sleep(3000); // 等待3秒
		deduct(skuCode, quantity); //等待完成后继续执行操作
	}
}

问题2:锁过期导致锁失效和无序释放

给key加上了10秒的过期时间,这样无论哪个持有锁的线程宕机了,都不会影响其他线程,但是新的问题又来了。

假如T1在执行doBiz()的时间超过了10s,这样T2会因为T1的锁过期了成功拿到锁,这样会导致业务出现异常。并且当T1的doBiz()执行完成后,开始释放锁,但是这个时刻的锁是属于T2的,假如这个时候T3也执行deduct方法,相当于T2的锁也失效了。

解决方案:可以把锁的时间加长和把当前线程的id作为key的值来判断是否有权释放锁来解决

public void deduct(String skuCode,int quantity) {
	Boolean locked = redisClient.setNx(skuCode, Thread.getId(), 60, TimeUnit.SECONDS);
	if (locked) {
		try {
			doBiz();
		} finally {
			redisClient.del(skuCode);
		}
	} else {
		sleep(3000); // 等待3秒
		deduct(skuCode, quantity); //等待完成后继续执行操作
	}
}

问题3:过期时间太长和太短的副作用太大

key的过期时间太长的话,如果线程宕掉,其他线程等待锁释放的时常太久了,会影响业务的正常运转。如果时间太短,那可能会导致锁又失效了。

解决方案:分解业务逻辑,在每个子业务逻辑里增加过期时间

public void doBiz(String skuCode) {
	doBiz1(skuCode);
	doBiz2(skuCode);
}
public void doBiz1(String skuCode) {
	redisClient.expire(skuCode, 10, TimeUnit.SECONDS);
	...
}
public void doBiz2(String skuCode) {
	redisClient.expire(skuCode, 10, TimeUnit.SECONDS);
	...
}

问题4:同一个线程无法重复加锁

将业务分解之后,分别给锁续期看上去能够解决锁过期问题,但是每个业务续多久时间实际上很难评估,而且需要把续期的代码耦合在业务代码里,可读性非常差,这里就算能够和好的续期,那还是存在其他问题,比如如果一个线程执行的多个方法都要获取同一把锁,setNx没办法很好的解决

public void doBiz(String skuCode) {
	doBiz1(skuCode);
	doBiz2(skuCode);
}
public void doBiz1(String skuCode) {
	Boolean locked = redisClient.setNx(skuCode, Thread.getId(), 10, TimeUnit.SECONDS);
	...
}
public void doBiz2(String skuCode) {
	Boolean locked = redisClient.setNx(skuCode, Thread.getId(), 10, TimeUnit.SECONDS);
	...
}

一个不完美的解决方案:通过ThreadLocal传递锁的状态到各个栈帧,但是这个有风险,因为判断逻辑和加锁逻辑不是一个原子操作。

public boolean lock(String key, String methodName) {
	if (ThreadLocalUtils.getLockKey().equals(key)) {
		return true;
	}
	Boolean locked = redisClient.setNx(skuCode, Thread.getId(), 60, TimeUnit.SECONDS);
	ThreadLocalUtils.setLockKey(key);
	ThreadLocalUtils.setLockMethod(key, methodName);
}
public boolean unLock(String key, String methodName) {
	if (ThreadLocalUtils.getLockKey().equals(key)) {
		if (ThreadLocalUtils.getLockMethod().equals(methodName)) {
			redisClient.del(key);
		}
		return true;
	}
}

上述思路不可能在生产环境使用,顶多在小项目里用,但是小项目也不会使用到可重入锁


如何解决上面这些问题呢?

老老实实用 Redisson 把,当然Redisson也不是完美的,但是绝大部分场景没问题,非常极端的情况下可能会出现问题,比如如果redis是cluster集群,因为redis cluster是保证可用性的ap架构,所以有可能leader的key没同步到follower,可能会导致锁丢失