前言

之前数据库的用户表的用户名、手机号码、邮箱都是设置了唯一索引,因此不需要考虑重复的问题。然而,由于手机号码和邮箱都可以为 null,而太多的 null 会影响索引的稳定性,因此去掉唯一索引并将默认值改为空字符串。但是这又引出了新的问题,如何保证在并发情况下手机号码(邮箱)不重复?

导致数据重复的原因

在需要插入或者更新不能重复的字段时,我们会进行 查询-插入(更新) 的操作。然而,由于该操作并不是原子的,因此在并发的情况下可能导致插入重复的数据。

Redis 锁解决方案

由于 Redis 命令的原子特性,我们可以尝试使用 Redis 的 setnx 命令,比如 setnx phone:13123456789 '',若设置成功,则拿到了该手机号码的锁。后续请求会因为无法拿到该锁而直接失败。在请求处理结束后再通过 del phone:13123456789 释放该锁。

如下代码所示,先获取锁,若获取不到直接返回,若获取到则进行业务处理。最后使用 try-finally 语句释放锁,防止锁释放失败。

// 获取锁
if (Boolean.FALSE.equals(redisTemplate.opsForValue().setIfAbsent(redisKey, ""))) {
return Result.fail(ErrorCodeEnum.OPERATION_CONFLICT, "Failed to acquire lock.");
}
try {
// 业务代码
} finally {
// 释放锁
if (Boolean.FALSE.equals(redisTemplate.delete(redisKey))) {
logger.error("Failed to release lock.")
}
}
复制代码
封装成分布式锁服务
由于分布式锁的需求很常见,因此我们封装成服务。代码比较简单,如下所示。
/**
* 描述:分布式锁服务
*
* @author xhsf
* @create 2020/12/10 19:13
*/
@Service
public class DistributedLockServiceImpl implements DistributedLockService{
private final StringRedisTemplate redisTemplate;
/**
* 锁的 key 在 Redis 里的前缀
*/
private static final String LOCK_KEY_REDIS_PREFIX = "distributed-lock:";
/**
* 锁在 Redis 里的值
*/
private static final String LOCK_DEFAULT_VALUE_IN_REDIS = "";
public DistributedLockServiceImpl(StringRedisTemplate redisTemplate){
this.redisTemplate = redisTemplate;
}
/**
* 获取分布式锁,不会自动释放锁
*
* @errorCode InvalidParameter: key 格式错误
* OperationConflict: 获取锁失败
*
* @param key 锁对应的唯一 key
* @return 获取结果
*/
@Override
public Result getLock(String key){
String redisKey = LOCK_KEY_REDIS_PREFIX + key;
if (Boolean.FALSE.equals(redisTemplate.opsForValue().setIfAbsent(redisKey, LOCK_DEFAULT_VALUE_IN_REDIS))) {
return Result.fail(ErrorCodeEnum.OPERATION_CONFLICT, "Failed to acquire lock.");
}
return Result.success();
}
/**
* 获取分布式锁,锁到期自动释放
*
* @errorCode InvalidParameter: key 或 expirationTime 格式错误
* OperationConflict: 获取锁失败
*
* @param key 锁对应的唯一 key
* @param expirationTime 锁自动释放时间
* @param timeUnit 时间单位
* @return 获取结果
*/
@Override
public Result getLock(String key, Long expirationTime, TimeUnit timeUnit){
String redisKey = LOCK_KEY_REDIS_PREFIX + key;
if (Boolean.FALSE.equals(redisTemplate.opsForValue().setIfAbsent(
redisKey, LOCK_DEFAULT_VALUE_IN_REDIS, expirationTime, timeUnit))) {
return Result.fail(ErrorCodeEnum.OPERATION_CONFLICT, "Failed to acquire lock.");
}
return Result.success();
}
/**
* 释放锁
*
* @errorCode InvalidParameter: key 格式错误
* InvalidParameter.NotExist: key 不存在
*
* @param key 锁对应的唯一 key
* @return 释放结果
*/
@Override
public Result releaseLock(String key){
String redisKey = LOCK_KEY_REDIS_PREFIX + key;
if (Boolean.FALSE.equals(redisTemplate.delete(redisKey))) {
return Result.fail(ErrorCodeEnum.INVALID_PARAMETER_NOT_EXIST, "The lock does not exist.");
}
return Result.success();
}
}
复制代码
分布式锁服务示例代码
这里是一个通过短信验证码注册账号的服务示例。
public Result signUpBySmsAuthCode(String phone, String authCode, String password){
// 尝试获取关于该手机号码的锁
String phoneLockKey = PHONE_DISTRIBUTED_LOCK_KEY_PREFIX + phone;
if (!distributedLockService.getLock(phoneLockKey).isSuccess()) {
return Result.fail(ErrorCodeEnum.OPERATION_CONFLICT, "Failed to acquire phone lock.");
}
try {
// 创建用户的逻辑
} finally {
// 释放锁关于该手机号码的锁
if (!distributedLockService.releaseLock(phoneLockKey).isSuccess()) {
logger.error("Failed to release phone lock. phoneLockKey={}", phoneLockKey);
}
}
}
复制代码
使用 AOP 实现注解加锁
加锁代码添加到业务代码里,总让人感觉不舒服,因此我们通过注解的方式进行加锁。这里实现了 EL 表达式的 key,可以满足大部分需求。
添加切面注解
这里添加了3个参数,可以指定 EL 表达式的 key,key 锁的过期时间和时间单位。
/**
* 描述: 分布式锁注解
*
* @author xhsf
* @create 2020-12-10 21:16
*/
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface DistributedLock {
/**
* 分布式锁 key,支持 EL 表达式,如#{#user.phone}
*/
String value();
/**
* 过期时间
*/
long expirationTime() default 0;
/**
* 过期时间单位,默认为秒
*/
TimeUnit timeUnit() default TimeUnit.SECONDS;
}
复制代码
实现切面
先通过注解和方法上面的参数构造 key,然后尝试加锁,若加锁失败返回统一的 Result 对象,若成功执行业务逻辑。最后释放锁。
/**
* 描述:分布式锁切面,配合 {@link DistributedLock} 可以便捷的使用分布式锁
*
* @author xhsf
* @create 2020/12/10 21:10
*/
@Aspect
public class DistributedLockAspect{
private static final Logger logger = LoggerFactory.getLogger(DistributedLockAspect.class);
@Reference
private DistributedLockService distributedLockService;
/**
* EL 表达式解析器
*/
private static final ExpressionParser expressionParser = new SpelExpressionParser();
/**
* 给方法添加分布式锁
*
* @param joinPoint ProceedingJoinPoint
* @return Object
*/
@Around("@annotation(com.xiaohuashifu.recruit.external.api.aspect.annotation.DistributedLock) " +
"&& @annotation(distributedLock)")
public Object handler(ProceedingJoinPoint joinPoint, DistributedLock distributedLock) throws Throwable{
// 获得键
String key = getKey(joinPoint, distributedLock);
// 尝试获取锁
if (!getLock(key, distributedLock)) {
return Result.fail(ErrorCodeEnum.OPERATION_CONFLICT, "Failed to acquire lock.");
}
// 执行业务逻辑
try {
return joinPoint.proceed();
} finally {
// 释放锁
releaseLock(key, joinPoint);
}
}
/**
* 获取 key
*
* @param joinPoint ProceedingJoinPoint
* @param distributedLock DistributedLock
* @return key
*/
private String getKey(ProceedingJoinPoint joinPoint, DistributedLock distributedLock){
// 获得方法参数的 Map
String[] parameterNames = ((CodeSignature) joinPoint.getSignature()).getParameterNames();
Object[] parameterValues = joinPoint.getArgs();
Map parameterMap = new HashMap<>();
for (int i = 0; i < parameterNames.length; i++) {
parameterMap.put(parameterNames[i], parameterValues[i]);
}
// 解析 EL 表达式
String key = distributedLock.value();
return getExpressionValue(key, parameterMap);
}
/**
* 获取锁
*
* @param key 键
* @param distributedLock DistributedLock
* @return 获取结果
*/
private boolean getLock(String key, DistributedLock distributedLock){
// 判断是否需要设置超时时间
long expirationTime = distributedLock.expirationTime();
if (expirationTime > 0) {
TimeUnit timeUnit = distributedLock.timeUnit();
return distributedLockService.getLock(key, expirationTime, timeUnit).isSuccess();
}
return distributedLockService.getLock(key).isSuccess();
}
/**
* 释放锁
*
* @param key 键
* @param joinPoint ProceedingJoinPoint
*/
private void releaseLock(String key, ProceedingJoinPoint joinPoint){
if (!distributedLockService.releaseLock(key).isSuccess()) {
logger.error("Failed to release lock. key={}, signature={}, parameters={}",
key, joinPoint.getSignature(), Arrays.toString(joinPoint.getArgs()));
}
}
/**
* 获取 EL 表达式的值
*
* @param elExpression EL 表达式
* @param parameterMap 参数名-值 Map
* @return 表达式的值
*/
private String getExpressionValue(String elExpression, Map parameterMap){
Expression expression = expressionParser.parseExpression(elExpression, new TemplateParserContext());
EvaluationContext context = new StandardEvaluationContext();
for (Map.Entry entry : parameterMap.entrySet()) {
context.setVariable(entry.getKey(), entry.getValue());
}
return expression.getValue(context, String.class);
}
}
复制代码
注解分布式锁使用示例
如下代码,添加 @DistributedLock 注解并指定参数即可。
@DistributedLock("phone:#{#phone}")
public Result signUpBySmsAuthCode(String phone, String authCode, String password){
// 业务代码
}
复制代码
注意,需要注册切面为 Bean
/**
* 分布式锁切面
*
* @return DistributedLockAspect
*/
@Bean
public DistributedLockAspect distributedLockAspect(){
return new DistributedLockAspect();
}

复制代码

使用 Redisson

看了 whosYourDaddy 的评论才知道 Redisson 已经实现了各种分布式锁,大家可以直接使用 Redisson,功能更加强大。