这一章节收获不少,其中最开眼界的是redis的强大的排序功能,以及如果信手拈来的事务功能。很多时候,技术本身没有问题,但是抽象的过于复杂,使得解决问题往往不是聚焦在问题本身,而是各类周边的工具。Redis在作为一个强有力的解决问题的工具方面,无疑是非常突出的。
事务
在我固有的印象中,事务是属于sql数据库才专有的特权,不过随着no-sql数据库的逐步成熟,这点确实有所变哈。
redis的事务功能,简单、直观,并且能够在实际的业务中发挥作用。一般的事务用法有两种:
- 使用Multi关键字包裹
使用multi关键字,可以将需要一起执行的语句,保证同时发送到redis-server侧进行处理。如果说语句中存在语法错误,那么会全部终止(redis 2.6.5 以后)。
但是如果在执行的时候出现了运行时错误,比如set了一个哈希map的key,那么这时候就无法进行回滚操作了,这点需要尤其注意。不过一般而言,这类错误应该在测试阶段就能够被发现并规避了。
这里举一个使用redis保存微博中的关注关系的实例:
每个用户都有一个关注的集合followingSet
,对应的被关注的用户也有一个被关注的集合followedSet
, 那么每次有关注关系发生的时候,绑定关系一定要是双向的:
$ret = $redis->multi()
->SADD("{$userIdA}:followingSet",userIdB)
->SADD("{$userIdB}followedSet",userIdA)
->exec();
- 使用watch关键字
很多时候我们希望对一些独占的变量进行修改的过程中,不希望被打断,因为一旦变量的值被他人更改,很容易出现不一致的问题。这在一些抢占邀请码、领礼券等常见的业务场景中都有需求。所以这个时候一般就会使用watch与multi关键字结合。
举一个领礼券的例子:
// 假设有100个礼物 关注礼物数量这个key
$redis->watch("giftcnt");
// 用户领一个礼物,礼物数量减1,同时标识用户已经领过礼物了
$ret = $redis->multi()
->incrBy("giftcnt",-1)
->set("user:hasgift",1)
->exec();
假如说出现了静态条件,用户A在进行执行事务操作的时候,发现giftcnt已经不是自己获取的那个了,那么他的操作就需要终止,从而保证数量和表示状态的一致性。
Watch的思想,即是乐观锁,重试一次,希望不再遇到静态条件。一般事务成功的执行了之后,那么watch的过程也就完成了。
过期时间
在过期时间这个点上,最能体现redis作为缓存的功用。这也是我在深入了解redis之前唯一有概念的点。由于之前一直在用腾讯自研的CKV,其中用到最多的,确实也是这个expire的特性。
应该说,key的expire特性最能够体现大家使用缓存的普遍方式。那就是被动缓存,数据落地在mysql,同时缓存一份有过期时间的在redis,一旦过期之后,client再重新获取,继而写入到缓存中。一旦数据变更,则写入mysql,再写入缓存。
不过根据左耳朵耗子在博客中的分享,这种做法尽管能够满足一些常见的访问量不高的场景,但是在高并发的情况下是有问题的。设想client A访问cache,发现miss了,读取mysql,写入cache。但是与此同时,client B写入了mysql,而这个时间是在A已经把旧的数据灌入cache之后的。这就出现了问题。关于这个问题,不再深究,但是可以看看http://coolshell.cn/articles/17416.html 这篇文章。
redis中expire命令,针对string类型的键:
EXPIRE key seconds
PEXPIRE key miliseconds
也可以使用PERSIST/SET key
命令把key恢复成永久。
从使用场景上面来看的话,比较常见的场景之一,就是实现访问的频率控制。针对唯一的用户+接口,我们希望限制他的访问频率在每分钟固定次数,比如100次。那么就可以通过如下的方式实现:
$listLength = LLEN rate.limiting:$IP
if $listLength < 100
LPUSH rate.limiting:$IP, noew()
else
$time = LINDEX rate.limiting:$IP, -1
if now() - $time < 60
print "访问频率超过限制"
else
MULTI
LPUSH rate.limiting:$IP, now()
LTRIM rate.limiting:$IP, 0, 99
EXEC
另外非常常见的场景,就是用作纯粹的缓存,这里需要注意两点:
- 缓存时间的设置expire命令需要单独执行,并非原子操作,最好使用事务进行包装
- 在用作缓存服务器的时候,必须限制redis使用的最大内存,并且使用一定的策略进行键值的淘汰,从而实现内存利用的最大化
- maxmemory
- memory-policy
排序
说老实话,在接触这部分内容之前,我以为redis的排序仅仅是使用有序集合来进行排列。但是其实redis内置了非常强大的SORT等命令,来完成各种排序,尤其是连接查询排序的功能。
如果我们相对包含了所有文章id的有序集合排序,那么就可以直接使用:
SORT articles:score DESC LIMIT 1 2
这样不但能够根据文章id的大小降序排列,还能limit返回的数量来方便翻页。
而对于更复杂的排序需求,则就需要使用BY关键字了。假如说,我们需要按照article的时间排序,那么就需要用到article的hashmap的结构了:
SORT articles:score BY article:*->time DESC
其中的*
号就会被集合中的元素来替代,你可以理解成对articles:score做了一个foreach,然后被通配符*
给替代了。这个用法确实令人耳目一新。
而除了使用哈希表中的元素,一个字符串类型的键同样也能被用作排序的key。
另外一个使用排序经常关心的问题,就是返回的结果的格式。按照上文中SORT的用法,会返回一系列排序好的文章id,然后需要手动的去hget其中的数据。而GET关键字就能够解决这个问题:
SORT articles:score BY article:*->time DESC GET article"*->title GET article"*->time GET #
#
号表示获取id。
如果想把返回的结果以列表的形式存储,那么可以使用:
SORT articles:score BY article:*->time DESC GET article"*->title GET article"*->time GET # STORE sort.result
再配合EXPIRE命令,就可以进行缓存,从而保证SORT这类高耗时的操作不那么频繁的执行。
从性能来来讲,SORT的时间复杂度是O(n+mlog(m))
, 同时redis也会提前建立一个长度为n的容器来存储待排序的元素。所以使用时一定注意:
- 尽可能减少待排序键中元素的数量(减少N)
- 使用LIMIT参数只获取需要的数据(减少M)
- 充分利用STORE命令进行必要的缓存
消息通知
除了缓存,另外一个redis被流传最广的用法就是实现一个简易的队列。尽管从业界的使用来看,越来越专业的如rabbitMQ、rocketMQ、CMQ、KAFUKA等队列实现方案已经越来越成熟了。但是redis用作队列的简洁高效,仍然是很多队列处理能力要求不高的场景下的不二之选。
一个比较常见的场景是用户注册后,发送验证的邮件。那么定义一个队列 queue:verifymail
,那么只需要开一个进程,阻塞的去等待队列,然后决定如何处理即可:
BRPOP queue:verifymail 0
但是有的时候,队列也会分优先级,比如找回密码的邮件和注册成功的通知邮件,其实优先级不同。所以可以进行优先级队列的阻塞等待:
BRPOP queue:findpassword queue:verifymail 0
除了实现队列任务之外,redis还可以用来实现不同频道的发布与订阅,从而实现后台任务更加顺畅的流转。
而对于发布者和订阅者的定义就是使用PUBLISH和SUBSCRIBE
。而不同的频道则可以实现业务的隔离。
比如定义个一个频道是用户发评论,那么可能有积分服务、审核服务等都订阅了这个频道,那么在收到相应的通知之后,就可以进行相应的操作了,比如进行审核,或者是增加用户的积分等等。