分布式锁分析(1)

Distributed lock

本节进行项目中分布式锁的使用的分析

​ 分布式锁是一种跨进程的保护全局共享资源的一种机制,实际上与普通的锁本身没有什么本质上的不同,只是视角不同,普通的锁最多也只是在进程内部,对于线程共享,协程共享等资源的保护,而分布式锁,其是在对于一些复杂分布式场景下的资源的保护。实现起来跟OS课上了解的基本锁实现没有什么区别,难度不大。因此,分布式锁的使用实际上不在于如何设计之上,而是在于为何使用,不使用可以吗,为了达成分布式锁的效果,是否存在别的更加简单的机制能够实现同样的效果呢?

​ 下面从多个问题方面进行分析。

1. 为什么要锁?

在本次分析中,我们围绕一个核心需求: 单点登录。为什么要实现单点登录?首先是需要考虑到一个IM系统的特殊性,一般来说,IM软件是比较私密的软件,不会期望你一个账户能够多人同时在线,这样可能会导致一些潜在的信息泄露,就比如在你当前无感知的情况下存在一个用户登录了你的账户来窃取信息。当然,市面上存在一个其他的设计,就比如微信,其能够支持同个账户多台设备的同时登录,但是,对应的设备本身是存在区别的,就比如其允许的实际上是手机app,PC端,IPad端的同时登录,但是对于手机端,PC端这种,其同一时间只允许一个用户登录,这是一个IM所应该具有的基本素质。

对于为什么要保持单点登录,我们再来分析一个更加明确的场景,还是先前的账户被窃取时另外一端在你在线或不在线登录的时候,用户期望的是什么?用户一定期望能够识别这种被窃取的行为,此时其会尝试去找回账户等动作,此时对应的关注点会在于账户窃取者的身上,而不是对应的IM运营方身上,这也是对于设计者本身的保护。我们应该提供一种保障,一种至少能够让用户识别当前账户是否安全的机制。更加抽象一点,我们期望的是一具完整的尸体能够让我们能够分析问题,而不是一具消失的尸体。

而且实际上我们如果考虑后续移植APP端的话,会存在一个重要的场景,就比如如果手机APP端在不断的移动中基站变量,此时对应的IP已经改变,如何来保证对应的服务正确提供,此时就需要涉及到一个无损迁移,这个也涉及到一个异地登录的场景,虽然只是物理上移动导致的,但是本质上也是一种异地登录的需求(类似场景就比如高铁或火车上的移动)。当然,这个场景会更加复杂,其涉及到如何无损的迁移或保持连接,暂且不在本次的考虑范围中,本次先考虑简单的异地登录逻辑即可。

回过头来。在明确单点登录这个需求之后,我们需要考虑如何来实现它。首先我们需要来回顾一下登录的流程

image-20250906141759990

在初始时刻,客户端通过携带登录的账户密码信息去到GateServer请求登录,GateServer收到该请求会回解析该数据并通过grpc向StatusServer请求一个合法的ChatServer信息。而StatusServer本身的这个查询ChatServer信息过程以及登录流程会涉及到集群内Redis的数据的查询和修改。在处理成功后,通过层层冒泡将数据返回给client,client依据这些信息来进行后续的ChatServer的TCP连接,注意在建立连接的这段过程中,会涉及到一些复杂状态的修改,因为实际上我们前面StatusServer的处理流程中只是简单的负载均衡查询连接数较少的一个服务器,没有进行更加复杂的操作。后续可以考虑将对应的处理分离到StatusServer中,这里为了简单,放置到ChatServer的处理中。

在这其中,最重要的是StatusServer与Redis的通信,这其中涉及到了部分共享信息的修改,这涉及到一系列的敏感情况,需要详细分析,这也是为什么需要分布式锁的一个因素,后续进行对于实际场景的分析应该能够更加清晰。

2.异地登录场景分析

在本次的分析中,由于个人能力问题,只专注于一个场景的分析,对应的本次考虑的场景是双端并发登录踢人竞态场景。

在当前的项目设计中,存在一个token机制,当一个数据中UID为x的用户登录请求打到对应的StatusServer中时,该StatusServer会生成一份token并且将其设置到对应的Redis中,实际上,该次过程中的设置是一个并发操作,可能存在多个RPC连接同时发起,可能会导致一个UID为x的用户的多次设置Token,并尝试携带各自的Token都进行登录,这在当前的系统中是可能的,如果被攻击,攻击者通过同一个用户来批量发起请求,此时就可能导致这种并发情况,不过这种情况是可以接受的。这是由于Redis本身的命令执行线程是一个单线程操作,并发到达的命令其并不会出现一种中间状态。最后这种攻击的结构只会是存在最后一次物理写入的Token作为该UID的token值。

那么,这种情况对于我们是否存在影响呢,或者说,我们是否需要进行处理呢,目前来说,其实有点风险。当用户携带其从GateServer最终得到的数据尝试去进行登录的时候,对应的ChatServer的校验逻辑目前只是进行一个token的匹配,实际上存在一种可能的情况,当对应的Redis修改请求串行中夹杂了某一次ChatServer的获取Token请求,而本次获取请求的最近一次Token设置刚好设置的就是本次校验所对应的Token时,此时就能够通过,以此类推,实际上可能存在多个客户端在严重并发压力下的同时登录情况下。当然,这种情况非常极端,其需要满足多个条件,其一是对应的登录请求是恶意且批量的,其二是对应的请求处理非常快,甚至于对应的RTT等使得用户连接ChatServer的RTT和ChatServer通信Redis的RTT等加起来赶上了前面某次特定登录请求的命令执行。

给上述分析总结一段简短的分析

高并发或恶意请求场景下:

  • A 请求写入 token1
  • B 请求写入 token2
  • C 请求在 token2 写入前读到 token1,并继续去 ChatServer 验证
  • 结果 token1 和 token2 都有机会在不同客户端上被用来登录。

实际上,这是非常极端的一种情况,并不在本次探讨的范围内,所以我们可以在本次先假定这种Token登录的并发请求暂且不会出现上面那种情况,我们可以先假设我们的系统要求的是一种最终一致性的模型。

下面就更加可能的场景进行分析,我们先考虑一种最普通的场景,当UID为1的用户成功登录后,该用户用其的用户信息在另外一个设备上尝试登录,在当前的设计中,我们初步设定只允许一个UID代表的用户只能同时登录一个设备。此时,我们就需要完善一个踢人逻辑。

多端登录踢人竞态

image-20250906152003463

直接观察多服务端登录时可能的竞态情况,对于单服务器登录时的竞态在多服分析后自然也就清晰了。

优先明确当前的几个前提条件:

  • 一个UID为x的设备已经登录,在另外一个设备中,另外一个UID也为x的设备也尝试登录
  • 当前已登录的设备已经处在一个完整的登录状态,而待登录设备处于一种等待与Server建立连接的状态

上诉流程图基本解释了在异地登录时所需要执行的基本操作,接下来就可能的竞态场景进行分析。当Server2查询到当前系统Redis中已经存在一个login_x的KV对的时候,其断定此时该用户已经在系统中存在登录的账户。本次系统设计以最后一次登录为准,因此会踢掉当前系统中已经存在的用户而保留当前新登录的用户。为了踢掉该用户,其需要执行下列步骤:

  • 查看当前已登录的改用户所在的服务器信息,判断是否为本服,对应的存在不同的逻辑
  • 若该用户当前位于本服务器上,此时与上图中的逻辑大致相同,只是省去了grpc间的通信,直接处理对应的socket关闭和key删除逻辑即可。如果是在它服上,则通过grpc进行通知,并执行对应相同的逻辑踢人即可
  • 在服务器收到对端服务器成功踢人之后,进入常规的逻辑即可,写入对应的登录信息并进行常规的业务逻辑。

接下俩针对于多服登录时的踢人逻辑进行详细分析。

当一个服务器收到需要踢人的请求后,其会去查找当前服务器中挂载的session,并向客户端发送一个close信号,注意此处不能由服务器本身进行close,否则可能出现TimeWait现象。然后其需要去修改Redis信息,将该用户所对应的信息进行删除,由于是调用的Redis服务,所以该次操作实际上只是投递了一个删除请求信息,在收到Redis的ACK信息后就返回了,然后其需要通过gRPC通知踢人请求发起者,该发起者收到后也会向Redis中发起一次请求,该请求本次是尝试对于key进行写入。

注意,在本个踢人流程中,我们对于Redis的操作,实际上只是投递了请求过去,实际上Redis内部是同步执行还是异步执行后返回的ACK信息,我们并无法保证,或者说,不能够依赖于其的保证,避免后续的Redis的迭代中由于其同步异步逻辑的切换导致的问题。所以我们需要以一种最坏的情况进行假设,就假设Redis本身是一个异步执行的逻辑,其会在收到请求后直接ACK,内部再切实执行。那么此时就可能存在下面的可能竞态。

我们知道,Redis内部的逻辑执行模块是单线程,但是其网络接收模块是多线程的。在多线程接受单线程处理的情况下,实际上我们不能够保证我们Server1的信息处理一定能够在Server2的处理之前完成。如果Server2发出的请求实际的排队在了Server1的请求之前,那么Redis的最终一致性将会导致该Key:login_TCWW在Redis中为空,这是不可接受的。这是因为实际上我们的业务逻辑中存在一个时序性,我们的写入新Key的这个逻辑需要严格串行在删除旧Key这个操作之后。然而由于当前是一个跨进程的操作,所以无锁的情况下难以保证,所以需要引入一个机制来保证这俩个操作的串行性。这也就是我们引入分布式锁的基础动机。

当前我们的需求是通过一种机制来保证删除Key的操作严格位于写入Key的操作之前。此时,分布式锁基本是最简单的方法。考虑将对应的key的修改权限锁住,由于本身删除key的请求一定严格先于删除key的请求到达。所以其一定能够优先获取到锁,由此能够人为的跨进程的维护一种先后顺序。对于此处的需求,使用分布式锁就能够轻松的解决。

-------------本文结束 感谢阅读-------------