앞으로의 함정을 피하기 위해 Redis 분산 잠금으로 인한 큰 사고를 기억하십시오!

풀어 주다: 2023-07-27 09:07:19
앞으로
1378명이 탐색했습니다.

머리말

Redis 기반의 분산 잠금을 사용하는 것은 오늘날 새로운 것이 아닙니다. 이 글은 주로 실제 프로젝트에서 Redis 분산 잠금으로 인해 발생하는 사고 분석 및 해결 방법을 기반으로 작성되었습니다.

배경: 우리 프로젝트의 긴급 주문은 분산 잠금을 사용하여 해결됩니다.

한번은 재고가 100병인 Feitian Moutai의 긴급 세일 이벤트를 진행했는데 너무 많이 팔렸습니다! 알다시피, 이 지구상에 Feitian Moutai가 부족합니다! ! ! 해당 사고는 P0 대형사고로 분류됐는데... 침착하게 받아들일 수 밖에 없습니다. 프로젝트팀 전체 성과 감점~~

사고 발생 후 CTO께서 저를 지명하셔서 처리를 총괄하라고 하셔서 오케이 차지~

사고 현장

어느 정도 이해한 후에 이것을 알게 되었습니다. 이전에는 급매 활동 인터페이스에서 이러한 상황이 발생한 적이 없는데 이번에는 왜 과매도됩니까?

이유는, 기존 급매물은 희소상품이 아니었는데, 이번 이벤트는 실제로 매장된 데이터 분석을 통해 모든 데이터가 기본적으로 2배가 되었다는 점입니다. 더 이상 고민할 필요 없이 바로 핵심 코드로 넘어가겠습니다. 기밀 부분은 의사 코드로 처리되었습니다. . .

public SeckillActivityRequestVO seckillHandle(SeckillActivityRequestVO request) {
SeckillActivityRequestVO response;
    String key = "key:" + request.getSeckillId;
    try {
        Boolean lockFlag = redisTemplate.opsForValue().setIfAbsent(key, "val", 10, TimeUnit.SECONDS);
        if (lockFlag) {
            // HTTP请求用户服务进行用户相关的校验
            // 用户活动校验
            
            // 库存校验
            Object stock = redisTemplate.opsForHash().get(key+":info", "stock");
            assert stock != null;
            if (Integer.parseInt(stock.toString()) <= 0) {
                // 业务异常
            } else {
                redisTemplate.opsForHash().increment(key+":info", "stock", -1);
                // 生成订单
                // 发布订单创建成功事件
                // 构建响应VO
            }
        }
    } finally {
        // 释放锁
        stringRedisTemplate.delete("key");
        // 构建响应VO
    }
    return response;
}
로그인 후 복사

위 코드는 비즈니스 로직에 대한 충분한 실행 시간을 보장하기 위해 10초의 분산 잠금 만료 시간을 사용합니다. try-finally 문 블록은 잠금이 제때 해제되도록 보장합니다. 재고는 비즈니스 코드 내에서도 확인됩니다. 매우 안전해 보입니다~ 걱정하지 마시고 분석을 계속하세요.

"

DD가 작성한 SpringBoot 기본 튜토리얼을 위한 창고를 추천하세요. 도움의 손길을 빕니다: https://gitee.com/didispace/SpringBoot-Learning/tree/master/2.1.x

사고 원인

Feitian Moutai 러시 세일 이벤트로 인해 많은 신규 사용자가 저희 앱을 다운로드하고 등록하게 되었으며, 그 중에는 전문적인 방법을 사용하여 양모를 수확하기 위해 신규 사용자를 등록하는 많은 양모 애호가들이 있습니다. 물론, 저희 사용자 시스템에서는 사전에 예방 조치를 취해 알리바바 클라우드 인간-컴퓨터 인증, 3단계 인증, 자체 개발한 위험 통제 시스템 등 다양한 기술에 접근하여 불법 사용자를 대거 차단했습니다~

하지만 그렇습니다.

급한 판매 활동 초기에 많은 수의 사용자 인증 요청이 사용자 서비스에 도달하여 짧은 응답 지연이 발생했습니다. 사용자 서비스 게이트웨이에서 일부 요청의 응답 시간이 10초를 초과했지만, HTTP 요청의 응답 시간 초과로 인해 30초로 설정하여 10초 이후에는 사용자 확인 영역에서 인터페이스가 차단되었습니다. 분산 잠금이 만료되면 새 요청이 들어올 때 잠금을 얻을 수 있습니다. 즉, 차단된 인터페이스가 실행된 후 잠금 해제 로직이 실행되어 잠금이 해제됩니다. 다른 스레드로 인해 새로운 요청이 잠금을 위해 경쟁하게 됩니다. 이것은 정말 매우 나쁜 사이클입니다.

지금은 재고검증만 할 수 있는데 재고검증은 비원자적이지 않고 가져오기 및 비교 방식을 사용합니다. 과매도의 비극이 일어났습니다~~~

사고 분석

꼼꼼한 분석 끝에 , 이 스냅업 인터페이스는 주로 다음 세 곳에 집중된 높은 동시성 시나리오에서 심각한 보안 위험이 있음을 알 수 있습니다.

다른 시스템 위험 내결함성은 없습니다.

엄격한 사용자 서비스로 인해 게이트웨이 A 응답이 지연되었지만 처리할 방법이 없으면 과매도의 원인이 됩니다.

겉으로는 안전해 보이는 분산 잠금은 실제로는 전혀 안전하지 않습니다

set key value [EX seconds] [PX milliseconds] [NX|XX] 메소드를 채택하더라도 스레드 A가 실행하는 데 오랜 시간이 걸리고 해제할 시간이 없으면 잠금이 만료되고 스레드 B가 획득할 수 있습니다. 지금은 자물쇠로. 스레드 A가 실행을 완료하고 잠금을 해제하면 스레드 B의 잠금이 실제로 해제됩니다.

이때 스레드 C가 다시 잠금을 획득할 수 있습니다. 이때 스레드 B가 실행을 마치고 잠금을 해제하면 실제로는 스레드 C가 설정한 잠금이 해제됩니다. 이것이 과매도의 직접적인 원인이다.

비원자 재고 검증

非原子性的库存校验导致在并发场景下,库存校验的结果不准确。这是超卖的根本原因。

通过以上分析,问题的根本原因在于库存校验严重依赖了分布式锁。因为在分布式锁正常set、del的情况下,库存校验是没有问题的。但是,当分布式锁不安全可靠的时候,库存校验就没有用了。

解决方案

知道了原因之后,我们就可以对症下药了。

实现相对安全的分布式锁

相对安全的定义:set、del是一一映射的,不会出现把其他现成的锁del的情况。从实际情况的角度来看,即使能做到set、del一一映射,也无法保障业务的绝对安全。

因为锁的过期时间始终是有界的,除非不设置过期时间或者把过期时间设置的很长,但这样做也会带来其他问题。故没有意义。

要想实现相对安全的分布式锁,必须依赖key的value值。在释放锁的时候,通过value值的唯一性来保证不会勿删。我们基于LUA脚本实现原子性的get and compare,如下:

public void safedUnLock(String key, String val) {
    String luaScript = "local in = ARGV[1] local curr=redis.call(&#39;get&#39;, KEYS[1]) if in==curr then redis.call(&#39;del&#39;, KEYS[1]) end return &#39;OK&#39;"";
    RedisScript<String> redisScript = RedisScript.of(luaScript);
    redisTemplate.execute(redisScript, Collections.singletonList(key), Collections.singleton(val));
}
로그인 후 복사

我们通过LUA脚本来实现安全地解锁。

实现安全的库存校验

如果我们对于并发有比较深入的了解的话,会发现想 get and compare/ read and save 等操作,都是非原子性的。如果要实现原子性,我们也可以借助LUA脚本来实现。

但就我们这个例子中,由于抢购活动一单只能下1瓶,因此可以不用基于LUA脚本实现而是基于redis本身的原子性。原因在于:

// redis会返回操作之后的结果,这个过程是原子性的
Long currStock = redisTemplate.opsForHash().increment("key", "stock", -1);
로그인 후 복사

发现没有,代码中的库存校验完全是“画蛇添足”。

改进之后的代码

经过以上的分析之后,我们决定新建一个DistributedLocker类专门用于处理分布式锁。

public SeckillActivityRequestVO seckillHandle(SeckillActivityRequestVO request) {
SeckillActivityRequestVO response;
    String key = "key:" + request.getSeckillId();
    String val = UUID.randomUUID().toString();
    try {
        Boolean lockFlag = distributedLocker.lock(key, val, 10, TimeUnit.SECONDS);
        if (!lockFlag) {
            // 业务异常
        }

        // 用户活动校验
        // 库存校验,基于redis本身的原子性来保证
        Long currStock = stringRedisTemplate.opsForHash().increment(key + ":info", "stock", -1);
        if (currStock < 0) { // 说明库存已经扣减完了。
            // 业务异常。
            log.error("[抢购下单] 无库存");
        } else {
            // 生成订单
            // 发布订单创建成功事件
            // 构建响应
        }
    } finally {
        distributedLocker.safedUnLock(key, val);
        // 构建响应
    }
    return response;
}
로그인 후 복사

深度思考

分布式锁有必要么

改进之后,其实可以发现,我们借助于redis本身的原子性扣减库存,也是可以保证不会超卖的。对的。但是如果没有这一层锁的话,那么所有请求进来都会走一遍业务逻辑,由于依赖了其他系统,此时就会造成对其他系统的压力增大。这会增加的性能损耗和服务不稳定性,得不偿失。基于分布式锁可以在一定程度上拦截一些流量。

分布式锁的选型

有人提出用RedLock来实现分布式锁。RedLock的可靠性更高,但其代价是牺牲一定的性能。在本场景,这点可靠性的提升远不如性能的提升带来的性价比高。如果对于可靠性极高要求的场景,则可以采用RedLock来实现。

再次思考分布式锁有必要么

由于bug需要紧急修复上线,因此我们将其优化并在测试环境进行了压测之后,就立马热部署上线了。实际证明,这个优化是成功的,性能方面略微提升了一些,并在分布式锁失效的情况下,没有出现超卖的情况。

然而,还有没有优化空间呢?有的!

由于服务是集群部署,我们可以将库存均摊到集群中的每个服务器上,通过广播通知到集群的各个服务器。网关层基于用户ID做hash算法来决定请求到哪一台服务器。这样就可以基于应用缓存来实现库存的扣减和判断。性能又进一步提升了!

// 通过消息提前初始化好,借助ConcurrentHashMap实现高效线程安全
private static ConcurrentHashMap<Long, Boolean> SECKILL_FLAG_MAP = new ConcurrentHashMap<>();
// 通过消息提前设置好。由于AtomicInteger本身具备原子性,因此这里可以直接使用HashMap
private static Map<Long, AtomicInteger> SECKILL_STOCK_MAP = new HashMap<>();

...

public SeckillActivityRequestVO seckillHandle(SeckillActivityRequestVO request) {
SeckillActivityRequestVO response;

    Long seckillId = request.getSeckillId();
    if(!SECKILL_FLAG_MAP.get(requestseckillId)) {
        // 业务异常
    }
     // 用户活动校验
     // 库存校验
    if(SECKILL_STOCK_MAP.get(seckillId).decrementAndGet() < 0) {
        SECKILL_FLAG_MAP.put(seckillId, false);
        // 业务异常
    }
    // 生成订单
    // 发布订单创建成功事件
    // 构建响应
    return response;
}
로그인 후 복사

通过以上的改造,我们就完全不需要依赖redis了。性能和安全性两方面都能进一步得到提升!

当然,此方案没有考虑到机器的动态扩容、缩容等复杂场景,如果还要考虑这些话,则不如直接考虑分布式锁的解决方案。

요약

희소품의 과잉판매는 분명 큰 사고입니다. 과매도 수량이 많으면 플랫폼의 운영 및 사회적 영향도 매우 심각합니다. 이 사고 이후 저는 프로젝트의 어떤 코드 라인도 가볍게 여겨질 수 없다는 것을 깨달았습니다. 그렇지 않으면 일부 시나리오에서는 정상적으로 작동하는 코드가 치명적인 킬러가 될 것입니다!

개발자는 개발 계획을 세울 때 계획을 신중하게 고려해야 합니다. 어떻게 계획을 철저하게 고려할 수 있습니까? 계속해서 배우세요!

위 내용은 앞으로의 함정을 피하기 위해 Redis 분산 잠금으로 인한 큰 사고를 기억하십시오!의 상세 내용입니다. 자세한 내용은 PHP 중국어 웹사이트의 기타 관련 기사를 참조하세요!

관련 라벨:
원천:Java学习指南
본 웹사이트의 성명
본 글의 내용은 네티즌들의 자발적인 기여로 작성되었으며, 저작권은 원저작자에게 있습니다. 본 사이트는 이에 상응하는 법적 책임을 지지 않습니다. 표절이나 침해가 의심되는 콘텐츠를 발견한 경우 admin@php.cn으로 문의하세요.
인기 튜토리얼
더>
최신 다운로드
더>
웹 효과
웹사이트 소스 코드
웹사이트 자료
프론트엔드 템플릿