一文解决Redis——阅读记录_Redis深度历险:核心原理与应用实践

Redis的基本数据结构

五种基本:字符串、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

list 列表

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 获取范围

hash 字典

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

set集合

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"

zset (有序集合)

有序集合中的元素可以排序。但是它和列表使用索引下标作为排序依据不同的是,
它给每个元素设置一个权重(score)作为排序的依据。
有序集合主要应用场景:
用户点赞统计
用户排序

Redis实现分布式锁

分布式锁是用于分布式环境下并发控制的一种机制,用于控制某个资源在同一时刻只能被一个应用所使用。如下图所示:

Redis 本身可以被多个客户端共享访问,正好就是一个共享存储系统,可以用来保存分布式锁,而且 Redis 的读写性能高,可以应对高并发的锁操作场景。
Redis 的 SET 命令有个 NX 参数可以实现「key不存在才插入」,所以可以用它来实现分布式锁:

  • 如果 key 不存在,则显示插入成功,可以用来表示加锁成功;
  • 如果 key 存在,则会显示插入失败,可以用来表示加锁失败。

基于 Redis 节点实现分布式锁时,对于加锁操作,我们需要满足三个条件。

  • 加锁包括了读取锁变量、检查锁变量值和设置锁变量值三个操作,但需要以原子操作的方式完成,所以,我们使用 SET 命令带上 NX 选项来实现加锁;
  • 锁变量需要设置过期时间,以免客户端拿到锁后发生异常,导致锁一直无法释放,所以,我们在 SET 命令执行时加上 EX/PX 选项,设置其过期时间;
  • 锁变量的值需要能区分来自不同客户端的加锁操作,以免在释放锁时,出现误释放操作,所以,我们使用 SET 命令设置锁变量值时,每个客户端设置的值是一个唯一值,用于标识客户端;

满足这三个条件的分布式命令如下:

SET lock_key unique_value NX PX 10000
  • lock_key 就是 key 键;
  • unique_value 是客户端生成的唯一的标识,区分来自不同客户端的锁操作;
  • NX 代表只在 lock_key 不存在时,才对 lock_key 进行设置操作;
  • PX 10000 表示设置 lock_key 的过期时间为 10s,这是为了避免客户端发生异常而无法释放锁。

而解锁的过程就是将 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 实现分布式锁的优点

  1. 性能高效(这是选择缓存实现分布式锁最核心的出发点)。
  2. 实现方便。很多研发工程师选择使用 Redis 来实现分布式锁,很大成分上是因为 Redis 提供了 setnx 方法,实现分布式锁很方便。
  3. 避免单点故障(因为 Redis 是跨集群部署的,自然就避免了单点故障)。

基于 Redis 实现分布式锁的缺点

  • 超时时间不好设置。如果锁的超时时间设置过长,会影响性能,如果设置的超时时间过短会保护不到共享资源。比如在有些场景中,一个线程 A 获取到了锁之后,由于业务代码执行时间可能比较长,导致超过了锁的超时时间,自动失效,注意 A 线程没执行完,后续线程 B 又意外的持有了锁,意味着可以操作共享资源,那么两个线程之间的共享资源就没办法进行保护了。
    • 那么如何合理设置超时时间呢? 我们可以基于续约的方式设置超时时间:先给锁设置一个超时时间,然后启动一个守护线程,让守护线程在一段时间后,重新设置这个锁的超时时间。实现方式就是:写一个守护线程,然后去判断锁的情况,当锁快失效的时候,再次进行续约加锁,当主线程执行完成后,销毁续约锁即可,不过这种方式实现起来相对复杂。
  • Redis 主从复制模式中的数据是异步复制的,这样导致分布式锁的不可靠性。如果在 Redis 主节点获取到锁后,在没有同步到其他节点时,Redis 主节点宕机了,此时新的 Redis 主节点依然可以获取锁,所以多个应用服务就可以同时获取到锁。

Redis 如何解决集群情况下分布式锁的可靠性?
为了保证集群环境下分布式锁的可靠性,Redis 官方已经设计了一个分布式锁算法 Redlock(红锁)。
它是基于多个 Redis 节点的分布式锁,即使有节点发生了故障,锁变量仍然是存在的,客户端还是可以完成锁操作。官方推荐是至少部署 5 个 Redis 节点,而且都是主节点,它们之间没有任何关系,都是一个个孤立的节点。
Redlock 算法的基本思路,是让客户端和多个独立的 Redis 节点依次请求申请加锁,如果客户端能够和半数以上的节点成功地完成加锁操作,那么我们就认为,客户端成功地获得分布式锁,否则加锁失败
这样一来,即使有某个 Redis 节点发生故障,因为锁的数据在其他节点上也有保存,所以客户端仍然可以正常地进行锁操作,锁的数据也不会丢失。
Redlock 算法加锁三个过程:

  • 第一步是,客户端获取当前时间(t1)。
  • 第二步是,客户端按顺序依次向 N 个 Redis 节点执行加锁操作:
    • 加锁操作使用 SET 命令,带上 NX,EX/PX 选项,以及带上客户端的唯一标识。
    • 如果某个 Redis 节点发生故障了,为了保证在这种情况下,Redlock 算法能够继续运行,我们需要给「加锁操作」设置一个超时时间(不是对「锁」设置超时时间,而是对「加锁操作」设置超时时间),加锁操作的超时时间需要远远地小于锁的过期时间,一般也就是设置为几十毫秒。
  • 第三步是,一旦客户端从超过半数(大于等于 N/2+1)的 Redis 节点上成功获取到了锁,就再次获取当前时间(t2),然后计算计算整个加锁过程的总耗时(t2-t1)。如果 t2-t1 < 锁的过期时间,此时,认为客户端加锁成功,否则认为加锁失败。

可以看到,加锁成功要同时满足两个条件(简述:如果有超过半数的 Redis 节点成功的获取到了锁,并且总耗时没有超过锁的有效时间,那么就是加锁成功):

  • 条件一:客户端从超过半数(大于等于 N/2+1)的 Redis 节点上成功获取到了锁;
  • 条件二:客户端从大多数节点获取锁的总耗时(t2-t1)小于锁设置的过期时间。

加锁成功后,客户端需要重新计算这把锁的有效时间,计算的结果是「锁最初设置的过期时间」减去「客户端从大多数节点获取锁的总耗时(t2-t1)」。如果计算的结果已经来不及完成共享数据的操作了,我们可以释放锁,以免出现还没完成数据操作,锁就过期了的情况。
加锁失败后,客户端向所有 Redis 节点发起释放锁的操作,释放锁的操作和在单节点上释放锁的操作一样,只要执行释放锁的 Lua 脚本就可以了。

队列延迟

在解决队列的延迟问题时,可以采用blpop/brpop 也就是Blocking  阻塞读。

阻塞读在队列没有数据的时候,会立即进入休眠状态,一旦数据到来,则立刻醒过来。消息的延迟几乎为0。用blpop/brpop替代前面的lpop/rpop就会减少延迟。

位图的实现

用于处理一些Bool型数据的存取,怎么去处理才能最节省空间,这时候就采用位图

HyperLogLog

当网站需要统计UV时,也就是一天内有多少用户访问了网站,这个数量该如何统计,这需要去重,采用原本的Redis技术器的方式不太好用去重,所以就引入了HyperLogLog这个数据结构,这个可以统计

HyperLogLog 提供了两个指令 pfadd 和 pfcount,根据字面意义很好理解,一个是增加
计数,一个是获取计数。pfadd 用法和 set 集合的 sadd 是一样的,来一个用户 ID,就将用
户 ID 塞进去就是。pfcount 和 scard 用法是一样的,直接获取计数值。

布隆过滤器

讲个使用场景,比如我们在使用新闻客户端看新闻时,它会给我们不停地推荐新的内
容,它每次推荐时要去重,去掉那些已经看过的内容。问题来了,新闻客户端推荐系统如何
实现推送去重的?
如果说像HyperLogLog那样,只完成了技术,不支持查找,所以这里引入了布隆过滤器,用于去重和记录

有几个问题需要思考:

  1. 布隆过滤器适合什么场景?(布隆过滤器的特点是什么?)
  2. 布隆过滤器的基本使用?
  3. 布隆过滤器的原理?

简单限流

限流的目的:除了控制流量,限流还有一个应用目的是用于控制用户行为,避免垃圾请求。
如何实现限流?
image.png

它的整体思路就是:每一个行为到来时,都维护一次时间窗口。将时间窗口外的记录全部清理掉,只保留窗口内的记录。
代码:

# 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 次这样的参数,它是不适合做这样的限流的,因为会消耗大量的存储空间。

漏斗限流

漏斗思想:
漏洞的容量是有限的,如果将漏嘴堵住,然后一直往里面灌水,它就会变满,直至再也装不进去。如果将漏嘴放开,水就会往下流,流走一部分之后,就又可以继续往里面灌水。如果漏嘴流水的速率大于灌水的速率,那么漏斗永远都装不满。如果漏嘴流水速率小于灌水的速率,那么一旦漏斗满了,灌水就需要暂停并等待漏斗腾空。
image.png
所以,漏斗的剩余空间就代表着当前行为可以持续进行的数量,漏嘴的流水速率代表着系统允许该行为的最大频率。

漏斗限流算法

# 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-Cell

Redis 4.0 提供了一个限流 Redis 模块,它叫 redis-cell。该模块也使用了漏斗算法,并提供了原子的限流指令。有了这个模块,限流问题就非常简单了

该模块只有 1 条指令 cl.throttle,它的参数和返回值都略显复杂,接下来让我们来看看这个指令具体该如何使用。
image.png

  • laoqian 就是 key值
  • 15 是令牌的初始容量,初始容量是该数加一,也就是16,初始化时,令牌是满的
  • 30 60 就是速率,意思是60秒内可以请求30次
  • 1 表示执行命令时取走的令牌数量,默认是 1
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

GeoHash 地理位置

用数据库来算附近的人

这里让我想到了阿里面试的问题,为用户找到最近的门店,似乎用这个方法解决挺好。
当两个元素的距离不是很远时,可以直接使用勾股定理就能算得元素之间的距离。我们平时使用的「附近的人」的功能,元素距离都不是很大,勾股定理算距离足矣。不过需要注意的是,经纬度坐标的密度不一样 (经度总共 360 度,纬度总共 180 度),勾股定律计算平方差时之后再求和时,需要按一定的系数比加权求和。
现在,如果要计算「附近的人」,也就是给定一个元素的坐标,然后计算这个坐标附近的其它元素,按照距离进行排序,该如何下手?
image.png

首先,你不可能通过遍历来计算所有的元素和目标元素的距离然后再进行排序,这个计算量太大了,性能指标肯定无法满足。一般的方法都是通过矩形区域来限定元素的数量,然后对区域内的元素进行全量距离计算再排序。这样可以明显减少计算量。
如何划分矩形区域呢?可以指定一个半径 r,使用一条 SQL 就可以圈出来。当用户对筛出来的结果不满意,那就扩大半径继续筛选。可以使用的SQL语句如下:
select id from positions where x0-r < x < x0+r and y0-r < y < y0+r
为了满足高性能的矩形区域算法,数据表需要在经纬度坐标加上双向复合索引 (x, y),这样可以最大优化查询性能。
但是数据库查询性能毕竟有限,如果「附近的人」查询请求非常多,在高并发场合,这可能并不是一个很好的方案。

GeoHash 算法

业界比较通用的地理位置距离排序算法是 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 还原成坐标值就可以得到元素的原始坐标

Redis 的 Geo 指令基本使用

增加 geoadd指令

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 指令

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

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 对二维坐标进行的一维映射是有损的,通过映射再还原回来的值会出现较小的差别。
但是这个对业务的影响比较小,所以可以忽略不计

获取元素的 hash 值 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

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 集合的大小。

大海捞针 —— Scan

为什么需要Scan?

在平时线上 Redis 维护工作中,有时候需要从 Redis 实例成千上万的 key 中找出特定前缀的 key 列表来手动处理数据,可能是修改它的值,也可能是删除 key。这里就有一个问题,如何从海量的 key 中找出满足特定前缀的 key 列表来?
这里有keys命令,可以简单粗暴地把所有key都列出来,但是这种简单的命令有着两个致命的缺陷:

  1. 当key值很多的时候,会一直输出数据
  2. key算法是遍历算法,复杂度是O(n)key值过多会导致Redis服务卡顿(Redis是单线程程序)

新的东西产生就是为了解决之前旧东西的问题,所以scan可以解决以上这两个问题,它具有以下特点:

  1. 复杂度虽然也是 O(n),但是它是通过游标分步进行的,不会阻塞线程;
  2. 提供 limit 参数,可以控制每次返回结果的最大条数,limit 只是一个 hint,返回的结果可多可少;
  3. 同 keys 一样,它也提供模式匹配功能;
  4. 服务器不需要为游标保存状态,游标的唯一状态就是 scan 返回给客户端的游标整数;
  5. 返回的结果可能会有重复,需要客户端去重复,这点非常重要;
  6. 遍历的过程中如果有数据修改,改动后的数据能不能遍历到是不确定的;
  7. 单次返回的结果是空的并不意味着遍历结束,而要看返回的游标值是否为零;

Scan该怎么使用?

scan的相关原理是啥?

有没有什么注意事项?

Redis的线程IO模型

Redis是个单线程程序!

Redis是个单线程程序,由于这个特点,所以要小心使用Redis的指令,使用不当会导致卡顿。
在所有事件处理上,Redis都是以单线程形式处理,所以说Redis是单线程的。同时,Redis基于Reactor模式开发了自己的I/O事件处理器,也就是文件事件处理器,Redis在I/O事件处理上,采用了I/O多路复用技术,同时监听多个套接字,并为套接字关联不同的事件处理函数,通过一个线程实现了多客户端并发处理。
但是,实际上Redis也并不是单线程的,比如生成RDB文件,就会fork一个子进程来实现。
在Redis6.0版本的多线程并非彻底的多线程,I/O线程只能同时执行读或者同时执行写操作。在开启多线程后,并不会存在线程并发的安全问题,因为Redis的多线程部分只是用来处理网络数据的读写和协议解析,执行命令仍然是单线程顺序执行。

Redis为什么会这么快?

主要有以下几个原因:
1、Redis的数据都存储在内存中,读写速度快
2、Redis有高效的数据结构
3、Redis是单线程模型,并且是IO多路复用
4、Redis 的虚拟内存机制:Redis直接自己构建了VM机制 ,不会像一般的系统会调用系统函数处理,会浪费一定的时间去移动和请求。

Redis采用的IO多路复用模型

Redis多路复用模型,跟其他的模型相似,如下图所示,这一部分我之前总结的就很全面
image.png

Redis的通信协议

1、Redis通信协议的特点

Redis 的作者认为数据库系统的瓶颈一般不在于网络流量,而是数据库自身内部逻辑处理上。所以即使 Redis 使用了浪费流量的文本协议,依然可以取得极高的访问性能。Redis 将所有数据都放在内存,用一个单线程对外提供服务,单个节点在跑满一个 CPU 核心的情 况下可以达到了 10w/s 的超高 QPS

2、 RESP(Redis Serialization Protocol) 介绍

译为: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

3 使用情况

1、 客户端向服务器发送

客户端向服务器发送的指令只有一种格式,多行字符串数组。
比如一个简单的 set 指令
set author codehole 会被序列化成下面的字符串。

*3\r\n$3\r\nset\r\n$6\r\nauthor\r\n$8\r\ncodehole\r\n
2、服务器向客户端发送

服务器向客户端回复的响应要支持多种数据结构,所以消息响应在结构上要复杂不少。不过再复杂的响应消息也是以上 5 中基本类型的组合。

  1. 单行字符串响应
127.0.0.1:6379> set author codehole
OK
这里的 OK 就是单行响应,没有使用引号括起来。
+OK
  1. 错误响应
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
  1. 整数响应
127.0.0.1:6379> incr books
(integer) 1
这里的 1 就是整数响应
:1
  1. 多行字符串响应
127.0.0.1:6379> get author
"codehole"
这里使用双引号括起来的字符串就是多行字符串响应
$8
codehole
  1. 数组响应
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
  1. 嵌套
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 持久化 机制

1、为什么需要持久化?

Redis 的数据全部在内存里,如果突然宕机,数据就会全部丢失,因此必须有一种机制来保证 Redis 的数据不会因为故障而丢失,这种机制就是 Redis 的持久化机制。

2、 持久化有哪些实现方式?

Redis 的持久化机制有两种,第一种是快照,第二种是 AOF 日志。快照是一次全量备份,AOF 日志是连续的增量备份。快照是内存数据的二进制序列化形式,在存储上非常紧凑,而 AOF 日志记录的是内存数据修改的指令记录文本。AOF 日志在长期的运行过程中会变的无比庞大,数据库重启时需要加载 AOF 日志进行指令重放,这个时间就会无比漫长。所以需要定期进行 AOF 重写,给 AOF 日志进行瘦身。

3、持久化具体实现过程

1、 Redis的快照(RDB)

Redis 使用操作系统的多进程 COW(Copy On Write) 机制来实现快照持久化。

1.1 COW机制

COW(copy-on-write 的简称),是一种计算机设计领域的优化策略,其核心思想是:如果有多个调用者(callers)同时要求相同资源(如内存或磁盘上的数据存储),他们会共同获取相同的指针指向相同的资源,直到某个调用者试图修改资源的内容时,系统才会真正复制一份专用副本(private copy)给该调用者,而其他调用者所见到的最初的资源仍然保持不变。这过程对其他的调用者都是透明的(transparently)。此作法主要的优点是如果调用者没有修改该资源,就不会有副本(private copy)被创建,因此多个调用者只是读取操作时可以共享同一份资源

技术实现原理:
fork()之后,kernel把父进程中所有的内存页的权限都设为read-only,然后子进程的地址空间指向父进程。当父子进程都只读内存时,相安无事。当其中某个进程写内存时,CPU硬件检测到内存页是read-only的,于是触发页异常中断(page-fault),陷入kernel的一个中断例程。中断例程中,kernel就会把触发的异常的页复制一份,于是父子进程各自持有独立的一份。

1.2 RDB具体实现

RDB 快照表示的是某一时刻 Redis 内存中所有数据的写照。在执行 RDB 持久化时,Redis 进程会 fork 一个子进程来执行持久化过程,该过程是阻塞的,当 fork 过程完成后父进程会继续接收客户端的命令。子进程与 Redis 进程共享内存中的数据,但是子进程并不会修改内存中的数据,而是不断的遍历读取写入文件中,但是 Redis 父进程则不一样,它需要响应客户端的命令对内存中数据不断地修改,这个时候就会使用操作系统的 COW 机制来进行数据段页面的分离,当 Redis 父进程对其中某一个页面的数据进行修改时,则会将页面的数据复制一份出来,然后对这个复制页进行修改,这个时候子进程相应的数据页并没有发生改变,依然是 fork 那一瞬间的数据。

2、Redis的AOF机制
2.1 AOF原理

AOF 日志存储的是 Redis 服务器的顺序指令序列,AOF 日志只记录对内存进行修改的指令记录。

恢复数据方法:

假设 AOF 日志记录了自 Redis 实例创建以来所有的修改性指令序列,那么就可以通过对一个空的 Redis实例顺序执行所有的指令,也就是「重放」,来恢复 Redis 当前实例的内存数据结构的状态。

何时记录指令?

Redis 会在收到客户端修改指令后,先进行参数校验,如果没问题,就立即将该指令文本存储到 AOF 日志中,也就是先存到磁盘,然后再执行指令。这样即使遇到突发宕机,已经存储到 AOF 日志的指令进行重放一下就可以恢复到宕机前的状态。

出现问题:

Redis 在长期运行的过程中,AOF 的日志会越变越长。如果实例宕机重启,重放整个AOF 日志会非常耗时,导致长时间 Redis 无法对外提供服务。所以需要对 AOF 日志瘦身

fsync机制

**提出来的原因:**如果机器突然宕机,AOF 日志内容可能还没有来得及完全刷到磁盘中,这个
时候就会出现日志丢失。那该怎么办?
所以:
Linux 的 glibc 提供了 fsync(int fd)函数可以将指定文件的内容强制从内核缓存刷到磁盘。只要 Redis 进程实时调用 fsync 函数就可以保证 aof 日志不丢失。但是 fsync 是一个磁盘 IO 操作,它很慢!如果 Redis执行一条指令就要 fsync 一次,那么 Redis 高性能的地位就不保了。
一般使用情况:
在生产环境的服务器中,Redis 通常是每隔 1s 左右执行一次 fsync 操作,周期 1s是可以配置的。这是在数据安全性和性能之间做了一个折中,在保持高性能的同时,尽可能使得数据少丢失。
Redis 同样也提供了另外两种策略,一个是永不 fsync——让操作系统来决定合适同步磁盘,很不安全,另一个是来一个指令就 fsync 一次——非常慢。但是在生产环境基本不会使用,了解一下即可。

2.2 AOF重写

Redis 提供了 bgrewriteaof 指令用于对 AOF 日志进行瘦身。其原理就是开辟一个子进程对内存进行遍历转换成一系列 Redis 的操作指令,序列化到一个新的 AOF 日志文件中。序列化完毕后再将操作期间发生的增量 AOF 日志追加到这个新的 AOF 日志文件中,追加完毕后就立即替代旧的 AOF 日志文件了,瘦身工作就完成了

2.3 AOF优缺点
1、优点

使用 AOF 持久化会让 Redis 变得非常耐久(much more durable):你可以设置不同的 fsync 策略,比如无 fsync ,每秒钟一次 fsync ,或者每次执行写入命令时 fsync 。 AOF 的默认策略为每秒钟 fsync 一次,在这种配置下,Redis 仍然可以保持良好的性能,并且就算发生故障停机,也最多只会丢失一秒钟的数据( fsync 会在后台线程执行,所以主线程可以继续努力地处理命令请求)。

2、缺点

对于相同的数据集来说,AOF 文件的体积通常要大于 RDB 文件的体积。根据所使用的 fsync 策略,AOF 的速度可能会慢于 RDB在一般情况下, 每秒 fsync 的性能依然非常高, 而关闭 fsync 可以让 AOF 的速度和 RDB 一样快, 即使在高负荷之下也是如此。 不过在处理巨大的写入载入时,RDB 可以提供更有保证的最大延迟时间(latency)。

4、两者选择

RDB持久化是指在指定的时间间隔内将内存中的数据集快照写入磁盘,实际操作过程是fork一个子进程,先将数据集写入临时文件,写入成功后,再替换之前的文件,用二进制压缩存储。
AOF持久化以日志的形式记录服务器所处理的每一个写、删除操作,查询操作不会记录,以文本的方式记录,可以打开文件看到详细的操作记录。
RDB 和 AOF ,我应该用哪一个?

  • 如果你非常关心你的数据,但仍然可以承受数分钟以内的数据丢失, 那么你可以只使用 RDB 持久。
  • AOF 将 Redis 执行的每一条命令追加到磁盘中,处理巨大的写入会降低 Redis 的性能,不知道你是否可以接受。

数据库备份和灾难恢复:定时生成 RDB 快照(snapshot)非常便于进行数据库备份, 并且 RDB 恢复数据集的速度也要比 AOF 恢复的速度要快。
Redis 支持同时开启 RDB 和 AOF,系统重启后,Redis 会优先使用 AOF 来恢复数据,这样丢失的数据会最少。

Redis的管道机制

Redis管道本质

服务器根本没有任何区别对待,还是收到一条消息,执行一条消息,回复一条消息的正常的流程。客户端通过对管道中的指令列表改变读写顺序就可以大幅节省 IO 时间。管道中指令越多,效果越好。

深入理解其本质

image.png
上图就是一个完整的请求交互流程图。我用文字来仔细描述一遍:
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 操作直接就可以从缓冲拿到结果,瞬间就返回了。

Redis的事务

Redis事务的基本使用

一般事务具有的形式:
每个事务的操作都有 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 使用解析

discard意味事务的丢弃,Redis 为事务提供了一个 discard 指令,用于丢弃事务缓存队列中的所有指令,在 exec
执行之前。

> get books
(nil)
> multi
OK
> incr books
QUEUED
> incr books
QUEUED
> discard
OK
> get books
(nil)

我们可以看到 discard 之后,队列中的所有指令都没执行,就好像 multi 和 discard 中间的所有指令从未发生过一样。

Redis主从同步

1、为什么使用主从同步(主从同步有什么作用?)

  • 数据冗余: 主从复制实现了数据的热备份,是持久化之外的一种数据冗余方式。
  • 故障恢复: 当主节点出现问题时,可以由从节点提供服务,实现快速的故障恢复 (实际上是一种服务的冗余)
  • 负载均衡: 在主从复制的基础上,配合读写分离,可以由主节点提供写服务, 由从节点提供读服务 (即写 Redis 数据时应用连接主节点,读 Redis 数据时应用 连接从节点) ,分担服务器负载。尤其是在写少读多的场景下,通过多个从节 点分担读负载,可以大大提高 Redis 服务器的并发量。
  • 高可用基石: 除了上述作用以外,主从复制还是哨兵和集群能够实施的基础 ,因此说主从复制是 Redis 高可用的基础。

很多企业都没有使用到 Redis 的集群,但是至少都做了主从。很多有了主从,当 master 挂掉的时候,运维让从库过来接管,服务就可以继续,否则 master 需要经过数据恢复和重启的过程,这就可能会拖很长的时间,影响线上业务的持续服务。

2、主从同步的基本原理(其实就是CAP原理)

1. CAP 的由来

要理解 CAP,首先我们要清楚,为何会有人提出 CAP?他提出 CAP 是为了解决什么问题?
时间回到 1985 年,彼时,后来证明了 CAP 理论的 Lynch 教授此时给当时的 IT 界来了一记惊雷:
她通过不可辩驳的证明告诉业界的工程师们,如果在一个不稳定(消息要么乱序要么丢了)的网络环境里(分布式异步模型),想始终保持数据一致是不可能的。

这是个什么概念呢?就是她打破了那些既想提供超高质量服务,又想提供超高性能服务的技术人员的幻想。
这本质是在告诉大家,在分布式系统里,需要妥协。
但是,如何妥协?分布式系统里到底应该怎么权衡这种 trade-off?

我们可以想象一下,在 CAP 定理提出之前,没有这些方向性的指引,在设计和实施分布式系统时该有多么混乱。一套分布式系统是由多个模块组成的,这些模块本身可能由不同的开发人员去完成。然而,对于这些人,在公共层面,竟然没有一个原则去指导他们该怎么完成这套功能。

比如,我们在同步两个节点的数据时,如果发生了错误,到底我们应该怎么做呢?如果没有统一的标准和方向,那很可能在一套分布式系统中的不同模块,会出现不同的处理情况。

假设一套系统,由 A、B 两个模块构成。
A 模块的设计理念是:节点间出现了问题,它可能会选择不断的重试,一直等到节点通信恢复。

image.png
而 B 的设计理念是:节点间出现了问题,它断开就是了,可能最多就记录下状态,等以后处理。
image.png
可是,当 A、B 之间出现了通信怎么办?那会出现 A 往 B 发请求,出问题会不断重试。而 B 往 A 发请求,出问题则直接断开的情况。
当然,在后面我们会说明,CAP 的理念在实际工程中,会允许这种不一致。可是,那种不一致是提前设计好和规划好的,是根据实际数据的重要性和业务需求做的妥协,而不是这种混乱的妥协。
所以,IT 界的人们就一直在摸索,试图找到一些纲领去指导分布式系统的设计,这一找就找了 15 年。
2000 年时,Eric Brewer 教授在 PODC 会议上提出了 CAP 理论,但是由于没有被证明过,所以,当时只能被称为 CAP 猜想。这个猜想引起了巨大的反响,因为 CAP 很符合人们对设计纲领的预期。
在 2002 年后,经过 Seth Gilbert 和 Nancy Lynch 从理论上证明了 CAP 猜想后,CAP 理论正式成为了分布式系统理论的基石之一。

2. CAP 到底是什么

CAP 定理表达了一个分布式系统里不可能同时满足以下的三个特性:数据一致性、可用性、分区容忍性。

2.1. C:数据一致性

什么是数据一致性?咋一看真的很让人糊涂,一致性是什么?是指数据能一起变化,是能让数据整齐划一。
那么问题又来了,数据何时会变化?数据怎么才能被称为一起变化?我们现在来回答这些问题,当我们搞清楚了这些问题,那么对数据一致性就会有了清晰的理解。
首先第一个问题,数据何时会一起变化?

答案是:仅且仅当包含数据的服务,收到数据更新请求的时候,数据才会发生变化。而数据更新请求则仅包括数据的增、删、改这三种请求,而这三种请求又被统称为写请求。所以,数据只有在写请求的时候才会发生变化。
那我们来回答第二个问题,数据要怎么样才能被称为一起变化了?即谁来判断数据是最终变化了?是服务器对写请求的返回结果吗?告诉写请求成功,数据就一定发生一致性变化了?

NO,数据发生变化是否一致是需要经过读请求来做检验的。那么读请求判断的依据是什么呢?

假设,我们的分布式存储系统有两个节点,每个节点都包含了一部分需要被变化的数据。如果经过一次写请求后,两个节点都发生了数据变化。然后,读请求把这些变化后的数据都读取到了,我们就把这次数据修改称为数据发生了一致性变化。
image.png
但是,这还不是完整的一致性。因为系统不可能永久的正常运行下去。

如果系统内部发生了问题从而导致系统的节点无法发生一致性变化会怎么样呢?当我们这样做的时候,就意味着想看到最新数据的读请求们,很可能会看到旧数据,或者说获取到不同版本的数据。此时,为了保证分布式系统对外的数据一致性,于是选择不返回任何数据。
image.png
这里需要注意一下,CAP 定理是在说在某种状态下的选择,和实际工程的理论是有差别的。上面描述的一致性和 ACID 事务中的一致性是两回事。事务中的一致性包含了实际工程对状态的后续处理。但是 CAP 定理并不涉及到状态的后续处理,对于这些问题,后续出现了 BASE 理论等工程结论去处理,目前,只需要明白 CAP 定理主要描述的是状态。

2.2. A:可用性

奥维德曾经说过:“行动被人们遗忘,结果却将永存”。

这句话说明了结果的重要性,而可用性在 CAP 里就是对结果的要求。它要求系统内的节点们接收到了无论是写请求还是读请求,都要能处理并给回响应结果。只是它有两点必须满足的条件:

条件 1:返回结果必须在合理的时间以内,这个合理的时间是根据业务来定的。业务说必须 100 毫秒内返回,合理的时间就是 100 毫秒,需要 1 秒内返回,那就是 1 秒,如果业务定的 100 毫秒,结果却在 1 秒才返回,那么这个系统就不满足可用性。

条件 2:需要系统内能正常接收请求的所有节点都返回结果。这包含了两重含义:

如果节点不能正常接收请求了,比如宕机了,系统崩溃了,而其他节点依然能正常接收请求,那么,我们说系统依然是可用的,也就是说,部分宕机没事儿,不影响可用性指标。
**如果节点能正常接收请求,但是发现节点内部数据有问题,那么也必须返回结果,哪怕返回的结果是有问题的。**比如,系统有两个节点,其中有一个节点数据是三天前的,另一个节点是两分钟前的,如果,一个读请求跑到了包含了三天前数据的那个节点上,抱歉,这个节点不能拒绝,必须返回这个三天前的数据,即使它可能不太合理。

image.png

2.3. P:分区容忍性

分布式的存储系统会有很多的节点,这些节点都是通过网络进行通信。而网络是不可靠的,当节点和节点之间的通信出现了问题,此时,就称当前的分布式存储系统出现了分区。但是,值得一提的是,分区并不一定是由网络故障引起的,也可能是因为机器故障。

比如,我们的分布式存储系统有 A、B 两个节点。那么,当 A、B 之间由于可能路由器、交换机等底层网络设备出现了故障,A 和 B 通信出现了问题,但是 A、B 依然都在运行,都在对外提供服务。这时候,就说 A 和 B 发生了分区。

还有一种情况也会发生分区,当 A 出现了宕机,A 和 B 节点之间通信也是出现了问题,那么我们也称 A 和 B 发生了分区。

综上,我们可以知道,只要在分布式系统中,节点通信出现了问题,那么就出现了分区。
image.png

那么,分区容忍性是指什么? 它是说,如果出现了分区问题,我们的分布式存储系统还需要继续运行。不能因为出现了分区问题,整个分布式节点全部就熄火了,罢工了,不做事情了。

image.png

2.4 Consistency 和 Availability 的矛盾

一致性和可用性,为什么不可能同时成立?
答案很简单,因为可能通信失败(即出现分区容错)
如果保证 G2 的一致性,那么 G1 必须在写操作时,锁定 G2 的读操作和写操作。只有数据同步后,才能重新开放读写。锁定期间,G2 不能读写,没有可用性不。 (没有可用性就失去了Avaliability的特点)
如果保证 G2 的可用性,那么势必不能锁定 G2,所以一致性不成立。(失去Consistency)
综上所述,G2 无法同时做到一致性和可用性。系统设计时只能选择一个目标。如果追求一致性,那么无法保证所有节点的可用性;如果追求所有节点的可用性,那就没法做到一致性。

3. CAP 怎么选择

我们上面已经知道了,在设计分布式系统时,架构师们在 C、A、P 这三种特性里,只能选择两种。

但是,这道 CAP 的选择题,就像别人在问你“小明的父亲有三个孩子,老大叫大朗,老二叫二郎,请问老三叫什么”一样。在以分布式存系统为限定条件的 CAP 世界里,P 是早已经确定的答案,P 是必须的。

因为,在分布式系统内,P 是必然的发生的,不选 P,一旦发生分区错误,整个分布式系统就完全无法使用了,这是不符合实际需要的。所以,对于分布式系统,我们只能能考虑当发生分区错误时,如何选择一致性和可用性。
而根据一致性和可用性的选择不同,开源的分布式系统往往又被分为 CP 系统和 AP 系统。
当一套系统在发生分区故障后,客户端的任何请求都被卡死或者超时,但是,系统的每个节点总是会返回一致的数据,则这套系统就是 CP 系统,经典的比如 Zookeeper。

如果一套系统发生分区故障后,客户端依然可以访问系统,但是获取的数据有的是新的数据,有的还是老数据,那么这套系统就是 AP 系统,经典的比如 Eureka。

说了这么多,其实 CAP 定理本质很简单,它就是一种分布式系统设计的不同理念概括,包括它说的一致性,可用性和分区容错性。这就类似一个大学的校训,是极度概念化的东西。

所以,大白话来形容下 CAP 吧,CAP 就是告诉程序员们当分布式系统出现内部问题了,你要做两种选择:

  • 要么迁就外部服务,像外包公司。
  • 要么让外部服务迁就你,像银行。

迁就外部服务就是我们不能因为我们自己的问题让外部服务的业务运行受到影响,所以要优先可用性。而让外部服务迁就我们,就要优先一致性。

4. 对 CAP 的常见误解

误解一:分布式系统因为 CAP 定理放弃了 C 或者 A 中的其中一个

很多人在没有对 CAP 做深入了解的情况下,听到很多人说分布式系统必须在 CAP 三个特性里选择两个,就觉得一套分布式系统肯定要么只有可用性要么只有一致性,不存在完整的可用性和一致性功能。

这种理解是大有问题的。因为,P 这种问题发生的概率非常低,所以:
当没有出现分区问题的时候,系统就应该有完美的数据一致性和可用性。

你什么时候见过一个系统,当内部没有问题的时候,会经常让外部请求卡一下的?要么就冷不丁的提供陈旧的老数据?那还能叫系统吗?

误解二:C 和 A 之间的选择是针对整个分布式系统的,只能整体考虑 C 和 A 之间的选择

这个理解也是不对的。当分区发生的时候,其实对一致性和可用性的抉择是局部性的,而不是针对整个系统的。

可能是在一些子系统做一些抉择,甚至很可能只需要对某个事件或者数据,做一致性和可用性的抉择而已。
比如,当我们做一套支付系统的时候,会员的财务相关像账户余额,账务流水是必须强一致性的。这时候,你就要考虑选 C。但是,会员的名字,会员的支付设置就不必考虑强一致性,可以选择可用性 A。

一套分布式系统的运行,就像人生一样,就是一次又一次的选择。在不同阶段,不同的时刻有不同的事件发生的时候,又怎么可能会有完全一样的选择呢?

误解三:CAP 的三个特性只有是和否两种极端选择,而不是一个范围

这种二元性的理解更是极其误导人。
CAP 理论的三种特性不是 Boolean 类型的,不是一致和不一致,可用和不可用,分区和没分区的这类二选一的选项。而是这三种特性都是范围类型。

拿可用性来说,就像我从银行取钱。当我目的是派发压岁钱的时候,我很可能就想全要新票子,但是,新票子很可能就还得多一个步骤,就是需要拿旧票子去换一些新票,此时,我可以多等会儿,能拿到新票子就好。而当我的目的就是做生活花销的时候,票子是新是旧,我根本不那么关心,快点拿到钱就行。这就是可用性的范围需求之一,对时延性的要求。

再比如,分区容错则由于探测机制的问题,可能还得各节点搞投票去协商分区是否存在,当某一台机器出现了问题,可能不影响业务的话,就会被机器投票认为分区不存在。然后一直等到多数机器出现了问题,才会投票确认出现了分区问题。这就好像新冠疫情,还会分低、中、高风险区呢,不是一出现通信故障就都被逻辑认定为分区问题。

5. CAP 理论的一些疑问

疑问一:在遵从 CAP 定理的系统中是否适合任意的写请求

首先,在 CAP 定理中,关于一致性会有多种说法,但是总的来说,都是在描述数据最新版本的可见性。而这些可见性往往代表的是读请求返回的数据的可见性。
那么问题来了,当我们要求读数据的可见性的时候,对写数据有什么要求吗?
比如,我们系统有三个节点,一个客户端给这个系统发了一个写请求,要求系统写入一个值为 20 的数据。那么,如果要满足 CAP 定理中的一致性,就需要在写完 20 这个数据之后,当其他客户端请求读取这个值为 20 的数据之后,无论请求被转发到系统中任何节点都能返回这个值。

这就要求写入这个值为 20 的写请求必须成功写到三个节点上,此时,系统就满足了写一致性的。所以,我们可以说对于读一致性的要求是同时约束了写一致性的。

image.png

其次,在 CAP 定理中,可用性本身要求对读、写请求都要处理。如果我们以可用性作为标准的时候,在发生分区错误时,由于我们对读请求并没有强行要求返回完全准确的数据,所以,可能在本次读请求之前的最近一次写请求可能是部分失败的。
同样的例子,我们的分布式系统由三个节点组成,最近一次写请求想把值为 20 的数据写到三个节点上。但是,由于发生了分区问题,有一个节点通信故障,写请求写不过去,因此只有两个节点包含了值为 20 的数据。

此时,写请求会返回给客户端一个结果,可能会告诉客户端写入成功了,也可能告诉客户端写入部分成功。

这时候,当后续的读请求恰巧被发送到有通信故障的那个节点,系统可能只能返回一个空的结果。但是,由于系统处理和返回了读写请求,所以,系统是满足了 CAP 中的可用性的。

image.png

疑问二:数据分片和数据副本的分布式系统是否都遵守 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 定理的特殊表现。

image.png
而当分布式系统是多个节点,每个节点存储了完整的一套数据,别的节点只是完整数据的备份的时候,即使事务只在一台机器上成功,当发生分区故障的时候,我们也是可以有充分的余地选择是单机事务的回退 or 就此认为写成功的

单机事务的回退,就可以对外表现为选择了一致性。

image.png
就此认为写成功,则可以认为选择了可用性。
image.png

疑问三:为何有时候区分一个系统是 AP 还是 CP 是如此之难

因为,就像我们前面讲过的,由于 AP 或者 CP 的选择,可能仅局限为整套系统的局部,甚至某些特殊的数据上,而我们又是用这种局部的特性去描述了整套系统,所以就导致了区分的困难。而这本身其实也日渐成为了 CAP 的一个大问题,从而被人诟病。

6. CAP 的不足

  1. CAP 定理本身是没有考虑网络延迟的问题的,它认为一致性是立即生效的,但是,要保持一致性,是需要时间成本的,这就导致往往分布式系统多选择 AP 方式
  2. 由于时代的演变,CAP 定理在针对所有分布式系统的时候,出现了一些力不从心的情况,导致很多时候它自己会把以前很严谨的数学定义改成了比较松弛的业务定义,类似于我们看到,CAP 定理把一致性、可用性、分区容错都变成了一个范围属性,而这和 CAP 定理本身这种数学定理般的称呼是有冲突的,出现了不符合数学严谨定义的问题。
  3. 在实践中以及后来 CAP 定理的提出者也承认,一致性和可用性并不仅仅是二选一的问题,只是一些重要性的区别,当强调一致性的时候,并不表示可用性是完全不可用的状态。比如,Zookeeper 只是在 master 出现问题的时候,才可能出现几十秒的不可用状态,而别的时候,都会以各种方式保证系统的可用性。而强调可用性的时候,也往往会采用一些技术手段,去保证数据最终是一致的。CAP 定理并没有给出这些情况的具体描述。
  4. CAP 理论从工程角度来看只是一种状态的描述,它告诉大家当有错的时候,分布式系统可能处在什么状态。但是,状态是可能变化的。状态间如何转换,如何修补,如何恢复是没有提供方向的。

7. 引申出来的 BASE

正因为 CAP 以上的种种不足,epay 的架构师 Dan Pritchett 根据他自身在大规模分布式系统的实践经验,总结出了 BASE 理论。BASE 理论是对 CAP 理论的延伸,核心思想是即使无法做到强一致性(Strong Consistency),但应用可以采用适合的方式达到最终一致性(Eventual Consitency)。
BASE 理论是实践工程的理论,它弥补了 CAP 理论过于抽象的问题,也同时解决了 AP 系统的总体工程实践思想,是分布式系统的核心理论之一,我们将在下一篇文章里,详细的讲解此套理论。

3 Redis如何实现主从同步?

Redis选择的是AP模式

Redis的主从数据是异步同步的,所以分布式的Redis 系统并不满足「一致性」要求。当客户端在Redis 的主节点修改了数据后,立即返回,即使在主从网络断开的情况下,主节点依旧可以正常对外提供修改服务,所以Redis 满足「可用性」。
Redis保证的是最终一致性,,从节点会努力追赶主节点,最终从节点的状态会和主节点的状态将保持一致。如果网络断开了,主从节点的数据将会出现大量不一致,一旦网络恢复,从节点会采用多种策略努力追赶上落后的数据,继续尽力保持和主节点一致。

Redis 的增量同步

Redis 同步的是指令流,主节点会将那些对自己的状态产生修改性影响的指令记录在本地的内存 buffer 中,然后异步将 buffer 中的指令同步到从节点,从节点一边执行同步的指令流来达到和主节点一样的状态,一遍向主节点反馈自己同步到哪里了 (偏移量)。
因为内存的 buffer 是有限的,所以 Redis 主库不能将所有的指令都记录在内存 buffer中。Redis 的复制内存 buffer 是一个定长的环形数组,如果数组内容满了,就会从头开始覆盖前面的内容。
image.png

Redis的快照同步

快照同步是一个非常耗费资源的操作,它首先需要在主库上进行一次 bgsave 将当前内存的数据全部快照到磁盘文件中,然后再将快照文件的内容全部传送到从节点。从节点将快照文件接受完毕后,立即执行一次全量加载,加载之前先要将当前内存的数据清空。加载完毕后通知主节点继续进行增量同步。

在整个快照同步进行的过程中,主节点的复制 buffer 还在不停的往前移动,如果快照同步的时间过长或者复制 buffer 太小,都会导致同步期间的增量指令在复制 buffer 中被覆盖,这样就会导致快照同步完成后无法进行增量复制,然后会再次发起快照同步,如此极有可能会陷入快照同步的死循环。
我的困惑buffer被覆盖后,为什么会导致快照同步完成后无法进行增量复制呢?
这里是从数据不一致方向出发,当Buffer中的指令被覆盖掉之后,就会导致主节点和从节点的数据不一致,因为Redis要求的是最终一致性,所以无法进行增量复制,需要再次快照同步主节点的数据。

所以务必配置一个合适的复制 buffer 大小参数,避免快照复制的死循环。
那如何设置合适的buffer呢?选择合适的缓冲区大小没有一个固定的标准,因为它取决于各种因素,如系统的写入速率、网络延迟、可用内存等。以下是一些建议和常见的缓冲区大小选择:

  1. 初始尝试:对于大多数情况,初始的缓冲区大小可以设置为主服务器执行 BGSAVE 操作期间的内存增量的两倍或三倍。这样可以确保缓冲区足够大,能够容纳在复制过程中产生的写入命令。
  2. 内存限制:确保你的系统有足够的可用内存来容纳所配置的缓冲区大小。监控系统的内存使用情况,确保不会导致内存不足或影响其他关键进程的正常运行。
  3. 性能测试和调整:根据实际情况进行性能测试和调整。可以逐步增加缓冲区大小,并观察复制延迟和系统性能的变化。如果复制延迟较高或内存消耗过大,可以逐步调整缓冲区大小,找到最佳的配置。
  4. 网络延迟考虑:如果主从服务器之间存在较高的网络延迟,你可能需要增加缓冲区大小以容纳更多的写入命令,以防止复制积压。较大的缓冲区可以提供更多的冗余,减少因网络延迟导致的复制延迟。

总之,选择合适的缓冲区大小是一个根据实际情况进行调整和优化的过程。建议从一个相对较大的初始缓冲区大小开始,然后根据实际测试结果进行逐步调整。监控复制延迟和系统性能,并根据需求进行适当的优化。

Redis增加从节点

当从节点刚刚加入到集群时,它必须先进行一次快照同步,同步完成后再继续增量同步。

Redis无盘复制

这个无盘复制其实是为了减少文件IO操作,通过套接字进行网络发送。这里是指在同步的时候这样处理。

主节点在进行快照同步时,会进行很重的文件 IO 操作,特别是对于非 SSD 磁盘存储时,快照会对系统的负载产生较大影响。特别是当系统正在进行 AOF 的 fsync 操作时如果发生快照,fsync 将会被推迟执行,这就会严重影响主节点的服务效率。
所以从 Redis 2.8.18 版开始支持无盘复制。所谓无盘复制是指主服务器直接通过套接字将快照内容发送到从节点,生成快照是一个遍历的过程,主节点会一边遍历内存,一遍将序列化的内容发送到从节点,从节点还是跟之前一样,先将接收到的内容存储到磁盘文件中,再进行一次性加载。

Redis Wait指令

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 的持久化功能,就必须认真对待主从复制,它是系统数据安全的基础保障。

Redis Sentinel (哨兵)

1、哨兵模式是用来解决什么问题的?

1.1 主要用来应对问题

如果主节点凌晨 3 点突发宕机怎么办?就坐等运维从床上爬起来,然后手工进行从主切换,再通知所有的程 序把地址统统改一遍重新上线么?毫无疑问,这样的人工运维效率太低,事故发生时估计得至少 1 个小时才能缓过来。

所以,我们必须有一个高可用方案来抵抗节点故障,当故障发生的时候可以自动进行自动进行从主切换,程序可以不用重启。Redis Sentinel可以通过自动监测和管理Redis数据库主从复制系统的状态,来保证Redis服务的高可用性。

1.2 Redis Sentinel的主要作用可以归纳为以下几个方面
  1. **监控Redis实例的健康状况:**Redis Sentinel可以周期性地对Redis实例进行健康检查,并识别出故障的Redis实例。一旦发现某个Redis实例出现故障,Sentinel就会自动将其标记为不可用状态,并开始进行故障恢复或者故障转移操作。
  2. 对Redis实例进行故障恢复和故障转移:当Redis主节点发生故障时,Redis Sentinel会自动将某个从节点升级为新的主节点,并重新配置其它从节点以连接到新的主节点上。这样,即使Redis主节点发生宕机等故障事件,整个Redis集群依然可以继续运行,保持业务的连续性和可用性。
  3. 滚动升级和重启Redis实例:Redis Sentinel可以通过观察Redis实例的运行状态,来进行滚动升级和重启Redis实例的操作。这样,就可以在保持服务连续性的同时,完成系统的升级或者维护工作。
1.3 Redis Sentinel的工作过程

image.png
如上图所示,我们可以将 Redis Sentinel 集群看成是一个 ZooKeeper 集群,它是集群高可用的心脏, 它一般是由 3~5 个节点组成,这样挂了个别节点集群还可以正常运转。
它负责持续监控主从节点的健康,当主节点挂掉时,自动选择一个最优的从节点切换为主节点。**客户端来连接集群时,会首先连接 sentinel,通过 sentinel 来查询主节点的地址,然后再去连接主节点进行数据交互。当主节点发生故障时,客户端会重新向 sentinel 要地址,sentinel 会将最新的主节点地址告诉客户端。**如此应用程序将无需重启即可自动完成节点切换。比如上图的主节点挂掉后,集群将可能自动调整为下图所示结构。
image.png
从这张图中我们能看到主节点挂掉了,原先的主从复制也断开了,客户端和损坏的主节点也断开了。从节点被提升为新的主节点,其它从节点开始和新的主节点建立复制关系。客户端通过新的主节点继续进行交互。Sentinel 会持续监控已经挂掉了主节点,待它恢复后,集群会调整为下面这张图。
image.png
此时原先挂掉的主节点现在变成了从节点,从新的主节点那里建立复制关系。

从以上所述过程看,个人认为较为重要的就是,客户端在连接Redis集群的时候,先去连接哨兵集群,从哨兵集群 那里获取主节点的信息,然后再去连接主节点,这难怪叫哨兵,像极了你去一个机关找人,你首先要在门卫那报道,他给你联系人,确认之后,你才能进去。

1.4 Redis如何 应对消息丢失

问题:Redis 主从采用异步复制,意味着当主节点挂掉时,从节点可能没有收到全部的同步消息,这部分未同步的消息就丢失了。如果主从延迟特别大,那么丢失的数据就可能会特别多。

解决:Sentinel 无法保证消息完全不丢失,但是也尽可能保证消息少丢失。它有两个选项可以限制主从延迟过大。

min-slaves-to-write 1
min-slaves-max-lag 10

第一个参数表示主节点必须至少有一个从节点在进行正常复制,否则就停止对外写服务,丧失可用性。
第二个参数是表示判断正常复制的标准,它的单位是秒,表示如果 10s 没有收到从节点的反馈,就意味着从节点同步不正常,要么网络断开了,要么一直没有给反馈。

这可就更好理解了,你去机关找人,门卫说我给你联系下,他打了电话等了十秒没人接,就判定这个人可能不在,然后他就问你,你还有其他人可以联系么,你说有,然后又打了个电话过去,发现还是十秒没人接,就判定这个人也不在,好的吧,门卫说,那你就在外面等着吧,我联系上他们了再通知你。你就只好在那等着,而不是你进去了找了一圈发现没人之后你走了,他们回来之后发现少了一位客人。

2、 Sentinel 基本使用

   接下来我们看看客户端如何使用 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 Sentinel是一种用于提高Redis服务高可用性的解决方案,其主要优点和缺点如下:
优点:

  1. 高可用性:Redis Sentinel可以自动检测和处理Master节点的故障,从而实现高可用性的Redis服务。
  2. 自动故障转移:当Redis Master节点发生故障时,Redis Sentinel可以自动将Slave节点晋升为新的Master节点,从而保证Redis服务的高可用性。
  3. 无需手动介入:Redis Sentinel是自动化运维的一种典型代表。一旦发现Redis Master节点出现故障,它能够立即自动进行故障转移操作,省去了手动介入的麻烦和风险。
  4. 配置简单:Redis Sentinel的配置相对简单,可以使用Redis Sentinel提供的命令进行轻松配置。
  5. 可扩展性:Redis Sentinel可以通过增加或减少Sentinel节点来提高系统的可扩展性和灵活性。

缺点:

  1. 性能有限:由于Redis Sentinel需要周期性地进行节点健康检查和信息同步,因此一定程度上会影响Redis服务的性能。
  2. 可靠性风险:如果Redis Sentinel集群中出现多个节点同时宕机等情况,可能会导致Redis服务不可用。
  3. 单点故障:如果Redis Sentinel自身出现故障,可能会导致整个Redis服务的不可用。
  4. 对流量有限制:Redis Sentinel对于处理高并发、大流量场景的性能有所限制。

总之,Redis Sentinel是一种简单、可靠的Redis服务高可用性解决方案,但同时也存在一些性能、可靠性风险和容量等方面的考虑。因此,在使用Redis Sentinel时需要根据实际情况进行评估和权衡,以确定是否适合使用该方案。

Redis集群下的分布式锁

Redis集群下分布式锁出现的问题

比如在 Sentinel 集群中,主节点挂掉时,从节点会取而代之,客户端上却并没有明显感知。原先第一个客户端在主节点中申请成功了一把锁,但是这把锁还没有来得及同步到从节点,主节点突然挂掉了。然后从节点变成了主节点,这个新的节点内部没有这个锁,所以当另一个客户端过来请求加锁时,立即就批准了。这样就会导致系统中同样一把锁被两个客户端同时持有,不安全性由此产生。
image.png
不过这种不安全也仅仅是在主从发生 failover 的情况下才会产生,而且持续时间极短,业务系统多数情况下可以容忍。

如何解决集群下分布式锁的问题?

为了保证集群环境下分布式锁的可靠性,Redis 官方已经设计了一个分布式锁算法 Redlock(红锁)。
它是基于多个 Redis 节点的分布式锁,即使有节点发生了故障,锁变量仍然是存在的,客户端还是可以完成锁操作。官方推荐是至少部署 5 个 Redis 节点,而且都是主节点,它们之间没有任何关系,都是一个个孤立的节点。
Redlock 算法的基本思路,是让客户端和多个独立的 Redis 节点依次请求申请加锁,如果客户端能够和半数以上的节点成功地完成加锁操作,那么我们就认为,客户端成功地获得分布式锁,否则加锁失败
这样一来,即使有某个 Redis 节点发生故障,因为锁的数据在其他节点上也有保存,所以客户端仍然可以正常地进行锁操作,锁的数据也不会丢失。

Redlock 算法加锁三个过程:

  • 第一步是,客户端获取当前时间(t1)。
  • 第二步是,客户端按顺序依次向 N 个 Redis 节点执行加锁操作:
    • 加锁操作使用 SET 命令,带上 NX,EX/PX 选项,以及带上客户端的唯一标识。
    • 如果某个 Redis 节点发生故障了,为了保证在这种情况下,Redlock 算法能够继续运行,我们需要给「加锁操作」设置一个超时时间(不是对「锁」设置超时时间,而是对「加锁操作」设置超时时间),加锁操作的超时时间需要远远地小于锁的过期时间,一般也就是设置为几十毫秒。
  • 第三步是,一旦客户端从超过半数(大于等于 N/2+1)的 Redis 节点上成功获取到了锁,就再次获取当前时间(t2),然后计算计算整个加锁过程的总耗时(t2-t1)。如果 t2-t1 < 锁的过期时间,此时,认为客户端加锁成功,否则认为加锁失败。

可以看到,加锁成功要同时满足两个条件(简述:如果有超过半数的 Redis 节点成功的获取到了锁,并且总耗时没有超过锁的有效时间,那么就是加锁成功):

  • 条件一:客户端从超过半数(大于等于 N/2+1)的 Redis 节点上成功获取到了锁;
  • 条件二:客户端从大多数节点获取锁的总耗时(t2-t1)小于锁设置的过期时间。

加锁成功后,客户端需要重新计算这把锁的有效时间,计算的结果是「锁最初设置的过期时间」减去「客户端从大多数节点获取锁的总耗时(t2-t1)」。如果计算的结果已经来不及完成共享数据的操作了,我们可以释放锁,以免出现还没完成数据操作,锁就过期了的情况。
加锁失败后,客户端向所有 Redis 节点发起释放锁的操作,释放锁的操作和在单节点上释放锁的操作一样,只要执行释放锁的 Lua 脚本就可以了。

Redlock使用场景

如果你很在乎高可用性,希望挂了一台 redis 完全不受影响,那就应该考虑 redlock。不过代价也是有的,需要更多的 redis 实例,性能也下降了,代码上还需要引入额外的 library,运维上也需要特殊对待,这些都是需要考虑的成本,使用前请再三斟酌。

Redis的过期策略

如何设置过期时间?

先说一下对 key 设置过期时间的命令。 设置 key 过期时间的命令一共有 4 个:

  • expire :设置 key 在 n 秒后过期,比如 expire key 100 表示设置 key 在 100 秒后过期;
  • pexpire :设置 key 在 n 毫秒后过期,比如 pexpire key2 100000 表示设置 key2 在 100000 毫秒(100 秒)后过期。
  • expireat :设置 key 在某个时间戳(精确到秒)之后过期,比如 expireat key3 1655654400 表示 key3 在时间戳 1655654400 后过期(精确到秒);
  • pexpireat :设置 key 在某个时间戳(精确到毫秒)之后过期,比如 pexpireat key4 1655654400000 表示 key4 在时间戳 1655654400000 后过期(精确到毫秒)

当然,在设置字符串时,也可以同时对 key 设置过期时间,共有 3 种命令:

  • set ex :设置键值对的时候,同时指定过期时间(精确到秒);
  • set px :设置键值对的时候,同时指定过期时间(精确到毫秒);
  • setex :设置键值对的时候,同时指定过期时间(精确到秒)。

如果你想查看某个 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已过期?

每当我们对一个 key 设置了过期时间时,Redis 会把该 key 带上过期时间存储到一个过期字典(expires dict)中,也就是说「过期字典」保存了数据库中所有 key 的过期时间。
过期字典存储在 redisDb 结构中,如下:

typedef struct redisDb {
    dict *dict;    /* 数据库键空间,存放着所有的键值对 */
    dict *expires; /* 键的过期时间 */
    ....
} redisDb;

过期字典数据结构结构如下:

  • 过期字典的 key 是一个指针,指向某个键对象;
  • 过期字典的 value 是一个 long long 类型的整数,这个整数保存了 key 的过期时间;

过期字典的数据结构如下图所示:

字典实际上是哈希表,哈希表的最大好处就是让我们可以用 O(1) 的时间复杂度来快速查找。当我们查询一个 key 时,Redis 首先检查该 key 是否存在于过期字典中:

  • 如果不在,则正常读取键值;
  • 如果存在,则会获取该 key 的过期时间,然后与当前系统时间进行比对,如果比系统时间大,那就没有过期,否则判定该 key 已过期。

过期键判断流程如下图所示:

过期策略有哪些?

在说 Redis 过期删除策略之前,先跟大家介绍下,常见的三种过期删除策略:

  • 定时删除;
  • 惰性删除;
  • 定期删除;

接下来,分别分析它们的优缺点。

定时删除策略是怎么样的?

定时删除策略的做法是,在设置 key 的过期时间时,同时创建一个定时事件,当时间到达时,由事件处理器自动执行 key 的删除操作。
定时删除策略的优点

  • 可以保证过期 key 会被尽快删除,也就是内存可以被尽快地释放。因此,定时删除对内存是最友好的。

定时删除策略的缺点

  • 在过期 key 比较多的情况下,删除过期 key 可能会占用相当一部分 CPU 时间,在内存不紧张但 CPU 时间紧张的情况下,将 CPU 时间用于删除和当前任务无关的过期键上,无疑会对服务器的响应时间和吞吐量造成影响。所以,定时删除策略对 CPU 不友好。
惰性删除策略是怎么样的?

惰性删除策略的做法是,不主动删除过期键,每次从数据库访问 key 时,都检测 key 是否过期,如果过期则删除该 key。
惰性删除策略的优点

  • 因为每次访问时,才会检查 key 是否过期,所以此策略只会使用很少的系统资源,因此,惰性删除策略对 CPU 时间最友好。

惰性删除策略的缺点

  • 如果一个 key 已经过期,而这个 key 又仍然保留在数据库中,那么只要这个过期 key 一直没有被访问,它所占用的内存就不会释放,造成了一定的内存空间浪费。所以,惰性删除策略对内存不友好。
定期删除策略是怎么样的?

定期删除策略的做法是,每隔一段时间「随机」从数据库中取出一定数量的 key 进行检查,并删除其中的过期key。
定期删除策略的优点

  • 通过限制删除操作执行的时长和频率,来减少删除操作对 CPU 的影响,同时也能删除一部分过期的数据减少了过期键对空间的无效占用。

定期删除策略的缺点

  • 内存清理方面没有定时删除效果好,同时没有惰性删除使用的系统资源少。
  • 难以确定删除操作执行的时长和频率。如果执行的太频繁,定期删除策略变得和定时删除策略一样,对CPU不友好;如果执行的太少,那又和惰性删除一样了,过期 key 占用的内存不会及时得到释放。
  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

下次一定少写BUG

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值