1. 原始版本

本文将利用减库存这一常见业务的递进实现,来介绍为何需要分布式锁,以及基于redis的分布式锁是如何一步一步完善的。

首先做一下设定:

假定我们将商品A(product_id=‘A’)的库存保存在redis中,并对外提供减库存接口。(限制redis中的库存不能执行原子减操作)
将商品A的初始库存设置为200
原始版本:

from flask import Flask
from flask_redis import FlaskRedis

app = Flask(__name__)
app.config['REDIS_URL'] = 'redis://:password@host:port/db'
redis = FlaskRedis(app)

product_id = 'A'
redis.set(product_id, 200)  # 设置商品的初始库存


def get_stock():
    return int(redis.get(product_id).decode())


def set_stock(stock):
    redis.set(product_id, stock)


@app.route("/reduce_stock")
def reduce():
    """此版本代码在客户端并发请求的情况下会出现线程安全问题。"""
    stock = get_stock()
    if stock > 0:
        print(f"当前库存为:{stock}")
        set_stock(stock - 1)
    else:
        print("库存不足")
    return "success"


if __name__ == '__main__':
    app.run(debug=True)

此版本代码在客户端并发请求的情况下会出现线程安全问题

2. 线程锁版本

import threading

lock = threading.RLock()  # 使用重入锁

@app.route("/reduce_stock2")
def reduce2():
    """
    此版本代码解决了线程安全性的问题,但是当应用以多进程方式运行或是运行在分布式环境时
    还是会出现不同客户端读到相同库存的情况.
    """
    with lock:
        stock = get_stock()
        if stock > 0:
            print(f"当前库存为:{stock}")
            redis.set(product_id, stock - 1)
        else:
            print("库存不足")
    return "success"

3. redis分布式锁——基础版本

@app.route("/reduce_stock3")
def reduce3():
    """
    redis分布式锁
    redis是以单线程的方式执行客户端的并发请求的,所以此版本代码利用了redis实现了分布式锁。
    """
    # setnx方法在键不存在时返回True,存在时返回False
    on_lock = redis.setnx("A_lock", "locked")
    if on_lock:
        try:
            stock = get_stock()
            if stock > 0:
                print(f"当前库存是:{stock}")
                set_stock(stock - 1)
            else:
                print("库存不足")
        finally:
            redis.delete("A_lock")
    return "success"

redis是以单线程的方式执行客户端的并发请求的,所以此版本代码利用了redis实现了分布式锁。
setnx在键不存在时返回True,存在时返回False。
每次在减库存操作前获取到锁,在减库存操作之后释放锁,粗略看来没有问题,但考虑这样的场景,在某一个客户端获取到锁还没来得及释放时,服务器宕机,锁就无法再被释放,从而造成死锁。
下面我们来解决这个死锁问题。

4. redis分布式锁——带锁过期的版本

@app.route("/reduce_stock4")
def reduce4():
    """
    redis分布式锁-带过期的版本
    """
    # setnx方法在键不存在时返回True,存在时返回False
    on_lock = redis.setnx("A_lock", "locked")
    if on_lock:
        redis.expire("A_lock", 30)
        try:
            stock = get_stock()
            if stock > 0:
                print(f"当前库存是:{stock}")
                set_stock(stock - 1)
            else:
                print("库存不足")
        finally:
            redis.delete("A_lock")
    return "success"

 上面版本的代码中,在锁获取成功后再为锁设置存活时长,这样的实现是有问题的,如获取到锁而还没来得及设置存活时间,此时服务器宕机,依然会出现死锁。所以我们需要将获取锁为锁设置存活时长两个操作作为一个原子操作

def setnx_with_ttl(key, value, expires=30):
    return redis.set(key, value, ex=expires, nx=True)


@app.route("/reduce_stock5")
def reduce5():
    """
    redis分布式锁-带过期的版本-同时解决服务器宕机产生的死锁问题
    """
    # setnx方法在键不存在时返回True,存在时返回False
    on_lock = setnx_with_ttl("A_lock", "locked")
    if on_lock:
        try:
            stock = get_stock()
            if stock > 0:
                print(f"当前库存是:{stock}")
                set_stock(stock - 1)
            else:
                print("库存不足")
        finally:
            redis.delete("A_lock")
    return "success"

此版本代码解决了死锁问题,但依然存在问题。比如原本30秒以内可以完成的减库存操作,当并发量很高时,突然客户端A要31秒才能完成。此时就出现这样的现象:

在30秒时,客户端A的锁被自动释放了
此时客户端B成功获取到锁,并开始执行。
客户端A在31秒减库存操作完成后,进行锁的释放。(实际释放的是客户端B的锁)
后面的客户端请求会不断出现这样的交替
这样的现象中包含两个问题:

锁的过期时间小于业务需要运行的时间
锁被非当前客户端误删
下面我们我们首先来解决“锁被误删”的问题。

5. redis分布式锁——带锁唯一标识的版本

import uuid

def release_lock(key, value):
    if redis.get(key).decode() == value:
        redis.delete(key)


@app.route("/reduce_stock6")
def reduce6():
    """
    redis分布式锁-带锁唯一标识的版本
    """
    uid = str(uuid.uuid4())
    # setnx方法在键不存在时返回True,存在时返回False
    on_lock = setnx_with_ttl("A_lock", uid)
    if on_lock:
        try:
            stock = get_stock()
            if stock > 0:
                print(f"当前库存是:{stock}")
                set_stock(stock - 1)
            else:
                print("库存不足")
        finally:
            # 其中锁的释放会涉及三个操作:获取锁值、判断和删除锁
            release_lock("A_lock", uid)
    return "success"

此版本代码通过将每个锁的值设置为一个唯一值,来标识不同的锁,解决了“锁被误删”的问题。其中锁的释放会涉及三个操作:获取锁值判断删除锁,所以代码中利用了lua脚本将这三个操作变为原子操作
接下来解决“锁的过期时间小于业务需要运行时间”的问题。

6. redis分布式锁——带锁延期的版本

import uuid
import time
import threading

from flask import Flask
from flask_redis import FlaskRedis

app = Flask(__name__)
app.config['REDIS_URL'] = 'redis://:'
redis = FlaskRedis(app)

product_id = 'A'
redis.set(product_id, 200)  # 设置商品的初始库存


def get_stock():
    """获取库存"""
    return int(redis.get(product_id).decode())


def set_stock(stock):
    """设置库存"""
    redis.set(product_id, stock)


def setnx_with_ttl(key, value, expires=30):
    """设置锁的过期时间、以及能否拿到锁的状态"""
    return redis.set(key, value, ex=expires, nx=True)


def release_lock(key, value):
    """获取锁、判断、释放锁"""
    if redis.get(key).decode() == value:
        redis.delete(key)


def set_with_ttl(key, value, expires=30):
    """设置锁的过期时间"""
    redis.set(key, value, ex=expires)


def lock_renewal(lock_key, lock_value, expires=30):
    """开启一个子线程对锁进行延期"""
    while True:
        rv = redis.get(lock_key)
        if rv is not None and rv.decode() == lock_value:
            print('执行锁延期...')
            set_with_ttl(lock_key, lock_value, expires)  # 将锁的过期时间重新设置为30秒
        else:
            break
        time.sleep(expires // 3)


def acquire_lock(lock_key, lock_value, expires=30):
    """设置锁"""
    on_lock = setnx_with_ttl(lock_key, lock_value, expires)
    if on_lock:
        threading.Thread(target=lock_renewal, args=(lock_key, lock_value, expires)).start()
    return on_lock


@app.route('/reducestock')
def reduce():
    uid = str(uuid.uuid4())
    on_lock = acquire_lock('product_lock', uid, 30)
    if on_lock:
        try:
            stock = get_stock()
            if stock > 0:
                print('当前库存为:{}'.format(stock))
                set_stock(stock - 1)
            else:
                print('库存不足')
            time.sleep(40)
        finally:
            release_lock('product_lock', uid)
    return 'success'

此版本代码,在每次锁获取成功后,新开一个线程定时将对应锁的过期时间延后,从而解决了“锁的过期时间小于业务需要运行时间”的问题。

总结
到这里基本已经实现了一个可用于生产环境的redis分布式锁,但依旧还是存着如下不足之处:

分布式锁降低了应用的并发性能
redis的主从复制架构是ap型(对应cap定理),锁被写入到主节点时就返回客户端,若锁还未同步到从节点时,主节点挂机,依然会出现锁丢失的问题。
对于第一点不足,可以利用锁分段的方法进行优化,比如库存问题,我们可以将一个商品拆分为几个来减小锁的粒度,从而提升性能。

对于第二点不足,有两种解决方案:①改用zookeeper实现分布式锁(主从复制架构是cp型);②Redlock实现分布式锁(本质也是cp型)。但是这两种方案都会降低并发性能,所以如何选择还是要看业务需求。