子弹短信内部技术分享:Redis

13,519 阅读22分钟

原理

Redis 是一个内存型「数据库」,除存储之外,它还有许多强大的命令,使之远远超出了数据库的定义,所以官方称之为「data structure store」,数据结构存储系统。 通过 Redis 提供的指令,我们可以实现缓存、消息队列、事件通知、排行榜、库存管理、分布式锁等功能。

基础结构

Redis 核心是单进程单线程服务,通过 epoll、select 等实现了 IO 多路复用,可以并发处理网络事件。

数据结构

Redis 提供了以下几种典型的数据结构

strings

Redis 实现了名为 SDS(Simple Dynamic String) 的字符串类型,与 C 字符串区别:

  1. 实现字符串拼接,减少内存重分配
  2. 维护了字符串的长度,以便快速获取及避免缓冲区溢出
  3. 二进制安全,即支持存储空格(\0)

linkedlist

Redis 实现了双向无环链表,并使用此数据结构实现了 list。

Hashtable

Redis 实现了符合自身使用场景的 HashMap,即数组加链表的实现。此数据结构实现了 Redis 中的 Hash、Set 数据类型。特点如下:

  1. 使用 MurmurHash3 Hash 算法,针对规律性强的字符串有更好分布性。
  2. 新节点插入到表头而非表尾,因为缓存一定程度上会存在,「后加入的缓存会比先前加入的缓存更容易被访问」的特点。
  3. 渐进式 rehash。Redis 数据库本身是个巨大的 Hash 表,每次 rehash 要操作几百上千万的 key,渐进式 rehash 则是其中必不可少的保障。 rehash 的方式是维护两张表和索引,需要 rehash 时将 rehashIndex 置为 0,然后每次除 insert 操作外,都会将 oldTable 的 rehashIndex 中数据转移到 newTable 中,直到 rehashIndex == oldTable.length() - 1,再将 rehashIndex 置为 -1,rehash 完成。

skiplist

跳跃表通过给链表分层,实现了平均 O(logN),最坏 O(N) 的时间复杂度。Redis 使用该数据结构实现了 Sorted Set 数据类型。另外 Sorted Set 中还需要使用 HashTable 来实现 O(1) 的查询。

intset

整数集合,即只保存整数的集合。Redis 使用该数据结构实现了 Set。

ziplist

压缩列表。压缩列表是一种牺牲性能节约空间的数据结构,相比链表,它节约了指针的空间,Redis 将它作为 List、Hash、Sorted Set 的实现,并使用 hash-max-ziplist-entries(512)、hash-max-ziplist-value(64)、list-max-ziplist-size(8 Kb)、zset-max-ziplist-entries(128)、zset-max-ziplist-value(64) 配置来决定是否使用 ziplist。

持久化

不论是内存型的数据库还是关系型数据库,宕机、停电后数据无法恢复都是不可接受的。Redis 有两种备份数据的方式:

AOF

即 Append-Only-File,当开启备份时,Redis 会创建出一个默认名称为 appendonly.aof 的文件。并将内存中所有数据以命令的形式写入文件中,后续执行新的操作数据的命令时,会放入缓冲区中定时写入文件(appendfsync 不为 always 时)。 在 redis.conf 中用以下参数配置 AOF 策略:

appendonly yes/no 是否开启 AOF 模式
appendfilename appendonly.aof
appendfsync always/everysec/no #写入磁盘时机,always 表示每次都会同步到磁盘,由于是同步操作,性能下降严重。everysec 表示每秒刷盘。no 表示只放入缓存区中,由操作系统指定刷盘时机(Linux 一般是 30 秒)

当我执行了以下命令时:

set liuzhiguo 123
set liuzhiguo abc
set liuzhiguo 456
set liuzhiguo 1231 ex 30

AOF 文件长这样:

*2 		消息行数
$6 		第一条消息长度
SELECT  消息内容
$1 		第二条消息长度
0		消息内容

*3		
$3
set
$9
liuzhiguo
$3
123

*3
$3
set
$9
liuzhiguo
$3
abc

*3
$3
set
$9
liuzhiguo
$3
456

*3
$3
set
$9
liuzhiguo
$4
1231

*3
$9
PEXPIREAT
$9
liuzhiguo
$13
1544420872751

可以看出 AOF 模式是直接将命令写入文件中,所以在恢复数据时,Redis 会逐条执行命令来恢复数据。所以 AOF 模式恢复数据的效率并不高,而且当重复对一个 key 进行操作时,也需要执行所有操作命令。 针对同一数据重复操作的问题,Redis 提供了 AOF 重写的功能,即丢弃原有的 appendonly.aof 文件,重新将内存中的数据作为命令写入文件中。

RDB

即 Redis DataBase,此持久化模式默认开启。 开始备份时,Redis 会 fork 出一个子进程(bgsave),创建默认名为 dump.rdb 的二进制文件,逐个对内存中的数据进行备份。每次备份时都会抛弃原有的 RDB 文件,重新将数据全量备份。 对于备份的时机,在 redis.conf 有以下选项来触发备份:

save 900 1		900 秒内有 1 次变动
save 300 10		300 秒内有 10 次变动
save 60 10000	60 秒内有 10000 次变动

RDB 由于体积和天然的指令压缩能力,恢复数据速度要大大快于 AOF。但是因为每次只能全量备份,资源消耗比 AOF 大,不如 AOF 灵活。并且因为备份时机的不确定性,数据完整不如 AOF。

RDB-AOF

Redis 在 4.0 之后提出了 RDB-AOF 混合模式持久化,可以在 redis.conf 中通过 aof-use-rdb-preamble 选项开启。 此模式下,全量备份、重写 AOF 时会使用 RDB 格式,随后执行命令还是以 AOF 的格式追加到文件中。

这样一来,恢复数据时性能比单纯 AOF 强,全量备份比 AOF 快,备份体积比 AOF 小,部分备份性能比 RDB 高。

高可用

Redis 通过哨兵(Sentinel)与复制的方式实现了高可用

复制

通过在 redis.conf 文件中配置「slaveof ip port」或给运行中的 redis 节点执行命令「slaveof ip port」,即可使得该节点成为某个 redis 实例的从节点。

从节点(slave)启动时会向主节点(master)发送 sync 指令,主节点使用 bgsave 方法生成 RDB 文件,并建立缓冲区记录写命令。RDB 文件生成会即发送给从节点,从节点开始载入 RDB 文件,此动作同步执行。 从节点完成载入后,主服务器会将缓冲区的记录发送给从服务器,此后主节点每当有执行命令时,都会传播给从节点一份。

断线重连后,从节点再次上线时会向主节点发送 psync 命令执行部分重同步,主节点会将此期间的命令发送给从节点执行。为实现此功能,主从节点维护了「复制偏移量」。

使用 info 可以查看复制的状态:

# Replication
role:master
connected_slaves:1
slave0:ip=127.0.0.1,port=6380,state=online,offset=280,lag=0		// 从节点信息
master_replid:6088224db78515c7c2cbef387fb90cefd459f0d5
master_repl_offset:280											// 主节点偏移量
repl_backlog_active:1
repl_backlog_size:1048576
repl_backlog_first_byte_offset:1
repl_backlog_histlen:280


# Replication
role:slave
master_host:127.0.0.1
master_port:6379
master_link_status:up
master_last_io_seconds_ago:1									// 与主节点 1 秒前同步
master_sync_in_progress:0										// 是否在进行 sync 同步
slave_repl_offset:280											// 从节点偏移量
slave_priority:100
slave_read_only:1
connected_slaves:0
master_replid:6088224db78515c7c2cbef387fb90cefd459f0d5
master_replid2:0000000000000000000000000000000000000000
master_repl_offset:280
second_repl_offset:-1
repl_backlog_active:1
repl_backlog_size:1048576
repl_backlog_first_byte_offset:1
repl_backlog_histlen:280

Sentinel

为实现高可用,只有复制是不够的,还需要主节点服务不可用后,从节点能自动补位。 Redis 通过 Sentinel 来实现节点监控与协调,Sentinel 是一个特殊的 Redis 节点,需要启动时指定参数 --sentinel 和 sentinel.conf 配置文件,并在配置文件中指定主节点的 ip、host。 Sentinel 启动后会向主节点发送 info 命令,获取到相应的从节点信息,并与从节点建立连接。 当主节点不响应时,Sentinel 会等待至配置中指定的 timeout 时间,随后将从节点提升为主节点。主节点再次启动时,Sentinel 会向主节点发送 slaveof 命令,要求其成为从节点。

Sentinel 本身同样支持高可用,多个 Sentinel 会向每个主从节点 publish 自己的信息,以此来得知其他 Sentinel 的存在并建立连接。多个 Sentinel 共存时,对主从节点状态、身份的共识会有更复杂的协调过程,这就是另外一个漫长的故事了。

对 Sentinel 的详细介绍,可以见:https://redis.io/topics/sentinel ,以及参考《Redis 设计与实现(第二版)》.

集群

Redis 因为是内存型数据库,在存储空间上容易捉襟见肘,于是产生了许多扩容方案。

客户端分片

如 ShardedJedis,通过在客户端对 key 进行 hash,再分给指定的节点。 优点:无需改动 Redis 即可扩容 缺点:只能扩容一次,无法平滑升级

代理层分片

如 Twemproxy。代理层接收客户端的请求,代理到对应的 Redis 节点上,通常也是使用一致性 hash 来分片。并由于代理层可以统一配置或读取同一数据源,做到可拓展代理层。 优点:客户端无需关心 Redis 服务状态,也无需分片。 缺点:难以扩容。

Redis Cluster

Redis 自己实现的集群,可实现无痛扩容,平滑迁移。启动集群模式需要在配置文件中配置:

cluster-enabled yes
cluster-config-file nodes.conf 
cluster-node-timeout 1500

集群模式下,会创建出 16384 个槽,并给集群中每个节点分配自己的槽数,槽必须被全部指定才能工作,一个节点最低指定一个槽。所以 Redis 集群理论上最大是 16384 个节点。

当需要添加/获取某个 key 时,通过 crc16(key) & 16384 得到这个 key 应在的槽,随后找出这个槽所在的节点,如果节点是自己直接执行,否则会返回给客户端对应的节点的 ip + port。

Redis 集群是去中心化的,彼此之间状态同步靠 gossip 协议通信,集群的消息有以下几种类型:

  1. Meet。通过「cluster meet ip port」命令,已有集群的节点会向新的节点发送邀请,加入现有集群。
  2. Ping。节点每秒会向集群中其他节点发送 ping 消息,消息中带有自己已知的两个节点的地址、槽、状态信息、最后一次通信时间等。
  3. Pong。节点收到 ping 消息后会回复 pong 消息,消息中同样带有自己已知的两个节点信息。
  4. Fail。节点 ping 不通某节点后,会向集群所有节点广播该节点挂掉的消息。其他节点收到消息后标记已下线。

由于去中心化和通信机制,Redis Cluster 选择了最终一致性和基本可用。例如当加入新节点时(meet),只有邀请节点和被邀请节点知道这件事,其余节点要等待 ping 消息一层一层扩散。除了 Fail 是立即全网通知的,其他诸如新节点、节点重上线、从节点选举成为主节点、槽变化等,都需要等待被通知到。

因此,由于 gossip 协议,Redis Cluster 对服务器时间的要求较高,否则时间戳不准确会影响节点判断消息的有效性。另外节点数量增多后的网络开销也会对服务器产生压力。因此官方推荐最大节点数为 1000。对于 Redis 集群的运维,可以参考 优酷蓝鲸近千节点的 Redis 集群运维经验总结

优点:

  1. 真正的弹性扩容缩容。
  2. 扩容期间不影响使用。

缺点:

  1. 缺乏管理平台。
  2. 客户端要另做兼容。
  3. 部分命令不支持

Redis 通过 Cluster 解决了扩容之后,客户端该怎么使用呢? 如 JedisCluster,每次请求前会拉取节点的 cluster info 来计算应该到哪个节点请求,并需要对错误节点返回的 ASK 消息做相应的处理。由此产生的问题是

  1. 每次操作最少请求两次。
  2. 每次如果只请求某一个节点,也会形成单点压力。

对问题 1,解决办法是客户端缓存集群状态。对问题 2,JedisCluster 支持配置多个节点,拉取节点信息时会随机选择某节点以分摊压力。对问题 2 的处理方式,需要将 Redis 节点信息同步到客户端配置中,产生了耦合。

另外的问题是,集群状态下是不支持 mget、mset 等需要跨节点执行的命令。该问题的解决方案是加一层 Proxy,推荐 优酷土豆的Redis服务平台化之路,其使用 Nginx + Redis Cluster 的思路令人赞叹,并用请求聚合的方式实现了跨节点执行命令的问题。

阿里云提供的 Redis 服务同样实现了集群模式下的跨节点命令,采用代理 + 分片服务器 + 分片配置服务器(很可能是 zookeeper),但是没有使用 Redis Cluster 机制,而是自己实现的「分片」,保留了 slot。阿里云的 Redis 好处是集群版无需客户端做兼容,可以当成单机 Redis 使用,出了问题方便甩锅。

第三方魔改 Redis

在等待 Redis 出官方集群方案之前,人们迫不及待想要集群版的 Redis,一些不满于现状以及不满于 Redis Cluster 实现的人们开始对 Redis 进行改造。前面提到的阿里云 Redis 也属于魔改后的 Redis。

Codis

Codis 几乎是最知名的第三方 Redis,对 Redis 进行了大量改造。 其架构为 zookeeper + proxy + server-group(master + slave),并提供了控制台以便可视化运维。

通过 zookeeper 记录可用的 proxy 节点,再使用 Codis 开发组基于 Jedis 修改的 Jodis 客户端到 zookeeper 中寻找可用的 proxy 节点进行调用。如果使用的是 jedis 或其他客户端,则只能到连接一个 proxy,或者想办法连接到 zookeeper 获取节点,再进行轮询调用。

Codis 支持弹性扩容,分片方式与 Redis Cluster 类似,通过 crc32(key) % 1024 分成 1024 个槽,每台实例保存对应槽的数据。

LedisDB 和 SSDB

LedisDB 和 SSDB 非常相似,都是用 LevelDB 底层,重新实现了 Redis,或者说只实现了 Redis 协议。通过多线程 + 硬盘的方式,实现了和单机 Redis 相似的 QPS 性能,并可以很大程度上对容量进行扩容。 LedisDB/SSDB 与 Redis 的关系,相当于 TiDB 与 MySQL 的关系。

缺点是出了容量上的成本优势,其他没有任何优势。

事务

Redis 提供 watchmultiexec 等方法实现乐观锁事务。使用事务的流程如下:

  1. watch key1 key2
  2. multi 开启事务
  3. set key1 value1、set key2 value2,将指令入队。
  4. exec,执行指令。

如果 multi ~ exec 之间 key1/key2 被其他客户端修改过,exec 时会返回 nil, set key1 value1、set key2 value2 均不会执行。 Redis 会保存一个 watch_keys 字典,结构为: client -> keys、is_dirty。Redis 在处理每一个会修改数据的命令时,会检查 watch_keys 是否存在该 key,如果有,则修改 is_dirty 为 true。

执行事务的客户端在执行 exec 时,会检查 is_dirty 字段,如果发现为 false,所有的积累的指令会直接丢弃不执行。

事务在 Redis 中的使用场景不多,并发量大的情况下需要反复重试,大部分情况下有更好的使用方式:

Lua

Redis 提供了对 Lua 脚本的支持,原子性执行一系列指令,并可以写代码做逻辑判断。 例如需要大量插入数据的场景:

for i=1,10000000,1 do
    local num = math.random(1000000,999999999);
    redis.call("set",num,i)
end

执行一千万条命令在本机大概用了 12 秒,QPS 83w。 Redis 在执行 Lua 脚本时是单线程,无法处理其他请求,这也是 Redis 原子性的原因。下面是抢红包时利用该特性实现的 Lua 脚本:

// 该脚本传入 4 个参数
// KEYS[1] = 未领取的红包列表 key
// KEYS[2] = 已领取的红包列表 key
// KEYS[3] = 红包已领取人ID列表 key
// KEYS[4] = 领取人ID

// 检查领取人是否在已领取列表内
if redis.call('hexists', KEYS[3], KEYS[4]) ~= 0 then
	return nil
else
	// 取出一个未领取的红包
	local redEnvelop = redis.call('rpop', KEYS[1]);
	if redEnvelop then
		// 红包中的 receiver 填入领取人 ID
		local x = cjson.decode(redEnvelop);
		x['receiver'] = KEYS[4];
		local re = cjson.encode(x);

		// 领取人放入已领取人ID列表,将红包放入已领取红包列表
		redis.call('hset', KEYS[3], KEYS[4], KEYS[4]);
		redis.call('lpush', KEYS[2], re);

		// 给相应的 key 续期
		if redis.call('llen', KEYS[2]) == 1 then
			redis.call('expire', KEYS[2], 172800);
		end
		if redis.call('hlen', KEYS[3]) == 1 then
			redis.call('expire', KEYS[3], 172800);
		end
		return re;
	end
end
return nil

需要注意的是,由于 Lua 脚本只能在单个 Redis 实例执行,所以在集群状态下执行 Lua 时,Redis 会对要执行的 key 进行检查。为了保证所有 key 一定在某一台机器上,Redis 限制了所有 key 都必须在同一个 slot 内才行。

所以针对红包的场景,对 Lua 中传入的 key 做了xxx{redpacketId}的处理,以保证所有 key 落在一个 slot 上。

管道(pipeline)

Redis 支持使用管道批量执行命令,再统一返回,减少往返次数,通常用于批量插入数据,批量获取数据。

实战

缓存

缓存方式

缓存是 Redis 最常见的场景。通常缓存的过程为:

  1. 未命中:从数据源中取得数据,放入缓存中。
  2. 命中:返回数据。
  3. 更新:先把数据存入数据库,再使缓存失效。

不推荐更新数据时同时更新到缓存,因为可能并发更新导致脏数据。见 为什么 Facebook 删除缓存而不是更新缓存? 以及 Scaling Memcache at Facebook,其中提到「We choose to delete cached data instead of updating it because deletes are idempotent」。 但删除缓存并不是完全不会导致脏数据,只是概率会相对小很多。

批量查询

查询时可能会需要类似 where id in (xx,yy,zz) 的情况,这时查询缓存可以使用 mget 同时查询多个 key,可以大大提高效率。下面是 benchmark 数据:

get 81833.06 requests per second	
mget 10 73475.39 requests per second		  734,753
mget 20 64226.07 requests per second		  642,260			
mget 30 59559.26 requests per second		1,786,770			99%   < 1 milliseconds
mget 50 48995.59 requests per second		2,449,750			99%   < 1.5 milliseconds
mget 100 29214.14 requests per second		2,921,414			99%   < 2.5 milliseconds
mget 200 16730.80 requests per second		3,346,000			99%   < 3 milliseconds
mget 500 7222.30 requests per second		3,611,150			99%   < 9 milliseconds

根据总获取数据个数、平均响应时间,通常认为 mget 数量控制在 100 以下是比较均衡的。

按每次 mget 100 与 get 相比,性能相当于提高了 35 倍。再加上跨机器调用往返的时间消耗,实际情况性能提升很可能 100 倍以上。

分布式锁

Redis 可以通过 SET key randomValue NX EX 30 给某个 key 赋值,并同时判断 key 是否存在,以及给定过期时间。过期时间要根据业务变化。

释放锁可以直接 del 掉这个 key。但是 del 是有风险的:

例如 A 获取到锁,过期时间 30 秒。因为某些原因 30 秒没能处理完请求,B 过来也获取到了锁。此时 A 处理完执行释放锁的操作,就会释放掉 B 所持有的锁。

为了避免这个问题,需要判断 value 是不是 set 时的 value,如果是才执行 del 操作。为了让这两条命令原子性执行,需要使用到 lua 脚本:

- KEYS[1] 为 锁名称,ARGV[1] 为锁内容, 即 set 时的 randomValue
if redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("del",KEYS[1])
else
    return 0
end

另外在 spring-data-redis 的实现中,是没有 set nx ex 的,所以需要找到 Jedis 或者 Lettuce 调用原生方法。

计数器

Redis 另一个值得称道的命令就是自增了,其提供了 incr/incrby/incrbyfloat(string)、hincrby/hincr(hash)、zincrby(zset)方法供不同数据类型使用。

通过这些命令可以实现对库存的扣减,记录接口访问频次,记录一篇文章的点赞数、评论数、转发数,抢红包扣减数量等。

排行榜

利用 zset 有序列表,比如要计算用户积分排行榜:

  • zadd/zincrby 保存或自增用户的积分
  • zrevrank 获取用户的排名
  • zscore 获取用户的积分
  • zrevrange 获取排行

消息队列

利用 list 的 lpush(Left Push) 和 brpop(Blocked Right Pop) 接口可以实现消息队列功能:

  1. 将消息 lpush 到队列中。
  2. 所有实例通过 brpop 监听队列并取出数据进行消费。

在消费的过程中可以通过配置线程池,根据业务情况决定消费速率。

异步延迟合并队列

比如秒杀、抢红包时,库存数据需要异步入库。但仅仅异步入库也是不够的,并不会减少对数据库操作的次数。这时候可能需要将 100 次请求压缩成一次请求,只取最后的数据落库。

此类需求则可以用 zset + list 实现,我们需要几个东西:

  1. 需要延迟执行的任务放入 zset 列表中,score 为需要执行的时间戳。
  2. 后台起一个线程每秒钟拉取 zset ,执行 zrangeByScore, score 范围为 0 ~ 当前时间戳,如果取到数据则放入执行队列 list 中,最后 zrem 查出来的数据。
  3. 监听 list 队列的执行器,此时开始执行任务。

这也是红包中的异步更新的实现方式。在抢群红包时,如果每次都更新数据库中的数据,势必会增加响应时间。使用这种更新方式的话,只在最后一次抢红包的 30s 后更新,30s 之内发生的数据更新,都只会合并为 1 条。

事件通知

Redis 提供了publishsubscribe 等命令实现了广播功能,publish 时可以将消息通知到某个频道(channel),此时 subscribe 了这个频道的节点均能收到消息。

通过这个机制我们能做到对全节点的事件通知。

比如在积分系统中会将所有活动、抽奖、签到、摇钱树等数据库配置数据放入 JVM 缓存中,以便获得最高的性能。 为了更新数据,一开始是每分钟到数据库更新一次。但问题是每台实例更新的时机都不同,导致请求到 A 实例的数据,与 B 实例上的不同。随后将定时任务的配置改成了每分钟的第 0 秒执行,则很大程度上改善了问题。

但是轮询的方式仍然不够优雅,绝大部分时候取得的配置并没有变化,是无用的请求。更新配置的时机应该是配置发生了变更才对。

这时就可以使用 Redis 广播,每当数据库数据发生变化时,通过广播通知所有节点更新数据,或者干脆将要更新的数据放入广播中。

优化

Redis 虽然性能强悍,但是由于单线程的特性,一旦产生慢查询,会将所有操作都阻塞住。所以使用上仍需要注意会踩哪些坑。Redis 提供了 slowlog get 查看慢查询。

常见雷区

  • keys * keys 命令的时间复杂度是 O(n),n 是 Redis 中所有键的数量,这个是最常见的性能最差的命令。一般线上都把这个命令 block 掉(在配置中加 rename-command KEYS "")

  • 大 key。一个 key 里存储的数据越多,通常性能越差,比如对超大的 List 进行 lindex 和 lrange。另外大 value 在集群数据迁移时会阻塞可能导致 fail over。甚至在删除时也会阻塞,例如删除一个 1kw 数据量的 set,需要耗时 5s。或者在集群中大 key 会导致集群内存分配不均匀。所以在使用时需要避免在一个 key 中放入过多数据。

  • bgrewriteaofbgsave,重写 aof 文件及备份 RDB 文件时,会 fork 出子进程和内存,此期间是阻塞的,取决于 Redis 内存大小和机器性能。所以许多企业的做法是主节点上关闭 aof 和 rdb,只在从节点上备份。

大 key 的拆分

积分系统中存在一个进贡的任务,邀请人可获得被邀请人做任务的奖励,并在每天凌晨入账。

对于这个任务,我们做的第一步优化就是每天将获得了进贡奖励的用户,保存在 set 里,通过 sscan 遍历需要进贡的用户,执行任务。以此避免了扫库,保证每次取得的 userId 都是确切有效的。问题在于万一子弹短信火了,set 中的 userId 会原来越多,也就遇到了大 key 的问题,需要将 set 拆分为多个 set。

拆分的思路和 Redis 集群分片类似,通过 hash(userId) % count 的方式,得到 0 ~ count 之间的分片数,将其加到原本的 key 上,过程如下:

  1. 通过 hash(userID) % count,得到分片数,如 16
  2. 原本 key 为「TRIBUTE:USER:SET:20181225」,再加上分片数即得到「TRIBUTE:USER:SET:20181225:16」,再将 userId sadd 放入即可
  3. 取出所有 key 时 for 循环从 0-count 拼到 key 上,再针对每个 key sscan。

使用 Hash

例如保存一篇文章的点赞数、转发数、评论数时,既可以保存为 3 个 value,即 article:like、article:repost、article:comment。也可以保存为一个 hash 对象,key 为 article,hashKey 为 like、repost、comment。

好处:

  1. 通过一条 hgetall 就能取得所需数据
  2. 节约内存。

使用 value :

# lua
for i=1,1000000,1 do
    redis.call("set","article:like:"..i,1)
    redis.call("set","article:repost:"..i,1)
    redis.call("set","article:comment:"..i,1)
end

# memory
used_memory:226568704
used_memory_human:216.07M
used_memory_rss:282144768
used_memory_rss_human:269.07M

使用 hash :

# lua
for i=1,1000000,1 do
    redis.call("HMSET","article:"..i, "like", 1, "repost", 1, "comment", 1)
end

# memory
used_memory:121402896
used_memory_human:115.78M
used_memory_rss:132640768
used_memory_rss_human:126.50M

value 几乎多使用了一倍内存。原因是 hash 类型这时会选择 ziplist 数据结构实现。