关于分布式锁:灵活运用分布式锁解决数据重复插入问题

7次阅读

共计 7017 个字符,预计需要花费 18 分钟才能阅读完成。

一、业务背景

许多面向用户的互联网业务都会在零碎后端保护一份用户数据,快利用核心业务也同样做了这件事。快利用核心容许用户对快利用进行珍藏,并在服务端记录了用户的珍藏列表,通过用户账号标识 OpenID 来关联珍藏的快利用包名。

为了使用户在快利用核心的珍藏列表可能与快利用 Menubar 的珍藏状态买通,咱们同时也记录了用户账号标识 OpenID 与客户端本地标识 local\_identifier 的绑定关系。因为快利用 Manubar 由快利用引擎持有,独立于快利用核心外,无奈通过账号体系获取到用户账号标识,只能获取到客户端本地标识 local\_identifier,所以咱们只能通过二者的映射关系来放弃状态同步。

在具体实现上,咱们是在用户启动快利用核心的时候触发一次同步操作,由客户端将 OpenID 和客户端本地标识提交到服务端进行绑定。服务端的绑定逻辑是:判断 OpenID 是否曾经存在,如果不存在则插入数据库,否则更新对应数据行的 local\_identifier 字段(因为用户可能先后在两个不同的手机上登录同一个 vivo 账号)。在后续的业务流程中,咱们就能够依据 OpenID 查问对应的 local\_identifier,反之亦可。

然而代码上线一段时间后,咱们发现 t_account 数据表中竟然存在许多反复的 OpenID 记录。依据如上所述的绑定逻辑,这种状况实践上是不应该产生的。所幸这些反复数据并没有对更新和查问的场景造成影响,因为在查问的 SQL 中咱们退出了 LIMIT 1 的限度,因而针对一个 OpenID 的更新和查问操作实际上都只作用于 ID 最小的那条记录。

二、问题剖析与定位

尽管冗余数据没有对理论业务造成影响,然而这种显著的数据问题也必定是不能容忍的。因而咱们开始着手排查问题。

首先想到的就是从数据自身动手。先通过对 t_account 表数据进行粗略察看,发现大概有 3% 的 OpenID 会存在反复的状况。也就是说反复插入的状况是偶现的,大多数申请的解决都是依照预期被正确处理了。咱们对代码从新进行了走读,确认了代码在实现上的确不存在什么显著的逻辑谬误。

咱们进一步对数据进行粗疏察看。咱们筛选了几个呈现反复状况的 OpenID,将相干的数据记录查问进去,发现这些 OpenID 反复的次数也不尽相同,有的只反复一次,有的则更多。然而,这时候咱们发现了一个更有价值的信息——这些雷同 OpenID 的数据行的创立工夫都是完全相同的,而且自增 ID 是间断的。

于是,咱们猜想问题的产生应该是因为并发申请造成的!咱们模仿了客户端对接口的并发调用,的确呈现了反复插入数据的景象,进一步证实了这个猜想的合理性。然而,明明客户端的逻辑是每个用户在启动的时候进行一次同步,为什么会呈现同一个 OpenID 并发申请呢?

事实上,代码的理论运行并不如咱们设想中的那么现实,计算机的运行过程中往往存在一些不稳固的因素,比方网络环境、服务器的负载状况。而这些不稳固因素就可能导致客户端发送申请失败,这里的“失败”可能并不意味着真正的失败,而是可能整个申请工夫过长,超过了客户端设定的超时工夫,从而被人为地断定为失败,于是通过重试机制再次发送申请。那么最终就可能导致同样的申请被提交了屡次,而且这些申请兴许在两头某个环节被阻塞了(比方当服务器的解决线程负载过大,来不及解决申请,申请进入了缓冲队列),当阻塞缓解后这几个申请就可能在很短的工夫内被并发解决了。

这其实是一个典型的并发抵触问题,能够把这个问题简略形象为:如何防止并发状况下写入反复数据。事实上,有很多常见的业务场景都可能面临这个问题,比方用户注册时不容许应用雷同的用户名。

一般来说,咱们在解决这类问题时,最直观的形式就是先进行一次查问,当判断数据库中不存在以后数据时才容许插入。

显然,这个流程从单个申请的角度来看是没有问题的。然而当多个申请并发时,申请 A 和申请 B 都先发动一次查问,并且都失去后果是不存在,于是两者都又执行了数据插入,最终导致并发抵触。

三、摸索可行的计划

既然问题定位到了,接下来就要开始寻求解决方案了。面对这种状况,咱们通常有两种抉择,一种是让数据库来解决,另一种是由应用程序来解决。

3.1 数据库层面解决——惟一索引

当应用 MySQL 数据库及 InnoDB 存储引擎时,咱们能够利用惟一索引来保障同一个列的值具备唯一性。显然,在 t\_account 这张表中,咱们最开始是没有为 open\_id 列创立惟一索引的。如果咱们想要此时加上惟一索引的话,能够利用下列的 ALTER TABLE 语句。

ALTER TABLE t_account ADD UNIQUE uk_open_id(open_id);

一旦为 open\_id 列加上惟一索引后,当上述并发状况产生时,申请 A 和申请 B 中必然有一者会优先实现数据的插入操作,而另一者则会失去相似谬误。因而,最终保障 t\_account 表中只有一条 openid=xxx 的记录存在。

Error Code: 1062. Duplicate entry 'xxx' for key 'uk_open_id'

3.2 应用程序层面解决——分布式锁

另一种解决的思路是咱们不依赖底层的数据库来为咱们提供唯一性的保障,而是靠应用程序本身的代码逻辑来防止并发抵触。应用层的保障其实是一种更具通用性的计划,毕竟咱们不能假如所有零碎应用的数据长久化组件都具备数据唯一性检测的能力。

那具体怎么做呢?简略来说,就是化并行为串行。之所以咱们会遇到反复插入数据的问题,是因为“检测数据是否曾经存在”和“插入数据”两个动作被宰割开来。因为这两个步骤不具备原子性,才导致两个不同的申请能够同时通过第一步的检测。如果咱们可能把这两个动作合并为一个原子操作,就能够防止数据抵触了。这时候咱们就须要通过加锁,来实现这个代码块的原子性。

对于 Java 语言,大家最相熟的锁机制就是 synchronized 关键字了。

public synchronized void submit(String openId, String localIdentifier){Account account = accountDao.find(openId);
    if (account == null) {// insert}
    else {// update}
}

然而,事件可没这么简略。要晓得,咱们的程序可不是只部署在一台服务器上,而是部署了多个节点。也就是说这里的并发不仅仅是线程间的并发,而是过程间的并发。因而,咱们无奈通过 java 语言层面的锁机制来解决这个同步问题,咱们这里须要的应该是分布式锁。

3.3 两种解决方案的衡量

基于以上的剖析,看上去两种计划都是可行的,但最终咱们抉择了分布式锁的计划。为什么明明第一种计划只须要简略地加个索引,咱们却不采纳呢?

因为现有的线上数据未然在 open_id 列上存在反复数据,如果此时间接去加惟一索引是无奈胜利的。为了加上惟一索引,咱们必须首先将已有的反复数据先进行清理。然而问题又来了,线上的程序始终继续运行着,反复数据可能会源源不断地产生。那咱们能不能找一个用户申请不沉闷的时间段去进行清理,并在新的反复数据插入之前实现惟一索引的建设?答案当然是必定的,只不过这种计划须要运维、DBA、开发多方协同解决,而且因为业务个性,最合适的解决时间段应该是凌晨这种夜深人静的时候。即使是采取这么刻薄的修复措施,也不能百分之百齐全保证数据清理实现到索引建设之间不会有新的反复数据插入。因而,基于惟一索引的修复计划乍看之下十分适合,然而具体操作起来还是略为麻烦。

事实上,建设惟一索引最合适的契机应该是在零碎最后的设计阶段,这样就能无效防止反复数据的问题。然而木已成舟,在以后这个情景下,咱们还是抉择了可操作性更强的分布式锁计划。因为抉择这个计划的话,咱们能够先上线退出了分布式锁修复的新代码,阻断新的反复数据插入,而后再对原有的反复数据执行清理操作,这样一来只须要批改代码并一次上线即可。当然,待问题彻底解决之后,咱们能够从新再思考为数据表加上惟一索引。

那么接下来,咱们就来看看基于分布式锁的计划如何实现。首先咱们先来回顾一下分布式锁的相干常识。

四、分布式锁概述

4.1 分布式锁须要具备哪些个性?

  • 在分布式系统环境下,同一时间只有一台机器的一个线程能够获取到锁;
  • 高可用的获取锁与开释锁;
  • 高性能的获取锁与开释锁;
  • 具备可重入个性;
  • 具备锁生效机制,避免死锁;
  • 具备阻塞 / 非阻塞锁个性。

4.2 分布式锁有哪些实现形式?

分布式锁实现次要有如下三种:

  • 基于数据库实现分布式锁;
  • 基于 Zookeeper 实现分布式锁;
  • 基于 Redis 实现分布式锁;

4.2.1 基于数据库的实现形式

基于数据库的实现形式就是间接创立一张锁表,通过操作表数据来实现加锁、解锁。以 MySQL 数据库为例,咱们能够创立这样一张表,并且对 method_name 进行加上惟一索引的束缚:

CREATE TABLE `myLock` (`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键',
 `method_name` varchar(100) NOT NULL DEFAULT ''COMMENT' 锁定的办法名 ',
 `value` varchar(1024) NOT NULL DEFAULT '锁信息',
 PRIMARY KEY (`id`),
 UNIQUE KEY `uidx_method_name` (`method_name `) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='锁定中的办法';

而后,咱们就能够通过插入数据和删除数据的形式来实现加锁和解锁:

# 加锁
insert into myLock(method_name, value) values ('m1', '1');
 
#解锁
delete from myLock where method_name ='m1';

基于数据库实现的形式尽管简略,然而存在一些显著的问题:

  • 没有锁生效工夫,如果解锁失败,就会导致锁记录永远留在数据库中,造成死锁。
  • 该锁不可重入,因为它不意识申请方是不是以后占用锁的线程。
  • 以后数据库是单点,一旦宕机,锁机制就会齐全崩坏。

4.2.2 基于 Zookeeper 的实现形式

ZooKeeper 是一个为分布式应用提供一致性服务的开源组件,它外部是一个分层的文件系统目录树结构,规定同一个目录下的节点名称都是惟一的。

ZooKeeper 的节点(Znode)有 4 种类型:

  • 长久化节点(会话断开后节点还存在)
  • 长久化程序节点
  • 长期节点(会话断开后节点就删除了)
  • 长期程序节点

当一个新的 Znode 被创立为一个程序节点时,ZooKeeper 通过将 10 位的序列号附加到原始名称来设置 Znode 的门路。例如,如果将具备门路 /mynode 的 Znode 创立为程序节点,则 ZooKeeper 会将门路更改为 /mynode0000000001,并将下一个序列号设置为 0000000002,这个序列号由父节点保护。如果两个程序节点是同时创立的,那么 ZooKeeper 不会对每个 Znode 应用雷同的数字。

基于 ZooKeeper 的个性,能够依照如下形式来实现分布式锁:

  • 创立一个目录 mylock;
  • 线程 A 想获取锁就在 mylock 目录下创立长期程序节点;
  • 获取 mylock 目录下所有的子节点,而后获取比本人小的兄弟节点,如果不存在,则阐明以后线程顺序号最小,取得锁;
  • 线程 B 获取所有节点,判断本人不是最小节点,设置监听比本人次小的节点;
  • 线程 A 解决完,删除本人的节点,线程 B 监听到变更事件,判断本人是不是最小的节点,如果是则取得锁。

因为创立的是长期节点,当持有锁的线程意外宕机时,锁仍然能够失去开释,因而能够防止死锁的问题。另外,咱们也能够通过节点排队监听机制实现阻塞个性,也能够通过在 Znode 中携带线程标识来实现可重入锁。同时,因为 ZooKeeper 集群的高可用个性,分布式锁的可用性也可能失去保障。不过,因为须要频繁的创立和删除节点,Zookeeper 形式在性能上不如 Redis 形式。

4.2.3 基于 Redis 的实现形式

Redis 是一个开源的键值对 (Key-Value) 存储数据库,其基于内存实现,性能十分高,经常被用作缓存。

基于 Redis 实现分布式锁的外围原理是:尝试对特定 key 进行 set 操作,如果设置胜利(key 之前不存在)了,则相当于获取到锁,同时对该 key 设置一个过期工夫,防止线程在开释锁之前退出造成死锁。线程执行完同步工作后被动开释锁则通过 delete 命令来实现。

这里须要特地留神的一点是如何加锁并设置过期工夫。有的人会应用 setnx + expire 这两个命令来实现,但这是有问题的。假如以后线程执行 setnx 取得了锁,然而在执行 expire 之前宕机了,就会造成锁无奈被开释。当然,咱们能够将两个命令合并在一段 lua 脚本里,实现两条命令的原子提交。

其实,咱们简略利用 set 命令能够间接在一条命令中实现 setnx 和设置过期工夫,从而实现加锁操作:

SET key value [EX seconds] [PX milliseconds] NX

解锁操作只须要:

DEL key

五、基于 Redis 分布式锁的解决方案

在本案例中,咱们采纳了基于 Redis 实现分布式锁的形式。

5.1 分布式锁的 Java 实现

因为我的项目采纳了 Jedis 框架,而且线上 Redis 部署为集群模式,因而咱们基于 redis.clients.jedis.JedisCluster 封装了一个 RedisLock 类,提供加锁与解锁接口。

public class RedisLock {
 
    private static final String LOCK_SUCCESS = "OK";
    private static final String LOCK_VALUE = "lock";
    private static final int EXPIRE_SECONDS = 3;
 
    @Autowired
    protected JedisCluster jedisCluster;
 
    public boolean lock(String openId) {String redisKey = this.formatRedisKey(openId);
        String ok = jedisCluster.set(redisKey, LOCK_VALUE, "NX", "EX", EXPIRE_SECONDS);
        return LOCK_SUCCESS.equals(ok);
    }
 
    public void unlock(String openId) {String redisKey = this.formatRedisKey(openId);
        jedisCluster.del(redisKey);
    }
 
    private String formatRedisKey(String openId){return "keyPrefix:" + openId;}
}

在具体实现上,咱们设置了 3 秒钟的过期工夫,因为被加锁的工作是简略的数据库查问和插入,而且服务器与数据库部署在同个机房,失常状况下 3 秒钟曾经齐全可能足够满足代码的执行。

事实上,以上的实现是一个简陋版本的 Redis 分布式锁,咱们在实现中并没有思考线程的可重入性,也没有思考锁被其余过程误开释的问题,然而它在这个业务场景下曾经可能满足咱们的需要了。假如推广到更为通用的业务场景,咱们能够思考在 value 中退出以后过程的特定标识,并在上锁和开释锁的阶段做绝对应的匹配检测,就能够失去一个更为安全可靠的 Redis 分布式锁的实现了。

当然,像 Redission 之类的框架也提供了相当齐备的 Redis 分布式锁的封装实现,在一些要求绝对严苛的业务场景下,我倡议间接应用这类框架。因为本文侧重于介绍排查及解决问题的思路,因而没有对 Redisson 分布式的具体实现原理做更多介绍,感兴趣的小伙伴能够在网上找到十分丰盛的材料。

5.2 改良后的代码逻辑

当初,咱们能够利用封装好的 RedisLock 来改良原来的代码了。

public class AccountService {
 
    @Autowired
    private RedisLock redisLock;
 
    public void submit(String openId, String localIdentifier) {if (!redisLock.lock(openId)) {
            // 如果雷同 openId 并发状况下,线程没有抢到锁,则间接抛弃申请
            return;
        }
 
        // 获取到锁,开始执行用户数据同步逻辑
        try {Account account = accountDao.find(openId);
            if (account == null) {// insert} else {// update}
        } finally {
            // 开释锁
            redisLock.unlock(openId);
        }
    }
}

5.3 数据清理

最初再简略说一下收尾工作。因为反复数据的数据量较大,不太可能手工去缓缓解决。于是咱们编写了一个定时工作类,每隔一分钟执行一次清理操作,每次清理 1000 个反复的 OpenID,防止短时间内大量查问和删除操作对数据库性能造成影响。当确认反复数据曾经齐全清理结束后就停掉定时工作的调度,并在下一次版本迭代中将此代码移除。

六、总结

在日常开发过程中难免会各种各样的问题,咱们要学会顺藤摸瓜逐渐剖析,找到问题的根因;而后在本人的认知范畴内尽量去寻找可行的解决方案,并且认真衡量各种计划的利弊,能力最终高效地解决问题。

正文完
 0