分布式锁

960 阅读8分钟

每个标题链接引用详细的理解。

一.概念

1.为什么需要分布式锁?

  • 效率:使用分布式锁可以避免不同节点重复相同的工作,这些工作会浪费资源。比如用户付了钱之后有可能不同节点会发出多封短信。
  • 正确性:加分布式锁同样可以避免破坏正确性的发生,如果两个节点在同一条数据上面操作,比如多个节点机器对同一个订单操作不同的流程有可能会导致该笔订单最后状态出现错误,造成损失,或者同时操作同一笔库存,导致结果的不一致。

2.分布式锁的特点(需要实现哪些功能)

1.互斥性

和我们本地锁一样互斥性是最基本,但是分布式锁需要保证在不同节点的不同线程的互斥。

2.可重入性

若一个程序或子程序可以在任意时刻被中断然后操作系统调度执行另一段代码,且这段代码又掉用了子程序不会出错,则称其为可重入

即在該子程序在运行时,执行线程可以再次进入并执行它。仍然获得符合符合设计预期的结果。与多线程并发执行的线程安全不同,可重入强调对单个线程执行时重新进入同一个子程序任然是安全的。

如果想要实现锁的重入,至少要解决一下两个问题

  • 1.线程再次获取锁:锁需要去识别获取锁的线程是否为当前占据锁的线程,如果是,则再次成功获取。
  • 2.锁的最终释放:线程重复n次获取了锁,随后在n次释放该锁后,其他线程能够获取该锁。锁的最终释放要求锁对于获取进行计数自增,计数表示当前锁被重复获取的次数,而锁被释放时,计数自减,当计数等于0时表示锁已经释放。

3.锁超时

防止死锁,比如redis设置超时时间。

4.高效

高可用:加锁和解锁需要高效,同时也需要保证高可用防止分布式锁失效,可以增加降级。

5.阻塞和非阻塞

阻塞: 如果获取不到锁就一直获取。(增加一个监听) 非阻塞: 如果获取不到锁就直接返回(或者定义个时间多久获取不到就直接返回)。

6.支持公平锁和非公平锁(可选)

公平锁的意思是按照请求加锁的顺序获得锁,非公平锁就相反是无序的。这个一般来说实现的比较少。

3.分布式锁的简单实现(python版)

常见分布式锁:

  • mysql
  • zookeeper
  • redis

redis实现分布式锁

redis在2.6.12 版本开始,为 SET 命令增加一系列选项:

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

如果是redis2.6.12版本以下,需要用setnx + lua保证原子性

setnx + eval + lua

  • 获取锁
import redis
r = redis.Redis('ip', port=xxxx, db=5)
def try_lock_with_lua(key, val, second):
    lua_script = "if redis.call('setnx',KEYS[1],ARGV[1]) == 1 then " \
                  "redis.call('expire',KEYS[1],ARGV[2]) return 1 else return 0 end"
    res = r.eval(lua_script, 1, key, val, second)
    return res == 1
    
if try_lock_with_lua('k5555', '11', 20):
    print(1)

  • 释放锁
def release_lock_with_lua(key, val):
    lua_script = "if redis.call('get',KEYS[1]) == ARGV[1] then " \
            "return redis.call('del',KEYS[1]) else return 0 end"
    res = r.eval(lua_script, 1, key, val)
    return res == 1

set + lua

  • 获取锁
def try_lock_with_self(key, val, second):
    res = r.set(key, val, ex=second, nx=True)
    return res == 1

if try_lock_with_self('k5554', '11', 20):
    print(1)

注意

value必须要有唯一性 通过随机字符串+redis判断,或者hash等方法保证(就是唯一版本号)。

如果不唯一:

  • 1.客户端1获取锁成功
  • 2.客户端1在某个操作上阻塞了太长时间
  • 3.设置的key过期了,锁自动释放了
  • 4.客户端2获取到了对应同一个资源的锁
  • 5.客户端1从阻塞中恢复过来,因为value值一样,所以执行释放锁操作时就会释放掉客户端2持有的锁,这样就会造成问题。

redis set实现锁的缺陷

  • 1.存在多把锁的风险(故障切换(failover)实现方式的局限性) A客户端在Redis的master节点上拿到了锁,但是这个加锁的key还没有同步到slave节点,master故障,发生故障转移,一个slave节点升级为master节点,B客户端也可以获取同个key的锁,但客户端A也已经拿到锁了,这就导致多个客户端都拿到锁
  • 2.不好掌控过期时间

redis升级版redLock分布式锁

关于relock对redis分布式锁的优化都在标题引用的链接中, redLock的几个关键(实现理念):

  • 1.redlock算法成立条件

    • 1.即使集群中没有同步时钟,各个进程的时间流逝速度也要大体一致
    • 2.并且误差与锁存活时间相比是比较小的,实际应用中的计算机也能满足这个条件:各个计算机中间有几毫秒的时钟漂移(clock drift)。
  • 2.失败重试机制

    • 如果一个Client无法获得锁,它将在一个随机延时后开始重试。使用随机延时的目的是为了与其他申请同一个锁的Client错开申请时间,减少脑裂(split brain)发生的可能性。

    • 脑裂:三个Client同时尝试获得锁,分别获得了2,2,1个实例中的锁,三个锁请求全部失败。

      一个client在全部Redis实例中完成的申请时间越短,发生脑裂的时间窗口越小,所 以比较理想的做法是同时向N个Redis实例发出异步的SET请求。

  • 3.释放锁操作 无论Client在指定的Master中有没有获得锁,都需要执行释放锁的操作。

当Client没有在大多数Master中获得锁时,立即释放已经取得的锁时非常必要的。(PS.当极端情况发生时,比如获得了部分锁以后,client发生网络故障,无法再释放锁资源。

  • 4.锁续约 当一个Client在工作计算到一半时发现锁的剩余有效期不足时,可以向Redis实例发送续约锁的Lua脚本。如果Client在一定的期限内(耗间与申请锁的耗时接近)成功的续约了半数以上的实例,那么续约锁成功。

为了提高系统的可用性,每个Client申请锁续约的次数需要有一个最大限制,避免其不断续约造成该key长时间不可用。

python版redLock

  • pip install redlock-py
from contextlib import contextmanager
from redlock import Redlock


@contextmanager
def worker_lock_manager(key, ttl, **kwargs):
    """
    分布式锁
    :param key: 分布式锁ID
    :param ttl: 分布式锁生存时间
    :param kwargs: 可选参数字典
    :return: None
    """
    redis_servers = [{
        'host': 'ip',
        'port': xxxx,
        'db': 5,
    }]
    # 多个host
	# [{host':'xxx.xxx.xxx.xxx,'port':6379,'db':0},
	# {host':'xxx.xxx.xxx,'port':6379,'db':0},
	# {host':'xxx.xxx.xxx,''port':6379,'db':0},
	# {'host':'xxx.xxx.xxx.xxx,'port':6379,'db':0}]
    
    rlk = Redlock(redis_servers)

    # 获取锁
    lock = rlk.lock(key, ttl)

    yield lock
    print(1)
    # 释放锁
    rlk.unlock(lock)


def do_something():
    print('获取锁成功,开始事务操作')
    time.sleep(5)
    print('事务操作成功,锁释放')

if __name__ == '__main__':
    with worker_lock_manager('unique_key', 1000) as w_lock:
        if w_lock:
            do_something()

zookeeper实现分布式锁

思路

利用zookeeper的临时节点

  • 方案一:临时有序节点,每次取最小的节点赋予锁。
  • 方案二:临时节点,create创建节点失败表示锁获取失败,当一个session释放后才能重新赋值抢锁。
  • 临时节点:客户端可以建立一个临时节点,在会话结束或者会话超时后,ZK 会自动删除该节点。 python有成熟的操作zookeeper的库kazoo
  • 1.服务器搭建zookeeper单例或集群
  • 2.pip install kazoo

zookeeper基础操作

import sys
from kazoo.client import KazooClient, KazooState
import logging

logging.basicConfig(
    level=logging.DEBUG,
    stream=sys.stdout,
    format='%(asctime)s %(pathname)s %(funcName)s%(lineno)d %(levelname)s: %(message)s')

zk = KazooClient(
    hosts='ip:2181',
    timeout=10.0,
    logger=logging
)
# 开始心跳
zk.start()

# 获取根节点数据和状态
data, stat = zk.get('/')
print("data:", data)
print("stat:", stat)

# 获取子节点
children = zk.get_children("/")
print(children)

# 创建子节点
# zk.create('/sanguo/shuguo', b'caoChao', ephemeral=False)
if not zk.exists('/sanguo/weiguo'):
    zk.create('/sanguo/weiguo', b'caoChao', ephemeral=False)


children = zk.get_children("/sanguo")
print(children)
data, stat = zk.get('/sanguo/weiguo')
print("data:", data)
print("stat:", stat)


def monitor(event):
    print(type(event))
    print(event)
    print(zk.get_children('/sanguo'))


import time

children = zk.get_children('/sanguo', watch=monitor)
print(children)

node = zk.create('/b')

zookeeper实现简单分布式锁

利用方案一实现简单锁。


import time
import sys
from kazoo.client import KazooClient
import logging


class ZkLock:
    def __init__(self, root='/Lock', node=None):
        self.lock = False
        self.root = root
        self.node = node
        logging.basicConfig(
            level=logging.DEBUG,
            stream=sys.stdout,
            format='%(asctime)s %(pathname)s %(funcName)s%(lineno)d %(levelname)s: %(message)s')
        self.zk = KazooClient(
            hosts='ip:2181',
            timeout=10.0,
            logger=logging
        )
        self.zk.start()

    def acquire(self):
        if not self.zk.exists(self.root):
            self.zk.create(self.root)
        if not self.node:
            self.node = self.zk.create(self.root+'/lock', ephemeral=True, sequence=True)
        nodes = self.zk.get_children(self.root)
        if nodes[0] == self.node:
            print('Acquire lock successfully')
            self.lock = True
        else:
            print('Acquire lock failed start watching')

            @self.zk.ChildrenWatch(self.root)
            def watch_func(data):
                print("Data is %s and node is %s and root.is %s" % (data, self.node, self.root))
                print(self.node, self.root + '/' + data[-1], self.node == self.root + '/' + data[-1])
                if self.node == self.root + '/' + data[-1]:
                    self.lock = True

    def release(self):
        self.zk.stop()


if __name__ == '__main__':
    t = ZkLock()
    while not t.lock:
        t.acquire()
    print('执行获取锁后的操作')
    time.sleep(10)
    print('执行操作结束释放锁')
    t.release()

分布式锁用redis还是zookeeper?

  • redis分布式锁的缺点

    • 1.获取锁的方式简单粗暴,获取不到锁直接不断尝试获取锁,比较消耗性能。
    • 2.Redis 的设计定位决定了它的数据并不是强一致性的,在某些极端情况下,可能会出现问题。
    • 3.即便使用 Redlock 算法来实现,在某些复杂场景下,也无法保证其实现 100% 没有问题,关于 Redlock 的讨论可以看 How to do distributed locking。
  • redis分布式锁的优点

    • redis性能很高如果对一致性没有那么敏感建议使用redis,而且大部分情况下都不会遇到所谓的“极端复杂场景”。
  • zookeeper分布式锁的缺点

    • 1.如果有较多的客户端频繁的申请加锁、释放锁,对于 zookeeper 集群的压力会比较大。
    • 2.zookeeper集群需要维护安装成本,需要基于架构考虑如何使用。
  • zookeeper分布式锁的优点

    • 1.zookeeper 天生设计定位就是分布式协调,强一致性。
    • 2.如果获取不到锁,只需要添加一个监听器就可以了,不用一直轮询,性能消耗较小。