redis 应用场景

缓存及过期键处理及键淘汰策略

缓存现在几乎是所有中大型网站都在用的必杀技,合理的利用缓存不仅能够提升网站访问速度,还能大大降低数据库的压力。Redis提供了键过期功能,也提供了灵活的键淘汰策略,所以,现在Redis用在缓存的场合非常多。

在redis中可以通过如下命令设置键的生存时间或生命周期:

  • EXPIRE KEY TTL 命令用于将key的生存时间设置为ttl秒
  • PEXPIRE KEY TTL 命令用于将key的生存时间设置为ttl毫秒
  • EXPIREAT KEY TIMESTAMP 命令用于将key的过期时间设置为timestamp所指定的秒数时间戳
  • PEXPIREAT KEY TIMESTAMP 命令用于将key的过期时间设置为timestamp所指定的毫秒时间戳
  • PERSIST KEY 移除key的过期时间

Redis是如何判断一个键过期的呢?

在redis中维护了一个expires字典,里面保存了数据库中所有设置了过期时间的键的过期时间,称为过期字典。我们可以用ttl(time to live)命令去查看key的剩余生存秒数,也可以用pttl查看key的剩余生存毫秒数,过程即是拿着key去expires字典中获取到key的过期毫秒时间戳,再减去当前时间戳,即可得到key的剩余生存时间。

而判断key是否过期,也是通过过期字典来完成的:

  1. 首先检查给定键是否存在于过期字典中,若存在则取得键的过期时间
  2. 检查当前UNIX时间戳是否大于键的过期时间,如果是的话则键已过期,若否则未过期

Redis的过期键删除策略:

如果一个键过期了那肯定是需要删除的,否则留在仍然留在内存中会导致取到过期数据,同时也浪费内存,那它什么时候会被删除呢?有两种策略:

  1. 惰性删除策略:程序在取出键时才对key进行过期检查,若过期则删除,否则照常执行,这个策略对cpu是友好的,因为不用额外的线程去自动清理过期key,但是是对内存不友好的,因为如果一直没有对这些过期键进行获取的话,这些键会一直留在内存中,造成垃圾数据内存泄漏
  2. 定期删除策略:每隔一段时间执行一次删除过期键的操作,并通过限制删除操作执行的时常和频率来减少删除操作对cpu时间的影响

Redis综合了这两种策略来实现过期键的删除:

  1. 首先所有读写数据库的redis命令在执行之前都会对输入键通过过期字典进行检查,如果已过期则将key删除,然后再执行请求命令,该返回空返回空,该set值set值;
  2. redis还实现了定期删除策略,在规定的时间内,分多次遍历服务器的各个数据库,从数据库的expires字典中随机抽查一部分键的过期时间,并删除其中过期键

通过两种策略的结合,redis实现了过期键删除的时间与空间的平衡。

内存不足时Redis的键淘汰策

avatar

redis在32位系统有最大内存限制3G,但是在64位系统并未有限制,因此我们需要在redis.conf文件中设置其最大占用内存值,否则将无限制使用内存甚至把服务器撑爆。设置方法如下:

maxmemory 100mb

当内存达到设置的最大值之后,再次进行写操作,redi会首先根据配置的键淘汰策略尝试淘汰数据,释放空间;若是未设置淘汰策略或是根据淘汰策略仍不能清理出空间,则拒绝写操作,但是读操作并不影响。

因此,我们在设置了最大使用内存后还得配置合适的键淘汰策略,以使redis服务更高可用。根据redis.conf注释我们知道键淘汰策略有如下几种:

volatile-lru:使用LRU算法进行数据淘汰(淘汰上次使用时间最早的,且使用次数最少的key),只淘汰设定了有效期的key

allkeys-lru:使用LRU算法进行数据淘汰,所有的key都可以被淘汰

volatile-random:随机淘汰数据,只淘汰设定了有效期的key

allkeys-random:随机淘汰数据,所有的key都可以被淘汰

volatile-ttl:淘汰剩余有效期最短的key

redis默认的淘汰策略为noeviction,即不淘汰,显然是不行的,我们需要根据不同的应用场景配置不同的淘汰策略。推荐使用volatile-lru,对于很重要的、更新又不频繁的数据不应该设置过期时间,而对于更新频率较高,或是对一致性要求较高的数据可以设置过期时间,然后通过volatile-lru,对于这些设置了过期时间的键值,通过least-recently-used最近不常使用的规则进行删除,保留热点数据,提高缓存命中率。

需要注意的是,如果是redis集群配置了主从复制的话,maxmemory不能设置得跟服务器主机内存太接近,因为主从复制将占用一部分内存,最好保证一定的内存余量。

排行榜

很多网站都有排行榜应用的,如京东的月度销量榜单、商品按时间的上新排行榜等。Redis提供的有序集合数据类构能实现各种复杂的排行榜应用。

Redis 有序集合和集合一样也是string类型元素的集合,且不允许重复的成员。

不同的是每个元素都会关联一个double类型的分数。redis正是通过分数来为集合中的成员进行从小到大的排序。

有序集合的成员是唯一的,但分数(score)却可以重复。

集合是通过哈希表实现的,所以添加,删除,查找的复杂度都是O(1)。 集合中最大的成员数为 232 - 1 (4294967295, 每个集合可存储40多亿个成员)。

avatar

示例

一个典型的游戏排行榜包括以下常见功能:

  1. 能够记录每个玩家的分数;
  2. 能够对玩家的分数进行更新;
  3. 能够查询每个玩家的分数和名次;
  4. 能够按名次查询排名前N名的玩家;
  5. 能够查询排在指定玩家前后M名的玩家。

更进一步,上面的操作都需要在短时间内实时完成,这样才能最大程度发挥排行榜的效用。

由于一个玩家名次上升x位将会引起x+1位玩家的名次发生变化(包括该玩家),如果采用传统数据库(比如MySQL)来实现排行榜,当玩家人数较多时,将会导致对数据库的频繁修改,性能得不到满足,所以我们只能另想它法。

假设lb为排行榜名称,user1、user2等为玩家唯一标识。

zadd —— 设置玩家分数

命令格式:zadd 排行榜名称 分数 玩家标识
时间复杂度:O(log(N))

1
2
3
4
5
6
7
8
127.0.0.1:6379> zadd lb 79 user1
(integer) 1
127.0.0.1:6379> zadd lb 83 user2
(integer) 1
127.0.0.1:6379> zadd lb 97 user3
(integer) 1
127.0.0.1:6379> zadd lb 90 user4
(integer) 1

zscore —— 查看玩家分数

命令格式:zscore 排行榜名称 玩家标识
时间复杂度:O(1)

1
2
127.0.0.1:6379> zscore lb user1
"79"

zrevrange —— 按名次查看排行榜

命令格式:zrevrange 排行榜名称 起始位置 结束位置 [withscores]
时间复杂度:O(log(N)+M)

由于排行榜一般是按照分数由高到低排序的,所以我们使用zrevrange,而命令zrange是按照分数由低到高排序。

起始位置和结束位置都是以0开始的索引,且都包含在内。如果结束位置为-1则查看范围为整个排行榜。

带上withscores则会返回玩家分数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
127.0.0.1:6379> zrevrange lb 0 -1 withscores
1) "user3"
2) "97"
3) "user4"
4) "90"
5) "user2"
6) "83"
7) "user1"
8) "79"
127.0.0.1:6379> zrevrange lb 0 2 withscores
1) "user3"
2) "97"
3) "user4"
4) "90"
5) "user2"
6) "83"
127.0.0.1:6379> zrevrange lb 0 2
1) "user3"
2) "user4"
3) "user2"
127.0.0.1:6379>

zrevrank —— 查看玩家排名

命令格式:zrevrank 排行榜名称 玩家标识
时间复杂度:O(log(N))

与zrevrange类似,zrevrank是以分数由高到低的排序返回玩家排名(实际返回的是以0开始的索引),对应的zrank则是以分数由低到高的排序返回排名。

1
2
3
4
127.0.0.1:6379> zrevrank lb user3
(integer) 0
127.0.0.1:6379> zrevrank lb user1
(integer) 3

zincrby —— 增减玩家分数

命令格式:zincrby 排行榜名称 分数增量 玩家标识
时间复杂度:O(log(N))

有的排行榜是在变更时重新设置玩家的分数,而还有的排行榜则是以增量方式修改玩家分数,增量可正可负。如果执行zincrby时玩家尚不在排行榜中,则认为其原始分数为0,相当于执行zdd。

1
2
3
4
5
6
7
8
9
10
11
127.0.0.1:6379> zincrby lb 10 user4
"100"
127.0.0.1:6379> zrevrange lb 0 -1 withscores
1) "user4"
2) "100"
3) "user3"
4) "97"
5) "user2"
6) "83"
7) "user1"
8) "79"

zrem —— 移除某个玩家

命令格式:zrem 排行榜名称 玩家标识
时间复杂度:O(log(N))

1
2
3
4
5
6
7
8
9
127.0.0.1:6379> zrem lb user4
(integer) 1
127.0.0.1:6379> zrevrange lb 0 -1 withscores
1) "user3"
2) "97"
3) "user2"
4) "83"
5) "user1"
6) "79"

del —— 删除排行榜名称

排行榜对象在我们首次调用zadd或zincrby时被创建,当我们要删除它时,调用redis通用的命令del即可。

1
2
3
4
5
6
7
127.0.0.1:6379> del lb
(integer) 1
127.0.0.1:6379> get lb
(nil)
127.0.0.1:6379> zrevrange lb 0 -1 withscores
(empty list or set)
127.0.0.1:6379>

相同分数问题

免费的方案总有那么一些不完美。从前面的例子我们可以看到,user2和user3具有相同的分数,但在按分数逆序排序时,user3排在了user2前面。而在实际应用场景中,我们更希望看到user2排在user3前面,因为user2比user3先加入排行榜,也就是说user2先到达该分数。

但Redis在遇到分数相同时是按照集合成员自身的字典顺序来排序,这里即是按照”user2″和”user3″这两个字符串进行排序,以逆序排序的话user3自然排到了前面。

要解决这个问题,我们可以考虑在分数中加入时间戳,计算公式为:

带时间戳的分数 = 实际分数*10000000000 + (9999999999 – timestamp)

timestamp我们采用系统提供的time()函数,也就是1970年1月1日以来的秒数,我们采用32位的时间戳(这能坚持到2038年),由于32位时间戳是10位十进制整数(最大值4294967295),所以我们让时间戳占据低10位(十进制整数),实际分数则扩大10^10倍,然后把两部分相加的结果作为zset的分数。考虑到要按时间倒序排列,所以时间戳这部分需要颠倒一下,这便是用9999999999减去时间戳的原因。当我们要读取玩家实际分数时,只需去掉后10位即可。

初步看起来这个方案还不错,但这里面有两个问题。

第一个问题是小问题,采用秒为时间戳可能区分度还不够,如果同一秒出现两个分数相同的仍然会出现前面的问题,当然我们可以选择精度更高的时间戳,但在实际场景中,同一秒谁排前面已经无关紧要。

第二个问题是大问题,因为Redis的分数类型采用的是double,64位双精度浮点数只有52位有效数字,它能精确表达的整数范围为-253到253,最高只能表示16位十进制整数(最大值为9007199254740992,其实连16位也不能完整表示)。这就是说,如果前面时间戳占了10位的话,分数就只剩下6位了,这对于某些排行榜分数来说是不够用的。我们可以考虑缩减时间戳位数,比如从2015年1月1日开始计时,但这仍然增加不了几位。或者减少区分度,以分钟、小时来作为时间戳单位。

如果Redis的分数类型为int64,我们就没有上面的烦恼。说到这里,其实Redis真应该再额外提供一个int64类型的ZSet,但目前只能是幻想,除非自己改其源码。

既然Redis也不能完美解决排行榜问题,那最终是不是有必要自己实现一个专门的排行榜数据结构呢?毕竟实际应用中的排行榜有很多可以优化的地方,比玩家呈金字塔分布,越是低分段玩家数量越多,同一分数拥有大量玩家,玩家增加一分都可能超越很多玩家,这就为优化提供了可能。

计数器

什么是计数器,如电商网站商品的浏览量、视频网站视频的播放数等。为了保证数据实时效,每次浏览都得给+1,并发量高时如果每次都请求数据库操作无疑是种挑战和压力。Redis提供的incr命令来实现计数器功能,内存操作,性能非常好,非常适用于这些计数场景。

INCR key

将 key 中储存的数字值增一。

如果 key 不存在,那么 key 的值会先被初始化为 0 ,然后再执行 INCR 操作。

如果值包含错误的类型,或字符串类型的值不能表示为数字,那么返回一个错误。

这是一个针对字符串的操作,因为 Redis 没有专用的整数类型,所以 key 内储存的字符串被解释为十进制 64 位有符号整数来执行 INCR 操作。

1
2
3
4
5
127.0.0.1:6379> incr test
(integer) 11
127.0.0.1:6379> get test
"11"
127.0.0.1:6379>

计数器的实现

计数器是 Redis 的原子性自增操作可实现的最直观的模式了,它的想法相当简单:每当某个操作发生时,向 Redis 发送一个 INCR 命令。

比如在一个 web 应用程序中,如果想知道用户在一年中每天的点击量,那么只要将用户 ID 以及相关的日期信息作为键,并在每次用户点击页面时,执行一次自增操作即可。

比如用户名是 peter ,点击时间是 2012 年 3 月 22 日,那么执行命令 INCR peter::2012.3.22 。

以下是防止刷单的逻辑示例:

1
2
3
4
5
6
7
8
9
10
11
12
$redisKey = “api_name_” + $api;

$count = $this->redis->incr($redisKey);

if ($count == 1) {
//设置有效期一s
$this->redis->expire($redisKey,1);// 设置一s的过期时间
}

if (count > 200) {// 防止刷单的安全拦截
return false;// 超过就返回false
}

聚合分类

redis set是集合类型的数据结构,那么集合类型就比较适合用于聚合分类。

  1. 标签:比如我们博客网站常常使用到的兴趣标签,把一个个有着相同爱好,关注类似内容的用户利用一个标签把他们进行归并。
  2. 共同好友功能,共同喜好,或者可以引申到二度好友之类的扩展应用。
  3. 统计网站的独立IP。利用set集合当中元素不唯一性,可以快速实时统计访问网站的独立IP。

案例:

在微博应用中,可以将一个用户所有的关注人存在一个集合中,将其所有粉丝存在一个集合。Redis还为集合提供了求交集、并集、差集等操作,可以非常方便的实现如共同关注、共同喜好、二度好友等功能,对上面的所有集合操作,你还可以使用不同的命令选择将结果返回给客户端还是存集到一个新的集合中。

交集,并集,差集

//tag表使用集合来存储数据,因为集合擅长求交集、并集

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
127.0.0.1:6379> sadd tag:ruby 1
(integer) 1
127.0.0.1:6379> sadd tag:ruby 2
(integer) 1
127.0.0.1:6379> sadd tag:web 2
(integer) 1
127.0.0.1:6379> sadd tag:erlang 3
(integer) 1
127.0.0.1:6379> SDIFF tag:ruby tag:web
1) "1"
127.0.0.1:6379> sinter tag:ruby tag:web
1) "2"
127.0.0.1:6379> sunion tag:ruby tag:web
1) "1"
2) "2"

秒杀

Redis列表是简单的字符串列表,按照插入顺序排序。你可以添加一个元素到列表的头部(左边)或者尾部(右边)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
127.0.0.1:6379> LPUSH testkey redis 1
(integer) 2
127.0.0.1:6379> LPUSH testkey mongodb 2
(integer) 4
127.0.0.1:6379> LPUSH testkey mysql 3
(integer) 6
127.0.0.1:6379> LRANGE testkey 0 10
1) "3"
2) "mysql"
3) "2"
4) "mongodb"
5) "1"
6) "redis"
127.0.0.1:6379>

秒杀场景防止商品超卖:

  1. 数据库中设置商品数量为无符号型,即不允许负数。当更新商品数量到负数时,返回false。
  2. 商品数量存在Redis的list队列中,每次抢购就pop删除一个元素出队列。
1
2
3
4
5
6
7
8
//存放商品数量的队列
for($j =1; $j <= 10; $j++){ //设置商品数量为10
$re = Redis::lpush(gooods_count,1);
}

// 判断商品数量逻辑

$count = Redis::lpop('gooods_count');//$count = Redis::llen('gooods_count'); //llen判断队列长度if(!$count){return'已经抢光了哦';