在分布式集群环境下,对 Redis 数据的修改也会发生冲突,这时候需要利用锁的机制,防止数据在同一时间被多个系统修改。
实现分布式锁的思路就是利用 Redis 的两个命令:setnx 和 setex,修改数据前使用 setnx 命令对操作加锁,防止其他系统执行相同操作,使用 setex 命令设置锁超时时间(这一步的目的是防止系统突然挂掉,没有解锁),在操作结束后,进行解锁。
我们新建一个任务 Scheduled,每10秒执行一次,在不同的机器(虚拟机)上启动相同的项目,因为锁的原因,同一时间只有一个任务被执行,代码如下:
@Service public class LockNxExJob { private static final Logger logger = LoggerFactory.getLogger(LockNxExJob.class); @Autowired private RedisService redisService; @Autowired private RedisTemplate redisTemplate; private static String LOCK_PREFIX = "prefix_"; @Scheduled(cron = "0/10 * * * * *") public void lockJob() { String lock = LOCK_PREFIX + "LockNxExJob"; try{ //redistemplate setnx操作 boolean nxRet = redisTemplate.opsForValue().setIfAbsent(lock,getHostIp());
//试想一下,加入程序运行到这里,系统发生宕机 Object lockValue = redisService.get(lock); //获取锁失败 if(!nxRet){ //宕机后,每次获取锁都会失败,这个锁除非人为解锁,否则一直被锁
String value = (String)redisService.get(lock); //打印当前占用锁的服务器IP logger.info("get lock fail,lock belong to:{}",value); return; }else{ redisTemplate.opsForValue().set(lock,getHostIp(),3600); //获取锁成功 logger.info("start lock lockNxExJob success"); Thread.sleep(5000); } }catch (Exception e){ logger.error("lock error",e); }finally { //任务完成后,释放锁 redisService.remove(lock); } } /** * 获取本机内网IP地址方法 * @return */ private static String getHostIp(){ try{ Enumeration<NetworkInterface> allNetInterfaces = NetworkInterface.getNetworkInterfaces(); while (allNetInterfaces.hasMoreElements()){ NetworkInterface netInterface = (NetworkInterface) allNetInterfaces.nextElement(); Enumeration<InetAddress> addresses = netInterface.getInetAddresses(); while (addresses.hasMoreElements()){ InetAddress ip = (InetAddress) addresses.nextElement(); if (ip != null && ip instanceof Inet4Address && !ip.isLoopbackAddress() //loopback地址即本机地址,IPv4的loopback范围是127.0.0.0 ~ 127.255.255.255 && ip.getHostAddress().indexOf(":")==-1){ return ip.getHostAddress(); } } } }catch(Exception e){ e.printStackTrace(); } return null; } }
上面代码使用到的 RedisService 类
@Service public class RedisService { @Autowired private RedisTemplate redisTemplate; private static double size = Math.pow(2, 32); /** * 写入缓存 * * @param key * @param offset 位 8Bit=1Byte * @return */ public boolean setBit(String key, long offset, boolean isShow) { boolean result = false; try { ValueOperations<Serializable, Object> operations = redisTemplate.opsForValue(); operations.setBit(key, offset, isShow); result = true; } catch (Exception e) { e.printStackTrace(); } return result; } /** * 写入缓存 * * @param key * @param offset * @return */ public boolean getBit(String key, long offset) { boolean result = false; try { ValueOperations<Serializable, Object> operations = redisTemplate.opsForValue(); result = operations.getBit(key, offset); } catch (Exception e) { e.printStackTrace(); } return result; } /** * 写入缓存 * * @param key * @param value * @return */ public boolean set(final String key, Object value) { boolean result = false; try { ValueOperations<Serializable, Object> operations = redisTemplate.opsForValue(); operations.set(key, value); result = true; } catch (Exception e) { e.printStackTrace(); } return result; } /** * 写入缓存设置时效时间 * * @param key * @param value * @return */ public boolean set(final String key, Object value, Long expireTime) { boolean result = false; try { ValueOperations<Serializable, Object> operations = redisTemplate.opsForValue(); operations.set(key, value); redisTemplate.expire(key, expireTime, TimeUnit.SECONDS); result = true; } catch (Exception e) { e.printStackTrace(); } return result; } /** * 批量删除对应的value * * @param keys */ public void remove(final String... keys) { for (String key : keys) { remove(key); } } /** * 删除对应的value * * @param key */ public void remove(final String key) { if (exists(key)) { redisTemplate.delete(key); } } /** * 判断缓存中是否有对应的value * * @param key * @return */ public boolean exists(final String key) { return redisTemplate.hasKey(key); } /** * 读取缓存 * * @param key * @return */ public Object genValue(final String key) { Object result = null; ValueOperations<String, String> operations = redisTemplate.opsForValue(); result = operations.get(key); return result; } /** * 哈希 添加 * * @param key * @param hashKey * @param value */ public void hmSet(String key, Object hashKey, Object value) { HashOperations<String, Object, Object> hash = redisTemplate.opsForHash(); hash.put(key, hashKey, value); } /** * 哈希获取数据 * * @param key * @param hashKey * @return */ public Object hmGet(String key, Object hashKey) { HashOperations<String, Object, Object> hash = redisTemplate.opsForHash(); return hash.get(key, hashKey); } /** * 列表添加 * * @param k * @param v */ public void lPush(String k, Object v) { ListOperations<String, Object> list = redisTemplate.opsForList(); list.rightPush(k, v); } /** * 列表获取 * * @param k * @param l * @param l1 * @return */ public List<Object> lRange(String k, long l, long l1) { ListOperations<String, Object> list = redisTemplate.opsForList(); return list.range(k, l, l1); } /** * 集合添加 * * @param key * @param value */ public void add(String key, Object value) { SetOperations<String, Object> set = redisTemplate.opsForSet(); set.add(key, value); } /** * 集合获取 * * @param key * @return */ public Set<Object> setMembers(String key) { SetOperations<String, Object> set = redisTemplate.opsForSet(); return set.members(key); } /** * 有序集合添加 * * @param key * @param value * @param scoure */ public void zAdd(String key, Object value, double scoure) { ZSetOperations<String, Object> zset = redisTemplate.opsForZSet(); zset.add(key, value, scoure); } /** * 有序集合获取 * * @param key * @param scoure * @param scoure1 * @return */ public Set<Object> rangeByScore(String key, double scoure, double scoure1) { ZSetOperations<String, Object> zset = redisTemplate.opsForZSet(); redisTemplate.opsForValue(); return zset.rangeByScore(key, scoure, scoure1); } //第一次加载的时候将数据加载到redis中 public void saveDataToRedis(String name) { double index = Math.abs(name.hashCode() % size); long indexLong = new Double(index).longValue(); boolean availableUsers = setBit("availableUsers", indexLong, true); } //第一次加载的时候将数据加载到redis中 public boolean getDataToRedis(String name) { double index = Math.abs(name.hashCode() % size); long indexLong = new Double(index).longValue(); return getBit("availableUsers", indexLong); } /** * 有序集合获取排名 * * @param key 集合名称 * @param value 值 */ public Long zRank(String key, Object value) { ZSetOperations<String, Object> zset = redisTemplate.opsForZSet(); return zset.rank(key,value); } /** * 有序集合获取排名 * * @param key */ public Set<ZSetOperations.TypedTuple<Object>> zRankWithScore(String key, long start,long end) { ZSetOperations<String, Object> zset = redisTemplate.opsForZSet(); Set<ZSetOperations.TypedTuple<Object>> ret = zset.rangeWithScores(key,start,end); return ret; } /** * 有序集合添加 * * @param key * @param value */ public Double zSetScore(String key, Object value) { ZSetOperations<String, Object> zset = redisTemplate.opsForZSet(); return zset.score(key,value); } /** * 有序集合添加分数 * * @param key * @param value * @param scoure */ public void incrementScore(String key, Object value, double scoure) { ZSetOperations<String, Object> zset = redisTemplate.opsForZSet(); zset.incrementScore(key, value, scoure); } /** * 有序集合获取排名 * * @param key */ public Set<ZSetOperations.TypedTuple<Object>> reverseZRankWithScore(String key, long start,long end) { ZSetOperations<String, Object> zset = redisTemplate.opsForZSet(); Set<ZSetOperations.TypedTuple<Object>> ret = zset.reverseRangeByScoreWithScores(key,start,end); return ret; } /** * 有序集合获取排名 * * @param key */ public Set<ZSetOperations.TypedTuple<Object>> reverseZRankWithRank(String key, long start, long end) { ZSetOperations<String, Object> zset = redisTemplate.opsForZSet(); Set<ZSetOperations.TypedTuple<Object>> ret = zset.reverseRangeWithScores(key, start, end); return ret; } }
当然,这样的做法虽然可以实现锁的功能,但是,无法解决突然的宕机,导致无法解除锁的问题,就像程序中红字标注的情况
既然这样,是否可以在加锁的同时设置锁的超时时间呢?只要加锁成功,那么锁必然会有超时时间,就可以解决问题了。这里提供两种解决方法。
第一种方法:使用 lua 脚本
@Service public class LuaDistributeLock { private static final Logger logger = LoggerFactory.getLogger(LockNxExJob.class); @Autowired private RedisService redisService; @Autowired private RedisTemplate redisTemplate; private static String LOCK_PREFIX = "lua_"; private DefaultRedisScript<Boolean> lockScript; @Scheduled(cron = "0/10 * * * * *") public void lockJob() { String lock = LOCK_PREFIX + "LockNxExJob"; boolean luaRet = false; try { luaRet = luaExpress(lock,getHostIp()); //获取锁失败 if (!luaRet) { String value = (String) redisService.genValue(lock); //打印当前占用锁的服务器IP //logger.info("lua get lock fail,lock belong to:{}", value); return; } else { //获取锁成功 //logger.info("lua start lock lockNxExJob success"); Thread.sleep(5000); } } catch (Exception e) { logger.error("lock error", e); } finally { if (luaRet) { //logger.info("release lock success"); redisService.remove(lock); } } } /** * 获取lua结果 * @param key * @param value * @return */ public Boolean luaExpress(String key,String value) { lockScript = new DefaultRedisScript<Boolean>(); lockScript.setScriptSource( new ResourceScriptSource(new ClassPathResource("add.lua"))); lockScript.setResultType(Boolean.class); // 封装参数 List<Object> keyList = new ArrayList<Object>(); keyList.add(key); keyList.add(value); Boolean result = (Boolean) redisTemplate.execute(lockScript, keyList); return result; } /** * 获取本机内网IP地址方法 * * @return */ private static String getHostIp() { try { Enumeration<NetworkInterface> allNetInterfaces = NetworkInterface.getNetworkInterfaces(); while (allNetInterfaces.hasMoreElements()) { NetworkInterface netInterface = (NetworkInterface) allNetInterfaces.nextElement(); Enumeration<InetAddress> addresses = netInterface.getInetAddresses(); while (addresses.hasMoreElements()) { InetAddress ip = (InetAddress) addresses.nextElement(); if (ip != null && ip instanceof Inet4Address && !ip.isLoopbackAddress() //loopback地址即本机地址,IPv4的loopback范围是127.0.0.0 ~ 127.255.255.255 && ip.getHostAddress().indexOf(":") == -1) { return ip.getHostAddress(); } } } } catch (Exception e) { e.printStackTrace(); } return null; } }
add.lua 脚本的内容如下
local lockKey = KEYS[1] local lockValue = KEYS[2] -- setnx info local result_1 = redis.call('SETNX', lockKey, lockValue) if result_1 == true then local result_2= redis.call('SETEX', lockKey,3600, lockValue) return result_1 else return result_1 end
脚本文件放置在 resources 下
第二种方法:Redis 原生 API 实现了 setnx 和 setex 两个命令连用的方法,我们就使用这个方法
引入依赖
<dependency> <groupId>redis.clients</groupId> <artifactId>jedis</artifactId> <version>2.9.0</version> </dependency>
代码如下:
@Component public class JedisDistributedLock { private final Logger logger = LoggerFactory.getLogger(JedisDistributedLock.class); private static String LOCK_PREFIX = "lua_"; @Resource private RedisTemplate<String, Object> redisTemplate; @Autowired private RedisService redisService; public static final String UNLOCK_LUA; static { StringBuilder sb = new StringBuilder(); sb.append("if redis.call("get",KEYS[1]) == ARGV[1] "); sb.append("then "); sb.append(" return redis.call("del",KEYS[1]) "); sb.append("else "); sb.append(" return 0 "); sb.append("end "); UNLOCK_LUA = sb.toString(); } @Scheduled(cron = "0/10 * * * * *") public void lockJob() { String lock = LOCK_PREFIX + "JedisNxExJob"; boolean lockRet = false; try { lockRet = this.setLock(lock, 600); //获取锁失败 if (!lockRet) { String value = (String) redisService.genValue(lock); //打印当前占用锁的服务器IP logger.info("jedisLockJob get lock fail,lock belong to:{}", value); return; } else { //获取锁成功 logger.info("jedisLockJob start lock lockNxExJob success"); Thread.sleep(5000); } } catch (Exception e) { logger.error("jedisLockJob lock error", e); } finally { if (lockRet) { logger.info("jedisLockJob release lock success"); redisService.remove(lock); } } } public boolean setLock(String key, long expire) { try { Boolean result = redisTemplate.execute(new RedisCallback<Boolean>() { @Override public Boolean doInRedis(RedisConnection connection) throws DataAccessException { return connection.set(key.getBytes(), "锁定的资源".getBytes(), Expiration.seconds(expire) ,RedisStringCommands.SetOption.ifAbsent()); } }); return result; } catch (Exception e) { logger.error("set redis occured an exception", e); } return false; } public String get(String key) { try { RedisCallback<String> callback = (connection) -> { JedisCommands commands = (JedisCommands) connection.getNativeConnection(); return commands.get(key); }; String result = redisTemplate.execute(callback); return result; } catch (Exception e) { logger.error("get redis occured an exception", e); } return ""; } public boolean releaseLock(String key, String requestId) { // 释放锁的时候,有可能因为持锁之后方法执行时间大于锁的有效期,此时有可能已经被另外一个线程持有锁,所以不能直接删除 try { List<String> keys = new ArrayList<>(); keys.add(key); List<String> args = new ArrayList<>(); args.add(requestId); // 使用lua脚本删除redis中匹配value的key,可以避免由于方法执行时间过长而redis锁自动过期失效的时候误删其他线程的锁 // spring自带的执行脚本方法中,集群模式直接抛出不支持执行脚本的异常,所以只能拿到原redis的connection来执行脚本 RedisCallback<Long> callback = (connection) -> { Object nativeConnection = connection.getNativeConnection(); // 集群模式和单机模式虽然执行脚本的方法一样,但是没有共同的接口,所以只能分开执行 // 集群模式 if (nativeConnection instanceof JedisCluster) { return (Long) ((JedisCluster) nativeConnection).eval(UNLOCK_LUA, keys, args); } // 单机模式 else if (nativeConnection instanceof Jedis) { return (Long) ((Jedis) nativeConnection).eval(UNLOCK_LUA, keys, args); } return 0L; }; Long result = redisTemplate.execute(callback); return result != null && result > 0; } catch (Exception e) { logger.error("release lock occured an exception", e); } finally { // 清除掉ThreadLocal中的数据,避免内存溢出 //lockFlag.remove(); } return false; } /** * 获取本机内网IP地址方法 * * @return */ private static String getHostIp() { try { Enumeration<NetworkInterface> allNetInterfaces = NetworkInterface.getNetworkInterfaces(); while (allNetInterfaces.hasMoreElements()) { NetworkInterface netInterface = (NetworkInterface) allNetInterfaces.nextElement(); Enumeration<InetAddress> addresses = netInterface.getInetAddresses(); while (addresses.hasMoreElements()) { InetAddress ip = (InetAddress) addresses.nextElement(); if (ip != null && ip instanceof Inet4Address && !ip.isLoopbackAddress() //loopback地址即本机地址,IPv4的loopback范围是127.0.0.0 ~ 127.255.255.255 && ip.getHostAddress().indexOf(":") == -1) { return ip.getHostAddress(); } } } } catch (Exception e) { e.printStackTrace(); } return null; } }
那么现在还有一个问题:假如一个任务的时间特别长,超过了设定的超时时间,此时锁已经自动解除了,那我们最后解除的锁会不会是别人持有的锁?答案是:会的。
为了解决这个问题,我们在最后解除锁的时候,还得判断下此时的锁是否还是当初的锁。代码如下(注意加粗的部分):
@Component public class JedisDistributedLock { private final Logger logger = LoggerFactory.getLogger(JedisDistributedLock.class); private static String LOCK_PREFIX = "JedisDistributedLock_"; private DefaultRedisScript<Boolean> lockScript; @Resource private RedisTemplate<Object, Object> redisTemplate; @Autowired private RedisService redisService; public static final String UNLOCK_LUA; static { StringBuilder sb = new StringBuilder(); sb.append("if redis.call("get",KEYS[1]) == ARGV[1] "); sb.append("then "); sb.append(" return redis.call("del",KEYS[1]) "); sb.append("else "); sb.append(" return 0 "); sb.append("end "); UNLOCK_LUA = sb.toString(); } @Scheduled(cron = "0/10 * * * * *") public void lockJob() { String lock = LOCK_PREFIX + "JedisNxExJob"; boolean lockRet = false; try { lockRet = this.setLock(lock, 600); //获取锁失败 if (!lockRet) { String value = (String) redisService.genValue(lock); //打印当前占用锁的服务器IP logger.info("jedisLockJob get lock fail,lock belong to:{}", value); return; } else { //获取锁成功 logger.info("jedisLockJob start lock lockNxExJob success"); Thread.sleep(5000); } } catch (Exception e) { logger.error("jedisLockJob lock error", e); } finally { if (lockRet) { logger.info("jedisLockJob release lock success"); releaseLock(lock,getHostIp()); } } } public boolean setLock(String key, long expire) { try { Boolean result = redisTemplate.execute(new RedisCallback<Boolean>() { @Override public Boolean doInRedis(RedisConnection connection) throws DataAccessException { return connection.set(key.getBytes(), getHostIp().getBytes(), Expiration.seconds(expire) ,RedisStringCommands.SetOption.ifAbsent()); } }); return result; } catch (Exception e) { logger.error("set redis occured an exception", e); } return false; } public String get(String key) { try { RedisCallback<String> callback = (connection) -> { JedisCommands commands = (JedisCommands) connection.getNativeConnection(); return commands.get(key); }; String result = redisTemplate.execute(callback); return result; } catch (Exception e) { logger.error("get redis occured an exception", e); } return ""; } /** * 释放锁操作 * @param key * @param value * @return */ private boolean releaseLock(String key, String value) { lockScript = new DefaultRedisScript<Boolean>(); lockScript.setScriptSource( new ResourceScriptSource(new ClassPathResource("unlock.lua"))); lockScript.setResultType(Boolean.class); // 封装参数 List<Object> keyList = new ArrayList<Object>(); keyList.add(key); keyList.add(value); Boolean result = (Boolean) redisTemplate.execute(lockScript, keyList); return result; } /** * 获取本机内网IP地址方法 * * @return */ private static String getHostIp() { try { Enumeration<NetworkInterface> allNetInterfaces = NetworkInterface.getNetworkInterfaces(); while (allNetInterfaces.hasMoreElements()) { NetworkInterface netInterface = (NetworkInterface) allNetInterfaces.nextElement(); Enumeration<InetAddress> addresses = netInterface.getInetAddresses(); while (addresses.hasMoreElements()) { InetAddress ip = (InetAddress) addresses.nextElement(); if (ip != null && ip instanceof Inet4Address && !ip.isLoopbackAddress() //loopback地址即本机地址,IPv4的loopback范围是127.0.0.0 ~ 127.255.255.255 && ip.getHostAddress().indexOf(":") == -1) { return ip.getHostAddress(); } } } } catch (Exception e) { e.printStackTrace(); } return null; } }
unlock.lua 脚本的内容如下
local lockKey = KEYS[1] local lockValue = KEYS[2] -- get key local result_1 = redis.call('get', lockKey) if result_1 == lockValue then local result_2= redis.call('del', lockKey) return result_2 else return false end
常见面试题:
问题一:什么是分布式锁?实现分布式锁的注意点?
同一时间同一资源只能被同一个应用操作。
首先,为了确保分布式锁可用,我们至少要确保锁的实现同时满足以下三个条件:
- 互斥性。在任意时刻,只有一个客户端能持有锁。
- 不会发生死锁。即使有一个客户端在持有锁的期间崩溃而没有主动解锁,也能保证后续其他客户端能加锁。
- 解铃还须系铃人。加锁和解锁必须是同一个客户端,客户端自己不能把别人加的锁给解了
问题二:怎么实现分布式锁?
1、采用lua脚本操作分布式锁
- 从 Redis 2.6.0 版本开始,通过内置的 Lua 解释器,可以使用 EVAL 命令对 Lua 脚本进行求值。
- Redis 使用单个 Lua 解释器去运行所有脚本,并且, Redis 也保证脚本会以原子性(atomic)的方式执行:当某个脚本正在运行的时候,不会有其他脚本或 Redis 命令被执行。这和使用 MULTI / EXEC 包围的事务很类似。在其他别的客户端看来,脚本的效果(effect)要么是不可见的(not visible),要么就是已完成的(already completed)。
2、采用 setnx、setex 命令连用的方式实现分布式锁
问题三:解锁需要注意什么?
解铃还须系铃人。加锁和解锁必须是同一个客户端,客户端自己不能把别人加的锁给解了