5

Redis 实战 —— 08. 实现自动补全、分布式锁和计数信号量

 3 years ago
source link: https://segmentfault.com/a/1190000039108946
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

Redis 实战 —— 08. 实现自动补全、分布式锁和计数信号量

发布于 1 月 27 日

自动补全 P109

自动补全在日常业务中随处可见,应该算一种最常见最通用的功能。实际业务场景肯定要包括包含子串的情况,其实这在一定程度上转换成了搜索功能,即包含某个子串的串,且优先展示前缀匹配的串。如果仅包含前缀,那么可以使用 Trie 树,但在包含其他的情况下,使用数据库/ ES 本身自带查询就足够了。可以按照四种情况(精确匹配、前缀、后缀、包含(也可将后两种融合成包含)),分别查询结果,直至达到数据条数上限或者全部查询完毕。但这种使用方法有缺点:查询次数多、难以分页。不过实际场景中需要补全的情况都只要第一页的数据即可。

自动补全最近联系人 P110

需求: 记录最近联系过的 100 个人名,并支持对输入的串进行自动补全。 P110

数据量很小,所以可以在 Redis 中用列表维护最近联系人,然后在内存中进行过滤可自动补全的串。

步骤: P111

  1. 维护长度为 100 的最近联系人列表

    1. 如果指定的联系人已在列表中,则从列表中移除 (LREM)
    2. 将指定的联系人添加到列表最前面 (LPUSH)
    3. 如果添加完成后,列表长度超过 100 ,则对列表进行修剪,仅保留列表 前面的 100 个联系人 (LTRIM)
  2. 获取整个最近联系人列表,在内存中根据四种情况进行过滤即可
通讯录自动补全 P112

需求: 有很多通讯录,每个通讯录中有几千个人(仅包含小写英文字母),尽量减少 Redis 传输给客户端的数据量,实现前缀自动补全。 P112

思路: 使用有序集合存储人名,利用有序集合的特性:当成员的分值相同时,将根据成员字符串的二进制顺序进行排序。如果要查找 abc 前缀的字符串,那么实际上就是查找介于 abbz... 之后和 abd 之前的字符串。所以问题转化为:如何找到第一个排在 abc 之前的元素的排名 和 第一个排在 abd 之前的元素的排名。我们可以构造两个不在有序集合中的字符串 (abb{, abc{) 辅助定位,因为 { 是排在 z 后第一个不适用的字符,这样可以保证这两个字符串不存在与有序集合中,且满足转化后的问题的限制。 P113

综上: 通过将给定前缀的最后一个字符替换为第一个排在该字符前的字符,再再在末尾拼接上左花括号,可以得到前缀的前驱 (predecessor) ,通过给前缀的末尾拼接上左花括号,可以得到前缀的后继 (successor) 。

  • 字符集:当处理的字符不仅仅限于 a~z 范围,那么要处理好以下三个问题: P113

    • 将所有字符转换为字节:使用 UTF-8UTF-16 或者 UTF-32 字符编码(注意: UTF-16UTF-32只有大端版本可用于上述方法)
    • 找出需要支持的字符范围,确保所选范围的前面和后面至少留有一个字符
    • 使用位于范围前后的字符分别代替反引号 ` 和左花括号 {

步骤: P114

  1. 运用思路中的方法找到前缀的前驱和后继(为了防止同时查询相同的前缀出现错误,可以在前驱和后继之后添加上 UUID
  2. 将前驱和后继插入到有序集合里
  3. 查看前驱和后继的排名
  4. 取出他们之间的元素
  5. 从有序集合中删除前驱和后继

通过向有序集合添加元素来创建查找范围,并在取得范围内的元素之后移除之前添加的元素,这种技术还可以应用在任何已排序索引 (sorted index) 上,并且能通过改善(第七章介绍)应用于几种不同类型的范围查询,且不需要通过添加元素来创建范围。 P115

分布式锁 P115

分布式锁在业务中也非常常见,能够避免在分布式环境中同时对同一个数据进行操作,进而可以避免并发问题。

导致锁出现不正确行为,以及锁在不正确运行时的症状 P119
  • 持有锁对进程因为操作时间过长而导致锁被自动释放,但进程本身并不知晓这一点,甚至还可能会错误地释放掉了其他进程持有但锁
  • 一个持有锁并打算执行长时间操作但进行已经崩溃,但其他想要获取锁但进程不知道哪个进程持有锁,也无法检测出持有锁但进程已经崩溃,只能白白地浪费时间等待锁自动释放
  • 在一个进程持有但锁过期之后,其他多个进程同时尝试去获取锁,并且都获取了锁
  • 第一种情况和第三种情况同时出现,导致有多个进程获取了锁,而每个进程都以为自己是唯一一个获得锁但进程
// 在 conn 上获取 key 的锁,锁超时时间为 expiryTime 毫秒,等待时间最长为 timeout 毫秒
func acquireLock(conn redis.Conn, key string, expiryTime int, timeout int) (token *int) {
    // 为了简化,用 纳秒时间戳 当 token ,实际应该用 UUID
    value := int(time.Now().UnixNano())

    for ; timeout >= 0; {
        // 尝试加锁
        _, err := redis.String(conn.Do("SET", key, value, "PX", expiryTime, "NX"))
        // 如果获取锁成功,则直接返回 token 指针
        if err == nil {
            return &value
        }
        // 睡 1ms
        time.Sleep(time.Millisecond)
        timeout --
    }

    // timeout 内仍未成功获取锁,则获取失败,返回 nil
    return nil
}

// 在 conn 上释放 key 的锁,且锁与 token 对应
func releaseLock(conn redis.Conn, key string, token int) error {
    // 用 lua 脚本保证原子性,只有 token 和值相等是才释放
    releaseLua := "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end"
    script := redis.NewScript(1, releaseLua)
    result, err := redis.Int(script.Do(conn, key, token))
    if err != nil {
        return err
    }
    if result == 0 {
        return errors.New("release failure")
    }
    return nil
}

计数信号量 P126

计数信号量是一种锁,它可以让用户限制一项资源最多能同时被多少个进程访问,通常用于限定能够同时使用的资源数量。 P126

基本的计数信号量 P126

将多个信号量的持有者的信息存储到同一个有序集合中,即为每个尝试获取的请求生成一个 UUID ,并将这个 UUID 作为有序集合的成员,而成员对应的分值则是尝试获取时的时间戳。 P127

获取信号量步骤: P127

  1. 清理有序集合中所有已过期的 UUID (时间戳 <= 当时时间戳 - 过期时间)
  2. 生成 UUID ,使用当时时间戳作为分值,将 UUID 添加到有序集合里面
  3. 检查刚刚的 UUID 的排名

    • 若排名低于可获取的信号量总数(成员排名从 0 开始计算),那么表示成功获取了信号量
    • 若排名等于或高于可获取的信号量总数,那么未获取成功,需要将刚刚的 UUID 移除

释放信号量时直接从有序集合中删除 UUID 即可。若返回值为 1 ,则表明成功手动释放;若返回值为 0 ,则表明已经由于过期而自动释放。 P128

缺点:

  • 所有信号量的过期时间都需要一样:为了方便删除过期的 UUID
  • 不公平,依赖系统时间:

    • 当多机环境下, A 的系统时间比 B 的系统时间快 10ms ,那么当 A 取得了最后一个信号量的时候, B 只要在 10ms 内尝试获取信号量,那么就会造成 B 获取了不存在的信号量,导致获取的信号量超过了信号量的总数。 P128
    • 还可能造成信号量提早被其他系统的获取请求释放
公平的计数信号量 P128

为了实现公平的计数信号量,即先发出获取请求的客户端能够获取到信号量。我们需要在 Redis 中维护一个自增的计数器,每次发出获取请求前先对其自增,并使用自增后的值作为分值将对应的 UUID 插入到另一个有序集合中。即原本的有序集合仅用来查找并删除过期的 UUID ,新的有序集合用来获取排名判断请求是否成功获取到信号量。同时为了保持新的有序集合及时删过期的 UUID ,在原本的有序集合执行完删除操作后,还要使用 ZINTERSTORE 命令,保留仅在原本有序集合中出现的 UUID (ZINTERSTORE count_set 2 count_set time_set WEIGHTS 1 0)。注意: 若信号量获取失败,则需要及时删除本次插入的无用数据。

上述方法能在一定程度上解决信号量获取数超过信号量总数的问题,但删除过期 UUID 的地方还是依赖本地时间,所以尽量保证各个主机的系统时间差距要足够小。 P131

自我思考:做到与系统时间无关

去除原本的有序集合,仅留下计数器和计数值作为分值的有序集合,并对于每个 UUID 都设置一个有过期时间的键,每次移除前,遍历有序集合,并查询其是否过期,并从有序集合中删除所有已过期的 UUID

这样做不仅能完全达到与系统时间无关,还不会存在信号量获取数超过信号量总数的问题,且能够实现单个获取的信号量能有不同的过期时间,也一定程度上降低了时间复杂度,不过会增加客户端与 Redis 服务器之间的交互次数。

刷新信号量 P131

信号量使用者可能在过期时间内无法处理完请求,此时就需要续约,延长过期时间。由于公平的计数信号量已将时间有序集合和计数有序集合分开,所以只需要在时间有序集合中对 UUID 执行 ZADD 即可,若执行失败,则已过期自动释放。 P131

对于我刚刚提出的那种方法,有两种方法可以续约:

  • 使用 lua 脚本保证原子性
  • 先读取过期时间

    • 未过期:再使用带 XX 选项的 SET 命令设置新的过期时间(需要加上原有的过期时间),返回成功则续约成功,否则续约失败
    • 已过期:续约失败
消除竞争条件 P132

两个进程 AB 都在尝试获取剩余的一个信号量时,即使 A 首先对计数器执行了自增操作,但只要 B 能够抢先将自己的 UUID 添加到计数有序集合中,并检查 UUID 的排名,那么 B 就可以成功获取信号量。之后 A 再将自己的 UUID 添加到有序集合里,并检查 UUID 排名,那么 A 也可以成功获取信号量,最终导致获取的信号量多余信号量总数。 P132

为了消除获取信号量时所有可能出现的竞争条件,构建一个正确的计数信号量,我们需要用到前面完成的带有超时功能的分布式锁。在想要获取信号量时,首先尝试获取分布式锁,若获取锁成功,则继续执行获取信号量的操作;若获取锁失败,那么获取信号量也失败。 P132

不同计数信号量的使用场景 P133
  • 基本的计数信号量:对于多机系统时间的差异不关心,也不需要对信号量进行刷新,并且能够接收信号量的数量偶尔超过限制
  • 公平的计数信号量:对于多机系统时间的差异不是非常敏感,但仍然能够接收信号量但数量偶尔超过限制
  • 正确的计数信号量:希望信号量一致具有正确的行为

本文首发于公众号:满赋诸机(点击查看原文) 开源在 GitHub :reading-notes/redis-in-action


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK