原来基于Redis分布式锁的打开方式是这样的啊

分布式锁是在分布式环境下(多个JVM进程)控制多个客户端对某一资源的同步访问的一种实现,与之相对应的是线程锁,线程锁控制的是同一个JVM进程内多个线程之间的同步。分布式锁的一般实现方法是在应用服务器之外通过一个共享的存储服务器存储锁资源,同一时刻只有一个客户端能占有锁资源来完成。通常有基于Zookeeper,Redis,或数据库三种实现形式。本文介绍基于Redis的实现方案。

要求

基于Redis实现分布式锁需要满足如下几点要求:

在分布式集群中,被分布式锁控制的方法或代码段同一时刻只能被一个客户端上面的一个线程执行,也就是互斥
锁信息需要设置过期时间,避免一个线程长期占有(比如在做解锁操作前异常退出)而导致死锁
加锁与解锁必须一致,谁加的锁,就由谁来解(或过期超时),一个客户端不能解开另一个客户端加的锁
加锁与解锁的过程必须保证原子性

实现

1. 加锁实现

基于Redis的分布式锁加锁操作一般使用 SETNX 命令,其含义是“将 key 的值设为 value ,当且仅当 key 不存在。若给定的 key 已经存在,则 SETNX 不做任何动作”。在 Spring Boot 中,可以使用 StringRedisTemplate 来实现,如下,一行代码即可实现加锁过程。(下列代码给出两种调用形式——立即返回加锁结果与给定超时时间获取加锁结果)

/**
    * 尝试获取锁(立即返回)
    * @param key  锁的redis key
    * @param value 锁的value
    * @param expire 过期时间/秒
    * @return 是否获取成功
    */
public boolean lock(String key, String value, long expire) {
    return stringRedisTemplate.opsForValue().setIfAbsent(key, value, expire, TimeUnit.SECONDS);
}

/**
    * 尝试获取锁,并至多等待timeout时长
    *
    * @param key  锁的redis key
    * @param value 锁的value
    * @param expire 过期时间/秒
    * @param timeout 超时时长
    * @param unit    时间单位
    * @return 是否获取成功
    */
public boolean lock(String key, String value, long expire, long timeout, TimeUnit unit) {
    long waitMillis = unit.toMillis(timeout);
    long waitAlready = 0;

    while (!stringRedisTemplate.opsForValue().setIfAbsent(key, value, expire, TimeUnit.SECONDS) && waitAlready < waitMillis) {
        try {
            Thread.sleep(waitMillisPer);
        } catch (InterruptedException e) {
            log.error("Interrupted when trying to get a lock. key: {}", key, e);
        }
        waitAlready += waitMillisPer;
    }

    if (waitAlready < waitMillis) {
        return true;
    }
    log.warn("<====== lock {} failed after waiting for {} ms", key, waitAlready);
    return false;
}

上述实现如何满足前面提到的几点要求:

客户端互斥: 可以将expire过期时间设置为大于同步代码的执行时间,比如同步代码块执行时间为1s,则可将expire设置为3s或5s。避免同步代码执行过程中expire时间到,其它客户端又可以获取锁执行同步代码块。
通过设置过期时间expire来避免某个客户端长期占有锁。
通过value来控制谁加的锁,由谁解的逻辑,比如可以使用requestId作为value,requestId唯一标记一次请求。
setIfAbsent方法 底层通过调用 Redis 的 SETNX 命令,操作具备原子性。

错误示例:

网上有如下实现,

public boolean lock(String key, String value, long expire) {
    boolean result = stringRedisTemplate.opsForValue().setIfAbsent(key, value);
    if(result) {
        stringRedisTemplate.expire(key, expire, TimeUnit.SECONDS);
    }
    return result;
}

该实现的问题是如果在result为true,但还没成功设置expire时,程序异常退出了,将导致该锁一直被占用而导致死锁,不满足第二点要求。

2. 解锁实现

解锁也需要满足前面所述的四个要求,实现代码如下:

private static final String RELEASE_LOCK_LUA_SCRIPT = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
private static final Long RELEASE_LOCK_SUCCESS_RESULT = 1L;
/**
    * 释放锁
    * @param key  锁的redis key
    * @param value 锁的value
    */
public boolean unLock(String key, String value) {
    DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>(RELEASE_LOCK_LUA_SCRIPT, Long.class);
    long result = stringRedisTemplate.execute(redisScript, Collections.singletonList(key), value);
    return Objects.equals(result, RELEASE_LOCK_SUCCESS_RESULT);
}

这段实现使用一个Lua脚本来实现解锁操作,保证操作的原子性。传入的value值需与该线程加锁时的value一致,可以使用requestId(具体实现下面给出)。

错误示例:

   public boolean unLock(String key, String value) {
        String oldValue = stringRedisTemplate.opsForValue().get(key);
        if(value.equals(oldValue)) {
            stringRedisTemplate.delete(key);
        }
}

该实现先获取锁的当前值,判断两值相等则删除。考虑一种极端情况,如果在判断为true时,刚好该锁过期时间到,另一个客户端加锁成功,则接下来的delete将不管三七二十一将别人加的锁直接删掉了,不满足第三点要求。该示例主要是因为没有保证解锁操作的原子性导致。

3. 注解支持

为了方便使用,添加一个注解,可以放于方法上控制方法在分布式环境中的同步执行。

/**
* 标注在方法上的分布式锁注解
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface DistributedLockable {
    String key();
    String prefix() default "disLock:";
    long expire() default 10L; // 默认10s过期
}

添加一个切面来解析注解的处理,

/**
* 分布式锁注解处理切面
*/
@Aspect
@Slf4j
public class DistributedLockAspect {

    private DistributedLock lock;

    public DistributedLockAspect(DistributedLock lock) {
        this.lock = lock;
    }

    /**
     * 在方法上执行同步锁
     */
    @Around(value = "@annotation(lockable)")
    public Object distLock(ProceedingJoinPoint point, DistributedLockable lockable) throws Throwable {
        boolean locked = false;
        String key = lockable.prefix() + lockable.key();
        try {
            locked = lock.lock(key, WebUtil.getRequestId(), lockable.expire());
            if(locked) {
                return point.proceed();
            } else {
                log.info("Did not get a lock for key {}", key);
                return null;
            }
        } catch (Exception e) {
            throw e;
        } finally {
            if(locked) {
                if(!lock.unLock(key, WebUtil.getRequestId())){
                    log.warn("Unlock {} failed, maybe locked by another client already. ", lockable.key());
                }
            }
        }
    }
}

RequestId 的实现如下,通过注册一个Filter,在请求开始时生成一个uuid存于ThreadLocal中,在请求返回时清除。

public class WebUtil {

    public static final String REQ_ID_HEADER = "Req-Id";

    private static final ThreadLocal<String> reqIdThreadLocal = new ThreadLocal<>();

    public static void setRequestId(String requestId) {
        reqIdThreadLocal.set(requestId);
    }

    public static String getRequestId(){
        String requestId = reqIdThreadLocal.get();
        if(requestId == null) {
            requestId = ObjectId.next();
            reqIdThreadLocal.set(requestId);
        }
        return requestId;
    }

    public static void removeRequestId() {
        reqIdThreadLocal.remove();
    }
}

public class RequestIdFilter implements Filter {
    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        HttpServletRequest httpServletRequest = (HttpServletRequest) servletRequest;
        String reqId = httpServletRequest.getHeader(WebUtil.REQ_ID_HEADER);
        //没有则生成一个
        if (StringUtils.isEmpty(reqId)) {
            reqId = ObjectId.next();
        }
        WebUtil.setRequestId(reqId);
        try {
            filterChain.doFilter(servletRequest, servletResponse);
        } finally {
            WebUtil.removeRequestId();
        }
    }
}

//在配置类中注册Filter

/**
* 添加RequestId
* @return
*/
@Bean
public FilterRegistrationBean requestIdFilter() {
    RequestIdFilter reqestIdFilter = new RequestIdFilter();
    FilterRegistrationBean registrationBean = new FilterRegistrationBean();
    registrationBean.setFilter(reqestIdFilter);
    List<String> urlPatterns = Collections.singletonList("/*");
    registrationBean.setUrlPatterns(urlPatterns);
    registrationBean.setOrder(Ordered.HIGHEST_PRECEDENCE + 1);
    return registrationBean;
}

4. 使用注解

@DistributedLockable(key = "test", expire = 10)
public void test(){
    System.out.println("线程-"+Thread.currentThread().getName()+"开始执行..." + LocalDateTime.now());
    try {
        Thread.sleep(2000);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
    System.out.println("线程-"+Thread.currentThread().getName()+"结束执行..." + LocalDateTime.now());
}

总结

本文给出了基于Redis的分布式锁的实现方案与常见的错误示例。要保障分布式锁的正确运行,需满足本文所提的四个要求,尤其注意保证加锁解锁操作的原子性,设置过期时间,及对同一个锁的加锁解锁线程一致。

感谢你看到这里,我是程序员麦冬,一个java开发从业者,深耕行业六年了,每天都会分享java相关技术文章或行业资讯

欢迎大家关注和转发文章,后期还有福利赠送!

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