Hi,大家好,我是编程小6,很荣幸遇见你,我把这些年在开发过程中遇到的问题或想法写出来,今天说一说子弹短信app_redis获取key,希望能够帮助你!!!。
Redis 是一个内存型「数据库」,除存储之外,它还有许多强大的命令,使之远远超出了数据库的定义,所以官方称之为「data structure store」,数据结构存储系统。 通过 Redis 提供的指令,我们可以实现缓存、消息队列、事件通知、排行榜、库存管理、分布式锁等功能。
Redis 核心是单进程单线程服务,通过 epoll、select 等实现了 IO 多路复用,可以并发处理网络事件。
Redis 提供了以下几种典型的数据结构
Redis 实现了名为 SDS(Simple Dynamic String) 的字符串类型,与 C 字符串区别:
Redis 实现了双向无环链表,并使用此数据结构实现了 list。
Redis 实现了符合自身使用场景的 HashMap,即数组加链表的实现。此数据结构实现了 Redis 中的 Hash、Set 数据类型。特点如下:
跳跃表通过给链表分层,实现了平均 O(logN),最坏 O(N) 的时间复杂度。Redis 使用该数据结构实现了 Sorted Set 数据类型。另外 Sorted Set 中还需要使用 HashTable 来实现 O(1) 的查询。
整数集合,即只保存整数的集合。Redis 使用该数据结构实现了 Set。
压缩列表。压缩列表是一种牺牲性能节约空间的数据结构,相比链表,它节约了指针的空间,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 有两种备份数据的方式:
即 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 文件,重新将内存中的数据作为命令写入文件中。
即 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。
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
为实现高可用,只有复制是不够的,还需要主节点服务不可用后,从节点能自动补位。 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-enabled yes
cluster-config-file nodes.conf
cluster-node-timeout 1500
集群模式下,会创建出 16384 个槽,并给集群中每个节点分配自己的槽数,槽必须被全部指定才能工作,一个节点最低指定一个槽。所以 Redis 集群理论上最大是 16384 个节点。
当需要添加/获取某个 key 时,通过 crc16(key) & 16384 得到这个 key 应在的槽,随后找出这个槽所在的节点,如果节点是自己直接执行,否则会返回给客户端对应的节点的 ip + port。
Redis 集群是去中心化的,彼此之间状态同步靠 gossip 协议通信,集群的消息有以下几种类型:
由于去中心化和通信机制,Redis Cluster 选择了最终一致性和基本可用。例如当加入新节点时(meet),只有邀请节点和被邀请节点知道这件事,其余节点要等待 ping 消息一层一层扩散。除了 Fail 是立即全网通知的,其他诸如新节点、节点重上线、从节点选举成为主节点、槽变化等,都需要等待被通知到。
因此,由于 gossip 协议,Redis Cluster 对服务器时间的要求较高,否则时间戳不准确会影响节点判断消息的有效性。另外节点数量增多后的网络开销也会对服务器产生压力。因此官方推荐最大节点数为 1000。对于 Redis 集群的运维,可以参考 优酷蓝鲸近千节点的 Redis 集群运维经验总结。
优点:
缺点:
Redis 通过 Cluster 解决了扩容之后,客户端该怎么使用呢? 如 JedisCluster,每次请求前会拉取节点的 cluster info 来计算应该到哪个节点请求,并需要对错误节点返回的 ASK 消息做相应的处理。由此产生的问题是
对问题 1,解决办法是客户端缓存集群状态。对问题 2,JedisCluster 支持配置多个节点,拉取节点信息时会随机选择某节点以分摊压力。对问题 2 的处理方式,需要将 Redis 节点信息同步到客户端配置中,产生了耦合。
另外的问题是,集群状态下是不支持 mget、mset 等需要跨节点执行的命令。该问题的解决方案是加一层 Proxy,推荐 优酷土豆的Redis服务平台化之路,其使用 Nginx + Redis Cluster 的思路令人赞叹,并用请求聚合的方式实现了跨节点执行命令的问题。
阿里云提供的 Redis 服务同样实现了集群模式下的跨节点命令,采用代理 + 分片服务器 + 分片配置服务器(很可能是 zookeeper),但是没有使用 Redis Cluster 机制,而是自己实现的「分片」,保留了 slot。阿里云的 Redis 好处是集群版无需客户端做兼容,可以当成单机 Redis 使用,出了问题方便甩锅。
在等待 Redis 出官方集群方案之前,人们迫不及待想要集群版的 Redis,一些不满于现状以及不满于 Redis Cluster 实现的人们开始对 Redis 进行改造。前面提到的阿里云 Redis 也属于魔改后的 Redis。
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 非常相似,都是用 LevelDB 底层,重新实现了 Redis,或者说只实现了 Redis 协议。通过多线程 + 硬盘的方式,实现了和单机 Redis 相似的 QPS 性能,并可以很大程度上对容量进行扩容。 LedisDB/SSDB 与 Redis 的关系,相当于 TiDB 与 MySQL 的关系。
缺点是出了容量上的成本优势,其他没有任何优势。
Redis 提供 watch
、multi
、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 中的使用场景不多,并发量大的情况下需要反复重试,大部分情况下有更好的使用方式:
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 上。
Redis 支持使用管道批量执行命令,再统一返回,减少往返次数,通常用于批量插入数据,批量获取数据。
缓存是 Redis 最常见的场景。通常缓存的过程为:
不推荐更新数据时同时更新到缓存,因为可能并发更新导致脏数据。见 为什么 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 有序列表,比如要计算用户积分排行榜:
利用 list 的 lpush
(Left Push) 和 brpop
(Blocked Right Pop) 接口可以实现消息队列功能:
lpush
到队列中。brpop
监听队列并取出数据进行消费。在消费的过程中可以通过配置线程池,根据业务情况决定消费速率。
比如秒杀、抢红包时,库存数据需要异步入库。但仅仅异步入库也是不够的,并不会减少对数据库操作的次数。这时候可能需要将 100 次请求压缩成一次请求,只取最后的数据落库。
此类需求则可以用 zset + list 实现,我们需要几个东西:
这也是红包中的异步更新的实现方式。在抢群红包时,如果每次都更新数据库中的数据,势必会增加响应时间。使用这种更新方式的话,只在最后一次抢红包的 30s 后更新,30s 之内发生的数据更新,都只会合并为 1 条。
Redis 提供了publish
、subscribe
等命令实现了广播功能,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 中放入过多数据。
bgrewriteaof
、bgsave
,重写 aof 文件及备份 RDB 文件时,会 fork 出子进程和内存,此期间是阻塞的,取决于 Redis 内存大小和机器性能。所以许多企业的做法是主节点上关闭 aof 和 rdb,只在从节点上备份。
积分系统中存在一个进贡的任务,邀请人可获得被邀请人做任务的奖励,并在每天凌晨入账。
对于这个任务,我们做的第一步优化就是每天将获得了进贡奖励的用户,保存在 set 里,通过 sscan 遍历需要进贡的用户,执行任务。以此避免了扫库,保证每次取得的 userId 都是确切有效的。问题在于万一子弹短信火了,set 中的 userId 会原来越多,也就遇到了大 key 的问题,需要将 set 拆分为多个 set。
拆分的思路和 Redis 集群分片类似,通过 hash(userId) % count 的方式,得到 0 ~ count 之间的分片数,将其加到原本的 key 上,过程如下:
例如保存一篇文章的点赞数、转发数、评论数时,既可以保存为 3 个 value,即 article:like、article:repost、article:comment。也可以保存为一个 hash 对象,key 为 article,hashKey 为 like、repost、comment。
好处:
使用 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 数据结构实现。