背景
之前遇到了一个情况,接口重复提交,出现了脏数据。如何避免这个情况呢?方案一是前端请求写数据的接口后,将按钮置灰,短时间内不可点击,但是这样也没办法从根本上解决问题。方案二是后端对接口加个锁,对请求的重要参数加一个唯一标识,锁未被释放之前,重复提交获取不到锁,也就不会有脏数据了。
实现
逻辑
- 加锁:其实就是key-value放到redis中,key是业务唯一键,value存储UUID或者什么也是唯一标记的东西
- 释放锁:将key-value从Redis中删除。为啥需要value存储UUID呢,作用是来区分不同线程的。线程A加锁,线程来B释放锁,就不得行,会出现误删的问题。
依赖
<!--redis-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<version>1.5.20.RELEASE</version>
</dependency>
配置
spring:
redis:
pool.max-active: 8
pool.max-wait: -1
pool.max-idle: 8
pool.min-idle: 0
timeout: 0
isCacheEnabled: true
代码
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisCallback;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.util.StringUtils;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisCluster;
import redis.clients.jedis.JedisCommands;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
@Slf4j
public abstract class AbstractRedisDistributedLock {
/**
* 锁过期时间
*/
long LOCK_TIMEOUT_MILLIS = 10000;
/**
* 尝试获取锁次数
*/
int LOCK_RETRY_TIMES = 10;
/**
* 获取锁休眠时间
*/
long LOCK_SLEEP_MILLIS = 500;
private RedisTemplate<String, Object> redisTemplate;
private ThreadLocal<Map<String, String>> lockThreadLocal = new ThreadLocal<>();
protected static final String UNLOCK_LUA_STR;
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_STR = sb.toString();
}
public boolean lock(String key) {
return this.lock(key, LOCK_TIMEOUT_MILLIS, LOCK_RETRY_TIMES, LOCK_SLEEP_MILLIS);
}
public boolean lock(String key, int retryTimes) {
return this.lock(key, LOCK_TIMEOUT_MILLIS, retryTimes, LOCK_SLEEP_MILLIS);
}
public boolean lock(String key, int retryTimes, long sleepMillis) {
return this.lock(key, LOCK_TIMEOUT_MILLIS, retryTimes, sleepMillis);
}
public boolean lock(String key, long expire) {
return this.lock(key, expire, LOCK_RETRY_TIMES, LOCK_SLEEP_MILLIS);
}
public boolean lock(String key, long expire, int retryTimes) {
return this.lock(key, expire, retryTimes, LOCK_SLEEP_MILLIS);
}
public boolean lock(String key, long expire, int retryTimes, long sleepMillis) {
boolean result = setRedisLock(key, expire);
// 如果获取锁失败,按照传入的重试次数进行重试
while ((!result) && retryTimes-- > 0) {
try {
log.debug("redisDistributedLock setRedisLock failed for key: {} retryTimes: {}", key, retryTimes);
Thread.sleep(sleepMillis);
} catch (InterruptedException e) {
log.error("redisDistributedLock setRedisLock sleep failed for key: {} retryTimes: {}", key, retryTimes);
return false;
}
result = setRedisLock(key, expire);
}
return result;
}
/**
* 释放锁
* 使用lua脚本删除redis中匹配value的key,可以避免由于方法执行时间过长而redis锁自动过期失效的时候误删其他线程的锁
*
* @param key
* @return
*/
public boolean releaseLock(String key) {
// 释放锁的时候,有可能因为持锁之后方法执行时间大于锁的有效期,此时有可能已经被另外一个线程持有锁,所以不能直接删除
try {
List<String> keys = new ArrayList<>();
keys.add(key);
List<String> args = new ArrayList<>();
args.add(lockThreadLocal.get().get(key));
// spring自带的执行脚本方法中,集群模式直接抛出不支持执行脚本的异常,所以只能拿到原redis的connection来执行脚本
Long result = getRedisTemplate().execute((RedisCallback<Long>) connection -> {
Object nativeConnection = connection.getNativeConnection();
// 集群模式和单机模式虽然执行脚本的方法一样,但是没有共同的接口,所以只能分开执行
// 集群模式
if (nativeConnection instanceof JedisCluster) {
return (Long) ((JedisCluster) nativeConnection).eval(UNLOCK_LUA_STR, keys, args);
}
// 单机模式
else if (nativeConnection instanceof Jedis) {
return (Long) ((Jedis) nativeConnection).eval(UNLOCK_LUA_STR, keys, args);
}
return 0L;
});
return result != null && result > 0;
} catch (Exception e) {
log.error("redisDistributedLock releaseLock failed for key: {} error: {}", key, e.getMessage(), e);
} finally {
lockThreadLocal.get().remove(key);
if (lockThreadLocal.get().size() <= 0) {
lockThreadLocal.remove();
}
}
return false;
}
/**
* 通过 redis命令 SET key value [EX seconds] [PX milliseconds] [NX|XX]
* 设置redis 锁随机内容防止 方法执行时间过长 错杀别的线程获取的锁
*
* @param key
* @param expireTime
* @return
*/
private boolean setRedisLock(String key, long expireTime) {
try {
String result = getRedisTemplate().execute((RedisCallback<String>) connection -> {
JedisCommands jedisCommands = (JedisCommands) connection.getNativeConnection();
String uuid = UUID.randomUUID().toString();
Map<String, String> map = Optional.ofNullable(lockThreadLocal.get()).orElse(new ConcurrentHashMap<>());
map.put(key, uuid);
lockThreadLocal.set(map);
return jedisCommands.set(key, uuid, "NX", "PX", expireTime);
});
return !StringUtils.isEmpty(result);
} catch (Exception e) {
log.error("redisDistributedLock setRedisLock occured an error: {} for key: {}", e.getMessage(), key, e);
return false;
}
}
@Autowired
public void setRedisTemplate(RedisTemplate<String, Object> redisTemplate) {
this.redisTemplate = redisTemplate;
}
public RedisTemplate<String, Object> getRedisTemplate() {
return redisTemplate;
}
}
参考
Spring Boot 整合 redisson 实现分布式锁
Redisson实现分布式锁(2)—RedissonLock