在分布式系统中,各个进程(本文使用进程来描述分布式系统中的运行主体,它们可以在同一个物理节点上也可以在不同的物理节点上)相互之间通常是需要协调进行运作的,有时是不同进程所处理的数据有依赖关系,必须按照一定的次序进行处理,有时是在一些特定的时间需要某个进程处理某些事务等等,人们通常会使用分布式锁、选举算法等技术来协调各个进程之间的行为。因为分布式系统本身的复杂特性,以及对于容错性的要求,这些技术通常是重量级的,比如 Paxos 算法,欺负选举算法,ZooKeeper 等,侧重于消息的通信而不是共享内存,通常也是出了名的复杂和难以理解,当在具体的实现和实施中遇到问题时都是一个挑战。
Redis 经常被人们认为是一种 NoSQL 软件,但其本质上是一种分布式的数据结构服务器软件,提供了一个分布式的基于内存的数据结构存储服务。在实现上,仅使用一个线程来处理具体的内存数据结构,保证它的数据操作命令的原子特性;它同时还支持基于 Lua 的脚本,每个 Redis 实例使用同一个 Lua 解释器来解释运行 Lua 脚本,从而 Lua 脚本也具备了原子特性,这种原子操作的特性使得基于共享内存模式的分布式系统的协调方式成了可能,而且具备了很大的吸引力,和复杂的基于消息的机制不同,基于共享内存的模式对于很多技术人员来说明显容易理解的多,特别是那些已经了解多线程或多进程技术的人。在具体实践中,也并不是所有的分布式系统都像分布式数据库系统那样需要严格的模型的,而所使用的技术也不一定全部需要有坚实的理论基础和数学证明,这就使得基于 Redis 来实现分布式系统的协调技术具备了一定的实用价值,实际上,人们也已经进行了不少尝试。本文就其中的一些协调技术进行介绍。
signal/wait 操作
在分布式系统中,有些进程需要等待其它进程的状态的改变,或者通知其它进程自己的状态的改变,比如,进程之间有操作上的依赖次序时,就有进程需要等待,有进程需要发射信号通知等待的进程进行后续的操作,这些工作可以通过 Redis 的 Pub/Sub 系列命令来完成,比如:
rc = redis.Redis()
def wait( wait_for ):
ps = rc.pubsub()
ps.subscribe( wait_for )
ps.get_message()
wait_msg = None
while True:
msg = ps.get_message()
if msg and msg['type'] == 'message':
wait_msg = msg
break
time.sleep(0.001)
ps.close()
return wait_msgdef
signal_broadcast( wait_in, data ):
wait_count = rc.publish(wait_in, data)
return wait_count
</div>
</div></div>
和其它的数据操作不同,订阅消息是即时易逝的,不在内存中保存,不进行持久化保存,如果客户端到服务端的连接断开的话也是不会重发的,但是在配置了 master/slave 节点的情况下,会把 publish 命令同步到 slave 节点上,这样我们就可以同时在 master 以及 slave 节点的连接上订阅某个频道,从而可以同时接收到发布者发布的消息,即使 master 在使用过程中出故障,或者到 master 的连接出了故障,我们仍然能够从 slave 节点获得订阅的消息,从而获得更好的鲁棒性。另外,因为数据不用写入磁盘,这种方法在性能上也是有优势的。
上面的方法中信号是广播的,所有在 wait 的进程都会收到信号,如果要将信号设置成单播,只允许其中一个收到信号,则可以通过约定频道名称模式的方式来实现,比如:
频道名称 = 频道名前缀 (channel) + 订阅者全局唯一 ID(myid)
其中唯一 ID 可以是 UUID,也可以是一个随机数字符串,确保全局唯一即可。在发送 signal 之前先使用“pubsub channels channel*”命令获得所有的订阅者订阅的频道,然后发送信号给其中一个随机指定的频道;等待的时候需要传递自己的唯一 ID,将频道名前缀和唯一 ID 合并为一个频道名称,然后同前面例子一样进行 wait。示例如下:</div>
import random
single_cast_script="""
local channels = redis.call('pubsub', 'channels', ARGV[1]..'*');
if #channels == 0
then
return 0;
end;
local index= math.mod(math.floor(tonumber(ARGV[2])), #channels) + 1;
return redis.call( 'publish', channels[index], ARGV[3]); """
def wait_single( channel, myid):
return wait( channel + myid )
def signal_single( channel, data):
rand_num = int(random.random() * 65535)
return rc.eval( single_cast_script, 0, channel, str(rand_num), str(data) )
</div>
分布式锁 Distributed Locks
分布式锁的实现是人们探索的比较多的一个方向,在 Redis 的官方网站上专门有一篇文档介绍基于 Redis 的分布式锁,其中提出了 Redlock 算法,并列出了多种语言的实现案例,这里作一简要介绍。
Redlock 算法着眼于满足分布式锁的三个要素:
安全性:保证互斥,任何时间至多只有一个客户端可以持有锁
免死锁:即使当前持有锁的客户端崩溃或者从集群中被分开了,其它客户端最终总是能够获得锁。
容错性:只要大部分的 Redis 节点在线,那么客户端就能够获取和释放锁。
锁的一个简单直接的实现方法就是用 SET NX 命令设置一个设定了存活周期 TTL 的 Key 来获取锁,通过删除 Key 来释放锁,通过存活周期来保证避免死锁。不过这个方法存在单点故障风险,如果部署了 master/slave 节点,则在特定条件下可能会导致安全性方面的冲突,比如:
- 客户端 A 从 master 节点获得锁
- master 节点在将 key 复制到 slave 节点之前崩溃了
- slave 节点提升为新的 master 节点
- 客户端 B 从新的 master 节点获得了锁,而这个锁实际上已经由客户端 A 所持有,导致了系统中有两个客户端在同一时间段内持有同一个互斥锁,破坏了互斥锁的安全性。
在 Redlock 算法中,通过类似于下面这样的命令进行加锁:
SET resource_name my_random_value NX PX 30000
</div>
这里的 my_random_value 为全局不同的随机数,每个客户端需要自己产生这个随机数并且记住它,后面解锁的时候需要用到它。
解锁则需要通过一个 Lua 脚本来执行,不能简单地直接删除 Key,否则可能会把别人持有的锁给释放了:
if redis.call("get",KEYS[1]) == ARGV[1] then return
redis.call("del",KEYS[1])else return 0end
</div>
这个 ARGV[1] 的值就是前面加锁的时候的 my_random_value 的值。
如果需要更好的容错性,可以建立一个有 N(N 为奇数)个相互独立完备的 Redis 冗余节点的集群,这种情况下,一个客户端获得锁和释放锁的算法如下:
先获取当前时间戳 timestamp_1,以毫秒为单位。
以相同的 Key 和随机数值,依次从 N 个节点获取锁,每次获取锁都设置一个超时,超时时限要保证小于所有节点上该锁的自动释放时间,以免在某个节点上耗时过长,通常都设的比较短。
客户端将当前时间戳减去第一步中的时间戳 timestamp_1,计算获取锁总消耗时间。只有当客户端获得了半数以上节点的锁,而且总耗时少于锁存活时间,该客户端才被认为已经成功获得了锁。
如果获得了锁,则其存活时间为开始预设锁存活时间减去获取锁总耗时间。
如果客户端不能获得锁,则应该马上在所有节点上解锁。
如果要重试,则在随机延时之后重新去获取锁。
获得了锁的客户端要释放锁,简单地在所有节点上解锁即可。
Redlock 算法不需要保证 Redis 节点之间的时钟是同步的(不论是物理时钟还是逻辑时钟),这点和传统的一些基于同步时钟的分布式锁算法有所不同。Redlock 算法的具体的细节可以参阅 Redis 的官方文档,以及文档中列出的多种语言版本的实现。
选举算法
在分布式系统中,经常会有些事务是需要在某个时间