Redis深度历险
Redis深度历险
读书笔记
1 第一篇 基础与应用篇
1.1 Redis的用途
- 记录帖子的点赞数、评论数和点击数 (hash)。
- 记录用户的帖子 ID 列表 (排序),便于快速显示用户的帖子列表 (zset)。
- 记录帖子的标题、摘要、作者和封面信息,用于列表页展示 (hash)。
- 记录帖子的点赞用户 ID 列表,评论 ID 列表,用于显示和去重计数 (zset)。
- 缓存近期热帖内容 (帖子内容空间占用比较大),减少数据库压力 (hash)。
- 记录帖子的相关文章 ID,根据内容推荐相关帖子 (list)。
- 如果帖子 ID 是整数自增的,可以使用 Redis 来分配帖子 ID(计数器)。
- 收藏集和帖子之间的关系 (zset)。
- 记录热榜帖子 ID 列表,总热榜和分类热榜 (zset)。
- 缓存用户行为历史,进行恶意行为过滤 (zset,hash)。
1.2 Redis安装
Docker安装
# 拉取 redis 镜像
docker pull redis
# 运行 redis 容器
docker run --name myredis -d -p6379:6379 redis
# 执行容器中的 redis-cli,可以直接使用命令行操作 redis
docker exec -it myredis redis-cli
1.3 5种基础数据类型
string(字符串)
Redis 的字符串是动态字符串,是可以修改的字符串,内部结构实现上类似于 Java 的ArrayList,采用预分配冗余空间的方式来减少内存的频繁分配,如图中所示,内部为当前字符串实际分配的空间 capacity 一般要高于实际字符串长度 len。当字符串长度小于 1M 时,扩容都是加倍现有的空间,如果超过 1M,扩容时一次只会多扩 1M 的空间。需要注意的是字符串最大长度为 512M。
算了放弃了,这块比较熟悉了,懒得记了
list(列表)
Redis 的列表相当于 Java 语言里面的 LinkedList,注意它是链表而不是数组。这意味着list 的插入和删除操作非常快,时间复杂度为 O(1),但是索引定位很慢,时间复杂度为O(n),这点让人非常意外。当列表弹出了最后一个元素之后,该数据结构自动被删除,内存被回收。Redis 的列表结构常用来做异步队列使用。将需要延后处理的任务结构体序列化成字符串塞进 Redis 的列表,另一个线程从这个列表中轮询数据进行处理。
# 插入元素
lpush/rpush books python java golang
# 查询元素个数
llen key
# 弹出元素
lpop/rpop
⚠️几个慢操作
index 相当于 Java 链表的 get(int index)方法,它需要对链表进行遍历,性能随着参数index 增大而变差。 ltrim 和字面上的含义不太一样,个人觉得它叫 lretain(保留) 更合适一些,因为 ltrim 跟的两个参数 start_index 和 end_index 定义了一个区间,在这个区间内的值,ltrim 要保留,区间之外统统砍掉。我们可以通过 ltrim 来实现一个定长的链表,这一点非常有用。index 可以为负数,index=-1 表示倒数第一个元素,同样 index=-2 表示倒数第二个元素。
# 根据索引获取 复杂度为O(n)慎用
lindex key index(0、1、2)
# 截取元素(其他的会被删掉)
ltrim key start end ( star 、end 均使用index表示)
# 范围读取
lrange key start end
如果再深入一点,你会发现 Redis 底层存储的还不是一个简单的 linkedlist
,而是称之为快速链表 quicklist
的一个结构。首先在列表元素较少的情况下会使用一块连续的内存存储,这个结构是 ziplist
,也即是压缩列表。它将所有的元素紧挨着一起存储,分配的是一块连续的内存。当数据量比较多的时候才会改成 quicklist
。因为普通的链表需要的附加指针空间太大,会比较浪费空间,而且会加重内存的碎片化。比如这个列表里存的只是 int 类型的数据,结构上还需要两个额外的指针prev
和 next
。所以 Redis 将链表和 ziplist
结合起来组成了 quicklist
。也就是将多个ziplist
使用双向指针串起来使用。这样既满足了快速的插入删除性能,又不会出现太大的空间冗余。
hash(字典)
Redis 的字典相当于 Java 语言里面的 HashMap,它是无序字典。内部实现结构上同Java 的 HashMap 也是一致的,同样的数组 + 链表二维结构。第一维 hash 的数组位置碰撞时,就会将碰撞的元素使用链表串接起来。
不同的是,Redis 的字典的值只能是字符串,另外它们 rehash 的方式不一样,因为Java 的 HashMap 在字典很大时,rehash 是个耗时的操作,需要一次性全部 rehash。Redis 为了高性能,不能堵塞服务,所以采用了渐进式 rehash 策略。
渐进式 rehash 会在 rehash 的同时,保留新旧两个 hash 结构,查询时会同时查询两个hash 结构,然后在后续的定时任务中以及 hash 的子指令中,循序渐进地将旧 hash 的内容一点点迁移到新的 hash 结构中。
当 hash 移除了最后一个元素之后,该数据结构自动被删除,内存被回收。
hash 结构也可以用来存储用户信息,不同于字符串一次性需要全部序列化整个对象,hash 可以对用户结构中的每个字段单独存储。这样当我们需要获取用户信息时可以进行部分获取。而以整个字符串的形式去保存用户信息的话就只能一次性全部读取,这样就会比较浪费网络流量。
hash 也有缺点,hash 结构的存储消耗要高于单个字符串,到底该使用 hash 还是字符串,需要根据实际情况再三权衡
# 新增元素
hset key field value
# 获取全部元素
hgetall key
# 查询元素个数
hlen key
# 获取属性值
hget key field
# 批量操作
hmset key field1 value1 field2 value2
🦋如何用hash计数?
# 若存在 key filed
hset key field value(num)
hincrby key value 1(任意数值,和string类似)
set(集合)
Redis 的集合相当于 Java 语言里面的 HashSet,它内部的键值对是无序的唯一的。它的内部实现相当于一个特殊的字典,字典中所有的 value 都是一个值 NULL。当集合中最后一个元素移除之后,数据结构自动删除,内存被回收。 set 结构可以用来存储活动中奖的用户 ID,因为有去重功能,可以保证同一个用户不会中奖两次。
# 插入元素
sadd key value
# 查询所有元素(乱序)
smembers key
# 判断某个value是否存在
sismember key value
# 获取长度
scard key
# 弹出元素
spop key
zset(有序列表)
zset 可能是 Redis 提供的最为特色的数据结构,它也是在面试中面试官最爱问的数据结构。它类似于 Java 的 SortedSet 和 HashMap 的结合体,一方面它是一个 set,保证了内部value 的唯一性,另一方面它可以给每个 value 赋予一个 score,代表这个 value 的排序权
重。它的内部实现用的是一种叫着「跳跃列表」的数据结构。
zset 中最后一个 value 被移除后,数据结构自动删除,内存被回收。 zset 可以用来存粉丝列表,value 值是粉丝的用户 ID,score 是关注时间。我们可以对粉丝列表按关注时间进行排序。
zset 还可以用来存储学生的成绩,value 值是学生的 ID,score 是他的考试成绩。我们可以对成绩按分数进行排序就可以得到他的名次。
# 新增元素
zadd key score member
# 逆序输出
zrevrange program start end (指score)
# 删除元素
zrem key member [member…]
# 查询元素个数
zcard key
# 获取指定value的score
zscore key value
# 获取排名
zrank key value
# 根据score划定区间
zrangebyscore key start end
zrevrangebyscore
# 增量
zincrby key increment member
🏹改天研究跳表
1.4 分布式锁
分布式锁本质上要实现的目标就是在 Redis 里面占一个“茅坑”,当别的进程也要来占时,发现已经有人蹲在那里了,就只好放弃或者稍后再试。占坑一般是使用 setnx(set if not exists) 指令,只允许被一个客户端占坑。先来先占, 用完了,再调用 del 指令释放茅坑。
⚠️解决设置锁与有效期的院子问题?
set key value [ex (秒数)] nx
超时问题
Redis 的分布式锁不能解决超时问题,如果在加锁和释放锁之间的逻辑执行的太长,以至于超出了锁的超时限制,就会出现问题。因为这时候锁过期了,第二个线程重新持有了这把锁,但是紧接着第一个线程执行完了业务逻辑,就把锁给释放了,第三个线程就会在第二个线程逻辑执行完之间拿到了锁。为了避免这个问题,Redis 分布式锁不要用于较长时间的任务。如果真的偶尔出现了,数据出现的小波错乱可能需要人工介入解决。
有一个更加安全的方案是为 set 指令的 value 参数设置为一个随机数,释放锁时先匹配随机数是否一致,然后再删除 key。但是匹配 value 和删除 key 不是一个原子操作,Redis 也没有提供类似于 delifequals 这样的指令,这就需要使用 Lua 脚本来处理了,因为 Lua 脚本可以保证连续多个指令的原子性执行。
可重入问题
可重入性是指线程在持有锁的情况下再次请求加锁,如果一个锁支持同一个线程的多次加锁,那么这个锁就是可重入的。比如 Java 语言里有个 ReentrantLock 就是可重入锁。Redis 分布式锁如果要支持可重入,需要对客户端的 set 方法进行包装,使用线程的 Threadlocal 变量存储当前持有锁的计数。
package com.szz.redis.reentrant;
import redis.clients.jedis.Jedis;
import java.util.HashMap;
import java.util.Map;
public class RedisWithReentrantLock {
private ThreadLocal<Map<String, Integer>> lockers = new ThreadLocal<Map<String, Integer>>();
private Jedis jedis;
public RedisWithReentrantLock(Jedis jedis) {
this.jedis = jedis;
}
// 加锁
private boolean _lock(String key) {
return jedis.set(key, "", "nx", "ex", 10L) != null;
}
// 解锁
private void _unlock(String key) {
jedis.del(key);
}
private Map<String, Integer> currentLockers() {
Map<String, Integer> refs = lockers.get();
if (refs != null) {
return refs;
}
lockers.set(new HashMap<String, Integer>());
return lockers.get();
}
public boolean lock(String key) {
Map<String, Integer> refs = currentLockers();
Integer refCnt = refs.get(key);
if (refCnt != null) {
refs.put(key, refCnt + 1);
return true;
}
boolean ok = this._lock(key);
if (!ok) {
return false;
}
refs.put(key, 1);
return true;
}
public boolean unlock(String key) {
Map<String, Integer> refs = currentLockers();
Integer refCnt = refs.get(key);
if (refCnt == null) {
return false;
}
refCnt -= 1;
if (refCnt > 0) {
refs.put(key, refCnt);
} else {
refs.remove(key);
this._unlock(key);
}
return true;
}
}
延时队列
Redis 的 list(列表) 数据结构常用来作为异步消息队列使用,使用rpush/lpush操作入队列,使用 lpop 和 rpop 来出队列。
🦋队列空了怎么半?
客户端是通过队列的 pop 操作来获取消息,然后进行处理。处理完了再接着获取消息,再进行处理。如此循环往复,这便是作为队列消费者的客户端的生命周期。可是如果队列空了,客户端就会陷入 pop 的死循环,不停地 pop,没有数据,接着再 pop,又没有数据。这就是浪费生命的空轮询。空轮询不但拉高了客户端的 CPU,redis 的 QPS 也会被拉高,如果这样空轮询的客户端有几十来个,Redis 的慢查询可能会显著增多。通常我们使用 sleep 来解决这个问题,让线程睡一会,睡个 1s 钟就可以了。不但客户端的 CPU 能降下来,Redis 的 QPS 也降下来了。
⚠️睡眠真的不好
睡眠虽然貌似解决了线程空转问题,但是同时也会造成消息的延迟问题,(虽然缩小睡眠时间貌似可以解决)但是这种方法终究是有点不尽人意,这里建议使用阻塞读的方法:
blpop key [key ...] timeout
⚠️阻塞了就真的完事大吉了?
但是如果线程一直阻塞,Redis的客户端就成了闲置连接,如果这个时间过长,那么Redis Server就会主动断开连接,此时就会抛出异常,所以编写客户端消费者的时候要小心,注意捕获异常,还要重试。
🦋锁冲突的处理手法?
- 直接抛出异常,通知用户重试
- sleep一会重试
- 将请求转移到延时队列,稍后重试
直接抛出特定类型的异常
这种方式比较适合由用户直接发起的请求,用户看到错误对话框后,会先阅读对话框的内容,再点击重试,这样就可以起到人工延时的效果。如果考虑到用户体验,可以由前端的代码替代用户自己来进行延时重试控制。它本质上是对当前请求的放弃,由用户决定是否重新发起新的请求。
sleep
sleep 会阻塞当前的消息处理线程,会导致队列的后续消息处理出现延迟。如果碰撞的比较频繁或者队列里消息比较多,sleep 可能并不合适。如果因为个别死锁的 key 导致加锁不成功,线程会彻底堵死,导致后续消息永远得不到及时处理。
延时队列
这种方式比较适合异步消息处理,将当前冲突的请求扔到另一个队列延后处理以避开冲突。
1.5 延时队列的实现
package com.szz.redis.delayqueue;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson2.TypeReference;
import redis.clients.jedis.Jedis;
import java.lang.reflect.Type;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
public abstract class RedisDelayingQueue<T> {
static class TaskItem<T> {
public String id;
public T msg;
}
private Type TaskType = new TypeReference<TaskItem<T>>() {
}.getType();
private Jedis jedis;
private String queueKey;
public RedisDelayingQueue(Jedis jedis, String queueKey) {
this.jedis = jedis;
this.queueKey = queueKey;
}
public void delay(T msg) {
TaskItem<T> task = new TaskItem<T>();
task.id = UUID.randomUUID().toString();
task.msg = msg;
String s = JSON.toJSONString(task);
// 塞入延时队列
jedis.zadd(queueKey, System.currentTimeMillis() + 5000, s);
}
public void loop() {
// 线程不断一直轮
while (!Thread.interrupted()) {
// 只取出一条
Set<String> values = jedis.zrangeByScore(queueKey, 0, System.currentTimeMillis(), 0, 1);
if (values.isEmpty()) {
try {
TimeUnit.MILLISECONDS.sleep(500);
} catch (InterruptedException e) {
break;
}
continue;
}
String s = values.iterator().next();
// 判断是否抢到任务
if (jedis.zrem(queueKey, s) > 0) {
TaskItem<T> task = JSON.parseObject(s, TaskType);
this.handleMsg(task.msg);
}
}
}
protected abstract void handleMsg(T msg);
}
package com.szz.redis.delayqueue;
import redis.clients.jedis.Jedis;
public class RedisDelayQueueExecTask<T> extends RedisDelayingQueue<T>{
public RedisDelayQueueExecTask(Jedis jedis, String queueKey) {
super(jedis, queueKey);
}
protected void handleMsg(T msg) {
//具体业务逻辑
}
}
这里使用模板设计模式
⚠️上述代码存在俩个问题:
- 命令的原子性(查询 + 删除 = 重复消费)
- zrangbyscore的复杂度过高
Lua脚本实现
---
--- Generated by Luanalysis
--- Created by zizaishen.
--- DateTime: 2023/1/14 11:15
---
local key = KEYS[1]
local min = ARGV[1]
local max = ARGV[2]
local result = redis.call('zrangebyscore', key, min, max, 'limit', 0, 1)
if next(result) ~= nil and #result > 0 then
local digit = redis.call('zrem', key, unpack(result));
if digit > 0 then
return result;
end
else
return {}
end
package com.szz.redis.delayqueue;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson2.TypeReference;
import com.szz.redis.utils.JedisPoolUtils;
import org.apache.commons.io.IOUtils;
import redis.clients.jedis.Jedis;
import java.io.InputStream;
import java.lang.reflect.Array;
import java.lang.reflect.Type;
import java.util.*;
import java.util.concurrent.TimeUnit;
public abstract class AtomicDelayQueueOperate<T> implements DelayQueue<T> {
private static Jedis jedis;
private static byte[] dequeueScript;
{
jedis = JedisPoolUtils.getInstance().getResource();
InputStream inputStream = Thread.currentThread().getContextClassLoader().getResourceAsStream("dequeue.lua");
try {
dequeueScript = IOUtils.toByteArray(inputStream);
} catch (Exception e) {
System.out.println("lua script may be not exist");
}
}
/**
* 元任务
*/
static class TaskItem<T> {
public String id;
public T msg;
}
private Type TaskType = new TypeReference<AtomicDelayQueueOperate.TaskItem<T>>() {
}.getType();
private String queueKey;
public AtomicDelayQueueOperate(String queueKey) {
this.queueKey = queueKey;
}
public void enqueue(T msg, long delayTime) {
doEnqueue(msg, delayTime);
}
public void enqueue(T msg) {
doEnqueue(msg, 5000);
}
void doEnqueue(T msg, long delayTime) {
TaskItem<T> task = new TaskItem<T>();
task.id = UUID.randomUUID().toString();
task.msg = msg;
String _task = JSON.toJSONString(task);
// 塞入延时队列
jedis.zadd(queueKey, System.currentTimeMillis() + delayTime, _task);
}
/**
* 循环消费
*/
public void loop() {
while (!Thread.interrupted()) {
String _task = jedis.eval(new String(dequeueScript), Collections.singletonList(queueKey), Arrays.asList("0", System.currentTimeMillis() + "")).toString();
String realTask = _task.substring(1, _task.length() - 1);
TaskItem<T> task = JSON.parseObject(
realTask
,
TaskType);
if (null != task) {
handleMsg(task.msg);
} else {
try {
TimeUnit.MILLISECONDS.sleep(500);
} catch (InterruptedException e) {
break;
}
continue;
}
}
}
}
package com.szz.redis.delayqueue;
public class AtomicDelayQueueOperateExecTask<T> extends AtomicDelayQueueOperate<T> {
public AtomicDelayQueueOperateExecTask(String queueKey) {
super(queueKey);
}
public void handleMsg(T msg) {
// 消费消息
}
}
🏹问题:Redis 作为消息队列为什么不能保证 100% 的可靠性?
1、消费者拉取到消息后,如果发生异常宕机,那这条消息就丢失了。由于的Redis的数据的都是在的存储在内存中,如果机器掉电的话,就可能存在的数据丢失的问题。为了竟可能的少的丢失的数据,可能开启redis的持久化功能。但是也不能百分之百。
2、因为没有ask机制,当消费端崩溃后消息丢失。pop出消息后,list 中就没这个消息了,如果处理消息的程序拿到消息还未处理就挂掉了,那消息就丢失了,所以是不可靠队列。如果对消息可靠性要求较高, 推荐使用 MQ 来实现。
1.6位图
Redis 的位数组是自动扩展,如果设置了某个偏移位置超出了现有的内容范围,就会自动将位数组进行零扩充。
通过位图操作可以实现 “零存整取”、“整存零取”、“零存零取”,如下:
setbit s 1 1
setbit s 2 1
setbit s 4 1
get s
===> h
以此类推即可
Redis 提供了位图统计指令 bitcount 和位图查找指令 bitpos,bitcount 用来统计指定位置范围内 1 的个数,bitpos 用来查找指定范围内出现的第一个 0 或 1。比如我们可以通过 bitcount 统计用户一共签到了多少天,通过 bitpos 指令查找用户从哪一天开始第一次签到。如果指定了范围参数[start, end],就可以统计在某个时间范围内用户签到了多少天,用户自某天以后的哪天开始签到。遗憾的是, start 和 end 参数是字节索引,也就是说指定的位范围必须是 8 的倍数,而不能任意指定。这很奇怪,我表示不是很能理解 Antirez 为什么要这样设计。因为这个设计,我们无法直接计算某个月内用户签到了多少天,而必须要将这个月所覆盖的字节内容全部取出来 (getrange 可以取出字符串的子串) 然后在内存里进行统计,这个非常繁琐。
# 比如
bitcount w 0 0 #其中 0 表示 第一个 byte
bitpos w 0 #搜索第一个0位
bitpos key bit [start [end [BYTE|BIT]]]
bitpos w 1 1 1 #从第二个字符算起,第一个1位
1.7 魔术指令(bitfield)
# 从第一个字符开始取四个无符号位
BITFIELD s get u4 0
# 若 u -> i 则位有符号位
# 也可多次执行
bitfield w get u4 0 get u3 2 get i4 0 get i3 2
从上可知,有符号数最多可以取64位,无符号数只能取63位
再看第三个子指令 incrby,它用来对指定范围的位进行自增操作。既然提到自增,就有可能出现溢出。如果增加了正数,会出现上溢,如果增加的是负数,就会出现下溢出。Redis 默认的处理是折返。如果出现了溢出,就将溢出的符号位丢掉。如果是 8 位无符号数 255, 加 1 后就会溢出,会全部变零。如果是 8 位有符号数 127,加 1 后就会溢出变成 -128
bitfield 指令提供了溢出策略子指令 overflow,用户可以选择溢出行为,默认是折返(wrap),还可以选择失败 (fail) 报错不执行,以及饱和截断 (sat),超过了范围就停留在最大最小值。overflow 指令只影响接下来的第一条指令,这条指令执行完后溢出策略会变成默认值折返 (wrap)。
itfield w overflow sat incrby u4 2 1
1.8 HyperLogLog
HyperLogLog 提供了两个指令 pfadd 和 pfcount,根据字面意义很好理解,一个是增加计数,一个是获取计数。pfadd 用法和 set 集合的 sadd 是一样的,来一个用户 ID,就将用户 ID 塞进去就是。pfcount 和 scard 用法是一样的,直接获取计数值。
# 增加元素
pfadd key element
# 计数
pfcount key
# 多个pf联合统计
pfmerge
HyperLogLog 这个数据结构不是免费的,不是说使用这个数据结构要花钱,它需要占据一定 12k 的存储空间,所以它不适合统计单个用户相关的数据。如果你的用户上亿,可以算算,这个空间成本是非常惊人的。但是相比 set 存储方案,HyperLogLog 所使用的空间那真是可以使用千斤对比四两来形容了。
不过你也不必过于当心,因为 Redis 对 HyperLogLog 的存储进行了优化,在计数比较小时,它的存储空间采用稀疏矩阵存储,空间占用很小,仅仅在计数慢慢变大,稀疏矩阵占用空间渐渐超过了阈值时才会一次性转变成稠密矩阵,才会占用 12k 的空间。
1.9 布隆过滤器
概述
布隆过滤器可以理解为一个不怎么精确的 set 结构,当你使用它的 contains 方法判断某个对象是否存在时,它可能会误判。但是布隆过滤器也不是特别不精确,只要参数设置的合理,它的精确度可以控制的相对足够精确,只会有小小的误判概率。
当布隆过滤器说某个值存在时,这个值可能不存在;当它说不存在时,那就肯定不存在。打个比方,当它说不认识你时,肯定就不认识;当它说见过你时,可能根本就没见过面,不过因为你的脸跟它认识的人中某脸比较相似 (某些熟脸的系数组合),所以误判以前见过你。
基本使用
# 新增元素
bf.add key element
# 判断元素是否存在
bf.exists key element
# 批量操作
bf.madd key element1 element2...
bf.mexists key element1 element2...
默认的布隆过滤器误判率会偏高,我们可以采用带参数的方式
Redis 其实还提供了自定义参数的布隆过滤器,需要我们在 add 之前使用 bf.reserve指令显式创建。如果对应的 key 已经存在,bf.reserve 会报错。bf.reserve 有三个参数,分别是 key, error_rate 和 initial_size。错误率越低,需要的空间越大。initial_size 参数表示预计放入的元素数量,当实际数量超出这个数值时,误判率会上升。所以需要提前设置一个较大的数值避免超出导致误判率升高。如果不使用 bf.reserve,默认的 error_rate 是 0.01,默认的 initial_size 是 100
⚠️注意事项
布隆过滤器的 initial_size 估计的过大,会浪费存储空间,估计的过小,就会影响准确率,用户在使用之前一定要尽可能地精确估计好元素数量,还需要加上一定的冗余空间以避免实际元素可能会意外高出估计值很多。布隆过滤器的 error_rate 越小,需要的存储空间就越大,对于不需要过于精确的场合,error_rate 设置稍大一点也无伤大雅。比如在新闻去重上而言,误判率高一点只会让小部分文章不能让合适的人看到,文章的整体阅读量不会因为这点误判率就带来巨大的改变。
1.10 简单限流
public class SimpleRateLimiter {
private static Jedis jedis;
static {
jedis = JedisPoolUtils.getInstance().getResource();
}
public boolean isActionAllowed(String userid, String actionKey, int period, int maxCount) {
String key = String.format("hist:%s:%s", userid, actionKey);
long nowTs = System.currentTimeMillis();
Pipeline pipe = jedis.pipelined();
// 开启事务
pipe.multi();
pipe.zadd(key, nowTs, "" + nowTs);
// 清空窗口之前的行为记录
pipe.zremrangeByScore(key, 0, nowTs - period * 1000);
Response<Long> count = pipe.zcard(key);
pipe.expire(key, period + 1);
pipe.exec();
pipe.shutdown();
return count.get() <= maxCount;
}
}
1.11 漏斗限流
漏洞的容量是有限的,如果将漏嘴堵住,然后一直往里面灌水,它就会变满,直至再也装不进去。如果将漏嘴放开,水就会往下流,流走一部分之后,就又可以继续往里面灌水。如果漏嘴流水的速率大于灌水的速率,那么漏斗永远都装不满。如果漏嘴流水速率小于灌水的速率,那么一旦漏斗满了,灌水就需要暂停并等待漏斗腾空。
所以,漏斗的剩余空间就代表着当前行为可以持续进行的数量,漏嘴的流水速率代表着系统允许该行为的最大频率。下面我们使用代码来描述单机漏斗算法。
package com.szz.redis.ratelimiter;
import java.util.HashMap;
import java.util.Map;
public class FunnelRateLimiter {
static class Funnel {
// 漏斗容量
int capacity;
// 漏斗流水速率
float leakingRate;
// 漏斗剩余空间
int leftQuota;
// 上一次漏水时间
long leakingTs;
public Funnel(int capacity, float leakingRate) {
this.capacity = capacity;
this.leakingRate = leakingRate;
this.leftQuota = capacity;
this.leakingTs = System.currentTimeMillis();
}
void makeSpace() {
long nowTs = System.currentTimeMillis();
long deltaTs = nowTs - leakingTs;
int deltaQuota = (int) (deltaTs * leakingRate);
if (deltaQuota < 0) {
// 间隔时间太长,整数数字过大溢出
this.leftQuota = capacity;
this.leakingTs = nowTs;
return;
}
if (deltaQuota < 1) {
// 腾出空间太小,最小单位是 1
return;
}
this.leftQuota += deltaQuota;
this.leakingTs = nowTs;
if (this.leftQuota > this.capacity) {
this.leftQuota = this.capacity;
}
}
boolean watering(int quota) {
makeSpace();
if (this.leftQuota >= quota) {
this.leftQuota -= quota;
return true;
}
return false;
}
}
private Map<String, Funnel> funnels = new HashMap<>();
public boolean isActionAllowed(String userId, String actionKey, int capacity, float leakingRate) {
String key = String.format("%s:%s", userId, actionKey);
Funnel funnel = funnels.get(key);
if (funnel == null) {
funnel = new Funnel(capacity, leakingRate);
funnels.put(key, funnel);
}
return funnel.watering(1); // 需要 1 个 quota
}
}
Redis-Cell
Redis 4.0 提供了一个限流 Redis 模块,它叫 redis-cell。该模块也使用了漏斗算法,并提供了原子的限流指令。有了这个模块,限流问题就非常简单了。
该模块只有 1 条指令 cl.throttle,它的参数和返回值都略显复杂,接下来让我们来看看这个指令具体该如何使用。
cl.throttle key capacity(漏洞容量) operations(每60s内的操作数,漏水速率) quota(可选参数-本次要申请的令牌数)
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisCluster;
import redis.clients.jedis.JedisCommands;
public class RedisCellDistributedRateLimiter {
/** jedis命令客户端*/
protected JedisCommands jedis;
/** 自定义限流key*/
protected String key;
/** 时间窗口 单位秒*/
protected int period;
/**最大令牌容量*/
protected int maxCapacity;
public RedisCellDistributedRateLimiter(JedisCommands jedis, String key, int period, int maxCapacity) {
this.jedis = jedis;
this.key = key;
this.period = period;
this.maxCapacity = maxCapacity;
}
/**
* 尝试申请资源
* @param quote 目标资源数
* @return 是否申请成功
*/
public boolean tryAcquire(int quote) throws IOException {
List<String> keys = new ArrayList<>();
keys.add(key);
List<String> argvs = new ArrayList<>();
argvs.add(String.valueOf(maxCapacity));
argvs.add(String.valueOf(maxCapacity));
argvs.add(String.valueOf(period));
argvs.add(String.valueOf(quote));
Object result = null;
if (jedis instanceof Jedis) {
result = ((Jedis) this.jedis).eval(LUA_SCRIPT, keys, argvs);
} else if (jedis instanceof JedisCluster) {
result = ((JedisCluster) this.jedis).eval(LUA_SCRIPT, keys, argvs);
} else {
throw new RuntimeException("redis instance is error") ;
}
return ((List<Long>)result).get(0) == 0;
}
public static final String LUA_SCRIPT = "local key = KEYS[1]\n"+
"local init_burst = tonumber(ARGV[1])\n"+
"local max_burst = tonumber(ARGV[2])\n"+
"local period = tonumber(ARGV[3])\n"+
"local quota = ARGV[4]\n"+
"return redis.call('CL.THROTTLE',key,init_burst,max_burst,period,quota)";
}
1.12 GEO Hash
算法简介
业界比较通用的地理位置距离排序算法是 GeoHash 算法,Redis 也使用 GeoHash 算法。GeoHash 算法将二维的经纬度数据映射到一维的整数,这样所有的元素都将在挂载到一条线上,距离靠近的二维坐标映射到一维后的点之间距离也会很接近。当我们想要计算「附近的人时」,首先将目标位置映射到这条线上,然后在这个一维的线上获取附近的点就行了。
那这个映射算法具体是怎样的呢?它将整个地球看成一个二维平面,然后划分成了一系列正方形的方格,就好比围棋棋盘。所有的地图元素坐标都将放置于唯一的方格中。方格越小,坐标越精确。然后对这些方格进行整数编码,越是靠近的方格编码越是接近。那如何编码呢?一个最简单的方案就是切蛋糕法。设想一个正方形的蛋糕摆在你面前,二刀下去均分分成四块小正方形,这四个小正方形可以分别标记为 00,01,10,11 四个二进制整数。然后对每一个小正方形继续用二刀法切割一下,这时每个小小正方形就可以使用 4bit 的二进制整数予以表示。然后继续切下去,正方形就会越来越小,二进制整数也会越来越长,精确度就会越来越高。其实真实算法中还会有很多其它刀法,最终编码出来的整数数字也都不一样。
编码之后,每个地图元素的坐标都将变成一个整数,通过这个整数可以还原出元素的坐标,整数越长,还原出来的坐标值的损失程度就越小。对于「附近的人」这个功能而言,损失的一点精确度可以忽略不计。
GeoHash 算法会继续对这个整数做一次 base32 编码 (0-9,a-z 去掉 a,i,l,o 四个字母) 变成一个字符串。在 Redis 里面,经纬度使用 52 位的整数进行编码,放进了 zset 里面,zset 的 value 是元素的 key,score 是 GeoHash 的 52 位整数值。zset 的 score 虽然是浮点数,但是对于 52 位的整数值,它可以无损存储。
在使用 Redis 进行 Geo 查询时,我们要时刻想到它的内部结构实际上只是一个zset(skiplist)。通过 zset 的 score 排序就可以得到坐标附近的其它元素 (实际情况要复杂一些,不过这样理解足够了),通过将 score 还原成坐标值就可以得到元素的原始坐标。
简单使用
# 增加地点
geoadd key 经度 纬度 member
# 删除地点 因为本质上是 zset 所以直接使用zrem就可以
# 计算俩个元素间距离
geodist key member1 member2 单位(m、km、ml、ft)米 千米 英里 尺
# 获取元素位置(返回经纬度)
geopop key member
# 获取元素的Hash值(因为geo的算法核心就是 geo hash 算法,结果就是一个字符串)
geohash key member
附近的人功能
# 切记不会排除自身
# 核心指令 查询附近
georadiusbymember key member 距离范围 距离单位 需要的元素数目(count num) asc\desc
# 同时还有三个可选参数
withcoord withdist withhash
# 直接查询范围内元素
georadius key 经度 纬度 距离范围 距离单位 三大参数 count num 排序规则
在一个地图应用中,车的数据、餐馆的数据、人的数据可能会有百万千万条,如果使用Redis 的 Geo 数据结构,它们将全部放在一个 zset 集合中。在 Redis 的集群环境中,集合可能会从一个节点迁移到另一个节点,如果单个 key 的数据过大,会对集群的迁移工作造成较大的影响,在集群环境中单个 key 对应的数据量不宜超过 1M,否则会导致集群迁移出现卡顿现象,影响线上服务的正常运行。
所以,这里建议 Geo 的数据使用单独的 Redis 实例部署,不使用集群环境。如果数据量过亿甚至更大,就需要对 Geo 数据进行拆分,按国家拆分、按省拆分,按市拆分,在人口特大城市甚至可以按区拆分。这样就可以显著降低单个 zset 集合的大小。
1.13 Key检索(scan)
在平时线上 Redis 维护工作中,有时候需要从 Redis 实例成千上万的 key 中找出特定前缀的 key 列表来手动处理数据,可能是修改它的值,也可能是删除 key。这里就有一个问题,如何从海量的 key 中找出满足特定前缀的 key 列表来?
Redis 提供了一个简单暴力的指令 keys 用来列出所有满足特定正则字符串规则的 key。
这个指令使用非常简单,提供一个简单的正则字符串即可,但是有很明显的两个缺点。
- 没有 offset、limit 参数,一次性吐出所有满足条件的 key,万一实例中有几百 w 个key 满足条件,当你看到满屏的字符串刷的没有尽头时,你就知道难受了。
- keys 算法是遍历算法,复杂度是 O(n),如果实例中有千万级以上的 key,这个指令就会导致 Redis 服务卡顿,所有读写 Redis 的其它的指令都会被延后甚至会超时报错,因为Redis 是单线程程序,顺序执行所有指令,其它指令必须等到当前的 keys 指令执行完了才可以继续。
🏹解决?Scan
Scan相比Keys又以下几个特点:
-
复杂度虽然也是 O(n),但是它是通过游标分步进行的,不会阻塞线程;
-
提供 limit 参数,可以控制每次返回结果的最大条数,limit 只是一个 hint,返回的结果可多可少;
-
同 keys 一样,它也提供模式匹配功能;
-
服务器不需要为游标保存状态,游标的唯一状态就是 scan 返回给客户端的游标整数;
-
返回的结果可能会有重复,需要客户端去重复,这点非常重要;
-
遍历的过程中如果有数据修改,改动后的数据能不能遍历到是不确定的;
-
单次返回的结果是空的并不意味着遍历结束,而要看返回的游标值是否为零;
🦋 scan 参数提供了三个参数,第一个是 cursor 整数值,第二个是 key 的正则模式,第三个是遍历的 limit hint。第一次遍历时,cursor 值为 0,然后将返回结果中第一个整数值作为下一次遍历的 cursor。一直遍历到返回的 cursor 值为 0 时结束。
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】凌霞软件回馈社区,博客园 & 1Panel & Halo 联合会员上线
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】博客园社区专享云产品让利特惠,阿里云新客6.5折上折
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· CSnakes vs Python.NET:高效嵌入与灵活互通的跨语言方案对比
· 【.NET】调用本地 Deepseek 模型
· Plotly.NET 一个为 .NET 打造的强大开源交互式图表库
· 上周热点回顾(2.17-2.23)
· DeepSeek “源神”启动!「GitHub 热点速览」