并发场景下的幂等问题——分布式锁详解

本文涉及的产品
Redis 开源版,标准版 2GB
推荐场景:
搭建游戏排行榜
云数据库 Tair(兼容Redis),内存型 2GB
简介: 并发场景下的幂等问题——分布式锁详解


写在前面:本文讨论的幂等问题,均为并发场景下的幂等问题。即系统本存在幂等设计,但是在并发场景下失效了。


一  摘要



本文从钉钉实人认证场景的一例数据重复问题出发,分析了其原因是因为并发导致幂等失效,引出幂等的概念。

针对并发场景下的幂等问题,提出了一种实现幂等可行的方法论,结合通讯录加人业务场景对数据库幂等问题进行了简单分析,就分布式锁实现幂等方法展开了详细讨论。

分析了锁在分布式场景下存在的问题,包括单点故障、网络超时、错误释放他人锁、提前释放锁以及分布式锁单点故障等,提出了对应的解决方案,介绍了对应方案的具体实现。


二  问题



钉钉实人认证业务存在数据重复的问题。


1  问题现象



正常情况下,数据库中应该只有一条实人认证成功记录,但是实际上某用户有多条。


2  问题原因



并发导致了不幂等。

我们先来回顾一下幂等的概念:


幂等(idempotent、idempotence)是一个数学与计算机学概念,常见于抽象代数中。
在编程中一个幂等操作的特点是其任意多次执行所产生的影响均与一次执行的影响相同。
--来自百度百科



实人认证在业务上有幂等设计,其一般流程为:
1)用户选择实人认证后会在服务端初始化一条记录;
2)用户在钉钉移动端按照指示完成人脸比对;
3)比对完成后访问服务端修改数据库状态。

在第3步中,在修改数据库状态之前,会判断「是否已经初始化」、「是否已经实人认证」以及「智科是否返回认证成功」以保证幂等。仅当请求首次访问服务端尝试修改数据库状态时,才能满足幂等的判断条件并修改数据库状态。其余任意次请求将直接返回,对数据库状态无影响。请求多次访问服务端所产生的结果,和请求首次访问服务端一致。因此,在实人认证成功的前提下,数据库应当有且仅有一条认证成功的记录。



但是在实际过程中我们发现,同一个请求会多次修改数据库状态,系统并未按照我们预期的那样实现幂等。究其原因,是因为请求并发访问,在首次请求完成修改服务端状态前,并发的其他请求和首次请求都通过了幂等判断,对数据库状态进行了多次修改。

并发导致了原幂等设计失效。

并发导致了不幂等。


三  解决方案



解决并发场景下幂等问题的关键,是找到唯一性约束,执行唯一性检查,相同的数据保存一次,相同的请求操作一次。

一次访问服务端的请求,可能产生以下几种交互:


  1. 与数据源交互,例如数据库状态变更等;
  2. 与其他业务系统交互,例如调用下游服务或发送消息等;



一次请求可以只包含一次交互,也可以包含多次交互。例如一次请求可以仅仅修改一次数据库状态,也可以在修改数据库状态后再发送一条数据库状态修改成功的消息。

于是我们可以得出一个结论:并发场景下,如果一个系统依赖的组件幂等,那么该系统在天然幂等。

以数据库为例,如果一个请求对数据造成的影响是新增一条数据,那么唯一索引可以是幂等问题的解法。数据库会帮助我们执行唯一性检查,相同数据不会重复落库。

钉钉通讯录加人就是通过数据库的唯一索引解决了幂等问题。以钉钉通讯录加人为例,在向数据库写数据之前,会先判断数据是否已经存在于数据库之中,如果不存在,加人请求最终会向数据库的员工表插入一条数据。大量相同的并发的通讯录加人请求让系统的幂等设计失效成为可能。在一次加人请求中,(组织ID,工号)可以唯一标记一个请求,在数据库中,也存在(组织ID,工号)的唯一索引。因此我们可以保证,多次相同的加人请求,只会修改一次数据库状态,即添加一条记录。

如果所依赖的组件天然幂等,那么问题就简单了,但是实际情况往往更加复杂。并发场景下,如果系统依赖的组件无法幂等,我们就需要使用额外的手段实现幂等。

一个常用的手段就是使用分布式锁。分布式锁的实现方式有很多,比较常用的是缓存式分布式锁。


四  分布式锁


在What is a Java distributed lock?中有这样几段话:


In computer science, locks are mechanisms in a multithreaded environment to prevent different threads from operating on the same resource. When using locking, a resource is "locked" for access by a specific thread, and can only be accessed by a different thread once the resource has been released. Locks have several benefits: they stop two threads from doing the same work, and they prevent errors and data corruption when two threads try to use the same resource simultaneously. Distributed locks in Java are locks that can work with not only multiple threads running on the same machine, but also threads running on clients on different machines in a distributed system. The threads on these separate machines must communicate and coordinate to make sure that none of them try to access a resource that has been locked up by another.


这几段话告诉我们,锁的本质是共享资源的互斥访问,分布式锁解决了分布式系统中共享资源的互斥访问的问题。

java.util.concurrent.locks包提供了丰富的锁实现,包括公平锁/非公平锁,阻塞锁/非阻塞锁,读写锁以及可重入锁等。

我们要如何实现一个分布式锁呢?


方案一


分布式系统中常见有两个问题:
1)单点故障问题,即当持有锁的应用发生单点故障时,锁将被长期无效占有;
2)网络超时问题,即当客户端发生网络超时但实际上锁成功时,我们无法再次正确的

获取锁。

要解决问题1,一个简单的方案是引入过期时间(lease time),对锁的持有将是有时效的,当应用发生单点故障时,被其持有的锁可以自动释放。

要解决问题2,一个简单的方案是支持可重入,我们为每个获取锁的客户端都配置一个不会重复的身份标识(通常是UUID),上锁成功后锁将带有该客户端的身份标识。当实际上锁成功而客户端超时重试时,我们可以判断锁已被该客户端持有而返回成功。

综上我们给出了一个lease-based distribute lock方案。出于性能考量,使用缓存作为锁的存储介质,利用MVCC(Multiversion concurrency control)机制解决共享资源互斥访问问题,具体实现可见附录代码。

分布式锁的一般使用方式如下
● 初始化分布式锁的工厂
● 利用工厂生成一个分布式锁实例
● 使用该分布式实例上锁和解锁操作


@Test
public void testTryLock() {
    //初始化工厂
    MdbDistributeLockFactory mdbDistributeLockFactory = new MdbDistributeLockFactory();
    mdbDistributeLockFactory.setNamespace(603);
    mdbDistributeLockFactory.setMtairManager(new MultiClusterTairManager());
    //获得锁
    DistributeLock lock = mdbDistributeLockFactory.getLock("TestLock");
    //上锁解锁操作
    boolean locked = lock.tryLock();
    if (!locked) {
        return;
    }
    try {
        //do something 
    } finally {
        lock.unlock();
    }
}

该方案简单易用,但是问题也很明显。例如,释放锁的时候只是简单的将缓存中的key失效,所以存在错误释放他人已持有锁问题。所幸只要锁的租期设置的足够长,该问题出现几率就足够小。

我们借用Martin Kleppmann在文章How to do distributed locking中的一张图说明该问题。



设想一种情况,当占有锁的Client 1在释放锁之前,锁就已经到期了,Client 2将获取锁,此时锁被Client 2持有,但是Client 1可能会错误的将其释放。一个更优秀的方案,我们给每个锁都设置一个身份标识,在释放锁的时候,1)首先查询锁是否是自己的,2)如果是自己的则释放锁。受限于实现方式,步骤1和步骤2不是原子操作,在步骤1和步骤2之间,如果锁到期被其他客户端获取,此时也会错误的释放他人的锁。


方案二


借助Redis的Lua脚本,可以完美的解决存在错误释放他人已持有锁问题的。在Distributed locks with Redis这篇文章的 Correct implementation with a single instance 这一节中,我们可以得到我们想要的答案——如何实现一个分布式锁。

当我们想要获取锁时,我们可以执行如下方法



SET resource_name my_random_value NX PX 30000


当我们想要释放锁时,我们可以执行如下的Lua脚本


if redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("del",KEYS[1])
else
    return 0
end

方案三


在方案一和方案二的讨论过程中,有一个问题被我们反复提及:锁的自动释放。

这是一把双刃剑:
1)一方面它很好的解决了持有锁的客户端单点故障的问题
2)另一方面,如果锁提前释放,就会出现锁的错误持有状态

这个时候,我们可以引入Watch Dog自动续租机制,我们可以参考以下Redisson是如何实现的。

在上锁成功后,Redisson会调用renewExpiration()方法开启一个Watch Dog线程,为锁自动续期。每过1/3时间续一次,成功则继续下一次续期,失败取消续期操作。

我们可以再看看Redisson是如何续期的。renewExpiration()方法的第17行renewExpirationAsync()方法是执行锁续期的关键操作,我们进入到方法内部,可以看到Redisson也是使用Lua脚本进行锁续租的:1)判断锁是否存在;2)如果存在则重置过期时间。


private void renewExpiration() {
    ExpirationEntry ee = EXPIRATION_RENEWAL_MAP.get(getEntryName());
    if (ee == null) {
        return;
    }
    Timeout task = commandExecutor.getConnectionManager().newTimeout(timeout -> {
        ExpirationEntry ent = EXPIRATION_RENEWAL_MAP.get(getEntryName());
        if (ent == null) {
            return;
        }
        Long threadId = ent.getFirstThreadId();
        if (threadId == null) {
            return;
        }
        RFuture<Boolean> future = renewExpirationAsync(threadId);
        future.onComplete((res, e) -> {
            if (e != null) {
                log.error("Can't update lock " + getRawName() + " expiration", e);
                EXPIRATION_RENEWAL_MAP.remove(getEntryName());
                return;
            }
            if (res) {
                // reschedule itself
                renewExpiration();
            } else {
                cancelExpirationRenewal(null);
            }
        });
    }, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);
    ee.setTimeout(task);
}
protected RFuture<Boolean> renewExpirationAsync(long threadId) {
    return evalWriteAsync(getRawName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
                          "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
                          "redis.call('pexpire', KEYS[1], ARGV[1]); " +
                          "return 1; " +
                          "end; " +
                          "return 0;",
                          Collections.singletonList(getRawName()),
                          internalLockLeaseTime, getLockName(threadId));
}

方案四



借助Redisson的自动续期机制,我们无需再担心锁的自动释放。但是讨论到这里,我还是不得不面对一个问题:分布式锁本身不是一个分布式应用。当Redis服务器故障无法正常工作时,整个分布式锁也就无法提供服务。

更进一步,我们可以看看Distributed locks with Redis这篇文章中提到的Redlock算法及其实现。

Redlock算法不是银弹,关于它的好与坏,也有很多争论:

How to do distributed locking:https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html

Is Redlock safe?:http://antirez.com/news/101

Martin Kleppmann和Antirez关于Redlock的争辩:https://news.ycombinator.com/item


参考资料


What is a Java distributed lock?
https://redisson.org/glossary/java-distributed-lock.html

Distributed locks and synchronizers:
https://github.com/redisson/redisson/wiki/8.-distributed-locks-and-synchronizers

Distributed locks with Redis:
https://redis.io/topics/distlock?spm=ata.21736010.0.0.31f77e3aFs96rz


相关文章
|
4月前
|
NoSQL 算法 安全
redis分布式锁在高并发场景下的方案设计与性能提升
本文探讨了Redis分布式锁在主从架构下失效的问题及其解决方案。首先通过CAP理论分析,Redis遵循AP原则,导致锁可能失效。针对此问题,提出两种解决方案:Zookeeper分布式锁(追求CP一致性)和Redlock算法(基于多个Redis实例提升可靠性)。文章还讨论了可能遇到的“坑”,如加从节点引发超卖问题、建议Redis节点数为奇数以及持久化策略对锁的影响。最后,从性能优化角度出发,介绍了减少锁粒度和分段锁的策略,并结合实际场景(如下单重复提交、支付与取消订单冲突)展示了分布式锁的应用方法。
312 3
|
4月前
|
存储 NoSQL Java
从扣减库存场景来讲讲redis分布式锁中的那些“坑”
本文从一个简单的库存扣减场景出发,深入分析了高并发下的超卖问题,并逐步优化解决方案。首先通过本地锁解决单机并发问题,但集群环境下失效;接着引入Redis分布式锁,利用SETNX命令实现加锁,但仍存在死锁、锁过期等隐患。文章详细探讨了通过设置唯一标识、续命机制等方法完善锁的可靠性,并最终引出Redisson工具,其内置的锁续命和原子性操作极大简化了分布式锁的实现。最后,作者剖析了Redisson源码,揭示其实现原理,并预告后续关于主从架构下分布式锁的应用与性能优化内容。
221 0
|
9月前
|
NoSQL Java Redis
秒杀抢购场景下实战JVM级别锁与分布式锁
在电商系统中,秒杀抢购活动是一种常见的营销手段。它通过设定极低的价格和有限的商品数量,吸引大量用户在特定时间点抢购,从而迅速增加销量、提升品牌曝光度和用户活跃度。然而,这种活动也对系统的性能和稳定性提出了极高的要求。特别是在秒杀开始的瞬间,系统需要处理海量的并发请求,同时确保数据的准确性和一致性。 为了解决这些问题,系统开发者们引入了锁机制。锁机制是一种用于控制对共享资源的并发访问的技术,它能够确保在同一时间只有一个进程或线程能够操作某个资源,从而避免数据不一致或冲突。在秒杀抢购场景下,锁机制显得尤为重要,它能够保证商品库存的扣减操作是原子性的,避免出现超卖或数据不一致的情况。
249 10
|
10月前
|
NoSQL Java 数据处理
基于Redis海量数据场景分布式ID架构实践
【11月更文挑战第30天】在现代分布式系统中,生成全局唯一的ID是一个常见且重要的需求。在微服务架构中,各个服务可能需要生成唯一标识符,如用户ID、订单ID等。传统的自增ID已经无法满足在集群环境下保持唯一性的要求,而分布式ID解决方案能够确保即使在多个实例间也能生成全局唯一的标识符。本文将深入探讨如何利用Redis实现分布式ID生成,并通过Java语言展示多个示例,同时分析每个实践方案的优缺点。
331 8
|
9月前
|
调度 数据库
什么场景下要使用分布式锁
分布式锁用于确保多节点环境下的资源互斥访问、避免重复操作、控制并发流量、防止竞态条件及任务调度协调,常见于防止超卖等问题。
246 4
|
11月前
|
NoSQL Java Redis
京东双十一高并发场景下的分布式锁性能优化
【10月更文挑战第20天】在电商领域,尤其是像京东双十一这样的大促活动,系统需要处理极高的并发请求。这些请求往往涉及库存的查询和更新,如果处理不当,很容易出现库存超卖、数据不一致等问题。
266 1
|
11月前
|
存储 缓存 NoSQL
大数据-38 Redis 高并发下的分布式缓存 Redis简介 缓存场景 读写模式 旁路模式 穿透模式 缓存模式 基本概念等
大数据-38 Redis 高并发下的分布式缓存 Redis简介 缓存场景 读写模式 旁路模式 穿透模式 缓存模式 基本概念等
263 4
|
人工智能 监控 虚拟化
操作系统的演变:从单任务到多任务,再到并发和分布式
随着计算技术的发展,操作系统经历了从简单的单任务处理到复杂的多任务、并发处理,再到现代的分布式系统的转变。本文将探索这一演变过程中的关键里程碑,以及它们如何塑造我们今天使用的计算机系统的架构和性能。
|
SQL 索引
分布式之接口幂等性
分布式之接口幂等性
127 2
|
人工智能 分布式计算 物联网
操作系统的演变:从单任务到多任务再到并发和分布式
在数字时代的浪潮中,操作系统作为计算机硬件与应用程序之间的桥梁,其发展史是一部技术革新与需求演进的史诗。本文将带领读者穿梭于操作系统的时空隧道,从早期简单而原始的单任务系统出发,一路见证它如何逐步进化为支持多任务、并发执行乃至分布式计算的复杂系统。我们将一探究竟,是什么推动了这些转变,它们又是如何影响我们日常的技术实践与生活的。
134 1

热门文章

最新文章