Redis由于数据存储在内存(随机访问内存要比硬盘快100万倍),下面是Google 工程师Jeff Dean在分布式系统PPT中给出的各种访问速度参考值:
访问方式 | 耗时 |
---|---|
L1 cache reference 读取CPU的一级缓存 | ~0.5 ns |
L2 cache reference 读取CPU的二级缓存 | ~7ns |
Mutex lock/unlock 互斥锁\解锁 | ~100 ns |
Main memory reference 读取内存数据 | ~100 ns |
Compress 1K bytes with Zippy 1k字节压缩 | ~10,000 ns |
Send 2K bytes over 1 Gbps network 在1Gbps的网络上发送2k字节 | ~20,000 ns |
Read 1 MB sequentially from memory 从内存顺序读取1MB | ~250,000 ns |
Round trip within same datacenter 从一个数据中心往返一次,ping一下 | ~500,000 ns |
Disk seek 磁盘搜索 | ~10,000,000 ns |
Read 1 MB sequentially from network 从网络上顺序读取1兆的数据 | ~10,000,000 ns |
Read 1 MB sequentially from disk 从磁盘里面读出1MB | ~30,000,000 ns |
Send packet CA->Netherlands->CA 一个包的一次远程访问 | ~150,000,000 ns |
由于基于内存随机访问效率极高,所以Redis设计为单线程操作,确保了线程安全。由于其线程安全,访问效率极高,但内存的存储空间较小,在实际项目应用中通常用来实现分布式锁和缓存。
下面我们看一下如何利用spring-data-redis提供的接口实现redis分布式锁。
一、SETNX设置KV并设置超时时间实现分布式锁
从spring-data-redis源码中可以看到setIfAbsent底层就是调用了Redis的SETNX命令,setIfAbsent方法的源码如下:
public Boolean setIfAbsent(K key, V value) {
final byte[] rawKey = rawKey(key);
final byte[] rawValue = rawValue(value);
return execute(new RedisCallback<Boolean>() {
public Boolean doInRedis(RedisConnection connection) throws DataAccessException {
return connection.setNX(rawKey, rawValue);
}
}, true);
}
继续查看connections.setNX, 最终是调用了BinaryClient的setnx方法
public void setnx(final byte[] key, final byte[] value) {
sendCommand(SETNX, key, value);
}
在调用了setIfAbsent方法,然后调用expire设置key的过期时间,获取锁的代码如下:
import org.springframework.data.redis.core.StringRedisTemplate;
@Autowired
public StringRedisTemplate stringRedisTemplate;
public boolean lock(String key, String lock, int timeout){
boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, lock);
if(!flag){
if(LOG.isDebugEnabled()){
LOG.debug("Don't get redis lock.[{}]", getName());
}
return flag;
}
/**
* 拿到锁设置锁key的超时时间
*/
stringRedisTemplate.expire(key, timeout, TimeUnit.SECONDS);
if(LOG.isDebugEnabled()){
LOG.debug("Get redis lock.[{}]", getName());
}
return flag;
}
二、SETNX设置kv(v为系统当前时间+超时时间)和GETSET(设置一个值并返回旧值)实现分布式锁
这种方式和第一种不同的是没有设置过期时间,而是将setNx的value值设置为系统当前+过期时间。
public boolean lock(String key, int timeout){
//锁的值为当前时间+超时时间
long lockValue = System.currentTimeMillis() + timeout;
if(stringRedisTemplate.opsForValue().setIfAbsent(key, String.valueOf(lockValue))) {
return true;
}
//其他人获取不到锁执行如下代码
//获取锁的值
String currentLockValue = stringRedisTemplate.opsForValue().get(key);
//锁的值小于当前时则锁已过期
if (!StringUtils.isEmpty(currentLockValue) && Long.parseLong(currentLockValue) < System.currentTimeMillis()) {
//getAndSet线程安全所以只会有一个线程重新设置锁的新值
String oldValue = stringRedisTemplate.opsForValue().getAndSet(key, String.valueOf(lockValue));
//比较锁的getSet获取到的最近锁值和最开始获取到的锁值,如果不相等则证明锁已经被其他线程获取了。
if (!StringUtils.isEmpty(oldValue) && oldValue.equals(currentLockValue)) {
return true;
}
}
return false;
}
假设有A、B、C两个服务实例去获取锁,由于setIfAbsent是线程安全的所以同一时间只有一个服务能获取到锁,假设为A,那么B、C去获取锁时就一定会执行第一个if之后的代码,先获取到当前锁的值。
String currentLockValue = stringRedisTemplate.opsForValue().get(key);
- 如果值为空或者值大于系统当前则证明锁还没有超时,这种情况直接B、C都会return false。
- 如果值不为空且值小于系统当前时间,则证明锁已经过期,B、C通过getSet获取上一个锁的值,由于getAndSet是线程安全的,所以B、C只有一个去执行getSet操作设置新的锁的值为当前时间+超时时间。
- 当B执行完getSet之后C再执行getSet获取到的oldValue就不会等于C开始获取的currentLockValue
if (!StringUtils.isEmpty(oldValue) && oldValue.equals(currentLockValue)) {
return true;
}
此时C不满足上面这个代码的if条件return false。则B获得锁。
总结
-
第一种方式:当执行完isetIfAbsent方法之后服务中断或宕机等就会导致该key没有设置过期时间,由于永远不过期导致分布式环境下的所有应用服务都获取不到该锁,最终死锁。尤其是在开发调试环境下,由于经常启停服务很容易出现这种情况。
-
第二种方式:要比第一种方式稳妥得多,但是也需要考虑A、B、C三个服务器上的系统时间问题,如果时间存在差异,如果A的系统时间比BC快很多的情况下,A获得锁之后,BC永远看到锁未过期永远获取不到锁。同样对于超大型的分布式应用在部署时还要考虑跨时区问题,当然在部署架构上应该避免这种情况,对于不同时区的服务应该使用不同的redis集群,服务和redis应该部署在一个时区中环境中。
-
结合业务场景合理的考虑超时时间的大小设置。
网友评论