五种基本:字符串、list列表、hash字典、set集合、zset有序集合
键值对设置:
set name value
批量键值对设置:
设置 mset nama1 boy name2 girl name3 unknown
批量读 mget name1 name2 name3
设置自动删除:
set name codehole
get name “codehole”
expire name 5 表示5s过期
数值自增
set age 30
incr age 得到31
incrby age 5 age变为36
Redis 的列表相当于java中的LinkedList , 它是链表,不是数组,所以具备链表的一些特点
list结构常用来做异步队列使用,将需要延后处理的任务结构体序列化字符串塞进Redis的列表,另一个线程从这个列表中轮询数据进行处理。
rpush books python java
lpop books
得到 python
rpush books python java
rpop books
得到 java
几个关键词: lindex ltrim lrange
lindex books 得到 2
ltrim可以设置两个参数 start_indx 和 end_index 定义一个区间,在这个区间的值保留,区间外的统统砍掉
lrang 获取范围
Redis 的字典相当于 Java 语言里面的 HashMap,它是无序字典。内部实现结构上同 Java 的 HashMap 也是一致的,同样的数组 + 链表二维结构。第一维 hash 的数组位置碰撞 时,就会将碰撞的元素使用链表串接起来。
不同的是,Redis 的字典的值只能是字符串,另外它们 rehash 的方式不一样,因为
Java 的 HashMap 在字典很大时,rehash 是个耗时的操作,需要一次性全部 rehash。Redis
为了高性能,不能堵塞服务,所以采用了渐进式 rehash 策略。
注意这里的渐进式rehash策略,在rehash的时候,保留新旧两个hash结构,查询时会同时查询两个Hash结构,然后在后续的定时任务中以及hash的子指令中,循序渐进地将旧hash的内容一点点迁移到新的Hash结构中。当 hash 移除了最后一个元素之后,该数据结构自动被删除,内存被回收。
> hset books java "think in java" # 命令行的字符串如果包含空格,要用引号括起来
(integer) 1
> hset books golang "concurrency in go"
(integer) 1
> hset books python "python cookbook"
(integer) 1
> hgetall books # entries(),key 和 value 间隔出现
1) "java"
2) "think in java"
3) "golang"
4) "concurrency in go"
5) "python"
6) "python cookbook"
> hlen books
(integer) 3
> hget books java
"think in java"
> hset books golang "learning go programming" # 因为是更新操作,所以返回 0
(integer) 0
> hget books golang "learning go programming"
> hmset books java "effective java" python "learning python" golang "modern golang
programming" # 批量 set
OK
同字符串一样,hash 结构中的单个子 key 也可以进行计数,它对应的指令是 hincrby,
和 incr 使用基本一样。
# 老钱又老了一岁
> hincrby user-laoqian age 1
(integer) 30
Redis 的集合相当于 Java 语言里面的 HashSet,它内部的键值对是无序的唯一的。它的 内部实现相当于一个特殊的字典,字典中所有的 value 都是一个值 NULL。 当集合中最后一个元素移除之后,数据结构自动删除,内存被回收。** set 结构可以用来 存储活动中奖的用户 ID,因为有去重功能,**可以保证同一个用户不会中奖两次。
> sadd books python
(integer) 1
> sadd bookspython # 重复
(integer) 0
> sadd books java golang
(integer) 2
> smembers books # 注意顺序,和插入的并不一致,因为 set 是无序的
1) "java"
2) "python"
3) "golang"
> sismember books java # 查询某个 value 是否存在,相当于 contains(o)
(integer) 1
> sismember books rust
(integer) 0
> scard books # 获取长度相当于 count()
(integer) 3
> spop books # 弹出一个
"java"
有序集合中的元素可以排序。但是它和列表使用索引下标作为排序依据不同的是,
它给每个元素设置一个权重(score)作为排序的依据。
有序集合主要应用场景:
用户点赞统计
用户排序
分布式锁是用于分布式环境下并发控制的一种机制,用于控制某个资源在同一时刻只能被一个应用所使用。如下图所示:
Redis 本身可以被多个客户端共享访问,正好就是一个共享存储系统,可以用来保存分布式锁,而且 Redis 的读写性能高,可以应对高并发的锁操作场景。
Redis 的 SET 命令有个 NX 参数可以实现「key不存在才插入」,所以可以用它来实现分布式锁:
基于 Redis 节点实现分布式锁时,对于加锁操作,我们需要满足三个条件。
满足这三个条件的分布式命令如下:
SET lock_key unique_value NX PX 10000
而解锁的过程就是将 lock_key 键删除(del lock_key),但不能乱删,要保证执行操作的客户端就是加锁的客户端。所以,解锁的时候,我们要先判断锁的 unique_value 是否为加锁客户端,是的话,才将 lock_key 键删除。
可以看到,解锁是有两个操作,这时就需要 Lua 脚本来保证解锁的原子性,因为 Redis 在执行 Lua 脚本时,可以以原子性的方式执行,保证了锁释放操作的原子性。
// 释放锁时,先比较 unique_value 是否相等,避免锁的误释放
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
这样一来,就通过使用 SET 命令和 Lua 脚本在 Redis 单节点上完成了分布式锁的加锁和解锁。
基于 Redis 实现分布式锁有什么优缺点?
基于 Redis 实现分布式锁的优点:
基于 Redis 实现分布式锁的缺点:
Redis 如何解决集群情况下分布式锁的可靠性?
为了保证集群环境下分布式锁的可靠性,Redis 官方已经设计了一个分布式锁算法 Redlock(红锁)。
它是基于多个 Redis 节点的分布式锁,即使有节点发生了故障,锁变量仍然是存在的,客户端还是可以完成锁操作。官方推荐是至少部署 5 个 Redis 节点,而且都是主节点,它们之间没有任何关系,都是一个个孤立的节点。
Redlock 算法的基本思路,是让客户端和多个独立的 Redis 节点依次请求申请加锁,如果客户端能够和半数以上的节点成功地完成加锁操作,那么我们就认为,客户端成功地获得分布式锁,否则加锁失败。
这样一来,即使有某个 Redis 节点发生故障,因为锁的数据在其他节点上也有保存,所以客户端仍然可以正常地进行锁操作,锁的数据也不会丢失。
Redlock 算法加锁三个过程:
可以看到,加锁成功要同时满足两个条件(简述:如果有超过半数的 Redis 节点成功的获取到了锁,并且总耗时没有超过锁的有效时间,那么就是加锁成功):
加锁成功后,客户端需要重新计算这把锁的有效时间,计算的结果是「锁最初设置的过期时间」减去「客户端从大多数节点获取锁的总耗时(t2-t1)」。如果计算的结果已经来不及完成共享数据的操作了,我们可以释放锁,以免出现还没完成数据操作,锁就过期了的情况。
加锁失败后,客户端向所有 Redis 节点发起释放锁的操作,释放锁的操作和在单节点上释放锁的操作一样,只要执行释放锁的 Lua 脚本就可以了。
在解决队列的延迟问题时,可以采用blpop/brpop 也就是Blocking 阻塞读。
阻塞读在队列没有数据的时候,会立即进入休眠状态,一旦数据到来,则立刻醒过来。消息的延迟几乎为0。用blpop/brpop替代前面的lpop/rpop就会减少延迟。
用于处理一些Bool型数据的存取,怎么去处理才能最节省空间,这时候就采用位图
当网站需要统计UV时,也就是一天内有多少用户访问了网站,这个数量该如何统计,这需要去重,采用原本的Redis技术器的方式不太好用去重,所以就引入了HyperLogLog这个数据结构,这个可以统计
HyperLogLog 提供了两个指令 pfadd 和 pfcount,根据字面意义很好理解,一个是增加
计数,一个是获取计数。pfadd 用法和 set 集合的 sadd 是一样的,来一个用户 ID,就将用
户 ID 塞进去就是。pfcount 和 scard 用法是一样的,直接获取计数值。
讲个使用场景,比如我们在使用新闻客户端看新闻时,它会给我们不停地推荐新的内
容,它每次推荐时要去重,去掉那些已经看过的内容。问题来了,新闻客户端推荐系统如何
实现推送去重的?
如果说像HyperLogLog那样,只完成了技术,不支持查找,所以这里引入了布隆过滤器,用于去重和记录
有几个问题需要思考:
限流的目的:除了控制流量,限流还有一个应用目的是用于控制用户行为,避免垃圾请求。
如何实现限流?
它的整体思路就是:每一个行为到来时,都维护一次时间窗口。将时间窗口外的记录全部清理掉,只保留窗口内的记录。
代码:
# coding: utf8
import time
import redis
client = redis.StrictRedis()
def is_action_allowed(user_id, action_key, period, max_count):
key = 'hist:%s:%s' % (user_id, action_key)
now_ts = int(time.time() * 1000) # 毫秒时间戳
with client.pipeline() as pipe: # client 是 StrictRedis 实例
# 记录行为
pipe.zadd(key, now_ts, now_ts) # value 和 score 都使用毫秒时间戳
# 移除时间窗口之前的行为记录,剩下的都是时间窗口内的
pipe.zremrangebyscore(key, 0, now_ts - period * 1000)
# 获取窗口内的行为数量
pipe.zcard(key)
# 设置 zset 过期时间,避免冷用户持续占用内存
# 过期时间应该等于时间窗口的长度,再多宽限 1s
pipe.expire(key, period + 1)
# 批量执行
_, _, current_count, _ = pipe.execute()
# 比较数量是否超标
return current_count <= max_count
for i in range(20):
print is_action_allowed("laoqian", "reply", 60, 5)
java版本
public class SimpleRateLimiter {
private Jedis jedis;
public SimpleRateLimiter(Jedis jedis) {
this.jedis = jedis;
}
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.close();
return count.get() <= maxCount;
}
public static void main(String[] args) {
Jedis jedis = new Jedis();
SimpleRateLimiter limiter = new SimpleRateLimiter(jedis);
for(int i=0;i<20;i++) {
System.out.println(limiter.isActionAllowed("laoqian", "reply", 60, 5));
}
}
}
有何缺点?
因为它要记录时间窗口内所有的行为记录,如果这个量很大,比如限定 60s 内操作不得超过 100w 次这样的参数,它是不适合做这样的限流的,因为会消耗大量的存储空间。
漏斗思想:
漏洞的容量是有限的,如果将漏嘴堵住,然后一直往里面灌水,它就会变满,直至再也装不进去。如果将漏嘴放开,水就会往下流,流走一部分之后,就又可以继续往里面灌水。如果漏嘴流水的速率大于灌水的速率,那么漏斗永远都装不满。如果漏嘴流水速率小于灌水的速率,那么一旦漏斗满了,灌水就需要暂停并等待漏斗腾空。
所以,漏斗的剩余空间就代表着当前行为可以持续进行的数量,漏嘴的流水速率代表着系统允许该行为的最大频率。
# coding: utf8
import time
class Funnel(object):
def __init__(self, capacity, leaking_rate):
self.capacity = capacity # 漏斗容量
self.leaking_rate = leaking_rate # 漏嘴流水速率
self.left_quota = capacity # 漏斗剩余空间
self.leaking_ts = time.time() # 上一次漏水时间
def make_space(self):
now_ts = time.time()
delta_ts = now_ts - self.leaking_ts # 距离上一次漏水过去了多久
delta_quota = delta_ts * self.leaking_rate # 又可以腾出不少空间了
if delta_quota < 1: # 腾的空间太少,那就等下次吧
return
self.left_quota += delta_quota # 增加剩余空间
self.leaking_ts = now_ts # 记录漏水时间
if self.left_quota > self.capacity: # 剩余空间不得高于容量
self.left_quota = self.capacity
def watering(self, quota):
self.make_space()
if self.left_quota >= quota: # 判断剩余空间是否足够
self.left_quota -= quota
return True
return False
funnels = {} # 所有的漏斗
# capacity 漏斗容量
# leaking_rate 漏嘴流水速率 quota/s
def is_action_allowed(
user_id, action_key, capacity, leaking_rate):
key = '%s:%s' % (user_id, action_key)
funnel = funnels.get(key)
if not funnel:
funnel = Funnel(capacity, leaking_rate)
funnels[key] = funnel
return funnel.watering(1)
for i in range(20):
print is_action_allowed('laoqian', 'reply', 15, 0.5)
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的会更多。
Redis 4.0 提供了一个限流 Redis 模块,它叫 redis-cell。该模块也使用了漏斗算法,并提供了原子的限流指令。有了这个模块,限流问题就非常简单了
该模块只有 1 条指令 cl.throttle,它的参数和返回值都略显复杂,接下来让我们来看看这个指令具体该如何使用。
127.0.0.1:6379> cl.throttle user123 15 30 60 1
1) (integer) 0 #0表示允许,1表示拒绝
2) (integer) 16 #漏斗的容量是 16
3) (integer) 15 #漏斗中还剩余的数量,本次执行取走了一个,所以还有15个
4) (integer) -1 #如果拒绝了需要等待的时间,单位是秒
5) (integer) 2 #表示漏斗装满需要等待的时间
127.0.0.1:6379> cl.throttle user123 15 30 60 4
1) (integer) 0
2) (integer) 16
3) (integer) 12
4) (integer) -1
5) (integer) 8
127.0.0.1:6379> cl.throttle user123 15 30 60 4
1) (integer) 0
2) (integer) 16
3) (integer) 8
4) (integer) -1
5) (integer) 14
127.0.0.1:6379> cl.throttle user123 15 30 60 4
1) (integer) 0
2) (integer) 16
3) (integer) 5
4) (integer) -1
5) (integer) 20
127.0.0.1:6379> cl.throttle user123 15 30 60 4
1) (integer) 0
2) (integer) 16
3) (integer) 2
4) (integer) -1
5) (integer) 27
127.0.0.1:6379> cl.throttle user123 15 30 60 4
1) (integer) 1
2) (integer) 16
3) (integer) 2
4) (integer) 2
5) (integer) 26
127.0.0.1:6379> cl.throttle user123 15 30 60 4
1) (integer) 0
2) (integer) 16
3) (integer) 0
4) (integer) -1
5) (integer) 31
127.0.0.1:6379> cl.throttle user123 15 30 60 17
1) (integer) 1
2) (integer) 16
3) (integer) 1
4) (integer) -1
5) (integer) 28
127.0.0.1:6379> cl.throttle user123 15 30 60 17
1) (integer) 1
2) (integer) 16
3) (integer) 16
4) (integer) -1
5) (integer) 0
这里让我想到了阿里面试的问题,为用户找到最近的门店,似乎用这个方法解决挺好。
当两个元素的距离不是很远时,可以直接使用勾股定理就能算得元素之间的距离。我们平时使用的「附近的人」的功能,元素距离都不是很大,勾股定理算距离足矣。不过需要注意的是,经纬度坐标的密度不一样 (经度总共 360 度,纬度总共 180 度),勾股定律计算平方差时之后再求和时,需要按一定的系数比加权求和。
现在,如果要计算「附近的人」,也就是给定一个元素的坐标,然后计算这个坐标附近的其它元素,按照距离进行排序,该如何下手?
首先,你不可能通过遍历来计算所有的元素和目标元素的距离然后再进行排序,这个计算量太大了,性能指标肯定无法满足。一般的方法都是通过矩形区域来限定元素的数量,然后对区域内的元素进行全量距离计算再排序。这样可以明显减少计算量。
如何划分矩形区域呢?可以指定一个半径 r,使用一条 SQL 就可以圈出来。当用户对筛出来的结果不满意,那就扩大半径继续筛选。可以使用的SQL语句如下:
select id from positions where x0-r < x < x0+r and y0-r < y < y0+r
为了满足高性能的矩形区域算法,数据表需要在经纬度坐标加上双向复合索引 (x, y),这样可以最大优化查询性能。
但是数据库查询性能毕竟有限,如果「附近的人」查询请求非常多,在高并发场合,这可能并不是一个很好的方案。
业界比较通用的地理位置距离排序算法是 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 指令携带集合名称以及多个经纬度名称三元组,注意这里可以加入多个三元组
127.0.0.1:6379> geoadd company 116.48105 39.996794 juejin
(integer) 1
127.0.0.1:6379> geoadd company 116.514203 39.905409 ireader
(integer) 1
127.0.0.1:6379> geoadd company 116.489033 40.007669 meituan
(integer) 1
127.0.0.1:6379> geoadd company 116.562108 39.787602 jd 116.334255 40.027400 xiaomi
(integer) 2
geodist指令可以用来计算两个元素之间的距离,携带集合名称、2 个名称和距离单位。
127.0.0.1:6379> geodist company juejin ireader km
"10.5501"
127.0.0.1:6379> geodist company juejin meituan km
"1.3878"
127.0.0.1:6379> geodist company juejin jd km
"24.2739"
127.0.0.1:6379> geodist company juejin xiaomi km
"12.9606"
127.0.0.1:6379> geodist company juejin juejin km
"0.0000"
geopos 指令可以获取集合中任意元素的经纬度坐标,可以一次获取多个。
127.0.0.1:6379> geopos company juejin
1) 1) "116.48104995489120483"
2) "39.99679348858259686"
127.0.0.1:6379> geopos company ireader
1) 1) "116.5142020583152771"
2) "39.90540918662494363"
127.0.0.1:6379> geopos company juejin ireader
1) 1) "116.48104995489120483"
2) "39.99679348858259686"
2) 1) "116.5142020583152771"
2) "39.90540918662494363"
可以看到有一定偏差,原因是 geohash 对二维坐标进行的一维映射是有损的,通过映射再还原回来的值会出现较小的差别。
但是这个对业务的影响比较小,所以可以忽略不计
geohash 可以获取元素的经纬度编码字符串,上面已经提到,它是 base32 编码。 你可以使用这个编码值去 [http://geohash.org/ h a s h ] ( h t t p : / / g e o h a s h . o r g / {hash}](http://geohash.org/ hash](http://geohash.org/{hash})中进行直接定位,它是 geohash 的标准编码值。
127.0.0.1:6379> geohash company ireader
1) "wx4g52e1ce0"
127.0.0.1:6379> geohash company juejin
1) "wx4gd94yjn0"
georadiusbymember 指令是最为关键的指令,它可以用来查询指定元素附近的其它元素,它的参数非常复杂。
# 范围 20 公里以内最多 3 个元素按距离正排,它不会排除自身
127.0.0.1:6379> georadiusbymember company ireader 20 km count 3 asc
1) "ireader"
2) "juejin"
3) "meituan"
# 范围 20 公里以内最多 3 个元素按距离倒排
127.0.0.1:6379> georadiusbymember company ireader 20 km count 3 desc
1) "jd"
2) "meituan"
3) "juejin"
# 三个可选参数 withcoord withdist withhash 用来携带附加参数
# withdist 很有用,它可以用来显示距离
127.0.0.1:6379> georadiusbymember company ireader 20 km withcoord withdist withhash count 3 asc
1) 1) "ireader"
2) "0.0000"
3) (integer) 4069886008361398
4) 1) "116.5142020583152771"
2) "39.90540918662494363"
2) 1) "juejin"
2) "10.5501"
3) (integer) 4069887154388167
4) 1) "116.48104995489120483"
2) "39.99679348858259686"
3) 1) "meituan"
2) "11.5748"
3) (integer) 4069887179083478
4) 1) "116.48903220891952515"
2) "40.00766997707732031"
通过具体坐标查看附近的事物
除了 georadiusbymember 指令根据元素查询附近的元素,Redis 还提供了根据坐标值来查询附近的元素,这个指令更加有用,它可以根据用户的定位来计算「附近的车」,「附近的餐馆」等。它的参数和 georadiusbymember 基本一致,除了将目标元素改成经纬度坐标值。
127.0.0.1:6379> georadius company 116.514202 39.905409 20 km withdist count 3 asc
1) 1) "ireader"
2) "0.0000"
2) 1) "juejin"
2) "10.5501"
3) 1) "meituan"
2) "11.5748"
当单个key值数据过大的时候,应该减少Redis集群部署,在 Redis 的集群环境中,集合可能会从一个节点迁移到另一个节点,如果单个 key 的数据过大,会对集群的迁移工作造成较大的影响,在集群环境中单个 key 对应的数据量不宜超过 1M,否则会导致集群迁移出现卡顿现象,影响线上服务的正常运行。
如果数据量过亿甚至更大,就需要对 Geo 数据进行拆分,按国家拆分、按省拆分,按市拆分,在人口特大城市甚至可以按区拆分。这样就可以显著降低单个 zset 集合的大小。
在平时线上 Redis 维护工作中,有时候需要从 Redis 实例成千上万的 key 中找出特定前缀的 key 列表来手动处理数据,可能是修改它的值,也可能是删除 key。这里就有一个问题,如何从海量的 key 中找出满足特定前缀的 key 列表来?
这里有keys命令,可以简单粗暴地把所有key都列出来,但是这种简单的命令有着两个致命的缺陷:
新的东西产生就是为了解决之前旧东西的问题,所以scan可以解决以上这两个问题,它具有以下特点:
Redis是个单线程程序,由于这个特点,所以要小心使用Redis的指令,使用不当会导致卡顿。
在所有事件处理上,Redis都是以单线程形式处理,所以说Redis是单线程的。同时,Redis基于Reactor模式开发了自己的I/O事件处理器,也就是文件事件处理器,Redis在I/O事件处理上,采用了I/O多路复用技术,同时监听多个套接字,并为套接字关联不同的事件处理函数,通过一个线程实现了多客户端并发处理。
但是,实际上Redis也并不是单线程的,比如生成RDB文件,就会fork一个子进程来实现。
在Redis6.0版本的多线程并非彻底的多线程,I/O线程只能同时执行读或者同时执行写操作。在开启多线程后,并不会存在线程并发的安全问题,因为Redis的多线程部分只是用来处理网络数据的读写和协议解析,执行命令仍然是单线程顺序执行。
主要有以下几个原因:
1、Redis的数据都存储在内存中,读写速度快
2、Redis有高效的数据结构
3、Redis是单线程模型,并且是IO多路复用
4、Redis 的虚拟内存机制:Redis直接自己构建了VM机制 ,不会像一般的系统会调用系统函数处理,会浪费一定的时间去移动和请求。
Redis多路复用模型,跟其他的模型相似,如下图所示,这一部分我之前总结的就很全面
Redis 的作者认为数据库系统的瓶颈一般不在于网络流量,而是数据库自身内部逻辑处理上。所以即使 Redis 使用了浪费流量的文本协议,依然可以取得极高的访问性能。Redis 将所有数据都放在内存,用一个单线程对外提供服务,单个节点在跑满一个 CPU 核心的情 况下可以达到了 10w/s 的超高 QPS
译为:Redis序列化协议,是一种直观的文本协议,优势在于实现异常简单,解析性能极好。
Redis 协议将传输的结构数据分为 5 种最小单元类型,单元结束时统一加上回车换行符
号\r\n。
1、单行字符串 以 + 符号开头。
2、多行字符串 以 $ 符号开头,后跟字符串长度。
3、整数值 以 : 符号开头,后跟整数的字符串形式。
4、错误消息 以 - 符号开头。
5、数组 以 * 号开头,后跟数组的长度。
**单行字符串 **hello world
+hello world\r\n
**多行字符串 **hello world
$11\r\nhello world\r\n
多行字符串当然也可以表示单行字符串。
**整数 **1024
:1024\r\n
**错误 **参数类型错误
-WRONGTYPE Operation against a key holding the wrong kind of value
**数组 **[1,2,3]
*3\r\n:1\r\n:2\r\n:3\r\n
**NULL **用多行字符串表示,不过长度要写成-1。
$-1\r\n
**空串 **用多行字符串表示,长度填 0。
$0\r\n\r\n
客户端向服务器发送的指令只有一种格式,多行字符串数组。
比如一个简单的 set 指令
set author codehole 会被序列化成下面的字符串。
*3\r\n$3\r\nset\r\n$6\r\nauthor\r\n$8\r\ncodehole\r\n
服务器向客户端回复的响应要支持多种数据结构,所以消息响应在结构上要复杂不少。不过再复杂的响应消息也是以上 5 中基本类型的组合。
127.0.0.1:6379> set author codehole
OK
这里的 OK 就是单行响应,没有使用引号括起来。
+OK
27.0.0.1:6379> incr author
(error) ERR value is not an integer or out of range
试图对一个字符串进行自增,服务器抛出一个通用的错误。
-ERR value is not an integer or out of range
127.0.0.1:6379> incr books
(integer) 1
这里的 1 就是整数响应
:1
127.0.0.1:6379> get author
"codehole"
这里使用双引号括起来的字符串就是多行字符串响应
$8
codehole
127.0.0.1:6379> hset info name laoqian
(integer) 1
127.0.0.1:6379> hset info age 30
(integer) 1
127.0.0.1:6379> hset info sex male
(integer) 1
127.0.0.1:6379> hgetall info
1) "name"
2) "laoqian"
3) "age"
4) "30"
5) "sex"
6) "male"
这里的 hgetall 命令返回的就是一个数值,第 0|2|4 位置的字符串是 hash 表的 key,第
1|3|5 位置的字符串是 value,客户端负责将数组组装成字典再返回。
*6
$4
name
$6
laoqian
$3
age
$2
30
$3
sex
$4
male
127.0.0.1:6379> scan 0
1) "0"
2) 1) "info"
2) "books"
3) "author"
scan 命令用来扫描服务器包含的所有 key 列表,它是以游标的形式获取,一次只获取一部分。
scan 命令返回的是一个嵌套数组。数组的第一个值表示游标的值,如果这个值为零,说明已经遍历完毕。如果不为零,使用这个值作为 scan 命令的参数进行下一次遍历。数组的第二个值又是一个数组,这个数组就是 key 列表。
*2
$1
0
*3
$4
info
$5
books
$6
author
Redis 的数据全部在内存里,如果突然宕机,数据就会全部丢失,因此必须有一种机制来保证 Redis 的数据不会因为故障而丢失,这种机制就是 Redis 的持久化机制。
Redis 的持久化机制有两种,第一种是快照,第二种是 AOF 日志。快照是一次全量备份,AOF 日志是连续的增量备份。快照是内存数据的二进制序列化形式,在存储上非常紧凑,而 AOF 日志记录的是内存数据修改的指令记录文本。AOF 日志在长期的运行过程中会变的无比庞大,数据库重启时需要加载 AOF 日志进行指令重放,这个时间就会无比漫长。所以需要定期进行 AOF 重写,给 AOF 日志进行瘦身。
Redis 使用操作系统的多进程 COW(Copy On Write) 机制来实现快照持久化。
COW(copy-on-write 的简称),是一种计算机设计领域的优化策略,其核心思想是:如果有多个调用者(callers)同时要求相同资源(如内存或磁盘上的数据存储),他们会共同获取相同的指针指向相同的资源,直到某个调用者试图修改资源的内容时,系统才会真正复制一份专用副本(private copy)给该调用者,而其他调用者所见到的最初的资源仍然保持不变。这过程对其他的调用者都是透明的(transparently)。此作法主要的优点是如果调用者没有修改该资源,就不会有副本(private copy)被创建,因此多个调用者只是读取操作时可以共享同一份资源
技术实现原理:
fork()之后,kernel把父进程中所有的内存页的权限都设为read-only,然后子进程的地址空间指向父进程。当父子进程都只读内存时,相安无事。当其中某个进程写内存时,CPU硬件检测到内存页是read-only的,于是触发页异常中断(page-fault),陷入kernel的一个中断例程。中断例程中,kernel就会把触发的异常的页复制一份,于是父子进程各自持有独立的一份。
RDB 快照表示的是某一时刻 Redis 内存中所有数据的写照。在执行 RDB 持久化时,Redis 进程会 fork 一个子进程来执行持久化过程,该过程是阻塞的,当 fork 过程完成后父进程会继续接收客户端的命令。子进程与 Redis 进程共享内存中的数据,但是子进程并不会修改内存中的数据,而是不断的遍历读取写入文件中,但是 Redis 父进程则不一样,它需要响应客户端的命令对内存中数据不断地修改,这个时候就会使用操作系统的 COW 机制来进行数据段页面的分离,当 Redis 父进程对其中某一个页面的数据进行修改时,则会将页面的数据复制一份出来,然后对这个复制页进行修改,这个时候子进程相应的数据页并没有发生改变,依然是 fork 那一瞬间的数据。
AOF 日志存储的是 Redis 服务器的顺序指令序列,AOF 日志只记录对内存进行修改的指令记录。
假设 AOF 日志记录了自 Redis 实例创建以来所有的修改性指令序列,那么就可以通过对一个空的 Redis实例顺序执行所有的指令,也就是「重放」,来恢复 Redis 当前实例的内存数据结构的状态。
Redis 会在收到客户端修改指令后,先进行参数校验,如果没问题,就立即将该指令文本存储到 AOF 日志中,也就是先存到磁盘,然后再执行指令。这样即使遇到突发宕机,已经存储到 AOF 日志的指令进行重放一下就可以恢复到宕机前的状态。
Redis 在长期运行的过程中,AOF 的日志会越变越长。如果实例宕机重启,重放整个AOF 日志会非常耗时,导致长时间 Redis 无法对外提供服务。所以需要对 AOF 日志瘦身
**提出来的原因:**如果机器突然宕机,AOF 日志内容可能还没有来得及完全刷到磁盘中,这个
时候就会出现日志丢失。那该怎么办?
所以:
Linux 的 glibc 提供了 fsync(int fd)函数可以将指定文件的内容强制从内核缓存刷到磁盘。只要 Redis 进程实时调用 fsync 函数就可以保证 aof 日志不丢失。但是 fsync 是一个磁盘 IO 操作,它很慢!如果 Redis执行一条指令就要 fsync 一次,那么 Redis 高性能的地位就不保了。
一般使用情况:
在生产环境的服务器中,Redis 通常是每隔 1s 左右执行一次 fsync 操作,周期 1s是可以配置的。这是在数据安全性和性能之间做了一个折中,在保持高性能的同时,尽可能使得数据少丢失。
Redis 同样也提供了另外两种策略,一个是永不 fsync——让操作系统来决定合适同步磁盘,很不安全,另一个是来一个指令就 fsync 一次——非常慢。但是在生产环境基本不会使用,了解一下即可。
Redis 提供了 bgrewriteaof 指令用于对 AOF 日志进行瘦身。其原理就是开辟一个子进程对内存进行遍历转换成一系列 Redis 的操作指令,序列化到一个新的 AOF 日志文件中。序列化完毕后再将操作期间发生的增量 AOF 日志追加到这个新的 AOF 日志文件中,追加完毕后就立即替代旧的 AOF 日志文件了,瘦身工作就完成了
使用 AOF 持久化会让 Redis 变得非常耐久(much more durable):你可以设置不同的 fsync 策略,比如无 fsync ,每秒钟一次 fsync ,或者每次执行写入命令时 fsync 。 AOF 的默认策略为每秒钟 fsync 一次,在这种配置下,Redis 仍然可以保持良好的性能,并且就算发生故障停机,也最多只会丢失一秒钟的数据( fsync 会在后台线程执行,所以主线程可以继续努力地处理命令请求)。
对于相同的数据集来说,AOF 文件的体积通常要大于 RDB 文件的体积。根据所使用的 fsync 策略,AOF 的速度可能会慢于 RDB。 在一般情况下, 每秒 fsync 的性能依然非常高, 而关闭 fsync 可以让 AOF 的速度和 RDB 一样快, 即使在高负荷之下也是如此。 不过在处理巨大的写入载入时,RDB 可以提供更有保证的最大延迟时间(latency)。
RDB持久化是指在指定的时间间隔内将内存中的数据集快照写入磁盘,实际操作过程是fork一个子进程,先将数据集写入临时文件,写入成功后,再替换之前的文件,用二进制压缩存储。
AOF持久化以日志的形式记录服务器所处理的每一个写、删除操作,查询操作不会记录,以文本的方式记录,可以打开文件看到详细的操作记录。
RDB 和 AOF ,我应该用哪一个?
数据库备份和灾难恢复:定时生成 RDB 快照(snapshot)非常便于进行数据库备份, 并且 RDB 恢复数据集的速度也要比 AOF 恢复的速度要快。
Redis 支持同时开启 RDB 和 AOF,系统重启后,Redis 会优先使用 AOF 来恢复数据,这样丢失的数据会最少。
服务器根本没有任何区别对待,还是收到一条消息,执行一条消息,回复一条消息的正常的流程。客户端通过对管道中的指令列表改变读写顺序就可以大幅节省 IO 时间。管道中指令越多,效果越好。
上图就是一个完整的请求交互流程图。我用文字来仔细描述一遍:
1、客户端进程调用 write 将消息写到操作系统内核为套接字分配的发送缓冲 send buffer。
2、客户端操作系统内核将发送缓冲的内容发送到网卡,网卡硬件将数据通过「网际路由」送到服务器的网卡。
3、服务器操作系统内核将网卡的数据放到内核为套接字分配的接收缓冲 recv buffer。
4、服务器进程调用 read 从接收缓冲中取出消息进行处理。
5、服务器进程调用 write 将响应消息写到内核为套接字分配的发送缓冲 send buffer。
6、服务器操作系统内核将发送缓冲的内容发送到网卡,网卡硬件将数据通过「网际路由」送到客户端的网卡。
7、客户端操作系统内核将网卡的数据放到内核为套接字分配的接收缓冲 recv buffer。
8、客户端进程调用 read 从接收缓冲中取出消息返回给上层业务逻辑进行处理。
9、结束。
其中步骤 5~8 和 1~4 是一样的,只不过方向是反过来的,一个是请求,一个是响应。
我们开始以为 write 操作是要等到对方收到消息才会返回,但实际上不是这样的。write操作只负责将数据写到本地操作系统内核的发送缓冲然后就返回了。剩下的事交给操作系统内核异步将数据送到目标机器。但是如果发送缓冲满了,那么就需要等待缓冲空出空闲空间来,这个就是写操作 IO 操作的真正耗时。
我们开始以为 read 操作是从目标机器拉取数据,但实际上不是这样的。read 操作只负责将数据从本地操作系统内核的接收缓冲中取出来就了事了。但是如果缓冲是空的,那么就需要等待数据到来,这个就是读操作 IO 操作的真正耗时。
所以对于 value = redis.get(key)这样一个简单的请求来说,write 操作几乎没有耗时,直接写到发送缓冲就返回,而 read 就会比较耗时了,因为它要等待消息经过网络路由到目标机器处理后的响应消息,再回送到当前的内核读缓冲才可以返回。这才是一个网络来回的真正开销。
而对于管道来说,连续的 write 操作根本就没有耗时,之后第一个 read 操作会等待一个网络的来回开销,然后所有的响应消息就都已经回送到内核的读缓冲了,后续的 read 操作直接就可以从缓冲拿到结果,瞬间就返回了。
一般事务具有的形式:
每个事务的操作都有 begin、commit 和 rollback,begin 指示事务的开始,commit 指示事务的提交,rollback 指示事务的回滚。它大致的形式如下。
begin();
try {
command1();
command2();
....
commit();
} catch(Exception e) {
rollback();
}
对于Redis来说
Redis 在形式上看起来也差不多,分别是 multi/exec/discard。multi 指示事务的开始,exec 指示事务的执行,discard 指示事务的丢弃。
> multi
OK
> incr books
QUEUED
> incr books
QUEUED
> exec
(integer) 1
(integer) 2
上面的指令演示了一个完整的事务过程,所有的指令在 exec 之前不执行,而是缓存在服务器的一个事务队列中,服务器一旦收到 exec 指令,才开执行整个事务队列,执行完毕后一次性返回所有指令的运行结果。因为 Redis 的单线程特性,它不用担心自己在执行队列的时候被其它指令打搅,可以保证他们能得到的「原子性」执行。
事务的原子性是指要么事务全部成功,要么全部失败,但是对于Redis事务的原子性,这个是不成立的。
> multi
OK
> set books iamastring
QUEUED
> incr books
QUEUED
> set poorman iamdesperate
QUEUED
> exec
1) OK
2) (error) ERR value is not an integer or out of range
3) OK
> get books
"iamastring"
> get poorman
"iamdesperate
由上可知,Redis事务中有语句没有执行成功的时候,它后面的语句会继续执行,不影响其执行
discard意味事务的丢弃,Redis 为事务提供了一个 discard 指令,用于丢弃事务缓存队列中的所有指令,在 exec
执行之前。
> get books
(nil)
> multi
OK
> incr books
QUEUED
> incr books
QUEUED
> discard
OK
> get books
(nil)
我们可以看到 discard 之后,队列中的所有指令都没执行,就好像 multi 和 discard 中间的所有指令从未发生过一样。
很多企业都没有使用到 Redis 的集群,但是至少都做了主从。很多有了主从,当 master 挂掉的时候,运维让从库过来接管,服务就可以继续,否则 master 需要经过数据恢复和重启的过程,这就可能会拖很长的时间,影响线上业务的持续服务。
要理解 CAP,首先我们要清楚,为何会有人提出 CAP?他提出 CAP 是为了解决什么问题?
时间回到 1985 年,彼时,后来证明了 CAP 理论的 Lynch 教授此时给当时的 IT 界来了一记惊雷:
她通过不可辩驳的证明告诉业界的工程师们,如果在一个不稳定(消息要么乱序要么丢了)的网络环境里(分布式异步模型),想始终保持数据一致是不可能的。
这是个什么概念呢?就是她打破了那些既想提供超高质量服务,又想提供超高性能服务的技术人员的幻想。
这本质是在告诉大家,在分布式系统里,需要妥协。
但是,如何妥协?分布式系统里到底应该怎么权衡这种 trade-off?
我们可以想象一下,在 CAP 定理提出之前,没有这些方向性的指引,在设计和实施分布式系统时该有多么混乱。一套分布式系统是由多个模块组成的,这些模块本身可能由不同的开发人员去完成。然而,对于这些人,在公共层面,竟然没有一个原则去指导他们该怎么完成这套功能。
比如,我们在同步两个节点的数据时,如果发生了错误,到底我们应该怎么做呢?如果没有统一的标准和方向,那很可能在一套分布式系统中的不同模块,会出现不同的处理情况。
假设一套系统,由 A、B 两个模块构成。
A 模块的设计理念是:节点间出现了问题,它可能会选择不断的重试,一直等到节点通信恢复。
而 B 的设计理念是:节点间出现了问题,它断开就是了,可能最多就记录下状态,等以后处理。
可是,当 A、B 之间出现了通信怎么办?那会出现 A 往 B 发请求,出问题会不断重试。而 B 往 A 发请求,出问题则直接断开的情况。
当然,在后面我们会说明,CAP 的理念在实际工程中,会允许这种不一致。可是,那种不一致是提前设计好和规划好的,是根据实际数据的重要性和业务需求做的妥协,而不是这种混乱的妥协。
所以,IT 界的人们就一直在摸索,试图找到一些纲领去指导分布式系统的设计,这一找就找了 15 年。
2000 年时,Eric Brewer 教授在 PODC 会议上提出了 CAP 理论,但是由于没有被证明过,所以,当时只能被称为 CAP 猜想。这个猜想引起了巨大的反响,因为 CAP 很符合人们对设计纲领的预期。
在 2002 年后,经过 Seth Gilbert 和 Nancy Lynch 从理论上证明了 CAP 猜想后,CAP 理论正式成为了分布式系统理论的基石之一。
CAP 定理表达了一个分布式系统里不可能同时满足以下的三个特性:数据一致性、可用性、分区容忍性。
什么是数据一致性?咋一看真的很让人糊涂,一致性是什么?是指数据能一起变化,是能让数据整齐划一。
那么问题又来了,数据何时会变化?数据怎么才能被称为一起变化?我们现在来回答这些问题,当我们搞清楚了这些问题,那么对数据一致性就会有了清晰的理解。
首先第一个问题,数据何时会一起变化?
答案是:仅且仅当包含数据的服务,收到数据更新请求的时候,数据才会发生变化。而数据更新请求则仅包括数据的增、删、改这三种请求,而这三种请求又被统称为写请求。所以,数据只有在写请求的时候才会发生变化。
那我们来回答第二个问题,数据要怎么样才能被称为一起变化了?即谁来判断数据是最终变化了?是服务器对写请求的返回结果吗?告诉写请求成功,数据就一定发生一致性变化了?
NO,数据发生变化是否一致是需要经过读请求来做检验的。那么读请求判断的依据是什么呢?
假设,我们的分布式存储系统有两个节点,每个节点都包含了一部分需要被变化的数据。如果经过一次写请求后,两个节点都发生了数据变化。然后,读请求把这些变化后的数据都读取到了,我们就把这次数据修改称为数据发生了一致性变化。
但是,这还不是完整的一致性。因为系统不可能永久的正常运行下去。
如果系统内部发生了问题从而导致系统的节点无法发生一致性变化会怎么样呢?当我们这样做的时候,就意味着想看到最新数据的读请求们,很可能会看到旧数据,或者说获取到不同版本的数据。此时,为了保证分布式系统对外的数据一致性,于是选择不返回任何数据。
这里需要注意一下,CAP 定理是在说在某种状态下的选择,和实际工程的理论是有差别的。上面描述的一致性和 ACID 事务中的一致性是两回事。事务中的一致性包含了实际工程对状态的后续处理。但是 CAP 定理并不涉及到状态的后续处理,对于这些问题,后续出现了 BASE 理论等工程结论去处理,目前,只需要明白 CAP 定理主要描述的是状态。
奥维德曾经说过:“行动被人们遗忘,结果却将永存”。
这句话说明了结果的重要性,而可用性在 CAP 里就是对结果的要求。它要求系统内的节点们接收到了无论是写请求还是读请求,都要能处理并给回响应结果。只是它有两点必须满足的条件:
条件 1:返回结果必须在合理的时间以内,这个合理的时间是根据业务来定的。业务说必须 100 毫秒内返回,合理的时间就是 100 毫秒,需要 1 秒内返回,那就是 1 秒,如果业务定的 100 毫秒,结果却在 1 秒才返回,那么这个系统就不满足可用性。
条件 2:需要系统内能正常接收请求的所有节点都返回结果。这包含了两重含义:
如果节点不能正常接收请求了,比如宕机了,系统崩溃了,而其他节点依然能正常接收请求,那么,我们说系统依然是可用的,也就是说,部分宕机没事儿,不影响可用性指标。
**如果节点能正常接收请求,但是发现节点内部数据有问题,那么也必须返回结果,哪怕返回的结果是有问题的。**比如,系统有两个节点,其中有一个节点数据是三天前的,另一个节点是两分钟前的,如果,一个读请求跑到了包含了三天前数据的那个节点上,抱歉,这个节点不能拒绝,必须返回这个三天前的数据,即使它可能不太合理。
分布式的存储系统会有很多的节点,这些节点都是通过网络进行通信。而网络是不可靠的,当节点和节点之间的通信出现了问题,此时,就称当前的分布式存储系统出现了分区。但是,值得一提的是,分区并不一定是由网络故障引起的,也可能是因为机器故障。
比如,我们的分布式存储系统有 A、B 两个节点。那么,当 A、B 之间由于可能路由器、交换机等底层网络设备出现了故障,A 和 B 通信出现了问题,但是 A、B 依然都在运行,都在对外提供服务。这时候,就说 A 和 B 发生了分区。
还有一种情况也会发生分区,当 A 出现了宕机,A 和 B 节点之间通信也是出现了问题,那么我们也称 A 和 B 发生了分区。
综上,我们可以知道,只要在分布式系统中,节点通信出现了问题,那么就出现了分区。
那么,分区容忍性是指什么? 它是说,如果出现了分区问题,我们的分布式存储系统还需要继续运行。不能因为出现了分区问题,整个分布式节点全部就熄火了,罢工了,不做事情了。
一致性和可用性,为什么不可能同时成立?
答案很简单,因为可能通信失败(即出现分区容错)。
如果保证 G2 的一致性,那么 G1 必须在写操作时,锁定 G2 的读操作和写操作。只有数据同步后,才能重新开放读写。锁定期间,G2 不能读写,没有可用性不。 (没有可用性就失去了Avaliability的特点)
如果保证 G2 的可用性,那么势必不能锁定 G2,所以一致性不成立。(失去Consistency)
综上所述,G2 无法同时做到一致性和可用性。系统设计时只能选择一个目标。如果追求一致性,那么无法保证所有节点的可用性;如果追求所有节点的可用性,那就没法做到一致性。
我们上面已经知道了,在设计分布式系统时,架构师们在 C、A、P 这三种特性里,只能选择两种。
但是,这道 CAP 的选择题,就像别人在问你“小明的父亲有三个孩子,老大叫大朗,老二叫二郎,请问老三叫什么”一样。在以分布式存系统为限定条件的 CAP 世界里,P 是早已经确定的答案,P 是必须的。
因为,在分布式系统内,P 是必然的发生的,不选 P,一旦发生分区错误,整个分布式系统就完全无法使用了,这是不符合实际需要的。所以,对于分布式系统,我们只能能考虑当发生分区错误时,如何选择一致性和可用性。
而根据一致性和可用性的选择不同,开源的分布式系统往往又被分为 CP 系统和 AP 系统。
当一套系统在发生分区故障后,客户端的任何请求都被卡死或者超时,但是,系统的每个节点总是会返回一致的数据,则这套系统就是 CP 系统,经典的比如 Zookeeper。
如果一套系统发生分区故障后,客户端依然可以访问系统,但是获取的数据有的是新的数据,有的还是老数据,那么这套系统就是 AP 系统,经典的比如 Eureka。
说了这么多,其实 CAP 定理本质很简单,它就是一种分布式系统设计的不同理念概括,包括它说的一致性,可用性和分区容错性。这就类似一个大学的校训,是极度概念化的东西。
所以,大白话来形容下 CAP 吧,CAP 就是告诉程序员们当分布式系统出现内部问题了,你要做两种选择:
迁就外部服务就是我们不能因为我们自己的问题让外部服务的业务运行受到影响,所以要优先可用性。而让外部服务迁就我们,就要优先一致性。
很多人在没有对 CAP 做深入了解的情况下,听到很多人说分布式系统必须在 CAP 三个特性里选择两个,就觉得一套分布式系统肯定要么只有可用性要么只有一致性,不存在完整的可用性和一致性功能。
这种理解是大有问题的。因为,P 这种问题发生的概率非常低,所以:
当没有出现分区问题的时候,系统就应该有完美的数据一致性和可用性。
你什么时候见过一个系统,当内部没有问题的时候,会经常让外部请求卡一下的?要么就冷不丁的提供陈旧的老数据?那还能叫系统吗?
这个理解也是不对的。当分区发生的时候,其实对一致性和可用性的抉择是局部性的,而不是针对整个系统的。
可能是在一些子系统做一些抉择,甚至很可能只需要对某个事件或者数据,做一致性和可用性的抉择而已。
比如,当我们做一套支付系统的时候,会员的财务相关像账户余额,账务流水是必须强一致性的。这时候,你就要考虑选 C。但是,会员的名字,会员的支付设置就不必考虑强一致性,可以选择可用性 A。
一套分布式系统的运行,就像人生一样,就是一次又一次的选择。在不同阶段,不同的时刻有不同的事件发生的时候,又怎么可能会有完全一样的选择呢?
这种二元性的理解更是极其误导人。
CAP 理论的三种特性不是 Boolean 类型的,不是一致和不一致,可用和不可用,分区和没分区的这类二选一的选项。而是这三种特性都是范围类型。
拿可用性来说,就像我从银行取钱。当我目的是派发压岁钱的时候,我很可能就想全要新票子,但是,新票子很可能就还得多一个步骤,就是需要拿旧票子去换一些新票,此时,我可以多等会儿,能拿到新票子就好。而当我的目的就是做生活花销的时候,票子是新是旧,我根本不那么关心,快点拿到钱就行。这就是可用性的范围需求之一,对时延性的要求。
再比如,分区容错则由于探测机制的问题,可能还得各节点搞投票去协商分区是否存在,当某一台机器出现了问题,可能不影响业务的话,就会被机器投票认为分区不存在。然后一直等到多数机器出现了问题,才会投票确认出现了分区问题。这就好像新冠疫情,还会分低、中、高风险区呢,不是一出现通信故障就都被逻辑认定为分区问题。
首先,在 CAP 定理中,关于一致性会有多种说法,但是总的来说,都是在描述数据最新版本的可见性。而这些可见性往往代表的是读请求返回的数据的可见性。
那么问题来了,当我们要求读数据的可见性的时候,对写数据有什么要求吗?
比如,我们系统有三个节点,一个客户端给这个系统发了一个写请求,要求系统写入一个值为 20 的数据。那么,如果要满足 CAP 定理中的一致性,就需要在写完 20 这个数据之后,当其他客户端请求读取这个值为 20 的数据之后,无论请求被转发到系统中任何节点都能返回这个值。
这就要求写入这个值为 20 的写请求必须成功写到三个节点上,此时,系统就满足了写一致性的。所以,我们可以说对于读一致性的要求是同时约束了写一致性的。
其次,在 CAP 定理中,可用性本身要求对读、写请求都要处理。如果我们以可用性作为标准的时候,在发生分区错误时,由于我们对读请求并没有强行要求返回完全准确的数据,所以,可能在本次读请求之前的最近一次写请求可能是部分失败的。
同样的例子,我们的分布式系统由三个节点组成,最近一次写请求想把值为 20 的数据写到三个节点上。但是,由于发生了分区问题,有一个节点通信故障,写请求写不过去,因此只有两个节点包含了值为 20 的数据。
此时,写请求会返回给客户端一个结果,可能会告诉客户端写入成功了,也可能告诉客户端写入部分成功。
这时候,当后续的读请求恰巧被发送到有通信故障的那个节点,系统可能只能返回一个空的结果。但是,由于系统处理和返回了读写请求,所以,系统是满足了 CAP 中的可用性的。
我们知道,在一套大规模的分布式系统里,一定是既需要把海量数据做切分,存储到不同的机器上,也需要对这些存储了数据的机器做副本备份的。
那么,如果,一个分布式系统里只有数据分片存储或者只有数据副本存储,他们都会遵守 CAP 定理吗?
答案是当数据分片时,也是要遵守 CAP 定理,但是,是种非常特殊的遵守。
当在一套分布式系统只有分片存储的时候,CAP 理论会表现成什么样?
比如,我们有个分布式系统,由三个节点 a、b、c 组成。其中节点 a 存放了 A 表的数据,b 存放了 B 表的数据,c 存放了 C 表的数据。
如果有一个业务,它的意图是想往 A 表插入一条新数据,在 B 表删除一条已有数据,在 C 表更新一条老数据,这个分布式系统该怎么处理这种业务?
技术上我们对这种一个意图想做多件事的情况往往会包装成一个事务。当我们包装成一个事务以后,我们可能会通过先在 a 节点执行,然后去 b 节点执行,最后去 c 节点执行,等到都成功了,才会返回成功。
但是,发生了分区以后怎么办?当在 a、b 节点都成功了,到 c 发现发生了通信故障?
此时,根据 CAP 定理,你有两个选择,要么就直接返回一个部分成功的结果给客户端,要么直接卡死等客户端超时或者返回失败给客户端。当返回部分成功的时候,这就是选择了可用性(A),当卡死或者返回失败给客户端的时候,就是选择了一致性(C)。
可是,我们将请求包装成了事务,而事务是要求要么都成功,要么都失败……为了遵守这种要求,对于分布式只有分片的情况,迫于客观条件,只能选择 C。所以分片的分布式系统,往往都是 CP 的系统。
可选择,但是无法选择是分布式系统只有分片数据存储的情况时,遵守 CAP 定理的特殊表现。
而当分布式系统是多个节点,每个节点存储了完整的一套数据,别的节点只是完整数据的备份的时候,即使事务只在一台机器上成功,当发生分区故障的时候,我们也是可以有充分的余地选择是单机事务的回退 or 就此认为写成功的。
单机事务的回退,就可以对外表现为选择了一致性。
因为,就像我们前面讲过的,由于 AP 或者 CP 的选择,可能仅局限为整套系统的局部,甚至某些特殊的数据上,而我们又是用这种局部的特性去描述了整套系统,所以就导致了区分的困难。而这本身其实也日渐成为了 CAP 的一个大问题,从而被人诟病。
正因为 CAP 以上的种种不足,epay 的架构师 Dan Pritchett 根据他自身在大规模分布式系统的实践经验,总结出了 BASE 理论。BASE 理论是对 CAP 理论的延伸,核心思想是即使无法做到强一致性(Strong Consistency),但应用可以采用适合的方式达到最终一致性(Eventual Consitency)。
BASE 理论是实践工程的理论,它弥补了 CAP 理论过于抽象的问题,也同时解决了 AP 系统的总体工程实践思想,是分布式系统的核心理论之一,我们将在下一篇文章里,详细的讲解此套理论。
Redis的主从数据是异步同步的,所以分布式的Redis 系统并不满足「一致性」要求。当客户端在Redis 的主节点修改了数据后,立即返回,即使在主从网络断开的情况下,主节点依旧可以正常对外提供修改服务,所以Redis 满足「可用性」。
Redis保证的是最终一致性,,从节点会努力追赶主节点,最终从节点的状态会和主节点的状态将保持一致。如果网络断开了,主从节点的数据将会出现大量不一致,一旦网络恢复,从节点会采用多种策略努力追赶上落后的数据,继续尽力保持和主节点一致。
Redis 同步的是指令流,主节点会将那些对自己的状态产生修改性影响的指令记录在本地的内存 buffer 中,然后异步将 buffer 中的指令同步到从节点,从节点一边执行同步的指令流来达到和主节点一样的状态,一遍向主节点反馈自己同步到哪里了 (偏移量)。
因为内存的 buffer 是有限的,所以 Redis 主库不能将所有的指令都记录在内存 buffer中。Redis 的复制内存 buffer 是一个定长的环形数组,如果数组内容满了,就会从头开始覆盖前面的内容。
快照同步是一个非常耗费资源的操作,它首先需要在主库上进行一次 bgsave 将当前内存的数据全部快照到磁盘文件中,然后再将快照文件的内容全部传送到从节点。从节点将快照文件接受完毕后,立即执行一次全量加载,加载之前先要将当前内存的数据清空。加载完毕后通知主节点继续进行增量同步。
在整个快照同步进行的过程中,主节点的复制 buffer 还在不停的往前移动,如果快照同步的时间过长或者复制 buffer 太小,都会导致同步期间的增量指令在复制 buffer 中被覆盖,这样就会导致快照同步完成后无法进行增量复制,然后会再次发起快照同步,如此极有可能会陷入快照同步的死循环。
我的困惑buffer被覆盖后,为什么会导致快照同步完成后无法进行增量复制呢?
这里是从数据不一致方向出发,当Buffer中的指令被覆盖掉之后,就会导致主节点和从节点的数据不一致,因为Redis要求的是最终一致性,所以无法进行增量复制,需要再次快照同步主节点的数据。
所以务必配置一个合适的复制 buffer 大小参数,避免快照复制的死循环。
那如何设置合适的buffer呢?选择合适的缓冲区大小没有一个固定的标准,因为它取决于各种因素,如系统的写入速率、网络延迟、可用内存等。以下是一些建议和常见的缓冲区大小选择:
总之,选择合适的缓冲区大小是一个根据实际情况进行调整和优化的过程。建议从一个相对较大的初始缓冲区大小开始,然后根据实际测试结果进行逐步调整。监控复制延迟和系统性能,并根据需求进行适当的优化。
当从节点刚刚加入到集群时,它必须先进行一次快照同步,同步完成后再继续增量同步。
这个无盘复制其实是为了减少文件IO操作,通过套接字进行网络发送。这里是指在同步的时候这样处理。
主节点在进行快照同步时,会进行很重的文件 IO 操作,特别是对于非 SSD 磁盘存储时,快照会对系统的负载产生较大影响。特别是当系统正在进行 AOF 的 fsync 操作时如果发生快照,fsync 将会被推迟执行,这就会严重影响主节点的服务效率。
所以从 Redis 2.8.18 版开始支持无盘复制。所谓无盘复制是指主服务器直接通过套接字将快照内容发送到从节点,生成快照是一个遍历的过程,主节点会一边遍历内存,一遍将序列化的内容发送到从节点,从节点还是跟之前一样,先将接收到的内容存储到磁盘文件中,再进行一次性加载。
Redis 的复制是异步进行的,wait 指令可以让异步复制变身同步复制,确保系统的强一致性 (不严格)。wait 指令是 Redis3.0 版本以后才出现的。
> set key value
OK
> wait 1 0
(integer) 1
wait 提供两个参数,第一个参数是从库的数量 N,第二个参数是时间 t,以毫秒为单位。它表示等待 wait 指令之前的所有写操作同步到 N 个从库 (也就是确保 N 个从库的同步没有滞后),最多等待时间 t。如果时间 t=0,表示无限等待直到 N 个从库同步完成达成一致。
假设此时出现了网络分区,wait 指令第二个参数时间 t=0,主从同步无法继续进行,wait 指令会永远阻塞,Redis 服务器将丧失可用性。
主从复制是 Redis 分布式的基础,Redis 的高可用离开了主从复制将无从进行。后面的章节我们会开始讲解 Redis 的集群模式,这几种集群模式都依赖于本节所讲的主从复制。
不过复制功能也不是必须的,如果你将 Redis 只用来做缓存,跟 memcache 一样来对待,也就无需要从库做备份,挂掉了重新启动一下就行。但是只要你使用了 Redis 的持久化功能,就必须认真对待主从复制,它是系统数据安全的基础保障。
如果主节点凌晨 3 点突发宕机怎么办?就坐等运维从床上爬起来,然后手工进行从主切换,再通知所有的程 序把地址统统改一遍重新上线么?毫无疑问,这样的人工运维效率太低,事故发生时估计得至少 1 个小时才能缓过来。
所以,我们必须有一个高可用方案来抵抗节点故障,当故障发生的时候可以自动进行自动进行从主切换,程序可以不用重启。Redis Sentinel可以通过自动监测和管理Redis数据库主从复制系统的状态,来保证Redis服务的高可用性。
如上图所示,我们可以将 Redis Sentinel 集群看成是一个 ZooKeeper 集群,它是集群高可用的心脏, 它一般是由 3~5 个节点组成,这样挂了个别节点集群还可以正常运转。
它负责持续监控主从节点的健康,当主节点挂掉时,自动选择一个最优的从节点切换为主节点。**客户端来连接集群时,会首先连接 sentinel,通过 sentinel 来查询主节点的地址,然后再去连接主节点进行数据交互。当主节点发生故障时,客户端会重新向 sentinel 要地址,sentinel 会将最新的主节点地址告诉客户端。**如此应用程序将无需重启即可自动完成节点切换。比如上图的主节点挂掉后,集群将可能自动调整为下图所示结构。
从这张图中我们能看到主节点挂掉了,原先的主从复制也断开了,客户端和损坏的主节点也断开了。从节点被提升为新的主节点,其它从节点开始和新的主节点建立复制关系。客户端通过新的主节点继续进行交互。Sentinel 会持续监控已经挂掉了主节点,待它恢复后,集群会调整为下面这张图。
此时原先挂掉的主节点现在变成了从节点,从新的主节点那里建立复制关系。
从以上所述过程看,个人认为较为重要的就是,客户端在连接Redis集群的时候,先去连接哨兵集群,从哨兵集群 那里获取主节点的信息,然后再去连接主节点,这难怪叫哨兵,像极了你去一个机关找人,你首先要在门卫那报道,他给你联系人,确认之后,你才能进去。
问题:Redis 主从采用异步复制,意味着当主节点挂掉时,从节点可能没有收到全部的同步消息,这部分未同步的消息就丢失了。如果主从延迟特别大,那么丢失的数据就可能会特别多。
解决:Sentinel 无法保证消息完全不丢失,但是也尽可能保证消息少丢失。它有两个选项可以限制主从延迟过大。
min-slaves-to-write 1
min-slaves-max-lag 10
第一个参数表示主节点必须至少有一个从节点在进行正常复制,否则就停止对外写服务,丧失可用性。
第二个参数是表示判断正常复制的标准,它的单位是秒,表示如果 10s 没有收到从节点的反馈,就意味着从节点同步不正常,要么网络断开了,要么一直没有给反馈。
这可就更好理解了,你去机关找人,门卫说我给你联系下,他打了电话等了十秒没人接,就判定这个人可能不在,然后他就问你,你还有其他人可以联系么,你说有,然后又打了个电话过去,发现还是十秒没人接,就判定这个人也不在,好的吧,门卫说,那你就在外面等着吧,我联系上他们了再通知你。你就只好在那等着,而不是你进去了找了一圈发现没人之后你走了,他们回来之后发现少了一位客人。
接下来我们看看客户端如何使用 sentinel,标准的流程应该是客户端可以通过 sentinel发现主从节点的地址,然后在通过这些地址建立相应的连接来进行数据存取操作。我们来看看 Python 客户端是如何做的。
>>> from redis.sentinel import Sentinel
>>> sentinel = Sentinel([('localhost', 26379)], socket_timeout=0.1)
>>> sentinel.discover_master('mymaster')
('127.0.0.1', 6379)
>>> sentinel.discover_slaves('mymaster')
[('127.0.0.1', 6380)]
sentinel 的默认端口是 26379,不同于 Redis 的默认端口 6379,通过 sentinel 对象的discover_xxx 方法可以发现主从地址,主地址只有一个,从地址可以有多个。
>>> master = sentinel.master_for('mymaster', socket_timeout=0.1)
>>> slave = sentinel.slave_for('mymaster', socket_timeout=0.1)
>>> master.set('foo', 'bar')
>>> slave.get('foo')
'bar
通过 xxx_for 方法可以从连接池中拿出一个连接来使用,因为从地址有多个,redis 客户端对从地址采用轮询方案,也就是 RoundRobin 轮着来。
有个问题是,但 sentinel 进行主从切换时,客户端如何知道地址变更了 ? 通过分析源码,我发现 redis-py 在建立连接的时候进行了主库地址变更判断。
连接池建立新连接时,会去查询主库地址,然后跟内存中的主库地址进行比对,如果变更了,就断开所有连接,重新使用新地址建立新连接。如果是旧的主库挂掉了,那么所有正在使用的连接都会被关闭,然后在重连时就会用上新地址。
但是这样还不够,如果是 sentinel 主动进行主从切换,主库并没有挂掉,而之前的主库连接已经建立了在使用了,没有新连接需要建立,那这个连接是不是一致切换不了?
继续深入研究源码,我发现 redis-py 在另外一个点也做了控制。那就是在处理命令的时候捕获了一个特殊的异常 ReadOnlyError,在这个异常里将所有的旧连接全部关闭了,后续指令就会进行重连。
主从切换后,之前的主库被降级到从库,所有的修改性的指令都会抛出 ReadonlyError。如果没有修改性指令,虽然连接不会得到切换,但是数据不会被破坏,所以即使不切换也没关系。
Redis Sentinel是一种用于提高Redis服务高可用性的解决方案,其主要优点和缺点如下:
优点:
缺点:
总之,Redis Sentinel是一种简单、可靠的Redis服务高可用性解决方案,但同时也存在一些性能、可靠性风险和容量等方面的考虑。因此,在使用Redis Sentinel时需要根据实际情况进行评估和权衡,以确定是否适合使用该方案。
比如在 Sentinel 集群中,主节点挂掉时,从节点会取而代之,客户端上却并没有明显感知。原先第一个客户端在主节点中申请成功了一把锁,但是这把锁还没有来得及同步到从节点,主节点突然挂掉了。然后从节点变成了主节点,这个新的节点内部没有这个锁,所以当另一个客户端过来请求加锁时,立即就批准了。这样就会导致系统中同样一把锁被两个客户端同时持有,不安全性由此产生。
不过这种不安全也仅仅是在主从发生 failover 的情况下才会产生,而且持续时间极短,业务系统多数情况下可以容忍。
为了保证集群环境下分布式锁的可靠性,Redis 官方已经设计了一个分布式锁算法 Redlock(红锁)。
它是基于多个 Redis 节点的分布式锁,即使有节点发生了故障,锁变量仍然是存在的,客户端还是可以完成锁操作。官方推荐是至少部署 5 个 Redis 节点,而且都是主节点,它们之间没有任何关系,都是一个个孤立的节点。
Redlock 算法的基本思路,是让客户端和多个独立的 Redis 节点依次请求申请加锁,如果客户端能够和半数以上的节点成功地完成加锁操作,那么我们就认为,客户端成功地获得分布式锁,否则加锁失败。
这样一来,即使有某个 Redis 节点发生故障,因为锁的数据在其他节点上也有保存,所以客户端仍然可以正常地进行锁操作,锁的数据也不会丢失。
Redlock 算法加锁三个过程:
可以看到,加锁成功要同时满足两个条件(简述:如果有超过半数的 Redis 节点成功的获取到了锁,并且总耗时没有超过锁的有效时间,那么就是加锁成功):
加锁成功后,客户端需要重新计算这把锁的有效时间,计算的结果是「锁最初设置的过期时间」减去「客户端从大多数节点获取锁的总耗时(t2-t1)」。如果计算的结果已经来不及完成共享数据的操作了,我们可以释放锁,以免出现还没完成数据操作,锁就过期了的情况。
加锁失败后,客户端向所有 Redis 节点发起释放锁的操作,释放锁的操作和在单节点上释放锁的操作一样,只要执行释放锁的 Lua 脚本就可以了。
如果你很在乎高可用性,希望挂了一台 redis 完全不受影响,那就应该考虑 redlock。不过代价也是有的,需要更多的 redis 实例,性能也下降了,代码上还需要引入额外的 library,运维上也需要特殊对待,这些都是需要考虑的成本,使用前请再三斟酌。
先说一下对 key 设置过期时间的命令。 设置 key 过期时间的命令一共有 4 个:
当然,在设置字符串时,也可以同时对 key 设置过期时间,共有 3 种命令:
如果你想查看某个 key 剩余的存活时间,可以使用 TTL 命令。
# 设置键值对的时候,同时指定过期时间位 60 秒
> setex key1 60 value1
OK
# 查看 key1 过期时间还剩多少
> ttl key1
(integer) 56
> ttl key1
(integer) 52
如果突然反悔,取消 key 的过期时间,则可以使用 PERSIST 命令。
# 取消 key1 的过期时间
> persist key1
(integer) 1
# 使用完 persist 命令之后,
# 查下 key1 的存活时间结果是 -1,表明 key1 永不过期
> ttl key1
(integer) -1
每当我们对一个 key 设置了过期时间时,Redis 会把该 key 带上过期时间存储到一个过期字典(expires dict)中,也就是说「过期字典」保存了数据库中所有 key 的过期时间。
过期字典存储在 redisDb 结构中,如下:
typedef struct redisDb {
dict *dict; /* 数据库键空间,存放着所有的键值对 */
dict *expires; /* 键的过期时间 */
....
} redisDb;
过期字典数据结构结构如下:
过期字典的数据结构如下图所示:
字典实际上是哈希表,哈希表的最大好处就是让我们可以用 O(1) 的时间复杂度来快速查找。当我们查询一个 key 时,Redis 首先检查该 key 是否存在于过期字典中:
在说 Redis 过期删除策略之前,先跟大家介绍下,常见的三种过期删除策略:
接下来,分别分析它们的优缺点。
定时删除策略的做法是,在设置 key 的过期时间时,同时创建一个定时事件,当时间到达时,由事件处理器自动执行 key 的删除操作。
定时删除策略的优点:
定时删除策略的缺点:
惰性删除策略的做法是,不主动删除过期键,每次从数据库访问 key 时,都检测 key 是否过期,如果过期则删除该 key。
惰性删除策略的优点:
惰性删除策略的缺点:
定期删除策略的做法是,每隔一段时间「随机」从数据库中取出一定数量的 key 进行检查,并删除其中的过期key。
定期删除策略的优点:
定期删除策略的缺点: