基础:万丈高楼平地起--Redis基础数据结构
- Redis的字符串是动态字符串SDS,采用预分配冗余空间的方式来减少内存的频繁分配。
- 在字符串长度小于1M时,扩容都是加倍现有的空间,如果超过1M,扩容时一次只会增加1M。
- 字符串最大长度为512M
- list(列表)底层存储在元素较少的情况下,使用一块连续内存存储,数据结构使用ziplist(压缩列表),数据量比较多的时候才会改成quicklist
- quicklist将链表和ziplist结合起来,将多个ziplist使用双向指针串起来使用
- redis为了高性能,不阻塞服务,采用渐进式rehash策略。查询时会同时查询两个hash结构,在后续的定时任务、hash的子指令中,循序渐进地将旧hash内容一点点迁移到新的hash结构中
- zset在内部实现中采用了跳跃表的数据结构
- list/set/hash/zset共享两条通用规则:create if not exists,drop if no elements
应用1:千帆竞发--分布式锁
- redis实现分布式锁使用setnx(set if not exists)指令
- 为了避免程序异常造成死锁,通常需要设定expire
- 为了避免错误解锁,需要给一个唯一的value,比对成功才能执行del解锁。lua脚本可以保证原子性
- 可重入锁的实现也可以通过lua脚本完成
应用2:缓兵之计--延时队列
- redis的list数据结构常用来作为异步消息队列使用
- 使用blpop/brpop可以阻塞获取,可以解决队列为空的问题
- 当使用阻塞操作时,一旦时间过久,客户端连接就会变成闲置连接,服务器一般会主动断开,阻塞操作会抛出异常,需要特殊处理重试
- 延时队列可以使用redis的zset实现,消息的到期处理时间作为score。多个线程获取zset到期的任务进行处理
- 延时队列的具体做法可以在lua脚本中使用zrangebyscore和zrem指令进行任务的获取,利用lua脚本的原子性,可以避免多个线程的并发重复执行命令的问题
- 如果不使用lua脚本,需要判断zrem是否删除成功来决定是否执行任务
应用3:节衣缩食--位图
- redis的位图不是特殊的数据结构,就是普通字符串。redis字符串支持使用getbit/setbit等将字符串(byte数组)看成位数组处理
- redis提供位图统计指令bitcount和位图查询指令bitpos,范围参数是字节索引(不是位索引)
应用4:四两拨千斤--HyperLogLog
- HLL可以用来解决不需要太精准的统计问题,能够极大节省空间
- HLL需要占据一定12k的存储空间,不适用与统计单个用户的相关数据
- HLL在计数较小时使用稀疏矩阵存储
应用5:层峦叠嶂--布隆过滤器
- 布隆过滤器说某个值存在,则可能存在;当它说不存在就肯定不存在
- 布隆过滤器原理:向布隆过滤器添加key时,使用多个hash函数对key进行hash算得一个整数索引值然后对位数组长度进行驱魔运算得到一个位置,每个hash函数都会得到一个不同的位置,再把位数组的这些位置都置1。判断key是否存在时,需要对key做hash再查看对应位置上的key是否都为1,如果存在一个不为1则key不存在,反之。
应用6:断尾求生--简单限流
- 滑动窗口法:使用zset做滑动窗口,score为时间戳。用户请求时,根据用户ID和操作,获取对应zset的key,将唯一性数据(可以是时间戳)作为value,当前时间作为score添加到zset;删除小于窗口时间的元素,判断当前zset的元素是否大于限制
- 因为滑动窗口法需要记录时间窗口内所有的行为,如果这个量很大,会消耗大量的存储空间,不太适合用此方法
local zsetKey = KEYS[1]
local curTime = ARGV[1]
local value = ARGV[2]
local period = ARGV[3]
local limit = ARGV[4]
redis.call('ZADD', zsetKey, curTime, value)
redis.call('ZREMRANGEBYSCORE', zsetKey, 0, curTime - period)
local count = redis.call('ZCARD', zsetKey)
if count < limit then
return {['ok'] = 'OK'}
else
return {['err'] = 'Over rate limit'}
end
应用7:一毛不拔--漏斗限流
- 漏斗限流法:初始化漏洞容量,流水速率,剩余空间(初始化为漏洞容量),上一次漏水时间。每次往漏洞中添加时需要先计算上一次添加到现在需要流出的量,然后再添加,判断是否大于容量,大于容量则超过限流
--参数说明,key[1]为对应服务接口的信息,argv1为capacity,argv2为漏水速率,argv3为一次所需流出的水量,argv4为时间戳
local limitInfo = redis.call('hmget', KEYS[1], 'capacity', 'passRate', 'addWater','water', 'lastTs')
local capacity = limitInfo[1]
local passRate = limitInfo[2]
local addWater= limitInfo[3]
local water = limitInfo[4]
local lastTs = limitInfo[5]
--初始化漏斗
if capacity == false then
capacity = tonumber(ARGV[1])
passRate = tonumber(ARGV[2])
--请求一次所要加的水量
addWater=tonumber(ARGV[3])
--当前水量
water = 0
lastTs = tonumber(ARGV[4])
redis.call('hmset', KEYS[1], 'capacity', capacity, 'passRate', passRate,'addWater',addWater,'water', water, 'lastTs', lastTs)
return {['ok'] = 'OK'}
else
local nowTs = tonumber(ARGV[4])
--计算距离上一次请求到现在的漏水量
local waterPass = tonumber((nowTs - lastTs)* passRate)
--计算当前水量,即执行漏水
water=math.max(0,water-waterPass)
--设置本次请求的时间
lastTs = nowTs
--判断是否可以加水
addWater=tonumber(addWater)
if capacity-water >= addWater then
--加水
water=water+addWater
--更新当前水量和时间戳
redis.call('hmset', KEYS[1], 'water', water, 'lastTs', lastTs)
return {['ok'] = 'OK'}
end
return {['err'] = ['Over rate limit']}
end
应用8:近水楼台--GeoHash
- 业界比较通用的地理位置距离排序算法是GeoHash
- GeoHash算法:将某个位置的经纬度在网格化地图中得到0和1的串,按照偶数位放经度,奇数位放维度的规则组合经度和维度的二进制串,可将此二进制串转换成数字或字符串。
- redis中经纬度使用52位的整数进行编码,放进zset中,value是元素的key,score是GeoHash的52位整数值
- redis提供的Geo在内存存储仅仅是一个zset
- redis的geo不提供删除功能,可以直接使用zset的删除指令zrem
- GEORADIUSBYMEMBER可以实现查询附近的人的功能
- 在地图应用中,地图数据可能会有百万千万条,如果全部放在一个zset集合中,在redis的集群环境中,集合可能会从一个节点迁移到另一个节点,如果单个key的数据过大,会对集群的迁移工作造成较大的影响,在集群环境中单个key对应的数据量不宜超过1M,否则会导致集群迁移出现卡顿现象,影响线上服务的正常运行。所以建议Geo的数据使用单独Redis实例部署,不用集群环境。
应用9:大海捞针--Scan
- scan相比keys具备的特点:
- 通过游标分步进行,不会阻塞线程
- 提供limit参数,可以控制每次返回结果的最大条数(limit只是个hint,返回的结果可多可少)
- 同keys一样,提供模式匹配
- 服务器不需要为游标保存状态
- 返回的结果可能会有重复,需要客户端去重复
- 遍历过程中如果有数据修改,修改后的数据能不能遍历到是不确定的
- 单词返回的结果是空并不意味着遍历结束,而是要看返回的游标是否为0
- scan参数limit不是限定返回结果的数量,而是限定服务器单词遍历的字典槽位数量
- 考虑到扩容与缩容时遍历的准确性,scan遍历顺序采用高位进位加法