Redis

基本操作

Redis 的各种数据类型的常规基本操作

键操作(key)

键操作

Redis 里面不管是哪种数据类型,他都是一个个的键名,键名是不允许重复的。

部分命令

  • del key :在 key 存在时删除 key

  • exists key :检查给定 key 是否存在

  • expire key seconds :给 key 设置过期时间,秒

  • expireat key timestamp :给 key 设置过期时间,时间戳

  • pexpire key milliseconds :给 kye 设置过期时间,毫秒

  • pexpireat key milliseconds-timestamp :给 key 设置过期时间,时间戳,毫秒

  • persist key :移除 key 的过期时间,key 永久保存

  • pttl key :返回 key 的剩余时间,毫秒

  • ttl key :返回 key 的剩余时间,秒

  • randomkey :从当前数据库中随机返回一个 key

  • rename key newkey :修改 key 的名称

  • renamenx key newkey :仅当 newkey 不存在时,将 key 改名为 newkey

  • type key :返回 key 所存储的值的类型

表格

命令 说明
del key 在 key 存在时删除 key
exists key 检查给定 key 是否存在
expire key seconds 给 key 设置过期时间,秒
expireat key timestamp 给 key 设置过期时间,时间戳
pexpire key milliseconds 给 kye 设置过期时间,毫秒
pexpireat key milliseconds-timestamp 给 key 设置过期时间,时间戳,毫秒
persist key 移除 key 的过期时间,key 永久保存
pttl key 返回 key 的剩余时间,毫秒
ttl key 返回 key 的剩余时间,秒
randomkey 从当前数据库中随机返回一个 key
rename key newkey 修改 key 的名称
renamenx key newkey 仅当 newkey 不存在时,将 key 改名为 newkey
type key 返回 key 所存储的值的类型

附录

更多Redis命令

字符串(string)

字符串

最基本的数据类型,使用场景很广,大部分都是用的这个,缓存数据

部分命令

  • set key value :设置指定 key 的值

  • setex key seconds value :设置指定 key 的值,并设置 key 的过期时间,秒

  • psetex key milliseconds value :设置指定 key 的值,并设置 key 的过期时间,毫秒

  • setnx key value :只有在 key 不存在时设置 key 的值

  • mset key value [key value] :同时设置一个或多个键值对

  • msetnx key value [key value ...] :同时设置一个或多个 key-value 对,当且仅当所有给定 key 都不存在

  • get key :获取指定 key 的值

  • mget key1 [key2] :获取多个 key 的值

  • incr key :将指定 key 中存储的数字自增 1

  • incr key increment :将指定 key 中存储的数字自增给定值(increment)

  • incrbyfloat key increment :将 key 所储存的值加上给定的浮点增量值(increment)

  • decr key :将指定 key 中存储的数字自减 1

  • decrby key decrement :将指定 key 中存储的数字减去给定值(decrement)

  • getrange key start end :返回 key 中字符串截取的子字符

  • getset key value :将 key 的值设置为 value,返回 key 的旧值

  • strlen key :返回 key 所存储的字符串的长度

表格

命令 说明
set key value 设置指定 key 的值
setex key seconds value 设置指定 key 的值,并设置 key 的过期时间,秒
psetex key milliseconds value 设置指定 key 的值,并设置 key 的过期时间,毫秒
setnx key value 只有在 key 不存在时设置 key 的值
mset key value [key value] 同时设置一个或多个键值对
msetnx key value [key value ...] 同时设置一个或多个 key-value 对,当且仅当所有给定 key 都不存在
get key 获取指定 key 的值
mget key1 [key2] 获取多个 key 的值
incr key 将指定 key 中存储的数字自增 1
incr key increment 将指定 key 中存储的数字自增给定值(increment)
incrbyfloat key increment 将 key 所储存的值加上给定的浮点增量值(increment)
decr key 将指定 key 中存储的数字自减 1
decrby key decrement 将指定 key 中存储的数字减去给定值(decrement)
getrange key start end 返回 key 中字符串截取的子字符
getset key value 将 key 的值设置为 value,返回 key 的旧值
strlen key 返回 key 所存储的字符串的长度

附录

更多Redis命令

哈希(hash)

哈希

hash 主要用来干嘛

部分命令

  • hset key field value :将哈希表 key 中的字段 field 的值设置为 value

  • hsetnx key field value :只有在字段 field 不存在时,设置哈希表字段的值

  • hget key field :获取存储在哈希表中指定字段的值

  • hmget key field1 [field2] :获取所有给定字段的值

  • hmset key field1 value1 [field2 value2] :同时将多个 field-value (域-值)对设置到哈希表 key 中

  • hgetall key :获取在哈希表中指定 key 的所有字段和值

  • hkeys key :获取哈希表中的所有 key

  • hvals key :获取哈希表中所有的值

  • hexists key field :查看哈希表 key 中,指定的字段是否存在

  • hdel key field1 [field2] :删除一个或多个哈希表字段

  • hlen key :获取哈希表中字段的数量

  • hincrby key field increment :为哈希表 key 中的指定字段的整数值加上增量 increment

  • hincrbyfloat key field increment :为哈希表 key 中的指定字段的浮点数值加上增量 increment

表格

命令 说明
hset key field value 将哈希表 key 中的字段 field 的值设置为 value
hsetnx key field value 只有在字段 field 不存在时,设置哈希表字段的值
hget key field 获取存储在哈希表中指定字段的值
hmget key field1 [field2] 获取所有给定字段的值
hmset key field1 value1 [field2 value2] 同时将多个 field-value (域-值)对设置到哈希表 key 中
hgetall key 获取在哈希表中指定 key 的所有字段和值
hkeys key 获取哈希表中的所有 key
hvals key 获取哈希表中所有的值
hexists key field 查看哈希表 key 中,指定的字段是否存在
hdel key field1 [field2] 删除一个或多个哈希表字段
hlen key 获取哈希表中字段的数量
hincrby key field increment 为哈希表 key 中的指定字段的整数值加上增量 increment
hincrbyfloat key field increment 为哈希表 key 中的指定字段的浮点数值加上增量 increment

附录

Redis所有命令

列表(List)

列表

list 主要是用来干嘛的

列表内元素可重复

部分命令

  • lpush key value1 [value2] :将一个或多个值插入到列表头部

  • lpushx key value :将一个值插入到已存在的列表头部,返回列表长度

  • rpush key value :将一个或多个值插入到列表尾部,返回列表长度

  • rpushx key value :将一个或多个值插入到已存在列表尾部,返回列表长度

  • lset key index value :通过索引设置列表元素的值,索引从 0 开始

  • llen key :返回当前列表长度,无则返回 0

  • lrange key start end :返回队列中一个区间的元素,(key 0 -1)表示返回所有元素

  • lindex key index :通过索引获取列表中的元素

  • lpop key :移出并获取列表的第一个元素

  • rpop key :移出并获取列表的最后一个元素

  • blpop key1 timeout :移出并获取列表的第一个元素, 如果列表没有元素会阻塞列表直到等待超时或发现可弹出元素为止

  • brpop key1 timeout :移出并获取列表的最后一个元素, 如果列表没有元素会阻塞列表直到等待超时或发现可弹出元素为止

  • rpoplpush :从一个队列中 pop 出元素并 push 到另一个队列,也可以是同一个队列

  • lrem key count value :删除队列中左起指定数量的字符,$data = Redis::lrem($list, 1, 'yuanyuan');// 删除队列中左起(右起 count < 0) 1个值为(count = 0 移除表中所有与 VALUE 相等的值) 'yuanyuan'的元素(若有)

  • ltrim key start end :保留左边起若干元素,其余删除,$data = Redis::ltrim($list, 1, 6);// 保留左边起第 1 个至第 6 个元素

  • linsert key before|after pivot value :在列表的元素前或者后插入元素,$data = Redis::linsert('list2', 'before', 'ab1', '123');//表示在元素 'ab1' 之前插入 '123'

表格

命令 说明
lpush key value1 [value2] 将一个或多个值插入到列表头部
lpushx key value 将一个值插入到已存在的列表头部,返回列表长度
rpush key value 将一个或多个值插入到列表尾部,返回列表长度
rpushx key value 将一个或多个值插入到已存在列表尾部,返回列表长度
lset key index value 通过索引设置列表元素的值,索引从 0 开始
llen key 返回当前列表长度,无则返回 0
lrange key start end 返回队列中一个区间的元素,(key 0 -1)表示返回所有元素
lindex key index 通过索引获取列表中的元素
lpop key 移出并获取列表的第一个元素
rpop key 移出并获取列表的最后一个元素
blpop key1 timeout 移出并获取列表的第一个元素, 如果列表没有元素会阻塞列表直到等待超时或发现可弹出元素为止
brpop key1 timeout 移出并获取列表的最后一个元素, 如果列表没有元素会阻塞列表直到等待超时或发现可弹出元素为止
rpoplpush 从一个队列中 pop 出元素并 push 到另一个队列,也可以是同一个队列
lrem key count value 删除队列中左起指定数量的字符,$data = Redis::lrem($list, 1, 'yuanyuan');// 删除队列中左起(右起 count < 0) 1个值为(count = 0 移除表中所有与 VALUE 相等的值) 'yuanyuan'的元素(若有)
ltrim key start end 保留左边起若干元素,其余删除,$data = Redis::ltrim($list, 1, 6);// 保留左边起第 1 个至第 6 个元素
linsert key (before、after) pivot value 在列表的元素前或者后插入元素,$data = Redis::linsert('list2', 'before', 'ab1', '123');//表示在元素 'ab1' 之前插入 '123'

部分代码展示

// 从一个队列中 pop 出元素并 push 到另一个队列

//	Redis::rpush('list1','ab0');
//	Redis::rpush('list1','ab1');
//	Redis::rpush('list2','ab2');
//	Redis::rpush('list2','ab3');
      // 返回的是 list1 pop 出来的数据
//	$data = Redis::rpoplpush('list1','list2');// 结果list1 =>array('ab0'), list2 =>array('ab1','ab2','ab3')
//	$data = Redis::lrange('list1', 0, -1);
      // 返回的是 list2 pop 出来的数据
//	$data = Redis::rpoplpush('list2','list2');// 也适用于同一个队列, 把最后一个元素移到头部 list2 =>array('ab3','ab1','ab2')
//	$data = Redis::lrange('list2', 0, -1);

附录

更多Redis命令

有序集合(Sset)

有序集合

有序集合能干嘛?

成员唯一,不允许重复

通过一个分数来排序的,分数可以重复

部分命令

  • zadd key score1 member1 [score2 member2] :向有序集合添加一个或多个成员,或者更新已存在成员的分数

  • zcard key :获取有序集合的成员数

  • zcount key min max :计算在有序集合中指定区间分数的成员数

  • zincrby key increment member :有序集合中对指定成员的分数加上增量 increment

  • zinterstore destination numkeys key [key ...] :计算给定的一个或多个有序集的交集并将结果集存储在新的有序集合 key 中

  • zlexcount key min max :在有序集合中计算指定字典区间内成员数量

  • ZRANGE key start stop [WITHSCORES] :通过索引区间返回有序集合指定区间内的成员

  • ZRANGEBYLEX key min max [LIMIT offset count] :通过字典区间返回有序集合的成员

  • ZRANGEBYSCORE key min max [WITHSCORES] [LIMIT] :通过分数返回有序集合指定区间内的成员

  • ZRANK key member :返回有序集合中指定成员的索引

  • ZREM key member [member ...] :移除有序集合中的一个或多个成员

  • ZREMRANGEBYLEX key min max :移除有序集合中给定的字典区间的所有成员

  • ZREMRANGEBYRANK key start stop :移除有序集合中给定的排名区间的所有成员

  • ZREMRANGEBYSCORE key min max :移除有序集合中给定的分数区间的所有成员

  • ZREVRANGE key start stop [WITHSCORES] :返回有序集中指定区间内的成员,通过索引,分数从高到低

  • ZREVRANGEBYSCORE key max min [WITHSCORES] :返回有序集中指定分数区间内的成员,分数从高到低排序

  • ZREVRANK key member :返回有序集合中指定成员的排名,有序集成员按分数值递减(从大到小)排序

  • ZSCORE key member :返回有序集中,成员的分数值

  • ZUNIONSTORE destination numkeys key [key ...] :计算给定的一个或多个有序集的并集,并存储在新的 key 中

  • ZSCAN key cursor [MATCH pattern] [COUNT count] :迭代有序集合中的元素(包括元素成员和元素分值)

表格

命令 说明
zadd key score1 member1 [score2 member2] 向有序集合添加一个或多个成员,或者更新已存在成员的分数
zcard key 获取有序集合的成员数
zcount key min max 计算在有序集合中指定区间分数的成员数
zincrby key increment member 有序集合中对指定成员的分数加上增量 increment
zinterstore destination numkeys key [key ...] 计算给定的一个或多个有序集的交集并将结果集存储在新的有序集合 key 中
zlexcount key min max 在有序集合中计算指定字典区间内成员数量
ZRANGE key start stop [WITHSCORES] 通过索引区间返回有序集合指定区间内的成员
ZRANGEBYLEX key min max [LIMIT offset count] 通过字典区间返回有序集合的成员
ZRANGEBYSCORE key min max [WITHSCORES] [LIMIT] 通过分数返回有序集合指定区间内的成员
ZRANK key member 返回有序集合中指定成员的索引
ZREM key member [member ...] 移除有序集合中的一个或多个成员
ZREMRANGEBYLEX key min max 移除有序集合中给定的字典区间的所有成员
ZREMRANGEBYRANK key start stop 移除有序集合中给定的排名区间的所有成员
ZREMRANGEBYSCORE key min max 移除有序集合中给定的分数区间的所有成员
ZREVRANGE key start stop [WITHSCORES] 返回有序集中指定区间内的成员,通过索引,分数从高到低
ZREVRANGEBYSCORE key max min [WITHSCORES] 返回有序集中指定分数区间内的成员,分数从高到低排序
ZREVRANK key member 返回有序集合中指定成员的排名,有序集成员按分数值递减(从大到小)排序
ZSCORE key member 返回有序集中,成员的分数值
ZUNIONSTORE destination numkeys key [key ...] 计算给定的一个或多个有序集的并集,并存储在新的 key 中
ZSCAN key cursor [MATCH pattern] [COUNT count] 迭代有序集合中的元素(包括元素成员和元素分值)

附录

更多Redis命令

集合(Set)

集合

set 无序集合可以用来干嘛?

集合成员是唯一的,不能重复

部分命令

  • sadd key member1 [member2] :向集合中添加一个或多个成员

  • srem key member1 [member2] :移除集合中的一个或多个成员

  • scard key :获取集合的成员个数

  • sismember key member :判断 member 元素是否是集合 key 的成员

  • smembers key :返回集合中的所有成员

  • spop key :移除并返回集合中的一个随机元素

  • smove source destination member :将 member 元素从 source 集合移动到 destination 集合

  • srandmember key [count] :返回集合中一个或多个随机数

  • sdiff key1 [key2] :返回集合 1 和集合 2 的差集

  • sinter key1 [key2] :返回集合 1 和集合 2 的交集

  • sunion key1 [key2] :返回集合 1 和集合 2 的并集

  • sdiffstore destination key1 [key2] :返回给定集合的差集并存储在 destination 集合中

  • SINTERSTORE destination key1 [key2] :返回给定集合的交集并存储在 destination 集合中

  • SUNIONSTORE destination key1 [key2] :返回给定集合的并集并存储在 destination 集合中

表格

命令 说明
sadd key member1 [member2] 向集合中添加一个或多个成员
srem key member1 [member2] 移除集合中的一个或多个成员
scard key 获取集合的成员个数
sismember key member 判断 member 元素是否是集合 key 的成员
smembers key 返回集合中的所有成员
spop key 移除并返回集合中的一个随机元素
smove source destination member 将 member 元素从 source 集合移动到 destination 集合
srandmember key [count] 返回集合中一个或多个随机数
sdiff key1 [key2] 返回集合 1 和集合 2 的差集
sinter key1 [key2] 返回集合 1 和集合 2 的交集
sunion key1 [key2] 返回集合 1 和集合 2 的并集
sdiffstore destination key1 [key2] 返回给定集合的差集并存储在 destination 集合中
SINTERSTORE destination key1 [key2] 返回给定集合的交集并存储在 destination 集合中
SUNIONSTORE destination key1 [key2] 返回给定集合的并集并存储在 destination 集合中

附录

更多Redis命令

主要使用场景

Redis 能干嘛

  • 记录帖子的点赞数、评论数和点击数 (hash)。
  • 记录用户的帖子 ID 列表 (排序),便于快速显示用户的帖子列表 (zset)。
  • 记录帖子的标题、摘要、作者和封面信息,用于列表页展示 (hash)。
  • 记录帖子的点赞用户 ID 列表,评论 ID 列表,用于显示和去重计数 (zset)。
  • 缓存近期热帖内容 (帖子内容空间占用比较大),减少数据库压力 (hash)。
  • 记录帖子的相关文章 ID,根据内容推荐相关帖子 (list)。
  • 如果帖子 ID 是整数自增的,可以使用 Redis 来分配帖子 ID(计数器)。
  • 收藏集和帖子之间的关系 (zset)。
  • 记录热榜帖子 ID 列表,总热榜和分类热榜 (zset)。
  • 缓存用户行为历史,进行恶意行为过滤 (zset,hash)。

用在哪些地方

string

  • 缓存数据

限流、计数器、分布式锁、session共享

限流、降级、熔断、隔离

hash

可以用来存用户信息,一个用户就只有一个键,但是用户有多个属性 用户主页访问量 组合查询等

list

基本用来做队列处理

  • lpush + lpop = Stack(栈)

  • lpush + rpop = Queue(队列)

  • lpush + ltrim = Capped Collection(有限集合)

  • lpush + brpop = Message Queue(消息队列)

set

赞,踩,标签,好友关系

Sset

有序集合有分数概念,所以可以用来做排行榜

雪崩、击穿、穿透

雪崩

简单讲:就是 Redis 挂了或者大量的键失效(击穿是一个热点 key 失效),请求都打到数据库了,所以数据库叫一下就死了

Redis服务器挂了,所有流量全部打到数据库,数据库肯定挂了,如果这个时候,重启数据库,那么数据库只能又被新流量给打死

缓存雪崩的事前事中事后的解决方案如下。

  • 事前:redis 高可用,主从+哨兵,redis cluster,避免全盘崩溃。

  • 事中:本地 ehcache 缓存 + hystrix 限流&降级,避免 MySQL 被打死。

  • 事后:redis 持久化,一旦重启,自动从磁盘上加载数据,快速恢复缓存数据。

限流,限制每秒只能有多少个请求通过,其余的全部降级,返回默认值,友情提示重试,或者空白值

限流可以保证数据库不会挂,数据库不挂,那么对用户来说,至少有一部分用户的请求是可以被处理的,系统不挂,对用户来说,就是多刷新几次就可能刷新出来一次

击穿

缓存击穿,就是说某个 key 非常热点,访问非常频繁,处于集中式高并发访问的情况,当这个 key 在失效的瞬间,大量的请求就击穿了缓存,直接请求数据库,就像是在一道屏障上凿开了一个洞。

解决方式也很简单,可以将热点数据设置为永远不过期;或者基于 redis or zookeeper 实现互斥锁,等待第一个请求构建完缓存之后,再释放锁,进而其它请求才能通过该 key 访问数据。

穿透

key 对应的数据在数据源并不存在,每次针对此 key 的请求从缓存获取不到,请求都会到数据源,从而可能压垮数据源。比如用一个不存在的用户id获取用户信息,缓存数据库 都没有,若黑客利用此漏洞进行攻击可能压垮数据库。

解决方案一:将击透的 key 也缓存起来,但是时间不能太长,缺点就是无法过滤动态的 key,每次进来都是不同的 kye 那其实作用就很有限了

解决方案二:使用布隆过滤器: 热点数据等场景(具体看使用场景)

布隆过滤器是什么?

布隆过滤器可以理解为一个不怎么精确的 set 结构,当你使用它的 contains 方法判断某个对象是否存在时,它可能会误判。但是布隆过滤器也不是特别不精确,只要参数设置的合理,它的精确度可以控制的相对足够精确,只会有小小的误判概率。

当布隆过滤器说某个值存在时,这个值可能不存在;当它说不存在时,那就肯定不存在

Redis持久化

Redis开启持久化

Redis支持 RDBAOF 两种方案来实现持久化,默认会开启 RDB

  • RDB 方式的持久化几乎不损耗 Redis 本身的性能,在进行 RDB 持久化时,Redis 主进程唯一需要做的事情就是 fork 出一个子进程,所有持久化工作都由子进程完成

RDB方式

采用 RDB 持久方式,Redis 会定期保存数据快照至一个 rbd 文件中,并在启动时自动加载 rdb 文件,恢复之前保存的数据。

默认的配置为:

save 900 1
save 300 10
save 60 10000

意思为: 900秒检查一次,如果发生了1条以上的数据变更,则快照保存 300秒检查一次,如果发生了10条以上的数据变更,则快照保存 60秒检查一次,如果发生了10000条以上的数据变更,则快照保存

注:yum安装默认配置文件在 /etc/redis.conf,如果是自己编译安装的,自己找去吧

AOF

采用 AOF 持久方式时,Redis 会把每一个写请求都记录在一个日志文件里。

在 Redis 重启时,会把 AOF 文件中记录的所有写操作顺序执行一遍,确保数据恢复到最新。缺点和优点这一看就很明确了吧!

AOF 默认是关闭的,如要开启,进行如下配置: appendonly yes

Redis 为此提供了三种 fsync (把文件在内存中的部分写回磁盘) 配置

  • appendfsync no:不进行 fsync,将 flush 文件的时机交给 OS 决定,速度最快
  • appendfsync always:每写入一条日志就进行一次 fsync 操作,数据安全性最高,但速度最慢
  • appendfsync everysec:折中的做法,交由后台线程每秒 fsync 一次

这样后期文件会越来越大,恢复时间也越来越长,咋办勒。

AOF rewrite功能

可以重写AOF文件,只保留能够把数据恢复到最新状态的最小写操作集。

auto-aof-rewrite-percentage 100
auto-aof-rewrite-min-size 64mb

Redis 在每次AOF rewrite 时,会记录完成 rewrite 后的 AOF 日志大小,当 AOF 日志大小在该基础上增长了 100% 后,自动进行 AOF rewrite,同时如果增长的大小没有达到 64mb,则不会进行 rewrite。

内存管理与数据淘汰机制

内存管理与数据淘汰机制

默认情况下,在32位OS中,Redis最大使用3GB的内存,在64位OS中则没有限制。

所以最好给设置一个最大可使用内存: maxmemory 100mb

在内存占用达到了 maxmemory 后,再向Redis写入数据时,Redis会:

  • 根据配置的数据淘汰策略尝试淘汰数据,释放空间

  • 如果没有数据可以淘汰,或者没有配置数据淘汰策略,那么Redis会对所有写请求返回错误,但读请求仍然可以正常执行

在为Redis设置 maxmemory 时,需要注意一下几点:

  • 如果采用了Redis的主从同步,主节点向从节点同步数据时,会占用掉一部分内存空间

  • 如果maxmemory过于接近主机的可用内存,会导致数据同步时内存不足。

  • 所以设置的maxmemory不要过于接近主机可用的内存,留出一部分预留用作主从同步。

内存管理

当 Redis 内存超出物理内存限制时,内存的数据会开始和磁盘产生频繁的交换 (swap)。交换会让 Redis 的性能急剧下降,对于访问量比较频繁的 Redis 来说,这样龟速的存取效率基本上等于不可用。 在生产环境中我们是不允许 Redis 出现交换行为的,为了限制最大使用内存,Redis 提供了配置参数 maxmemory 来限制内存超出期望大小。 当实际内存超出 maxmemory 时,Redis 提供了几种可选策略 (maxmemory-policy) 来让用户自己决定该如何腾出新的空间以继续提供读写服务。

  • noeviction 不会继续服务写请求 (DEL 请求可以继续服务),读请求可以继续进行。这样可以保证不会丢失数据,但是会让线上的业务不能持续进行。这是默认的淘汰策略。

  • volatile-lru 尝试淘汰设置了过期时间的 key,最少使用的 key 优先被淘汰。没有设置过期时间的 key 不会被淘汰,这样可以保证需要持久化的数据不会突然丢失。

  • volatile-ttl 跟上面一样,除了淘汰的策略不是 LRU,而是 key 的剩余寿命 ttl 的值,ttl 越小越优先被淘汰。

  • volatile-random 跟上面一样,不过淘汰的 key 是过期 key 集合中随机的 key。

  • allkeys-lru 区别于 volatile-lru,这个策略要淘汰的 key 对象是全体的 key 集合,而不只是过期的 key 集合。这意味着没有设置过期时间的 key 也会被淘汰。

  • allkeys-random 跟上面一样,不过淘汰的策略是随机的 key。

  • volatile-xxx 策略只会针对带过期时间的 key 进行淘汰,allkeys-xxx 策略会对所有的 key 进行淘汰。如果你只是拿 Redis 做缓存,那应该使用 allkeys-xxx,客户端写缓存时不必携带过期时间。如果你还想同时使用 Redis 的持久化功能,那就使用 volatile-xxx 策略,这样可以保留没有设置过期时间的 key,它们是永久的 key 不会被 LRU 算法淘汰。

数据淘汰机制

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

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

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

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

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

配置: maxmemory-policy volatile-lru 默认注释且值为:noeviction,即不进行数据淘汰

Redis集群

主从模式

哨兵模式

Cluster模式

主从模式和哨兵模式暂不介绍,主要介绍 cluster 模式

前置准备工作

拉取镜像

docker pull redis:7

创建网络

docker network create -d bridge my-net

查看创建得网络列表 docker network ls

查看网络详细信息 docker network inspect my-net

准备 redis.conf 文件

一个容器准备一个配置文件,这里用脚本创建,直接运行如下命令即可

for port in $(seq 6380 6385);
do
mkdir -p /home/shuxiaoyuan/docker/redis/node-${port}/conf
touch /home/shuxiaoyuan/docker/redis/node-${port}/conf/redis.conf
cat  << EOF > /home/shuxiaoyuan/docker/redis/node-${port}/conf/redis.conf
# 节点端口
port ${port}

# 密码
requirepass 123456

# 保护模式,默认值 yes,即开启。开启保护模式以后,需配置 bind ip 或者设置访问密码;关闭保护模式,外部网络可以直接访问;
protected-mode no

# 是否以守护线程的方式启动(后台启动),默认 no;
daemonize no

# 是否开启 AOF 持久化模式,默认 no;
appendonly yes

# 是否开启集群模式,默认 no;
cluster-enabled yes

# 集群节点信息文件,自动在/data目录中生成
cluster-config-file nodes.conf

# 集群节点连接超时时间;
cluster-node-timeout 5000
EOF
done

批量重置脚本

当发生错误的时候,批量停止、删除容器并清理相关文件,方便重来

停止容器,删除容器,删除容器产生的数据文件

for port in $(seq 6380 6385);
do 
docker stop redis-${port}
docker rm redis-${port}
sudo rm -rf /home/shuxiaoyuan/docker/redis/node-${port}/data
done

采用 docker compose

# 定义服务,可以多个
services:
  
  # 服务名称
  redis-6380:
    # 采用 image 指定镜像
    image: "redis:7"
    
    # 指定容器名称。默认将会使用 项目名称_服务名称_序号 这样的格式。
    container_name: redis-6380
    
    # 指定容器退出后的重启策略为始终重启。
    # 该命令对保持服务始终运行十分有效,在生产环境中推荐配置为 always 或者 unless-stopped
    restart: always

    # 配置日志
    logging:
      # 日志驱动类型:json-file、syslog、none
      driver: "json-file"
      # 其他选项
      options:
        max-size: "1g"
    
    # 配置容器连接的网络
    network_mode: "my-net"

    # 暴露端口信息
    ports:
      - "6380:6380"
    
    # 数据卷所挂载路径设置
    volumes:
      - /home/shuxiaoyuan/docker/redis/node-6380/conf/redis.conf:/usr/local/etc/redis/redis.conf
      - /home/shuxiaoyuan/docker/redis/node-6380/data:/data

    # 覆盖容器启动后默认执行的命令。
    command: ["redis-server", "/usr/local/etc/redis/redis.conf"]

  # 服务名称
  redis-6381:
    # 采用 image 指定镜像
    image: "redis:7"
    
    # 指定容器名称。默认将会使用 项目名称_服务名称_序号 这样的格式。
    container_name: redis-6381
    
    # 指定容器退出后的重启策略为始终重启。
    # 该命令对保持服务始终运行十分有效,在生产环境中推荐配置为 always 或者 unless-stopped
    restart: always

    # 配置日志
    logging:
      # 日志驱动类型:json-file、syslog、none
      driver: "json-file"
      # 其他选项
      options:
        max-size: "1g"
    
    # 配置容器连接的网络
    network_mode: "my-net"

    # 暴露端口信息,使用 host 模式后,这个不需要
    ports:
      - "6381:6381"
    
    # 数据卷所挂载路径设置
    volumes:
      - /home/shuxiaoyuan/docker/redis/node-6381/conf/redis.conf:/usr/local/etc/redis/redis.conf
      - /home/shuxiaoyuan/docker/redis/node-6381/data:/data

    # 覆盖容器启动后默认执行的命令。
    command: ["redis-server", "/usr/local/etc/redis/redis.conf"]

  # 服务名称
  redis-6382:
    # 采用 image 指定镜像
    image: "redis:7"
    
    # 指定容器名称。默认将会使用 项目名称_服务名称_序号 这样的格式。
    container_name: redis-6382
    
    # 指定容器退出后的重启策略为始终重启。
    # 该命令对保持服务始终运行十分有效,在生产环境中推荐配置为 always 或者 unless-stopped
    restart: always

    # 配置日志
    logging:
      # 日志驱动类型:json-file、syslog、none
      driver: "json-file"
      # 其他选项
      options:
        max-size: "1g"
    
    # 配置容器连接的网络
    network_mode: "my-net"

    # 暴露端口信息
    ports:
      - "6382:6382"
    
    # 数据卷所挂载路径设置
    volumes:
      - /home/shuxiaoyuan/docker/redis/node-6382/conf/redis.conf:/usr/local/etc/redis/redis.conf
      - /home/shuxiaoyuan/docker/redis/node-6382/data:/data

    # 覆盖容器启动后默认执行的命令。
    command: ["redis-server", "/usr/local/etc/redis/redis.conf"]

  # 服务名称
  redis-6383:
    # 采用 image 指定镜像
    image: "redis:7"
    
    # 指定容器名称。默认将会使用 项目名称_服务名称_序号 这样的格式。
    container_name: redis-6383
    
    # 指定容器退出后的重启策略为始终重启。
    # 该命令对保持服务始终运行十分有效,在生产环境中推荐配置为 always 或者 unless-stopped
    restart: always

    # 配置日志
    logging:
      # 日志驱动类型:json-file、syslog、none
      driver: "json-file"
      # 其他选项
      options:
        max-size: "1g"
    
    # 配置容器连接的网络
    network_mode: "my-net"

    # 暴露端口信息
    ports:
      - "6383:6383"
    
    # 数据卷所挂载路径设置
    volumes:
      - /home/shuxiaoyuan/docker/redis/node-6383/conf/redis.conf:/usr/local/etc/redis/redis.conf
      - /home/shuxiaoyuan/docker/redis/node-6383/data:/data

    # 覆盖容器启动后默认执行的命令。
    command: ["redis-server", "/usr/local/etc/redis/redis.conf"]

  # 服务名称
  redis-6384:
    # 采用 image 指定镜像
    image: "redis:7"
    
    # 指定容器名称。默认将会使用 项目名称_服务名称_序号 这样的格式。
    container_name: redis-6384
    
    # 指定容器退出后的重启策略为始终重启。
    # 该命令对保持服务始终运行十分有效,在生产环境中推荐配置为 always 或者 unless-stopped
    restart: always

    # 配置日志
    logging:
      # 日志驱动类型:json-file、syslog、none
      driver: "json-file"
      # 其他选项
      options:
        max-size: "1g"
    
    # 配置容器连接的网络
    network_mode: "my-net"

    # 暴露端口信息
    ports:
      - "6384:6384"
    
    # 数据卷所挂载路径设置
    volumes:
      - /home/shuxiaoyuan/docker/redis/node-6384/conf/redis.conf:/usr/local/etc/redis/redis.conf
      - /home/shuxiaoyuan/docker/redis/node-6384/data:/data

    # 覆盖容器启动后默认执行的命令。
    command: ["redis-server", "/usr/local/etc/redis/redis.conf"]

  # 服务名称
  redis-6385:
    # 采用 image 指定镜像
    image: "redis:7"
    
    # 指定容器名称。默认将会使用 项目名称_服务名称_序号 这样的格式。
    container_name: redis-6385
    
    # 指定容器退出后的重启策略为始终重启。
    # 该命令对保持服务始终运行十分有效,在生产环境中推荐配置为 always 或者 unless-stopped
    restart: always

    # 配置日志
    logging:
      # 日志驱动类型:json-file、syslog、none
      driver: "json-file"
      # 其他选项
      options:
        max-size: "1g"
    
    # 配置容器连接的网络
    network_mode: "my-net"

    # 暴露端口信息
    ports:
      - "6385:6385"
    
    # 数据卷所挂载路径设置
    volumes:
      - /home/shuxiaoyuan/docker/redis/node-6385/conf/redis.conf:/usr/local/etc/redis/redis.conf
      - /home/shuxiaoyuan/docker/redis/node-6385/data:/data

    # 覆盖容器启动后默认执行的命令。
    command: ["redis-server", "/usr/local/etc/redis/redis.conf"]

运行 docker compose

# 后台运行
docker compose up -d

# 停止但不删除
docker compose stop

# 删除
docker compose down

进入容器配置集群

docker exec -it redis-6380 bash

# cluster-replicas 1 意思是主从配置 1:1
redis-cli -a 123456 --cluster create redis-6380:6380 redis-6381:6381 redis-6382:6382 redis-6383:6383 redis-6384:6384 redis-6385:6385 --cluster-replicas 1

采用 Docker 方式

在 Windows 上采用docker方式,启动多个容器来模拟集群

脚本批量启动 Redis 容器

for port in $(seq 6380 6385); \
do \
  docker run -it -d -p ${port}:${port} -p 1${port}:1${port} \
  --restart always \
  --mount type=bind,source=/home/shuxiaoyuan/docker/redis/node-${port}/conf/redis.conf,target=/usr/local/etc/redis/redis.conf \
  --mount type=bind,source=/home/shuxiaoyuan/docker/redis/node-${port}/data,target=/data \
  --name redis-${port} --net my-net \
  redis:7 redis-server /usr/local/etc/redis/redis.conf
done

进入容器配置集群

docker exec -it redis-6380 bash

# cluster-replicas 1 意思是主从配置 1:1
redis-cli -a 123456 --cluster create redis-6380:6380 redis-6381:6381 redis-6382:6382 redis-6383:6383 redis-6384:6384 redis-6385:6385 --cluster-replicas 1

连接 Redis 查看信息

redis-cli -c -p 6380 -a 123456
cluster info
cluster nodes

测试数据

可以看到,每次 set 操作的时候,会将相应的键分配到对应的卡槽,然后自动转到相应的机器上,get 的时候,同理

Windows下安装多个 Redis 实例

GitHub 上下载 Windows 版的 Redis(3.2.100 的 zip 版),点击此处

创建多个 Redis 实例

根据你需要启动的实例个数,创建相应的 conflog 文件,文件名可以随便取,不过为了更加直观,这里统一采用带端口的文件名形式取名

conf 文件内容,根据你的端口,将配置文件中的端口号都替换掉

port 6380
loglevel notice
logfile "E:/Redis-x64-3.2.100/logs/redis6380_log.txt"
appendonly yes
appendfilename "appendonly.6380.aof"
cluster-enabled yes
cluster-config-file nodes.6380.conf
cluster-node-timeout 15000
cluster-slave-validity-factor 10
cluster-migration-barrier 1
cluster-require-full-coverage yes

启动多个 Redis 服务

安装 Redis 系统服务 redis-server.exe --service-install redis.6380.conf --service-name redis6380

查看 Redis 服务,可以在此配置手动启动或者自动启动等

连接 Redis

删除 Redis 服务

运行 regedit 打开注册表编辑器

找到:计算机\HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Services\

找到对应的 Redis 服务,右键删除即可

下载并安装Ruby

下载 Ruby :http://dl.bintray.com/oneclick/rubyinstaller/rubyinstaller-2.2.4-x64.exe

其他版本:http://dl.bintray.com/oneclick/rubyinstaller/

安装:略

安装 gem 驱动

下载 Ruby 环境下 Redis 的驱动:确保 gem 命令可用

redis-3.2.2.gem 下载地址(https://rubygems.org/downloads/redis-3.2.2.gem)

安装,下图的 --local 后面跟下载好的 gem 文件路径,自行替换即可

搭建集群

下载 Redis 官方提供的创建 Redis集群 的 ruby 脚本文件 redis-trib.rb

下载地址:https://raw.githubusercontent.com/MSOpenTech/redis/3.0/src/redis-trib.rb

如果无法下载,本文最后有附录,如果打开后是一个页面,就将页面上的内容全部不保存到本地

搭建 redis-trib.rb create --replicas 0 127.0.0.1:6380 127.0.0.1:6381 127.0.0.1:6382 127.0.0.1:6383 127.0.0.1:6384

测试

127.0.0.1:6380 中添加一个 key,刷新其他实例,可以发现,该 key 已同步过来

使用一个实例的不同数据库模拟

<?php

/**
 * 请提供简单说明
 *
 * User: 舒孝元
 * Date: 2020/8/19 15:01
 * Mail: sxy@shuxiaoyuan.com
 * Website: https://www.shuxiaoyuan.com
 */


namespace App\Common;


class RedisHa {
    protected static $name = 'redis_ha';
    protected static $connections = [];

    protected static function init() {
        $hosts = explode(',', env('REDIS_HA_HOSTS'));
        $databases = explode(',', env('REDIS_HA_DATABASES'));

        foreach ($hosts as $index => $host) {
            $redisManage = new \Illuminate\Redis\RedisManager('redis-cluster',config('database.redis.client'), [
                'default' => [
                    'host'     => $host,
                    'password' => env('REDIS_PASSWORD', null),
                    'port'     => env('REDIS_PORT', 6379),
                    'database' => $databases[$index],
                ]
            ]);

            self::$connections[$index] = $redisManage->connection();
        }
    }

    public static function count() {
        if (empty(self::$connections)) {
            self::init();
        }

        return count(self::$connections);
    }

    public static function connection($index) {
        if (empty(self::$connections)) {
            self::init();
        }

        if (!isset(self::$connections[$index]))
            throw new Exception('未找到redis连接' . $index);

        return self::$connections[$index];
    }

    public static function __callStatic($method, $parameters) {
        if (empty(self::$connections)) {
            self::init();
        }

        $result = [];
        foreach (self::$connections as $connection) {
            $result[] = $connection->{$method}(...$parameters);
        }

        return $result;
    }
}

简单分配逻辑

    /**
     * 根据 openID 分配 Redis
     */
    private function type4() {
        for ($i = 0; $i < 10000; $i++) {
            $openid = str_random(28);

            $openid_crc32 = crc32($openid);
            $redis_id = (int)$openid_crc32 % count(explode(',', env('REDIS_HA_HOSTS')));

            $redis = RedisHa::connection($redis_id);
            $redis->set('test:' . $openid, json_encode(['openid' => $openid, 'redis_id' => $redis_id]));
            $redis->incr('number');
        }

        return 'success';
    }

哈希存储

数据一致性问题

Redis脚本

Redis脚本采用 Lua 解释器来执行脚本

语法:EVAL script numkeys key [key ...] arg [arg ...]

Laravel 代码示例

/**
 * Redis Lua 脚本,对应的在这里的语法为:Redis::eval($script, $numkeys, $key [$key...], $arg [$arg...]);
 * $script 需要运行的脚本
 * $numkeys 指定键名的个数
 * $key 键名 从第三个参数开始为键名,有几个键名就依次数下去,在 Lua 中通过类似 KEYS[1],KEYS[2] 来获取
 * $arg 附加参数,在 Lua 脚本中通过类似 ARGV[1],ARGV[2] 来获取
 */
private function redisLuaEval() {
    $script1 = <<<SCRIPT
return redis.call('set', KEYS[1], ARGV[1])
SCRIPT;

	$key1 = 'shuxiaoyuan';
	$key2 = 'xiaoyuanshu';
	$a1 = Redis::eval($script1, 1, $key1, $key2);

	$script2 = <<<SCRIPT
return redis.call('SADD', KEYS[1], ARGV[1], ARGV[2],ARGV[3],ARGV[4])
SCRIPT;
	$key1 = 'eventID_2:user_name';
	$value1 = time() . mt_rand(1, 9999);
	$value2 = time() . mt_rand(1, 9999);
	$value3 = time() . mt_rand(1, 9999);
	$value4 = time() . mt_rand(1, 9999);
	$a2 = Redis::eval($script2, 1, $key1, $value1, $value2, $value3, $value4);
	dd($a1, $a2);
}

Redis键过期事件的应用

利用键过期事件

链接:https://www.shuxiaoyuan.com/info/50

过期策略

过期的 key 集合

redis 会将每个设置了过期时间的 key 放入到一个 独立的字典 中,以后会 定时遍历 这个字典来删除到期的 key。

除了定时遍历之外,它还会使用 惰性策略来删除 过期的 key,所谓惰性策略就是在客户端访问这个 key 的时候,redis 对 key的过期时间进行检查,如果过期了就立即删除。

定时删除 是集中处理,惰性删除 是零散处理。

定时扫描策略删除

Redis 默认会每秒进行十次过期扫描,过期扫描不会遍历过期字典中所有的 key,而是采用了一种简单的贪心策略。

  1. 从过期字典中随机 20 个 key;

  2. 删除这 20 个 key 中已经过期的 key;

  3. 如果过期的 key 比率超过 1/4,那就重复步骤 1;

LRU 算法删除

淘汰上次使用时间最早的,且使用次数最少的key

当访问某个元素时,将这个元素移动到头部,所以尾部的都是不被重用的,可以进行淘汰,这个的弊端就是:一个数据长期不使用,突然被访问到以后就到了头部。

Redis 使用的是一种近似 LRU 算法,在现有数据结构的基础上使用随机采样法来淘汰元素

懒惰删除

实际功能代码演示

Redis GEO

laravel示例代码

<?php
/*
 * Description: Redis GEO,地理位置信息
 * Author: Shuxiaoyuan
 * Email: sxy@shuxiaoyuan.com
 * DateTime: 2021/12/30 15:49
 *
 * 参考链接:
 * 1.百度拾取坐标系统:http://api.map.baidu.com/lbsapi/getpoint/
 * 2.GeoHash核心原理解析:https://www.cnblogs.com/LBSer/p/3310455.html
 */

namespace App\Http\Controllers\Redis;

use App\Common\Tools;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Redis;

/**
 * Description: Redis GEO
 * GeoHash 算法将二维的经纬度数据映射到一维的整数,这样所有的元素都将在挂载到一条线上,距离靠近的二维坐标映射到一维后的点之间距离也会很接近。
 * 当我们想要计算「附近的人时」,首先将目标位置映射到这条线上,然后在这个一维的线上获取附近的点就行了。
 * 那这个映射算法具体是怎样的呢?
 * 它将整个地球看成一个二维平面,然后划分成了一系列正方形的方格,就好比围棋棋盘。
 * 所有的地图元素坐标都将放置于唯一的方格中。方格越小,坐标越精确。然后对这些方格进行整数编码,越是靠近的方格编码越是接近。
 * 那如何编码呢?一个最简单的方案就是切蛋糕法。设想一个正方形的蛋糕摆在你面前,二刀下去均分分成四块小正方形,这四个小正方形可以分别标记为 00,01,10,11 四个二进制整数。
 * 然后对每一个小正方形继续用二刀法切割一下,这时每个小小正方形就可以使用 4bit 的二进制整数予以表示。
 * 然后继续切下去,正方形就会越来越小,二进制整数也会越来越长,精确度就会越来越高。
 * 上面的例子中使用的是二刀法,真实算法中还会有很多其它刀法,最终编码出来的整数数字也都不一样。
 * 编码之后,每个地图元素的坐标都将变成一个整数,通过这个整数可以还原出元素的坐标,整数越长,还原出来的坐标值的损失程度就越小。对于「附近的人」这个功能而言,损失的一点精确度可以忽略不计。
 */
class RedisGeoController extends Controller
{
    public $key = 'RedisGeoController:';

    /**
     * Description: 添加地理位置的坐标
     * Author: Shuxiaoyuan
     * Email: sxy@shuxiaoyuan.com
     * DateTime: 2021/12/30 16:02
     *
     * @param Request $request
     *
     * @return array
     */
    public function geoadd(Request $request)
    {
        $key = $this->key . __FUNCTION__;

        /**
         * 上海几个地点,经纬度
         * 浦东机场:121.813454,31.152136
         * 虹桥T2:121.33267,31.20067
         * 东方明珠:121.506231,31.245524
         * 上海南站:121.437097,31.159724
         * 龙阳路磁悬浮:121.563783,31.208983
         */
        $geos = [
            ['121.813454', '31.152136', '浦东机场'],
            ['121.33267', '31.20067', '虹桥T2'],
            ['121.506231', '31.245524', '东方明珠'],
            ['121.437097', '31.159724', '上海南站'],
            ['121.464752', '31.25593', '上海火车站'],
            ['121.563783', '31.208983', '龙阳路磁悬浮'],
        ];
        foreach ($geos as $geo) {
            /**
             * 存储指定的地理空间位置
             * 语法:GEOADD key longitude latitude member [longitude latitude member ...]
             * longitude:经度
             * latitude:维度
             * member:位置名称,唯一(有序集合,每一个位置都对应一个分数)
             */
            Redis::geoadd($key, $geo[0], $geo[1], $geo[2]);
        }

        $data = [
            'redis_key'  => $key,
            'geos'       => $geos,
            'redis_geos' => Redis::ZRANGE($key, 0, -1, true),
        ];

        return Tools::outSuccessInfo($data);
    }

    /**
     * Description: 获取地理位置的坐标
     * Author: Shuxiaoyuan
     * Email: sxy@shuxiaoyuan.com
     * DateTime: 2021/12/30 16:02
     *
     * @param Request $request
     *
     * @return array|void
     */
    public function geopos(Request $request)
    {
        $key     = $this->key . $request->input('redis_key');
        $address = $request->input('address');

        if (!Redis::exists($key)) {
            return Tools::outErrorInfo(__LINE__, 'redis 键' . '<' . $key . '>' . '不存在');
        }

        /**
         * 从给定的 key 里返回所有指定名称(member)的位置(经度和纬度),不存在的返回 nil。
         * 语法:GEOPOS key member [member ...]
         */
        $geo = Redis::geopos($key, $address);

        $data = [
            'redis_key' => $key,
            'geo'       => $geo,
        ];

        return Tools::outSuccessInfo($data);
    }

    /**
     * Description: 计算两个位置之间的距离
     * Author: Shuxiaoyuan
     * Email: sxy@shuxiaoyuan.com
     * DateTime: 2021/12/30 16:03
     *
     * @param Request $request
     *
     * @return array
     */
    public function geodist(Request $request)
    {
        $key     = $this->key . $request->input('redis_key');
        $member1 = $request->input('member1', '浦东机场');
        $member2 = $request->input('member2', '虹桥T2');

        if (!Redis::exists($key)) {
            return Tools::outErrorInfo(__LINE__, 'redis 键' . '<' . $key . '>' . '不存在');
        }

        /**
         * 计算两个位置之间的距离
         * GEODIST key member1 member2 [m|km|ft|mi]
         * m :米,默认单位
         * km :千米
         * mi :英里
         * ft :英尺
         */
        $distance = Redis::geodist($key, $member1, $member2);

        $data = [
            'redis_key' => $key,
            'distance'  => $distance,
            'member1'   => $member1,
            'member2'   => $member2,
        ];

        return Tools::outSuccessInfo($data);
    }

    /**
     * Description: 根据经纬度坐标来获取指定范围内的地理位置集合
     * Author: Shuxiaoyuan
     * Email: sxy@shuxiaoyuan.com
     * DateTime: 2021/12/30 16:03
     *
     * @param Request $request
     *
     * @return array|void
     */
    public function georadius(Request $request)
    {
        $key = $this->key . $request->input('redis_key');

        // 2号线,人民广场站经纬度:121.481887,31.238603
        $lng = $request->input('lng', '121.481887');
        $lat = $request->input('lat', '31.238603');

        // 半径范围。人民广场离东方明珠:2.7公里,距上海火车站:2.3公里
        $radius = $request->input('radius', 10000);

        if (!Redis::exists($key)) {
            return Tools::outErrorInfo(__LINE__, 'redis 键' . '<' . $key . '>' . '不存在');
        }

        /**
         * 根据经纬度坐标来获取指定范围内的地理位置集合
         * GEORADIUS key longitude latitude radius m|km|ft|mi [WITHCOORD] [WITHDIST] [WITHHASH] [COUNT count] [ASC|DESC] [STORE key] [STOREDIST key]
         * longitude:经度
         * latitude:纬度
         * radius:半径
         * m|km|ft|mi:米|千米|英里|英尺
         * WITHCOORD:将位置元素的经度和纬度也一并返回
         * WITHDIST:在返回位置元素的同时, 将位置元素与中心之间的距离也一并返回
         * WITHHASH:以 52 位有符号整数的形式, 返回位置元素经过原始 geohash 编码的有序集合分值
         * COUNT count:限定返回的记录数
         * ASC|DESC:结果排序
         * STORE key
         * STOREDIST key
         */
        // 源码在:vendor/predis/predis/src/Command/GeospatialGeoRadius.php
        $geos = Redis::georadius($key, $lng, $lat, $radius, 'm', ['WITHCOORD', 'WITHDIST', 'ASC', 'COUNT' => 4]);
        $data = [
            'redis_key' => $key,
            'lng'       => $lng,
            'lat'       => $lat,
            'radius'    => $radius,
            'geos'      => $geos
        ];

        return Tools::outSuccessInfo($data);
    }

    /**
     * Description: 根据储存在位置集合里面的某个地点获取指定范围内的地理位置集合,重点,重点,重点
     * Author: Shuxiaoyuan
     * Email: sxy@shuxiaoyuan.com
     * DateTime: 2021/12/30 16:03
     *
     * @param Request $request
     *
     * @return array|void
     */
    public function georadiusbymember(Request $request)
    {
        $key    = $this->key . $request->input('redis_key');
        $member = $request->input('member', '东方明珠');

        // 半径范围。东方明珠距离人民广场:2.7km、上海火车站:4.1km
        $radius = $request->input('radius', 10000);

        if (!Redis::exists($key)) {
            return Tools::outErrorInfo(__LINE__, 'redis 键' . '<' . $key . '>' . '不存在');
        }

        /**
         * GEORADIUSBYMEMBER key member radius M|KM|FT|MI [WITHCOORD] [WITHDIST] [WITHHASH] [COUNT count [ANY]] [ASC|DESC] [STORE key] [STOREDIST key]
         * member:
         * radius:半径
         * m|km|ft|mi:米|千米|英里|英尺
         * WITHCOORD:将位置元素的经度和纬度也一并返回
         * WITHDIST:在返回位置元素的同时, 将位置元素与中心之间的距离也一并返回
         * WITHHASH:以 52 位有符号整数的形式, 返回位置元素经过原始 geohash 编码的有序集合分值
         * COUNT count:限定返回的记录数
         * ASC|DESC:结果排序
         * STORE key
         * STOREDIST key
         */
        // 源代码位置:vendor/predis/predis/src/Command/GeospatialGeoRadiusByMember.php、vendor/predis/predis/src/Command/GeospatialGeoRadius.php
        $options = ['WITHCOORD', 'WITHDIST', 'WITHHASH', 'DESC', 'COUNT' => 30];
        $geos    = Redis::georadiusbymember($key, $member, $radius, 'm', $options);

        $data = [
            'redis_key' => $key,
            'member'    => $member,
            'radius'    => $radius,
            'geos'      => $geos
        ];

        return Tools::outSuccessInfo($data);
    }

    /**
     * Description: Redis GEO 使用 geohash 来保存地理位置的坐标,返回一个或多个位置对象的 geohash 值
     * Author: Shuxiaoyuan
     * Email: sxy@shuxiaoyuan.com
     * DateTime: 2021/12/30 16:03
     *
     * @param Request $request
     *
     * @return array
     */
    public function geohash(Request $request)
    {
        $key    = $this->key . $request->input('redis_key');
        $member = $request->input('member', '浦东机场');

        if (!Redis::exists($key)) {
            return Tools::outErrorInfo(__LINE__, 'redis 键' . '<' . $key . '>' . '不存在');
        }

        // GEOHASH key member [member ...]
        $geohash = Redis::geohash($key, $member);

        $data = [
            'redis_key' => $key,
            'member'    => $member,
            'geohash'   => $geohash,
        ];

        return Tools::outSuccessInfo($data);
    }
}

布隆过滤器

布隆过滤器简介

布隆过滤器:一种数据结构,是由一串很长的二进制向量组成,可以将其看成一个二进制数组,也称 位数组 初始默认值都是 0

添加数据

当要向布隆过滤器中添加一个元素 key 时,我们通过多个 hash 函数,算出多个值,然后将这些值所在的方格置为 1

判断数据是否存在

同理,通过多个 hash 函数计算出多个值,判断对应值是否为1

  • 如果有一个不为 1 那么可以确定该数据一定不存在于这个布隆过滤器中

  • 如果全为 1 那么这个数据 不一定 存在于这个布隆过滤器中,因为 hash 计算出来的结果又重复的

优缺点

  • 优点,占用内存少,插入和查询速度都贼快

  • 缺点,随着数据的增加,误判率会增加;无法判断数据一定存在;无法删除数据

如何选择哈希个数和过滤器长度

很显然,过小的布隆过滤器很快所有的 bit 位均为 1,那么查询任何值都会返回“可能存在”,起不到过滤的目的了。布隆过滤器的长度会直接影响误报率,布隆过滤器越长其误报率越小。

另外,哈希函数的个数也需要权衡,个数越多则布隆过滤器 bit 位置位 1 的速度越快,且布隆过滤器的效率越低;但是如果太少的话,那我们的误报率会变高。

k 为哈希函数个数,m 为布隆过滤器长度,n 为插入的元素个数,p 为误报率

如何选择适合业务的 k 和 m 值呢,这里直接贴一个公式:

注意

Redis 因其支持 setbit 和 getbit 操作,且纯内存性能高等特点,因此天然就可以作为布隆过滤器来使用。但是布隆过滤器的不当使用极易产生大 Value,增加 Redis 阻塞风险,因此生成环境中建议对体积庞大的布隆过滤器进行拆分。

拆分的形式方法多种多样,但是本质是不要将 Hash(Key) 之后的请求分散在多个节点的多个小 bitmap 上,而是应该拆分成多个小 bitmap 之后,对一个 Key 的所有哈希函数都落在这一个小 bitmap 上。

附录

  • 参考链接:https://zhuanlan.zhihu.com/p/43263751
<?php

/**
 * 布隆过滤器:说存在,不一定存在;说不存在,那一定不存在
 *
 * User: 舒孝元
 * Date: 2020/8/19 14:43
 * Mail: sxy@shuxiaoyuan.com
 * Website: https://www.shuxiaoyuan.com
 */

/**
 * 布隆过滤器简介
 * 介绍下Bloom Filter的基本处理思路:
 * 申请一批空间用于保存0 1信息,再根据一批哈希函数确定元素对应的位置,如果每个哈希函数对应位置的值为全部1,说明此元素存在。
 * 相反,如果为0,则要把对应位置的值设置为1。由于不同的元素可能会有相同的哈希值,即同一个位置有可能保存了多个元素的信息,从而导致存在一定的误判率。
 * 如果申请空间太小,随着元素的增多,1会越来越多,各个元素冲突的机会越来越来大,导致误判率会越来越大。
 * 另外哈希函数的选择及个数上也要平衡好,多个哈希函数虽然可以提供判断的准确性,但是会降低程序的处理速度,而哈希函数的增加又要求有更多的空间来存储位置信息。
 *
 * Bloom-Filter的应用。
 * Bloom-Filter一般用于在大数据量的集合中判定某元素是否存在。
 * 例如邮件服务器中的垃圾邮件过滤器。
 * 在搜索引擎领域,Bloom-Filter最常用于网络蜘蛛(Spider)的URL过滤,网络蜘蛛通常有一个 URL列表,保存着将要下载和已经下载的网页的URL,
 * 网络蜘蛛下载了一个网页,从网页中提取到新的URL后,需要判断该URL是否已经存在于列表中。此时,Bloom-Filter算法是最好的选择。
 * 比如说,一个象 Yahoo,Hotmail 和 Gmai 那样的公众电子邮件(email)提供商,总是需要过滤来自发送垃圾邮件的人(spamer)的垃圾邮件。
 * 一个办法就是记录下那些发垃圾邮件的 email 地址。由于那些发送者不停地在注册新的地址,全世界少说也有几十亿个发垃圾邮件的地址,将他们都存起来则需要大量的网络服务器。
 * 布隆过滤器是由巴顿.布隆于一九七零年提出的。它实际上是一个很长的二进制向量和一系列随机映射函数。我们通过上面的例子来说明起工作原理。
 * 假定我们存储一亿个电子邮件地址,我们先建立一个十六亿二进制(比特),即两亿字节的向量,
 * 然后将这十六亿个二进制位全部设置为零。对于每一个电子邮件地址 X,我们用八个不同的随机数产生器(F1,F2, ...,F8) 产生八个信息指纹(f1, f2, ..., f8)。
 * 再用一个随机数产生器 G 把这八个信息指纹映射到 1 到十六亿中的八个自然数 g1, g2, ...,g8。现在我们把这八个位置的二进制位全部设置为一。
 * 当我们对这一亿个 email 地址都进行这样的处理后。一个针对这些 email 地址的布隆过滤器就建成了。(见下图)
 * 现在,让我们看看如何用布隆过滤器来检测一个可疑的电子邮件地址 Y 是否在黑名单中。
 * 我们用相同的八个随机数产生器(F1, F2, ..., F8)对这个地址产生八个信息指纹 s1,s2,...,s8,
 * 然后将这八个指纹对应到布隆过滤器的八个二进制位,分别是 t1,t2,...,t8。如果 Y 在黑名单中,显然,t1,t2,..,t8 对应的八个二进制一定是一。
 * 这样在遇到任何在黑名单中的电子邮件地址,我们都能准确地发现。
 * 布隆过滤器决不会漏掉任何一个在黑名单中的可疑地址。
 * 但是,它有一条不足之处。也就是它有极小的可能将一个不在黑名单中的电子邮件地址判定为在黑名单中,
 * 因为有可能某个好的邮件地址正巧对应八个都被设置成一的二进制位。
 * 好在这种可能性很小。我们把它称为误识概率。在上面的例子中,误识概率在万分之一以下。
 * 布隆过滤器的好处在于快速,省空间。但是有一定的误识别率。常见的补救办法是在建立一个小的白名单,存储那些可能别误判的邮件地址。
 */

namespace App\Http\Controllers\Redis;

use App\Common\BloomFilterHash;
use App\Common\Tools;
use App\Constants\RedisDemo\RedisDemo;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Redis;

/**
 * Redis::setBit 偏移量为 0-2^32-1,所以生成的哈希不能大于 2^32 -1
 * Redis::setBit 参考链接 https://redis.io/commands/setbit
 * 注意,一旦完成了第一次分配,后续对相同键的SETBIT调用将不会有分配开销。
 *
 * 该布隆过滤器总位数为2^32位, 判断条数为2^30条. hash函数最优为3个.(能够容忍最多的hash函数个数)
 *
 * 注意, 在存储的数据量到2^30条时候, 误判率会急剧增加, 因此需要定时判断过滤器中的位为1的的数量是否超过50%, 超过则需要清空.
 */
class RedisBloomFilterController
{
    public string $key = RedisDemo::REDIS_BLOOM_FILTER;

    /**
     * 位图思想,需要用到的哈希算法,一般1~3个就行
     */
    private array $hashFunction = ['FNVHash', 'SDBMHash'];

    public function __destruct()
    {
        // 设置 Redis 的过期时间
        Redis::EXPIRE($this->key, 24 * 3600);
    }

    /**
     * Description: 简单测试-位图思想
     * Author: Shuxiaoyuan
     * Email: sxy@shuxiaoyuan.com
     * DateTime: 2022/1/11 11:26
     *
     * @param Request $request
     *
     * @return array
     */
    public function bloomIndex(Request $request): array
    {
        $key = $this->key;

        // 偏移量
        $offset = (int)$request->input('offset', 1);

        /**
         * Redis 最大只支持 0xFFFFFFFF == 4294967295 ≈ 512MB
         * 注意:本地上测试的时候,会发现,经常 flash ,原因有以下几点
         * 一:Redis配置文件里面设置了最大内存
         * 二:本机没那么多空余内存分配
         * 三:偏移量太大
         */
        $old_status = Redis::setbit($key, $offset, 1);
        $new_status = Redis::getbit($key, $offset);

        $data = [
            'redis_key'  => $key,
            'offset'     => $offset,
            'old_status' => $old_status,
            'new_status' => $new_status,
        ];

        return Tools::outSuccessInfo($data);
    }

    /**
     * Description: 获取字符串经过哈希算法后的值
     * Author: Shuxiaoyuan
     * Email: sxy@shuxiaoyuan.com
     * DateTime: 2022/1/10 16:12
     *
     * @param Request $request
     *
     * @return array
     */
    public function getHash(Request $request): array
    {
        $string = $request->input('string', 'shuxiaoyuan');

        // 获取该类所有方法
        $rc = new \ReflectionClass(BloomFilterHash::class);

        // 默认获取所有,这里只拿静态方法
        // 只获取 public 和 static 方法(\ReflectionMethod::IS_STATIC | \ReflectionMethod::IS_PUBLIC)
        $reflection_methods = $rc->getMethods(\ReflectionMethod::IS_STATIC);
        // dd($reflection_methods); // 返回包含每个方法 ReflectionMethod 对象的数组。

        $data['string'] = $string;
        foreach ($reflection_methods as $method) {
            $name = $method->name;

            $data['hash_value'][$method->name] = BloomFilterHash::$name($string);
        }

        return Tools::outSuccessInfo($data);
    }

    /**
     * Description: 添加一个元素到布隆过滤器
     * Author: Shuxiaoyuan
     * Email: sxy@shuxiaoyuan.com
     * DateTime: 2022/1/10 18:04
     *
     * @param Request $request
     *
     * @return array
     */
    public function bloomAddOne(Request $request): array
    {
        $key    = $this->key;
        $string = $request->input('string', 'shuxiaoyuan');

        /**
         * Redis 官方提供的布隆过滤器到了 Redis 4.0 提供了插件功能之后才正式登场。
         * 布隆过滤器作为一个插件加载到 Redis Server 中,给 Redis 提供了强大的布隆去重功能。
         *
         * docker pull redislabs/rebloom
         * bf.add 只能一次添加一个元素
         * bf.madd 可以添加多个 bf.madd test user1 user2 user3 ……
         */
        $script = <<<SCRIPT
return redis.call('bf.add', KEYS[1], ARGV[1])
SCRIPT;
        $value  = Redis::eval($script, 1, $key, $string);

        // 采用位图思想
        // $value = $this->addOne($key, $string);

        $data = [
            'redis_key' => $key,
            'string'    => $string,
            'value'     => $value,
        ];
        return Tools::outSuccessInfo($data);
    }

    /**
     * Description: 判断一个元素是否存在
     * Author: Shuxiaoyuan
     * Email: sxy@shuxiaoyuan.com
     * Website: https://www.shuxiaoyuan.com
     * DateTime: 2023/4/19 16:30
     *
     * @param Request $request
     *
     * @return array
     */
    public function bloomGetOne(Request $request): array
    {
        $key    = $this->key;
        $string = $request->input('string', 'shuxiaoyuan');

        /**
         * bf.exists 一次只能查询一个
         * bf.mexists 一次可以查询多个 bf.mexists test user1 user2 user3 ……
         */
        $script = <<<SCRIPT
return redis.call('bf.exists', KEYS[1], ARGV[1])
SCRIPT;
        $value  = Redis::eval($script, 1, $key, $string);

        // 采用位图思想
        // $value = $this->exists($key, $string);

        $data = [
            'redis_key' => $key,
            'string'    => $string,
            'status'    => $value,
        ];
        return Tools::outSuccessInfo($data);
    }

    /**
     * Description: 测试误判率1:查找存在的数据
     * Author: Shuxiaoyuan
     * Email: sxy@shuxiaoyuan.com
     * Website: https://www.shuxiaoyuan.com
     * DateTime: 2023/4/20 11:07
     *
     */
    public function test1(Request $request): array
    {
        set_time_limit(0);
        ini_set('memory_limit', '-1M');

        $key    = $this->key . 'tmp';
        $number = (int)$request->input('number', 1000);

        $insert = 0;
        $exists = 0;
        for ($i = 0; $i < $number; $i++) {
            $script = <<<SCRIPT
return redis.call('bf.add', KEYS[1], ARGV[1])
SCRIPT;
            $insert += Redis::eval($script, 1, $key, 'user' . $i);

            $script = <<<SCRIPT
return redis.call('bf.exists', KEYS[1], ARGV[1])
SCRIPT;
            // 经过测试,发现百分百无误判
            $exists += Redis::eval($script, 1, $key, 'user' . $i);
        }

        Redis::del($key);

        $data = [
            'insert' => $insert,
            'exists' => $exists,
        ];

        return Tools::outSuccessInfo($data);
    }

    /**
     * Description: 测试误判率2:查找不存在的数据
     * Author: Shuxiaoyuan
     * Email: sxy@shuxiaoyuan.com
     * Website: https://www.shuxiaoyuan.com
     * DateTime: 2023/4/20 11:29
     *
     * @param Request $request
     *
     * @return array
     */
    public function test2(Request $request): array
    {
        set_time_limit(0);
        ini_set('memory_limit', '-1M');

        $key    = $this->key . 'tmp';
        $number = (int)$request->input('number', 1000);

        $insert = 0;
        $exists = [];
        for ($i = 0; $i < $number; $i++) {
            $script = <<<SCRIPT
return redis.call('bf.add', KEYS[1], ARGV[1])
SCRIPT;
            $insert += Redis::eval($script, 1, $key, 'user' . $i);

            $script = <<<SCRIPT
return redis.call('bf.exists', KEYS[1], ARGV[1])
SCRIPT;
            // 每次塞入时,肯定是没有 i+1 这个的
            $tmp = Redis::eval($script, 1, $key, 'user' . ($i + 1));
            if ($tmp) {
                // 当数量为 1000 时,误判为:306,310,442,611,741,902
                $exists[] = $i;
            }
        }

        Redis::del($key);

        $data = [
            'insert' => $insert,
            'exists' => $exists,
        ];

        return Tools::outSuccessInfo($data);
    }

    /**
     * Description: 测量误判率
     * Author: Shuxiaoyuan
     * Email: sxy@shuxiaoyuan.com
     * Website: https://www.shuxiaoyuan.com
     * DateTime: 2023/4/20 11:34
     *
     * 测量方法:
     * 我们先随机出一堆字符串,然后切分为 2 组,
     * 将其中一组塞入布隆过滤器,然后再判断另外一组的字符串存在与否,
     * 取误判的个数和字符串总量一半的百分比作为误判率。
     */
    public function test3(Request $request): array
    {
        set_time_limit(0);
        ini_set('memory_limit', '-1M');

        $key    = $this->key . 'tmp';
        $number = (int)$request->input('number', 1000);

        if ($number % 2) {
            $number += 1;
        }

        $half = (int)bcdiv($number, 2);
        for ($i = 0; $i < $half; $i++) {
            $script = <<<SCRIPT
return redis.call('bf.add', KEYS[1], ARGV[1])
SCRIPT;
            Redis::eval($script, 1, $key, 'user' . $i);
        }

        $count = 0;
        for ($i = $half + 1; $i < $number; $i++) {
            $script = <<<SCRIPT
return redis.call('bf.exists', KEYS[1], ARGV[1])
SCRIPT;
            if (Redis::eval($script, 1, $key, 'user' . $i)) {
                $count += 1;
            }
        }

        Redis::del($key);

        $data = [
            'insert'     => $number,
            'count'      => $count,
            'proportion' => bcdiv($count, $half, 8)
        ];

        return Tools::outSuccessInfo($data);
    }

    /**
     * Description: 添加一个元素到布隆过滤器,采用位图实现
     * Author: Shuxiaoyuan
     * Email: sxy@shuxiaoyuan.com
     * DateTime: 2022/1/10 17:27
     *
     * 这里采用的思想是用的位图实现,将 string 按照一定的 hash 函数计算后,将相应位置1
     *
     * @param string $string
     * @param string $key
     *
     * @return mixed
     */
    private function addOne(string $key, string $string): mixed
    {
        // 位图最大为 2^32 = 4294967296
        $bit = [];
        foreach ($this->hashFunction as $function) {
            $bit[] = (int)BloomFilterHash::$function($string);
        }

        $redis_response = Redis::pipeline(function ($pipeline) use ($string, $key) {
            foreach ($this->hashFunction as $function) {
                $hash = (int)BloomFilterHash::$function($string);
                $pipeline->setbit($key, $hash, 1);
            }
        });

        return [
            'bit'            => $bit,
            'redis_response' => $redis_response,
        ];
    }

    /**
     * Description: 判断元素是否存在,采用位图实现
     * Author: Shuxiaoyuan
     * Email: sxy@shuxiaoyuan.com
     * DateTime: 2022/1/10 17:27
     *
     * 采用的思想是位图的思想,将 string 按照相同的 hash 函数计算后,取出相应的所有位,依次判断每一位是否为1
     *
     * @param $string
     * @param $key
     *
     * @return bool
     */
    private function exists(string $key, string $string): bool
    {
        // 采用管道
        $dataResponse = Redis::pipeline(function ($pipeline) use ($string, $key) {
            $len = strlen($string);
            foreach ($this->hashFunction as $function) {
                $hash = BloomFilterHash::$function($string, $len);
                $pipeline->getBit($key, $hash);
            }
        });

        foreach ($dataResponse as $bit) {
            if ($bit == 0) {
                return false;
            }
        }

        return true;
    }
}

位图应用

<?php
/**
 *......................位图......................
 *                       _oo0oo_
 *                      o8888888o
 *                      88" . "88
 *                      (| -_- |)
 *                      0\  =  /0
 *                    ___/`---'\___
 *                  .' \\|     |// '.
 *                 / \\|||  :  |||// \
 *                / _||||| -卍-|||||- \
 *               |   | \\\  -  /// |   |
 *               | \_|  ''\---/''  |_/ |
 *               \  .-\__  '-'  ___/-. /
 *             ___'. .'  /--.--\  `. .'___
 *          ."" '<  `.___\_<|>_/___.' >' "".
 *         | | :  `- \`.;`\ _ /`;.`/ - ` : | |
 *         \  \ `_.   \_ __\ /__ _/   .-` /  /
 *     =====`-.____`.___ \_____/___.-`___.-'=====
 *                       `=---='
 *
 *..................佛祖开光 ,永无BUG...................
 *
 * Description: 请简单描述
 * Author: Shuxiaoyuan
 * Email: sxy@shuxiaoyuan.com
 * Website: https://www.shuxiaoyuan.com
 * DateTime: 2023/2/28 15:52
 */

namespace App\Http\Controllers\Redis;

use App\Common\Tools;
use App\Constants\ErrorCode;
use App\Constants\RedisDemo\RedisDemo;
use App\Facades\ToolsFacade;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Redis;
use Illuminate\Support\Facades\Validator;

/**
 * Description: Redis 位图
 * Author: Shuxiaoyuan
 * Email: sxy@shuxiaoyuan.com
 * DateTime: 2021/12/29 17:38
 *
 * 基础知识:
 * 1TB = 1024GB
 * 1GB = 1024MB
 * 1MB = 1024KB
 * 1KB = 1024B (byte)
 * 1B  = 8b (bit位)
 *
 * 适用场景:
 * 签到1天送1积分,连续签到2天送2积分,3天送3积分,3天以上均送3积分等。
 * 如果连续签到中断,则重置计数,每月初重置计数。
 * 当月签到满3天领取奖励1,满5天领取奖励2,满7天领取奖励3……等等。
 * 显示用户某个月的签到次数和首次签到时间。
 * 在日历控件上展示用户每月签到情况,可以切换年月显示……等等。
 *
 * 最大位只能设置为 2^32 = (这会将位图限制为 512MB)
 *
 * Class RedisBitmapController
 *
 * @package App\Http\Controllers\Redis
 */
class RedisBitmapController
{
    public string $key = RedisDemo::REDIS_BITMAP;

    public string $tmp_key = RedisDemo::REDIS_BITMAP . ':tmp';

    /**
     * Description: 设置相应位为0或者1
     * Author: Shuxiaoyuan
     * Email: sxy@shuxiaoyuan.com
     * Website: https://www.shuxiaoyuan.com
     * DateTime: 2023/4/14 18:00
     *
     * @param Request $request
     *
     * @return array
     */
    public function setBit(Request $request): array
    {
        $key = $request->input('redis_key');
        if (!$key) {
            $key = $this->tmp_key;
        }

        $bit = (int)$request->input('bit', 66);

        // 返回值:0或1,存储在偏移量的原始位值
        Redis::setbit($key, $bit, 1);

        $status = Redis::getbit($key, $bit);

        $data = [
            'bit'       => $bit,
            'redis_key' => $key,
            'status'    => $status,
            'bitcount'  => Redis::bitcount($key), // 一共有多少位设置成 1
        ];

        Redis::expire($key, 8 * 2400);

        return Tools::outSuccessInfo($data);
    }

    /**
     * Description: 获取相应位是0还是1
     * Author: Shuxiaoyuan
     * Email: sxy@shuxiaoyuan.com
     * Website: https://www.shuxiaoyuan.com
     * DateTime: 2023/4/14 18:07
     *
     * @param Request $request
     *
     * @return array
     */
    public function getBit(Request $request): array
    {
        $key = $request->input('redis_key');
        if (!$key) {
            $key = $this->tmp_key;
        }

        $bit = (int)$request->input('bit', 66);

        $status = Redis::getbit($key, $bit);

        $data = [
            'bit'       => $bit,
            'redis_key' => $key,
            'status'    => $status,
            'bitcount'  => Redis::bitcount($key), // 一共有多少位设置成 1
            // 'value'     => Redis::get($key), // 编码需要转换
        ];

        return Tools::outSuccessInfo($data);
    }

    /**
     * Description: 获取指定范围内1的个数
     * Author: Shuxiaoyuan
     * Email: sxy@shuxiaoyuan.com
     * Website: https://www.shuxiaoyuan.com
     * DateTime: 2023/4/14 18:12
     *
     * @param Request $request
     *
     * @return array
     */
    public function getCountByStartToEnd(Request $request): array
    {
        $key = $request->input('redis_key');
        if (!$key) {
            $key = $this->tmp_key;
        }

        $start_bytes = $request->input('start_bytes', 0);
        $end_bytes   = $request->input('end_bytes', -1);

        // 这里的开始和结束指 byte,从 0 开始算,0到2 查找的是 0到(2+1)*8=24位
        // 0-0 就是 1byte 也指 0-7位
        $bitcount = Redis::bitcount($key, $start_bytes, $end_bytes);

        $data = [
            'redis_key'   => $key,
            'start_bytes' => $start_bytes,
            'end_bytes'   => $end_bytes,
            'bitcount'    => $bitcount,
        ];

        return Tools::outSuccessInfo($data);
    }

    /**
     * Description: 获取指定范围内出现的第一个 0 或 1
     * Author: Shuxiaoyuan
     * Email: sxy@shuxiaoyuan.com
     * Website: https://www.shuxiaoyuan.com
     * DateTime: 2023/4/17 14:10
     *
     * @param Request   $request
     * @param ErrorCode $errorCode
     *
     * @return array|void
     */
    public function getBitPosByStartToEnd(Request $request, ErrorCode $errorCode)
    {
        $validator = Validator::make($request->all(), [
            'start_bytes' => 'required|integer|min:0',
            'end_bytes'   => 'required|integer',
            'status'      => 'required|in:0,1',
        ], $message = [
            'start_bytes.required' => '开始位未设置',
            'start_bytes.integer'  => '开始位必须为整数',
            'start_bytes.min'      => '开始位最小为1',
            'end_bytes.required'   => '结束位未设置',
            'end_bytes.integer'    => '结束位必须为整数',
            'status.required'      => '状态未设置',
            'status.in'            => '状态只能为0或1',
        ]);
        if ($validator->fails()) {
            return $errorCode->getValidatorErrorMessage($validator);
        }

        $key = $request->input('redis_key');
        if (!$key) {
            $key = $this->tmp_key;
        }

        $start_bytes = $request->input('start_bytes', 0);
        $end_bytes   = $request->input('end_bytes', -1);
        $status      = $request->input('status', 1);

        // 这里的开始和结束指 byte,从 0 开始算
        $bitpos = Redis::bitpos($key, $status, $start_bytes, $end_bytes);

        $data = [
            'redis_key'   => $key,
            'start_bytes' => $start_bytes,
            'end_bytes'   => $end_bytes,
            'bitcount'    => Redis::bitcount($key),
            'bitpos'      => $bitpos,
        ];

        return Tools::outSuccessInfo($data);
    }

    /**
     * Description: 设置位,简单字符串演示
     * Author: Shuxiaoyuan
     * Email: sxy@shuxiaoyuan.com
     * DateTime: 2022/1/7 16:07
     *
     * @param Request $request
     *
     * @return array
     */
    public function setBitExample(Request $request): array
    {
        $key = $this->key;

        $string = $request->input('string', 'shuxiao');

        $ascii  = [];
        $bin    = [];
        $offset = [];
        for ($i = 0; $i < strlen($string); $i++) {
            // $o = $string[$i] . $o; // 反转字符串

            // 将字符串转 ASCII 码: "s":115,"h":104,"u":117,"x":120,"i":105,"a":97,"o":111
            $ascii[$string[$i]] = ord($string[$i]);

            // 转成二进制值: "s":"1110011","h":"1101000","u":"1110101","x":"1111000","i":"1101001","a":"1100001","o":"1101111"
            $bin[] = decbin(ord($string[$i]));
        }

        /**
         * 字符 s=>1110011 和字符 h=>1101000 表示如下
         *  高位  字符s  低位   高位  字符h  低位
         * |0|1|1|1|0|0|1|1|  |0|1|1 |0 |1 |0 |0 |0 |   用8位字节表示
         * |7|6|5|4|3|2|1|0|  |7|6|5 |4 |3 |2 |1 |0 |   8个字节中,左边为高位,右边为低位
         * |0|1|2|3|4|5|6|7|……|8|9|10|11|12|13|14|15|   这个是位顺序
         *
         * 注意: 位数组的顺序和字符的位顺序是相反的
         * 所以 s 需要设置的位是: 1、2、3、6、7
         * Redis::setbit($key, 1, 1);
         * Redis::setbit($key, 2, 1);
         * Redis::setbit($key, 3, 1);
         * Redis::setbit($key, 6, 1);
         * Redis::setbit($key, 7, 1);
         * // 应该输出一个 s
         * dd(Redis::get($key));
         *
         * h 需要设置的位是: 9、10、12
         * Redis::setbit($key, 9, 1);
         * Redis::setbit($key, 10, 1);
         * Redis::setbit($key, 12, 1);
         * // 应该输出一个 sh
         * dd(Redis::get($key));
         *
         */

        // 设置偏移量:主要是计算偏移量在多少位
        foreach ($bin as $k => $v) {
            for ($i = 0; $i < strlen($v); $i++) {
                if ($v[$i]) {
                    // 计算偏移量
                    $bit = (int)bcmul($k, 8) + ($i + 1);

                    $offset[] = $bit;
                    Redis::setbit($key, $bit, 1);
                }
            }
        }

        $data = [
            '输入的字符串'   => $string,
            '取出键值'     => Redis::get($key),
            'ascii码'   => $ascii,
            '二进制值'     => $bin,
            '需要设置的偏移量' => $offset,
        ];

        Redis::del($key);

        return Tools::outSuccessInfo($data);
    }

    /**
     * Description: 用户签到
     * Author: Shuxiaoyuan
     * Email: sxy@shuxiaoyuan.com
     * DateTime: 2022/1/10 13:21
     *
     * @param Request   $request
     * @param ErrorCode $errorCode
     *
     * @return array
     */
    public function setUserTask(Request $request, ErrorCode $errorCode): array
    {
        $validator = Validator::make($request->all(), [
            'member_id' => 'required|integer|min:1',
            'date'      => 'required|date_format:Y-m-d',
        ], $message = [
            'member_id.required' => '用户ID未设置',
            'member_id.integer'  => '用户ID必须为整数',
            'member_id.min'      => '用户ID最小为1',
            'date.required'      => '签到日期未设置',
            'date.date_format'   => '签到日期格式不正确',
        ]);

        if ($validator->fails()) {
            return $errorCode->getValidatorErrorMessage($validator);
        }

        $member_id = $request->input('member_id');
        $date      = $request->input('date');
        $year      = date('Y', strtotime($date));

        // 将每一个用户的签到按照年份进行划分,Redis键按照会员和年来划分
        $key = $this->key . ':' . $year . ':' . $member_id;

        // 测试一年的每一天都签到
//        $days = ToolsFacade::getEverydayDate($year . '0101', $year . '1231', 'nd');

        /**
         * 将该年中的月日作为位来设置,这样每一个键最大的存储空间为 1231(bit) / 8 = 154(Bytes)
         * 因为月日不连续,并且第一天是101,所以这样会浪费一部分空间,可以进一步缩减到 365/366 天
         */
//        // 缩短到 366 天方案
//        // 今年的开始天到现在的天数
//        $formatted_dt1  = Carbon::parse(Carbon::make($date)->firstOfYear()->toDateString());
//        $formatted_dt2  = Carbon::parse($date);
//        $date_diff_days = $formatted_dt1->diffInDays($formatted_dt2); // 这个就当做相应位
//        dd($date_diff_days);

        $day = date('md', strtotime($date));

        // 返回值:0或1,存储在偏移量的原始位值
        $status = Redis::setbit($key, (int)$day, 1);

        $bitcount = Redis::bitcount($key);

        // 入库持久化过程略
        $data = [
            'redis_key'    => $key,
            'status'       => $status,
            'count_number' => $bitcount,
        ];

        Redis::expire($key, 8 * 3600);

        return Tools::outSuccessInfo($data);
    }

    /**
     * Description: 统计用户某段时间内的签到次数
     * Author: Shuxiaoyuan
     * Email: sxy@shuxiaoyuan.com
     * Website: https://www.shuxiaoyuan.com
     * DateTime: 2023/4/14 14:51
     *
     * @param Request $request
     *
     * @return array
     */
    public function getUserTaskByStartToEnd(Request $request): array
    {
        $member_id = $request->input('member_id');

        // 开始时间和结束时间-这里假设不考虑跨年的情况
        $start_date = $request->input('start_date', '2023-01-01');
        $end_date   = $request->input('end_date', '2023-01-31');

        $year = date('Y', strtotime($start_date));
        $key  = $this->key . ':' . $year . ':' . $member_id;

        /**
         * 方案一:根据开始时间和结束时间循环去取相应位,然后统计
         */
        $method1 = Redis::pipeline(function ($pipeline) use ($key, $start_date, $end_date) {
            $start_bytes = (int)date('md', strtotime($start_date));
            $end_bytes   = (int)date('md', strtotime($end_date));
            for ($i = $start_bytes; $i < $end_bytes; $i++) {
                $pipeline->getbit($key, $i);
            }
        });

        // 后续研究下效率问题
        $count1 = array_sum($method1);

        // 如果是采用的缩短到 366 天方案
//        $year_first_day = Carbon::parse(Carbon::make($start_date)->firstOfYear()->toDateString()); // 今年的第一天
//        $start_bytes    = $year_first_day->diffInDays($start_date);
//        $end_bytes      = $year_first_day->diffInDays($end_date);
//        dd($start_bytes, $end_bytes);

        /**
         * 方案二:使用 bitcount 命令,将包含开始时间和结束时间内的统计出来,然后过滤掉不合法数据
         * 采用默认即: 12B - 17B
         */
        $start_bytes = floor(bcdiv((int)date('md', strtotime($start_date)), 8, 2));
        $end_bytes   = ceil(bcdiv((int)date('md', strtotime($end_date)), 8, 2));

        // 这个范围内不仅包含了指定的日期,还包含了未指定的日期,所以要处理
        $method2 = Redis::bitcount($key, $start_bytes, $end_bytes);

        $tmp_start_bytes = bcmul($start_bytes, 8);  // 96b
        $tmp_end_bytes   = bcmul($end_bytes, 8);    // 136b

        // 需要剔除的位
        $exclude = Redis::pipeline(function ($pipeline) use ($key, $start_date, $end_date, $tmp_start_bytes, $tmp_end_bytes) {
            $start_bytes = (int)date('md', strtotime($start_date)); // 101b
            $end_bytes   = (int)date('md', strtotime($end_date));   // 131b

            // 96b - 101b
            for ($i = $tmp_start_bytes; $i < $start_bytes; $i++) {
                $pipeline->getbit($key, $i);
            }

            // 131b - 136b
            for ($i = $end_bytes; $i < $tmp_end_bytes; $i++) {
                $pipeline->getbit($key, $i);
            }
        });

        // 因为只有0和1两个值,所以可以直接求和
        $exclude_count = array_sum($exclude);

        $count2 = $method2 - $exclude_count;

        $data = [
            'redis_key'  => $key,
            'start_date' => $start_date,
            'end_date'   => $end_date,
            'count1'     => $count1,
            'method2'    => $method2,
            'count2'     => $count2,
        ];

        return Tools::outSuccessInfo($data);
    }

    /**
     * Description: 查看用户某一天是否签到
     * Author: Shuxiaoyuan
     * Email: sxy@shuxiaoyuan.com
     * DateTime: 2022/1/7 14:52
     *
     * @param Request $request
     *
     * @return array
     */
    public function getUserIsTaskByDay(Request $request): array
    {
        $member_id = $request->input('member_id');
        $date      = $request->input('date');
        $year      = date('Y', strtotime($date));

        // 将每一个用户的签到按照年份进行划分,Redis键按照会员和年来划分
        $key = $this->key . ':' . $year . ':' . $member_id;

        $day = (int)date('md', strtotime($date));

        // 查找某一位上面的值
        $status = Redis::getbit($key, $day);

        $data = [
            'redis_key' => $key,
            'day'       => $day,
            'status'    => $status,
        ];

        return Tools::outSuccessInfo($data);
    }
}

Redis HyperLogLog

laravel示例代码

<?php
/*
 * Description: Redis HyperLogLog:统计基数算法
 * Author: Shuxiaoyuan
 * Email: sxy@shuxiaoyuan.com
 * DateTime: 2021/12/29 17:48
 *
 * 参考链接:
 * 1. https://zhuanlan.zhihu.com/p/58519480
 * 1. http://content.research.neustar.biz/blog/hll.html
 */

namespace App\Http\Controllers\Redis;

use App\Common\Tools;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Redis;

/**
 * Description: Redis HyperLogLog 是用来做基数统计的算法
 * 他不会真的存每一个元素的值,使用的是一个概率算法
 *
 * 对于一个输入的字符串,首先得到64位的hash值,用前14位来定位桶的位置(共有 2^14 ,即 16384 个桶)
 * 后面50位即为伯努利过程,每个桶有6bit,记录第一次出现1的位置count,如果count>oldcount,就用count替换oldcount。
 */
class RedisHyperLogLogController extends Controller
{
    public $key = 'RedisHyperLogLogController:';

    /**
     * Description: 添加指定元素到 HyperLogLog 中。
     * Author: Shuxiaoyuan
     * Email: sxy@shuxiaoyuan.com
     * DateTime: 2021/12/29 17:57
     *
     * @param Request $request
     *
     * @return array
     */
    public function pfadd(Request $request): array
    {
        set_time_limit(0);
        ini_set('memory_limit', '-1M');

        $key    = $request->input('redis_key');
        $number = (int)$request->input('number', 100);

        if (!$key) {
            $key = $this->key . __FUNCTION__;
        } else {
            $key = $this->key . $key;
        }

        $add_count = 0;
        // 注意:每个 HyperLogLog 键,大概可以计算接近 2^64 个不同元素的基数
        // 我本地好像一次只能塞大概一百万的数据进去,为了塞更多的数据,这里做了切分,多次插入
        if ($number > 1000001) {
            // 计算需要分几次写入
            $count = (int)ceil(bcdiv($number, 1000000, 10));

            for ($i = 1; $i <= $count; $i++) {
                $last_number = (1000000 * $i) < $number ? (1000000 * $i) : $number;
                for ($j = (1000000 * ($i - 1)); $j < $last_number; $j++) {
                    $user[] = 'user' . $j;
                    $add_count++;
                }
                Redis::pfadd($key, $user);
                unset($user);
            }
        } else {
            for ($i = 0; $i < $number; $i++) {
                $user[] = 'user' . $i;
                $add_count++;
            }

            // 第二个参数必须是一个数组
            Redis::pfadd($key, $user);
            unset($user);
        }

        $count = Redis::pfcount($key);

        $data = [
            'key'         => $key,
            'pfadd_count' => $count,
            'add_count'   => $add_count,
        ];

        return Tools::outSuccessInfo($data);
    }

    /**
     * Description: 返回给定 HyperLogLog 的基数估算值。
     * Author: Shuxiaoyuan
     * Email: sxy@shuxiaoyuan.com
     * DateTime: 2021/12/29 17:57
     *
     * @param Request $request
     *
     * @return array
     */
    public function pfcount(Request $request): array
    {
        $key = $this->key . $request->input('redis_key');

        if (!Redis::exists($key)) {
            return Tools::outErrorInfo(__LINE__, 'redis 键' . '<' . $key . '>' . '不存在');
        }

        $count = Redis::pfcount($key);

        $data = [
            'key'     => $key,
            'pfcount' => $count,
        ];

        return Tools::outSuccessInfo($data);
    }

    /**
     * Description: 将多个 HyperLogLog 合并为一个 HyperLogLog
     * Author: Shuxiaoyuan
     * Email: sxy@shuxiaoyuan.com
     * DateTime: 2021/12/29 17:58
     *
     * @param Request $request
     *
     * @return array
     */
    public function pfmerge(Request $request): array
    {
        $keys    = $request->input('redis_key');
        $new_key = $this->key . __FUNCTION__;

        Redis::del($new_key);

        foreach ($keys as &$value) {
            $value = $this->key . $value;
        }

        // 第二个参数必须是一个数组
        $pfmerge = Redis::pfmerge($new_key, $keys);

        $count = Redis::pfcount($new_key);

        $data = [
            'pfmerge' => $pfmerge,
            'count'   => $count,
            'new_key' => $new_key,
            'keys'    => $keys,
        ];

        return Tools::outSuccessInfo($data);
    }
}

查看单个key占用内存大小

memory usage key_name 单位为 bytes