分布式锁分析(2)

Distributed lock

紧接上文,进行跨服登录中涉及到的分布式锁分析

首先,先来分析在当前的服务中,需要在跨服登录中进行分析处理的场景

下面给出一个在未进行分布式锁定同步情况下的一个可能场景

image-20250909222513337

在详细分析前,先明确当前场景与需求:当多个用户并发登录时,需要考虑数据流动情况。这里假设这些用户并非同一 uid,而是普通的并发登录。

回顾架构设计:系统包含一个 GateServer、一个 StatusServer、多个 ChatServer。拆分多个 ChatServer 是为水平扩展和负载均衡,目前的策略仅为统计每个 ChatServer 的连接数。要保证负载均衡能起作用,就必须分析其中可能的竞态。本文借助分布式锁的视角进一步分析。

负载均衡策略:当 Client 获取到 ChatServer 信息并建立长连接时,ChatServer 会更新 Redis 中的连接数。核心依赖于:

  • StatusServer 从 Redis 读取 LoginCount 并选择负载最小的服务器;
  • ChatServer 在连接建立或销毁时更新 LoginCount。

登录流程推导

为了使得流程清晰,我们来重新推理一遍用户的登录流程来跟踪数据流动

  1. Client 登录请求 → GateServer
  2. GateServer → StatusServer 请求可用 ChatServer
  3. StatusServer 从 Redis 获取 LoginCount,返回最小负载服务器
  4. GateServer 将结果返回给客户端
  5. Client 与 ChatServer 建立长连接
  6. ChatServer 更新 Redis 中 LoginCount,并初始化

此时,我们能够清晰的看到,在整个登录流程中,为了支持负载均衡,核心由StatusServer对于Redis的LoginCount域的读取和ChatServer对于LoginCount域的写入合作提供,所以我们需要保证这俩者能够符合我们预想的逻辑。但是这个实际上比较难以思考,实际上,由于没有接触过,我也只能够根据他人对于该类场景的分析来排查对应的竞态条件,本文来对于该类竞态场景进行二次分析。

在一次登录流程中,我们观察到,在用户发起登录请求到获取到登录所必须的信息,获取到必须的信息到真正成功的登录之间,都存在着一段时间窗口,这是由于一系列的网络通信导致的。在Client1获取到登录信息尝试登录到真正登录成功更新连接数信息这段时间内,可能存在任一用户尝试登录。此时可能会出现一种竞态条件,假设初始连接数都为(0,0),那么此时StatusServer返回的服务器信息都会是ChatServer1的信息。如果一个新连接在另外一个已获取到ChatServer1信息但是尚未建立起连接更新连接数时将请求打到了StatusServer上,并成功获取返回了一个ChatServer信息。那么此时可能存在俩种情况,StatusServer可能返回ChatServer1的信息,也可能返回ChatServer2的信息。为什么呢?

如前文所说,这是由于一个登录用户在获取到服务器信息到实际登录更新服务器连接数之间存在一个时间窗口所致。我们这里先不考虑直接在StatusServer中更新连接数,具体为什么可以交由个人自己考虑。那么,既然存在这个时间窗口,那么任一登录请求都可能在这个窗口内到达StatusServer获取服务器信息,此时由于先前的登录请求可能还未实际的更新,所以StatusServer实际查到的LoginCount域中数据还是(0,0)的情况,此时期望中的负载均衡操作就会被打破。

但是,实际上存在一个问题,要实现一种机制来排除这种竞态是比较麻烦的。我们考虑对应的StatusServer返回信息一直到ChatServer成功登录的时间窗口跨越的时间,其中涉及到了StatusServer->GateServer,GateServer->Client,Client->ChatServer,ChatServer->Redis,这一整个网络链条,而一次普通的登录请求所要考虑的网络链条为Client->GateServer,GateServer->StatusServer,StatusServer->Redis。实际上后一个链条想要位于前一条链条的子集是相当容易的。但是,注意,我没能力,我目前懒的实现一种机制实现一种互斥,使得对应的一次先到达的StatusServer获取信息的登录请求最终的处理能够位于另外一个先发起的登录请求获取服务器信息之前。或者说,目前要实现起来过于麻烦,其需要涉及到一系列的跨服信息传递,需要后续继续进行进一步的详细分析。

我们这里先按下不表,如果存在一个可能的实现,欢迎交流。

由于我们决定目前先不解决对应的一个登录请求位于另外一个已处理一般的登录请求的窗口中,所以为了能够继续推进问题,我们需要进行一些假设和约束。我们假设对应的登录流程的最后ChatServer对于Redis的写能够与对应的Client初始登录中的StatusServer对于Redis中的读产生竞态,而不是位于一段完全的窗口范围内,或者说,更加严格一点,对应的一个StatusServer->GateServer,GateServer->Client,Client->ChatServer,ChatServer->Redis这个链条的RTT比起Client->GateServer,GateServer->StatusServer,StatusServer->Redis的RTT要短,或者说,壁布对应的RTT长。在我看来,在这种假设下,对应的后续的登录总归不至于产生前文中产生的难以解决的竞态,即使这种假设在目前看来不甚合理,但是可待后续机制补充来解决。

由于个人能力以及为了遵守KISS,个人不考虑在一开始就引入考虑过分复杂的场景,目前的想法是先通过假设来约定对应的时间窗口的重合情况,使得对应的并发可控,实际上这只是理论上的分析,实际中不会给我如此的宽容。就比如,这里的上文中考虑到的一个新登录请求在某一个请求还位于其写入Redis的整个窗口是在高并发下并发发生的,但是我在当前阶段并不考虑解决,从小的做起,后续如果积累足够,我再考虑回过头来将这里的逻辑给补全。

实际上,我当前要实现的负载均衡若想要能够实现其的统计,其必须依托于一个假设。即,一个登录请求对于Redis的影响的可见性必须位于另外一个在时序上落后于它的登录请求之前。什么意思呢,就是对应的登录请求最终登录到ChatServer并改变LoginCount域中的数值必须强一致的位于一个时序上在该请求初始发起时的时序。但是,正如前文所说,这是相当难以实现的。真要实现一种强一致性,我们必须封锁相当长的一个逻辑流程,相对来说这是不太容易接受的。所以我们这里先不考虑在初期就实现一种强一致支持的负载均衡操作。

为了能够继续推进问题,我们需要对于当前的系统进行一些严格的假设来缩小考虑的范围。提取上文所说的俩个登录链条。

时间窗口1:StatusServer->GateServer,GateServer->Client,Client->ChatServer,ChatServer->Redis

时间窗口2:Client->GateServer,GateServer->StatusServer,StatusServer->Redis

  • 不考虑窗口2的并发情况

    在系统中,我们考虑以实际登录到ChatServer的Session数作为一个Server的负载情况。如果我们考虑这一段的并发,我们会有一个很简单的解决方案,将对应的连接数更新直接放置到对应的StatusServer的登录逻辑中。但是,这样会存在一种问题,就是客户端在收到对应的服务器信息之后,实际上可能不会连接到对应的ChatServer服务器上,对于客户端来说,这种场景自然不会频繁发生。但是如果是通过一些程序发起的连接呢。可能存在一些恶意的请求会通过talnet等程序尝试进行登录请求但是不进行后续的流程,我们如果将这类连接也直接统计到对应的连接数中,可能会导致一种恶意的请求使得StatusServer误判,进而导致负载均衡服务根本发生不了作用。即使我们这里先不考虑对于这种攻击的解决方案,我们也先不考虑将连接数统计耦合到StatusServer->Redis这个流程中。接下来讨论为什么不考虑窗口2的并发情况,正如前面所说,对应的窗口2实际上所能做的只是获取当前快照下的连接数信息并且给出局部最优,窗口2并发的情况会导致这段窗口时间内的所有请求最终可能打到同一个服务器上。对于我个人来说,这个场景稍微可以接受,考虑对于这中并发请求发生时如果进行锁等互斥行为,其会导致最后的登录体验会有较大的延迟,与其考虑这段时间可能带来的一个服务器的突发负载,我更倾向于保证这种小峰值期的客户端登录体验。当然,我们后续可以采取一种更加优雅的方式,就比如引入一个消息队列中间件,将对应的登录请求实际上按照一定的时间区间来批次获取,并对于这些并发的请求,继续在内部进行轮转来分配服务器信息,这种能够保证服务器针对于突发请求也有一定的负载均衡的能力。

  • 对于窗口1与窗口2之间的并发,需要约束对应的窗口重叠

    对于窗口1,首先需要明确一点,关于窗口1所涵盖的流程,在对应的ChatServer收到对应的连接请求之前,我们是无法区分到底其是没有发起后续的连接请求还是请求正在网络传输中,与其费心费力来检测这种现象,考虑在初期直接假设这个窗口不是竞态发生处。因此,我们不进行StatusServer->GateServer,GateServer->Client,Client->ChatServer这段的并发分析。只进行最终ChatServer->RedisClient->GateServer,GateServer->StatusServer,StatusServer->Redis的并发分析。

上面是我们对应的假设,通过这些假设,我们能够进行一些比较简单的分析。但是,这些都是一些假设,只依赖于这些假设会导致在服务器集群的不断运行中偏差逐渐严重,如果不加以矫正,对应的负载均衡操作根本上就是一个笑话。所以,我们需要一种兜底机制来作为一种不断修改偏差的方法。在我目前的构想中,我们考虑使用一种服务器之间的心跳机制来作为这种数据的修正。考虑每个ChatServer在每一段时间后统计本机上已经连接的Session数,这个数量即是当前服务器的负载。每次统计后,将这段数据上报到Redis中。也就是说,对应的服务器的负载依赖于ChatServer的不断心跳来逐渐修正,对应的允许存在一段不一致的窗口期,这段窗口期就是ChatServer的心跳时间,在这段时间内,我们允许StatusServer使用的数据可以进行一定的偏移导致一定的负载不均衡。但是对应的每次一定需要能够在下一次心跳时上报能够收敛到一个快照。在这种设计下,对应的大用户量只要不是在短时间内突发登录,那么最终一定不会发散到特别离谱,对应的每次心跳总归能够收敛一些负载。


Distributed lock(精简版)

紧接上文,进行跨服登录中涉及到的分布式锁分析

首先,先来分析在当前服务中,跨服登录涉及到的分布式锁场景。

下面给出一个未进行分布式锁同步情况下的可能场景:

image-20250909222513337

在详细分析前,先明确当前场景与需求:当多个用户并发登录时,需要考虑数据流动情况。这里假设这些用户并非同一 uid,而是普通的并发登录。

回顾架构设计:系统包含一个 GateServer、一个 StatusServer、多个 ChatServer。拆分多个 ChatServer 是为水平扩展和负载均衡,目前的策略仅为统计每个 ChatServer 的连接数。要保证负载均衡能起作用,就必须分析其中可能的竞态。本文借助分布式锁的视角进一步分析。

负载均衡策略:当 Client 获取到 ChatServer 信息并建立长连接时,ChatServer 会更新 Redis 中的连接数。核心依赖于:

  • StatusServer 从 Redis 读取 LoginCount 并选择负载最小的服务器;
  • ChatServer 在连接建立或销毁时更新 LoginCount。

登录流程推导

用户登录流程如下:

  1. Client 登录请求 → GateServer
  2. GateServer → StatusServer 请求可用 ChatServer
  3. StatusServer 从 Redis 获取 LoginCount,返回最小负载服务器
  4. GateServer 将结果返回给客户端
  5. Client 与 ChatServer 建立长连接
  6. ChatServer 更新 Redis 中 LoginCount,并初始化

从流程可见,负载均衡依赖 StatusServer 的读取与 ChatServer 的写入配合。但二者存在时间窗口,可能出现竞态。

例如,初始连接数为 (0,0),当 Client1 获取 ChatServer1 信息但尚未更新连接数时,Client2 请求到达 StatusServer。此时 Redis 中仍是 (0,0),StatusServer 可能再次返回 ChatServer1,从而破坏负载均衡。

要消除这种竞态较麻烦,因为涉及从获取服务器信息到真正建立连接的整个窗口:

  • StatusServer->GateServer->Client->ChatServer->Redis
    而普通登录链路是:
  • Client->GateServer->StatusServer->Redis

前者窗口包含后者,不易保证时序一致。要完全解决需跨服互斥,但目前我没有实现能力,也过于复杂,所以暂不处理。

为了推进分析,做以下假设:认为 ChatServer 最终写 Redis 的操作可能与 StatusServer 读 Redis 产生竞态,但忽略整个窗口的重叠问题。换言之,假设 ChatServer->Redis 的 RTT 短于 StatusServer->Redis 的 RTT,从而竞态范围仅限 Redis 的读写。虽然这一假设并不完全合理,但可以作为初期简化。

出于个人能力限制和 KISS 原则,暂不考虑过分复杂的情况,先基于假设控制并发范围。未来再补充更严格的机制。


时间窗口拆分

提取两段窗口:

  • 窗口1:StatusServer->GateServer->Client->ChatServer->Redis
  • 窗口2:Client->GateServer->StatusServer->Redis

忽略窗口2并发

系统以实际连接到 ChatServer 的 Session 数作为负载。如果直接在 StatusServer 更新连接数,可能被恶意请求利用(发起请求但不建连,导致误判)。因此不耦合在 StatusServer。

至于窗口2内部并发,会导致短时间内多个请求打到同一服务器,但相比加锁带来的延迟,我更愿意接受小范围的负载不均衡,以保证用户体验。后续可以考虑用消息队列中间件批量分配,改善突发登录的均衡性。

约束窗口1与窗口2的重叠

在窗口1中,ChatServer->Redis 前的部分不可区分是“未建连”还是“在路上”,因此不考虑该并发,仅分析最终的 ChatServer->RedisStatusServer->Redis 间的竞态。


兜底机制

上述假设只能缩小分析范围,若长期依赖,偏差会积累,导致负载均衡失效。因此需要兜底机制:

考虑通过心跳机制矫正。每个 ChatServer 定期统计自身 Session 数并上报到 Redis,作为实际负载数据。这样即使短期内存在不一致,最终也会收敛,避免负载发散。

只要不是极端的突发登录场景,心跳修正能维持整体的均衡性。


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