《Redis深度历险》学习笔记:分布式锁&消息队列

分布式锁

  • 分布式锁类似数据库锁,都是为了解决并发时非原子操作导致的问题而设计的。
  • 设置分布式锁一般使用 setnx(set if not exists) 指令,调用 del 指令释放:
    SETNX mykey "Hello"
    del mykey
    
  • 上锁->解锁 过程中可能死锁,故使用 expire 命令加入过期时间:上锁->设置过期->解锁
    SETNX mykey "Hello"
    expire mykey 5
    del mykey
    
  • 上锁->设置过期 过程中可能死锁,Redis 2.8 之后 setnx 命令和 expire 命令可以一起作为原子操作执行(其中的 NX 即为 SETNX 的最新实现),这也是目前 Redis 官方推荐使用的 Redlock 方式:
    set mykey "Hello" EX 5 NX
    del mykey
    

超时问题

问题原因

如图所示,如果 Client 1 的执行时间超过了自己设置的锁过期时间,则该锁可能会被其他程序持有,产生数据异常:

分布式锁超时问题 (Source: How to do distributed locking

解决方案

  1. 分布式锁不要用于较长时间的任务

  2. 为 set 指令的 value 参数设置为一个随机数,释放锁时先匹配随机数是否一致,然后再删除 key。使用 Lua 脚本将匹配 key 和 del 操作作为原子操作执行:

    if redis.call("get",KEYS[1]) == ARGV[1] then
        return redis.call("del",KEYS[1])
    else
        return 0
    end
    

可重入性

  • 可重入性是指线程在持有锁的情况下再次请求加锁
  • Redis 可重入分布式锁要求包装客户端的 set 方法,使用线程的 Threadlocal 变量存储当前持有锁的计数。
  • 由于可重入分布式锁会加重程序复杂性,一般建议优化代码逻辑,避免使用可重入分布式锁。

锁冲突

由于锁冲突导致无法成功加锁的情况,可以使用以下 3 种策略来处理客户端加锁失败的情况:

  • 直接抛出异常:本质上是对当前请求的放弃,由用户决定是否重新发起新的请求。适合由用户直接发起的请求。
  • sleep:会导致队列的后续消息处理出现延迟。死锁的 key 加锁失败会堵死线程。适合碰撞少 / 消息少的场景。
  • 延时队列:加入延时队列稍后重试。适合异步消息处理,避免冲突。

消息队列

  • Redis 队列适用于只有一组队列消息消费者的情况。
  • Redis 的消息队列功能简单,没有太多高级特性。
  • Redis 的消息队列没有 ack 保证,消息可靠性不足,适用于数据敏感性不强的场景。

异步消息队列

  • 通常使用 list 结构来实现异步消息队列。
  • 使用 rpush/lpush 命令入队列,lpop/rpop 命令出队列。

Redis队列

空队列

问题原因

队列为空时,客户端会陷入 pop 死循环的空轮询。空轮询会增加系统负载和 Redis 的 QPS,大大降低 Redis 的查询效率。

解决方案

  1. 线程闲置:使用 sleep 让线程闲置,降低轮询频率。
  2. 队列延迟:使用 blpop/brpop 阻塞读命令(b-blocking)替代 lpop/rpop 作为消息消费者接受数据的方法,在队列没有数据的时候,会立即进入休眠状态。

ps. 线程阻塞时,Redis 客户端连接成为闲置连接,闲置超时会被服务器主动断开连接,导致阻塞读异常。使用时注意完善异常捕获和重连机制。

延时消息队列

  • 通常使用 zset 来实现延时消息队列。
  • 将消息序列化成字符串作为 zsetvalue,消息的到期处理时间作为 score,使用多线程轮询获取到期的任务进行处理。
  • 多线程需要考虑并发争抢问题,通过 zrem 来决定唯一的属主,确保任务不能被多次执行:
    success = redis.zrem("delay-queue", value) 
    
  • 另外注意异常捕获,避免因为个别任务处理问题导致循环异常退出。

PubSub

  • Redis 消息队列不支持消息的多播机制PubSub(PublisherSubscriber,发布者订阅者模型)是 Redis 实现多播模式的模块。

  • 客户端发起订阅命令,获得订阅成功通知后,即可收到生产者发布的信息。如果当前没有消息,会返回空,所以 PubSub 默认不是阻塞的。可以使用 listen 来阻塞监听消息来进行处理。

  • Redis 不允许连接在 subscribe 等待消息时进行其它的操作。

    > subscribe codehole.image codehole.text  # 同时订阅 2 个主题
    馈信息
    1) "subscribe"
    2) "codehole.image"
    3) (integer) 1
    1) "subscribe"
    2) "codehole.text"
    3) (integer) 2
    
    > psubscribe codehole.* # 模式匹配订阅
    以收到
    1) "psubscribe"
    2) "codehole.*"
    3) (integer) 1
    
    > publish codehole.image https://www.google.com/dudo.png
    (integer) 1
    > publish codehole.text " 你好,欢迎加入码洞 "
    (integer) 1
    > publish codehole.blog '{"content": "hello, everyone", "title": "welcome"}'
    (integer) 1
    
  • PubSub 消费者接收的消息格式形如:

    {
    'pattern': None,
    'type': 'message',
    'channel': 'codehole',
    'data': 'python comes'
    }
    
    • data:消息内容。字符串格式。
    • channel:订阅的主题名称。
    • type:消息类型。普通消息为 message;控制消息为 subscribe;模式订阅的反馈为 psubscribe;取消订阅指令为 unsubscribe 和 punsubscribe。
    • pattern:订阅模式。通过 subscribe 指令订阅的主题,该字段为空。
  • PubSub 的槽点比较致命,一般不推荐将 PubSub 应用于消息队列场景

    • 生产者传递消息时,如果没有对应消费者,消息将被丢弃。
    • PubSub 的消息不会持久化,如果消费者宕机导致没有及时接收到,则将丢失消息。
updatedupdated2023-09-272023-09-27