P109
自动补全在日常业务中随处可见,应该算一种最常见最通用的功能。实际业务场景肯定要包括包含子串的情况,其实这在一定程度上转换成了搜索功能,即包含某个子串的串,且优先展示前缀匹配的串。如果仅包含前缀,那么可以使用 Trie
树,但在包含其他的情况下,使用数据库/ ES
本身自带查询就足够了。可以按照四种情况(精确匹配、前缀、后缀、包含(也可将后两种融合成包含)),分别查询结果,直至达到数据条数上限或者全部查询完毕。但这种使用方法有缺点:查询次数多、难以分页。不过实际场景中需要补全的情况都只要第一页的数据即可。
P110
需求: 记录最近联系过的 100 个人名,并支持对输入的串进行自动补全。 P110
数据量很小,所以可以在 Redis
中用列表维护最近联系人,然后在内存中进行过滤可自动补全的串。
步骤: P111
LREM
)LPUSH
)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
的排名
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