Redis 的主从同步机制不仅可以让从库服务更多的读请求,分担主库的压力,而且还能在主库发生故障时,进行主从库切换,提供高可靠服务。
不过,在实际使用主从机制时会踩到一些“坑”:主从数据不一致、读到过期数据以及配置项设置得不合理而导致服务挂掉。
主从数据不一致,就是指客户端从从库中读取到的值和主库中的最新值并不一致。
假设,主从库之前保存的用户年龄是 19,但是主库收到了修改命令,已经把这个数据更新为 20 了,但是,从库中的值仍然是 19。那么,如果客户端从从库中读取用户年龄值,就会读到旧值。
因为主从库的命令复制是异步进行的。
具体来说,在主从库命令传播阶段,主库收到新的写命令后,会发送给从库。但是,主库并不会等到从库实际执行完命令后,再把结果返回给客户端,而是主库自己在本地执行完命令后,就会向客户端返回结果了。如果从库还没有执行主库同步过来的命令,主从库间的数据就不一致了。
造成从库之后执行同步命令的原因有两个:
有两种解决办法:
因为 Redis 的 INFO replication
命令可以查看主库接收写命令的进度信息(master_repl_offset)和从库复制写命令的进度信息(slave_repl_offset),所以,我们可以开发一个监控程序,先用 INFO replication
命令查到主、从库的进度,然后,用 master_repl_offset 减去 slave_repl_offset,这样就能得到主库和从库间的复制进度差值了。
如果某个从库的进度差值大于我们预设的阈值,可以让客户端不再和这个从库就进行数据读取,这样就可以减少得到不一致数据的情况。不过,为了避免出现客户端和所有从库都不能连接的情况,我们要把复制进度差值的阈值设置得大一些。
当然,监控程序可以一直监控这从库的复制进度,当从库的复制进度又赶上了主库时,我们允许客户端再次跟这些从库连接。
我们在使用 Redis 主从集群时,有时会读到过期数据。例如,数据 X 的过期时间是 202401010900,但是客户端在 202401010910 时,仍然可以从从库中读到数据 X。一个数据过期后,应该是被删除的,客户端不能再读取到该数据。
其实,这是有 Redis 的过期策略引起的。Redis 同时使用了两种策略来删除过期数据,分别是惰性删除策略和定期删除策略。
惰性删除策略。当一个数据的过期时间到了以后,并不会立即删除数据,而是等到再由请求来读写这个数据时,对数据进行检查,如果发现数据已经过期了,再删除这个数据。
这个策略的好处是尽量减少删除操作对 CPU 资源的使用,对于用不到的数据,就不在浪费时间进行检查和删除了。但是,这个策略会导致大量已经过期的数据留存在内存中,占用较多的内存资源。所以,Redis 在使用这个策略的同时,还使用了定期删除策略。
定期删除策略是指,Redis 每隔一段时间(默认 100ms),就会随机选出一定数量的数据,检查他们是否过期,并把其中过期的数据删除,这样就可以及时释放一些内存。
清楚了删除策略,看看他们为什么会导致读取到过期数据。
首先,虽然定期删除可以释放一些内存,但是 Redis 为了避免过多删除操作对性能产生影响,每次随机检查数据的数量并不多。如果过期数据很多,并且一直没有再被访问,这些数据就会留存在 Redis 实例中。业务应用之所以会读到过期数据,这些留存数据就是一个重要因素。
其次,惰性删除策略实现后,数据只有被再次访问时,才会被实际删除。如果客户端从主库上读取留存的过期数据,主库会触发删除,此时,客户端并不会读到过期数据。但是,从库本身不会执行删除操作,如果客户端在从库中访问的过期数据,从库并不会触发数据删除。
那么,从库会给客户端返回过期数据吗?
如果是 Redis 3.2 之前的版本,那么,从库在服务读取请求时,并不会判断是否过期,而是会返回过期数据。在,Redis 3.2 版本后,Reids 做了改进,如果读取的数据已经过期,从库虽然不删除数据,但是会返回空值,所以就避免了客户端读到过期数据。所以,在应用主从集群时,尽量使用 Redis 3.2 以上版本。
另外,Redis 的过期命令也可能导致读取到过期数据。有些命令给数据设置的过期时间在从库上可能会被延后,导致应该该过期的数据又在从库上被读取到了。
设置数据过期时间的命令一个有 4 个,我们可以把他们分成两类:
EXPIRE
和 PEXPIRE
:它们给数据设置的是从命令执行时开始计算的存活时间。EXPIREAT
和 PEXPIREAT
:它们会直接把数据的过期时间设置为具体的一个时间点。过期时间 设置命令 |
参数 | 含义 |
---|---|---|
EXPIRE | [key] [ttl] | 将 key 的存活时间设置为 ttl 秒 |
PEXPIRE | [key] [ttl] | 将 key 的存活时间设置为 ttl 毫秒 |
EXPIREAT | [key] [timestamp] | 将key的过期时间设置为timestamp指定的秒数时间点 |
PEXPIREAT | [key] [timestamp] | 将key的过期时间设置为timestamp指定的毫秒数时间点 |
再举两个例子。
第一个例子是使用 EXPIRE 命令,当执行下面的命令时,我们就把 testkey 的过期时间设置为 60s 后。
EXPIRE testkey 60
第二例子是使用 EXPIREAT 命令,例如,我们执行下面的命令,就可以让 testkey 在 2024年1月1日上午9点过期,命令中的 1673079000 就是以秒数时间戳表示的 2024年1月1日上午9点。
EXPIREAT testkey 1673079000
下面来看看这些命令是如何导致读到过期数据的。
当主从全量同步时,如果主库收到了 EXPIRE 命令,那么,主库会直接执行这条命令。这条命令会在全量同步完成后,发给从库执行。而从库在执行时,就会在当前时间的基础上加上数据的存活时间,这样一来,从库上的数据的过期时间就会比主库延后了。
假设当前时间是 2024年1月1日上午9点,主从库正在同步,主库收到了一条命令 EXPIRE testkey 60,这就表示,testkey 的过期时间就是 1日上午9 点 1 分,主库直接执行了这条命令。
- 但是,主从库全量同步花了 2 分支才完成。等从库开始执行这条命令时,时间已经是 9 点 2 分了。而 EXPIRE 命令就是把过期时间设置为当前时间的 60s 后,也就是 9 点 3 分。
- 如果客户端在 9 点 2 分 30 秒时在从库上读取 testkey,仍可以读取到 testkey 值。但是 testkey 实际已经过期了。
为了避免这种情况,在业务应用中使用 PEXPIREAT、PPEXPIREAT 命令,把数据过期时间设置为具体的时间点,避免读到过期数据。
简单总结下刚刚学到过的这两个坑:
这里涉及到两个配置项: protected-mode 和 cluster-node-timeout。
这个配置项的作用是限定哨兵机制能够被其他服务器访问。当这个配置项是 yes
时,哨兵实例只能在部署的服务器本地进行访问。当设置为 no
时,其他服务器也可以访问这个哨兵实例。
这是因为这样,如果 protected-mode 设为 yes
,而其余哨兵实例部署在其他服务器,那么这些哨兵视力健就无法通信,当主库故障时,哨兵无法判断主库下线,也无法进行主从切换,最终 Redis 不可用。
所以,我们在应用主从集群时,要注意将 protected-mode 配置项设置为 no,并且将 bind 配置项设置为 其他哨兵实例的 IP 地址。这样一来,只有在 bind 中配置了 IP 地址的哨兵,才可以向才可以访问,既保证了实例间能够通信进行主从切换,也保证了哨兵的安全。
如果设置了下面的配置项,那么,部署在 192.168.10.3/4/5
这三台服务器上的哨兵实例就可以相互通信,执行主从切换。
protected-mode no
bind 192.168.10.3 192.168.10.4 192.168.10.5
我们在 Redis Cluster 集群中为每个实例配置了 “一主一从” 模式时,如果实例发送故障,从实例会切为主实例,受网络延迟和切换操作执行的影响,切换时间可能更长,就会导致实例的心跳超时(超出 cluster-node-timeout)。实例超时后,就会被 Redis Cluster 判断为异常。而 Redis Cluster 能正常运行的条件是,有半数以上的实例都能正常运行。
如果,执行主从切换的实例超过半数,而且主从库切换时间又过长的话,就可能有半数以上的实例心跳超时,从而可能导致整个集群挂掉。所以,建议你将 cluster-node-timeout 调大些。(例如 10 到 20 秒)。