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

自动补全 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

posted @ 2021-01-27 22:24  满赋诸机  阅读(131)  评论(0编辑  收藏