快看一下Java使用分布式锁的正确方式

分布式系统开发中常常用到分布式锁,比如防止多个用户同时预订同一个商品,传统的synchronized就无法实现了,而基于数据库的乐观锁实现又可能会对数据库产生较大的压力。而分布式锁相对较轻量,对性能影响也较小。目前主流的分布式锁都基于Redis实现。使用分布式锁的流程一般如下:

获取分布式锁流程图.png

如果需要使用分布式锁的地方有多个,那么就需要写多个类似的代码。而重复代码是开发中最常见到的 bad smell 。我们可以使用 AOP 把这段逻辑抽象出来,这样就避免了重复代码,也极大地减去了工作量。

目标

  • 对业务代码无侵入(或侵入性较小)
  • 使用方便
  • 对性能影响小
  • 易维护

方案

  1. 使用注解(假设注解为@Lock)声明要使用分布式锁的业务method、要锁定的对象(一般是业务主键)、失效时间等信息。在这里插入代码片
  2. 使用Spring AOP arround(环绕通知)增强被@Lock注解的方法,把前面提到的”使用分布式锁的流程“逻辑抽象到切面中。
  3. 使用Redis实现分布式锁。一般是基于string类型的set命令实现。

难点

  1. 如何根据请求的不同,锁定不同的对象
    可以使用 Spring EL 表达式指定锁定对象,加锁时根据业务方法参数值、参数名称解析表达式,得出要加锁的对象(Redis string的key)。
  2. 分布式锁该如何选择
    • 可以选择自己实现(加锁使用set命令即可,解锁需要使用lua脚本保证命令的原子性,先判断锁是否仍有效、是否由当前线程加锁,是的话才能通过del来解锁)。
    • 也可以选择使用redisson等第三方库。使用方式可以参考官方示例:Spring版/Spring Boot版

优点

使用时只需在业务方法上加一个注解就可以了,使用灵活、开发效率高、侵入小、适用性强。业务方法只需专注于业务代码,可读性强,易维护。

适用场景

  • 防止并发修改
  • 防止重复提交
  • 幂等校验
  • ……

实现

1. 引入包

Spring Boot方式

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
  <groupId>org.redisson</groupId>
  <artifactId>redisson-spring-boot-starter</artifactId>
  <version>3.12.5</version>
</dependency>

Spring 方式

<dependency>
  <groupId>org.springframework</groupId>
  <artifactId>spring-aop</artifactId>
  <version>5.1.3.RELEASE</version>
</dependency>
<dependency>
  <groupId>org.aspectj</groupId>
  <artifactId>aspectjweaver</artifactId>
  <version>1.9.2</version>
</dependency>
<dependency>
  <groupId>org.redisson</groupId>
  <artifactId>redisson</artifactId>
  <version>3.12.5</version>
</dependency>  

2. 定义注解

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@Documented
@Inherited
public @interface Lock {

    /** 锁分组名称,用于避免key重复 */
    String group() default "redis_lock_order";

    /** key 表达式 */
    String value();

    /** 获取锁失败时的提示信息 */
    String lockFailedMsg() default "当前订单正在修改中,请稍候重试";

    /**
     * 等待时长(ms),默认为0,获取不到锁立即返回。
     * @return 等待时长 ms
     */
    long waitTime() default 0;

    /**
     * 最大持有锁时间,如果超过该时间仍未主动释放,将自动释放锁。
     * @return 最大持有锁时间 ms
     */
    long leaseTime() default 5000;
}

3. 定义注解数据模型

@Value
public class LockVO {
    /** key前缀 */
    private String group;
    /** key */
    private String key;
    /** 加锁失败时的提示信息 */
    private String lockFailedMsg;
    /** 等待时长 */
    private Long waitTime;
    /** 持有锁时长 */
    private Long leaseTime;
}

4. 定义切面逻辑

@Aspect
@Component
@Slf4j
public class LockAspect {
    /** 拦截所有注解了@Lock的方法 */
    @Pointcut(value = "@annotation(lock)")
    public void pointcut(Lock lock) {}

    @Around(value = "pointcut(lock)", argNames = "jp,lock")
    public Object aroundRemote(ProceedingJoinPoint jp, Lock lock) throws Throwable {
        // 解析注解数据
        LockVO lockVo = parseLockAnnotationData(jp, lock);
        // 生成锁key
        String lockKey = lockVo.getGroup() + ":" + lockVo.getKey();

        // 竞争分布式锁
        String lockId = lock(lockVo, lockKey);
        try {
            return jp.proceed();
        } finally {
            // 释放锁
            unlock(lockKey, lockId);
        }
    }

    private String lock(LockVO lockVo, String lockKey) {
        // 具体加锁逻辑可以自己选择,推荐使用redisson,也可以选择自己实现。
        // 自己实现需要考虑满足可重入性、锁超时等问题。
        // 目前没有完美的分布式锁实现,需要根据自己的项目的应用场景做出权衡和选择。

        // 以下介绍自己实现逻辑的大致思路
        // 1. 如果当前线程已经拿到锁,直接返回
        // 可以通过ThreadLocal实现可重入式分布式锁,具体实现省略。
        
        // 2. 否则,获取redis分布式锁,拿到新的lockId
        Long waitTime = lockVo.getWaitTime();
        Long leaseTime = lockVo.getLeaseTime();

        if (waitTime > 0) {
            // 基于set命令实现,指定key失效时间,如果未获取到锁,则等待若干ms后重试,
            // 在waitTime过后仍未获取到锁则获取锁失败。
            lockId = RedisStringUtil.tryLock(lockKey, waitTime, leaseTime, TimeUnit.MILLISECONDS);
            if (lockId == null) {
                log.error("WARN:延迟加锁失败,数据可能出现问题,锁key:{},锁数据:{}",
                        lockKey, lockVo);
            }
        } else {
            // 基于set命令实现,指定key失效时间,如果未获取到锁。则加锁失败。
            lockId = RedisStringUtil.tryLock(RedisNamespaceEnum.LOCK.getValue(), lockKey, leaseTime, TimeUnit.MILLISECONDS);
        }
        // 未拿到锁
        if (lockId == null) {
            throw new RuntimeException(lockVo.getLockFailedMsg());
        }
        // 放入ThreadLocal,用于实现可重入性
        return lockId;
    }

    private void unlock(String lockKey, String lockId) {
        // 释放分布式锁
        if(ReentrantUtil.canRemove(lockKey)){
            ReentrantUtil.remove(lockKey);
            // 如果lockId代表的锁依然存在,则可以解锁成功。
            Boolean success = RedisStringUtil.unlock(lockKey, lockId);
            if (!success) {
                // 锁超时情况下会出现该问题,当出现该问题时,需要根据情况特殊处理。
                log.error("释放锁失败,lockKey:{},lockId:{}", lockKey, lockId);
            }
        } else {
            ReentrantUtil.release(lockKey);
        }
    }

    /**
     * 解析@Lock数据
     */
    private LockVO parseLockAnnotationData(ProceedingJoinPoint jp, Lock lock) {
        Method method = (MethodSignature)jp.getSignature();

        String keyExpression = lock.value();
        // 解析el表达式,获取锁key
        String key = parseElExpression(jp.getArgs(), method, keyExpression, String.class);
        return new LockVO(lock.group(), key, lock.lockFailedMsg(), lock.waitTime(), lock.leaseTime());
    }

    /**
     * 解析EL表达式
     * @param args 方法参数
     * @param method 方法
     * @param elExpression EL表达式
     * @param resultType 结果类型
     * @param <T> 结果类型
     * @return 结果
     */
    private static <T> T parseElExpression(Object[] args, Method method, String elExpression, Class<T> resultType) {
        Parameter[] parameters = method.getParameters();
        StandardEvaluationContext elContext = new StandardEvaluationContext();
        if (parameters != null && parameters.length > 0) {
            // 设置解析变量
            for (int i = 0; i < parameters.length; i++) {
                String paraName = parameters[i].getName();
                Object paraValue = args[i];
                elContext.setVariable(paraName, paraValue);
            }
        }
        ExpressionParser parser = new SpelExpressionParser();
        return parser.parseExpression(elExpression)
                .getValue(elContext, resultType);
    }

}

5. 打开编译开关

在项目的pom文件中的编译插件中添加参数:-parameters(JDK8+才支持),用于在编译时把方法参数名称保留到 class 文件中。这样我们就可以通过Spring EL表达式动态指定要加锁的key。

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-compiler-plugin</artifactId>
    <version>3.3</version>
    <configuration>
        <source>1.8</source>
        <target>1.8</target>
        <compilerArgs>
            <arg>-parameters</arg>
        </compilerArgs>
    </configuration>
</plugin>

JDK8以下版本可以使用别的方式获取方法参数名称,也可以将表达式写为类似于
[0].getOrderId()(获取第一个入参的orderId属性的值)
的格式动态指定key(因为方法入参可以看做是一个Object[])。

6. 使用

  • 防止并发修改,可以把Lock注解在例如修改订单的接口方法上,waitTime设置为0,key为订单id,这样就可以防止多个人并发修改订单。
    @Lock(group = "order_lock", value = "#dto.getOrderId()", 
            lockFailedMsg = "当前订单正在修改中,请稍候重试", leaseTime = 5000)
    public void modifyOrder(ModifyOrderDTO dto) {
        // ......
    }
  • 防止重复提交。例如防止重复下单,可以将waitTime设置为0,key为会员id,在订单未保存成功前,用户多次提交订单都会直接返回提示信息。
    @Lock(group = "forbid_repeat_submission_4_order", value = "#orderVO.getMemberId()",
            lockFailedMsg = "订单已提交,请稍候", leaseTime = 10000)
    @Transactional(rollbackFor = Exception.class)
    public OrderVO saveOrder(OrderVO orderVO) {
        // ......
    }

总结

基于注解的分布式锁可以帮助我们减少大量模板代码,使用方便,出现问题也很容易修复。对于具体的加锁逻辑可以选择自己实现,也可以选择使用redisson等第三方库。
博客原文链接

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,271评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,275评论 2 380
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,151评论 0 336
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,550评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,553评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,559评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,924评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,580评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,826评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,578评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,661评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,363评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,940评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,926评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,156评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,872评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,391评论 2 342