【锁】在数据库无法使用唯一索引时如何保证数据的不重复?

2,965 阅读5分钟

前言

之前数据库的用户表的用户名、手机号码、邮箱都是设置了唯一索引,因此不需要考虑重复的问题。然而,由于手机号码和邮箱都可以为 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<Void> 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<Void> 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<Void> 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<UserDTO> 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 表达式的 keykey 锁的过期时间和时间单位。

/**
 * 描述: 分布式锁注解
 *
 * @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<String, Object> 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<String, Object> parameterMap) {
        Expression expression = expressionParser.parseExpression(elExpression, new TemplateParserContext());
        EvaluationContext context = new StandardEvaluationContext();
        for (Map.Entry<String, Object> entry : parameterMap.entrySet()) {
            context.setVariable(entry.getKey(), entry.getValue());
        }
        return expression.getValue(context, String.class);
    }

}

注解分布式锁使用示例

如下代码,添加 @DistributedLock 注解并指定参数即可。

    @DistributedLock("phone:#{#phone}")
    public Result<UserDTO> signUpBySmsAuthCode(String phone, String authCode, String password) {
    	// 业务代码
    }

注意,需要注册切面为 Bean

    /**
     * 分布式锁切面
     * 
     * @return DistributedLockAspect
     */
    @Bean
    public DistributedLockAspect distributedLockAspect() {
        return new DistributedLockAspect();
    }

使用 Redisson

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