Redis 实战 —— 08. 实现自动补全、分布式锁和计数信号量
自动补全 P109
自动补全在日常业务中随处可见,应该算一种最常见最通用的功能。实际业务场景肯定要包括包含子串的情况,其实这在一定程度上转换成了搜索功能,即包含某个子串的串,且优先展示前缀匹配的串。如果仅包含前缀,那么可以使用 Trie
树,但在包含其他的情况下,使用数据库/ ES
本身自带查询就足够了。可以按照四种情况(精确匹配、前缀、后缀、包含(也可将后两种融合成包含)),分别查询结果,直至达到数据条数上限或者全部查询完毕。但这种使用方法有缺点:查询次数多、难以分页。不过实际场景中需要补全的情况都只要第一页的数据即可。
自动补全最近联系人 P110
需求: 记录最近联系过的 100 个人名,并支持对输入的串进行自动补全。 P110
数据量很小,所以可以在 Redis
中用列表维护最近联系人,然后在内存中进行过滤可自动补全的串。
步骤: P111
- 维护长度为 100 的最近联系人列表
- 如果指定的联系人已在列表中,则从列表中移除 (
LREM
) - 将指定的联系人添加到列表最前面 (
LPUSH
) - 如果添加完成后,列表长度超过 100 ,则对列表进行修剪,仅保留列表 前面的 100 个联系人 (
LTRIM
)
- 如果指定的联系人已在列表中,则从列表中移除 (
- 获取整个最近联系人列表,在内存中根据四种情况进行过滤即可
通讯录自动补全 P112
需求: 有很多通讯录,每个通讯录中有几千个人(仅包含小写英文字母),尽量减少 Redis
传输给客户端的数据量,实现前缀自动补全。 P112
思路: 使用有序集合存储人名,利用有序集合的特性:当成员的分值相同时,将根据成员字符串的二进制顺序进行排序。如果要查找 abc
前缀的字符串,那么实际上就是查找介于 abbz...
之后和 abd
之前的字符串。所以问题转化为:如何找到第一个排在 abc
之前的元素的排名 和 第一个排在 abd
之前的元素的排名。我们可以构造两个不在有序集合中的字符串 (abb{
, abc{
) 辅助定位,因为 {
是排在 z
后第一个不适用的字符,这样可以保证这两个字符串不存在与有序集合中,且满足转化后的问题的限制。 P113
综上: 通过将给定前缀的最后一个字符替换为第一个排在该字符前的字符,再再在末尾拼接上左花括号,可以得到前缀的前驱 (predecessor) ,通过给前缀的末尾拼接上左花括号,可以得到前缀的后继 (successor) 。
- 字符集:当处理的字符不仅仅限于
a~z
范围,那么要处理好以下三个问题:P113
- 将所有字符转换为字节:使用
UTF-8
、UTF-16
或者UTF-32
字符编码(注意:UTF-16
和UTF-32
只有大端版本可用于上述方法) - 找出需要支持的字符范围,确保所选范围的前面和后面至少留有一个字符
- 使用位于范围前后的字符分别代替反引号
`
和左花括号{
- 将所有字符转换为字节:使用
步骤: P114
- 运用思路中的方法找到前缀的前驱和后继(为了防止同时查询相同的前缀出现错误,可以在前驱和后继之后添加上
UUID
) - 将前驱和后继插入到有序集合里
- 查看前驱和后继的排名
- 取出他们之间的元素
- 从有序集合中删除前驱和后继
通过向有序集合添加元素来创建查找范围,并在取得范围内的元素之后移除之前添加的元素,这种技术还可以应用在任何已排序索引 (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
- 清理有序集合中所有已过期的
UUID
(时间戳 <= 当时时间戳 – 过期时间) - 生成
UUID
,使用当时时间戳作为分值,将UUID
添加到有序集合里面 - 检查刚刚的
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
两个进程 A
和 B
都在尝试获取剩余的一个信号量时,即使 A
首先对计数器执行了自增操作,但只要 B
能够抢先将自己的 UUID
添加到计数有序集合中,并检查 UUID
的排名,那么 B
就可以成功获取信号量。之后 A
再将自己的 UUID
添加到有序集合里,并检查 UUID
排名,那么 A
也可以成功获取信号量,最终导致获取的信号量多余信号量总数。 P132
为了消除获取信号量时所有可能出现的竞争条件,构建一个正确的计数信号量,我们需要用到前面完成的带有超时功能的分布式锁。在想要获取信号量时,首先尝试获取分布式锁,若获取锁成功,则继续执行获取信号量的操作;若获取锁失败,那么获取信号量也失败。 P132
不同计数信号量的使用场景 P133
- 基本的计数信号量:对于多机系统时间的差异不关心,也不需要对信号量进行刷新,并且能够接收信号量的数量偶尔超过限制
- 公平的计数信号量:对于多机系统时间的差异不是非常敏感,但仍然能够接收信号量但数量偶尔超过限制
- 正确的计数信号量:希望信号量一致具有正确的行为
本文首发于公众号:满赋诸机(点击查看原文) 开源在 GitHub :reading-notes/redis-in-action