编者注:笔者整理了一份【Redis不完全指南】,包含了很多详细的知识点和Redis经典面试题,可关注「TopCoder」公众号,发送 Reids 来获取~
5种基本类型:字符串String、字典Hash、列表List、集合Set、有序集合SortedSet。
如果你使用的是较新版本的Redis,还需要加上下面几种数据结构HyperLogLog(用于统计非重复计数,类似于取set.size,set用于保存计数项,不过HyperLogLog占用内存较小,可用于大型网站的UV统计)、Geo、Pub/Sub、BloomFilter等。
先拿setnx来争抢锁,抢到之后,再用expire给锁加一个过期时间防止锁忘记了释放。当然这样如果在setnx之后执行expire之前进程意外crash或者要重启维护了会有隐患,可以使用setnx命令直接加上过期时间。
不过redis分布式锁存在锁续约问题,可以在加锁期间针对锁不断进行续约。如果业务能够保证在加锁时间范围内执行完毕,那么理论上不需要进行锁续约。一般来说,Redis分布式锁key对应的value要保证操作的唯一性,避免程序unlock了不属于自己lock的锁。分布式锁除了基于Redis的实现之外,还可以基于DB或者zookeeper。
使用keys指令可以扫出指定模式的key列表。
因为Redis是单线程的,keys指令会导致线程阻塞一段时间,线上服务会停顿,直到指令执行完毕,服务才能恢复。这个时候可以使用scan指令,scan指令可以无阻塞的提取出指定模式的key列表,但是会有一定的重复概率,在客户端做一次去重就可以了,但是整体所花费的时间会比直接用keys指令长。(scan原理是分批次扫描hash表中的slot进行查找,批次最小单位为slot,有可能返回值重复是因为扫描时间内发生了rehash缩容操作)
一般使用list结构作为队列,rpush生产消息,lpop消费消息。当lpop没有消息的时候,要适当sleep一会再重试。如果应用不想用sleep呢,其实list还有个指令叫blpop,在没有消息的时候,它会阻塞(超时)住直到消息到来。
使用pub/sub主题订阅者模式,可以实现1:N的消息队列。Redis5.0增加一个stream结构,用于消息发布订阅。注意:Redis的发布订阅模式只能针对在线的client,并且没有消息进度机制,因此client断线时无法接收到消息,client断线重连后历史消息无法消费。
在消费者下线的情况下,生产的消息会丢失,得使用专业的消息队列如rabbitmq/rocketmq等。
Redis本身不支持延时队列,不过可以借助于sortedSet来实现。使用sortedset,拿时间戳作为score,消息作为key/value,调用zadd来生产消息,消费者用zrangebyscore指令获取N秒之前的数据轮询进行处理。
如果大量的key过期时间设置的过于集中,到过期的那个时间点,redis可能会出现短暂的卡顿现象。一般需要在时间上加一个随机值,使得过期时间分散一些。
bgsave(RDB)做镜像全量持久化,aof做增量持久化。因为bgsave会耗费较长时间,不够实时,在停机的时候会导致大量丢失数据,所以需要aof来配合使用。在redis实例重启时,优先使用aof进行恢复,aof文件不存在时会使用rdb文件恢复。
注意,aof在rewrite时也是做全量持久化的,会耗费不少时间。执行rdb和aof重写都是fork子线程来完成的,避免影响主线程。
取决于aof日志sync属性的配置,如果不要求性能,在每条写指令时都sync一下磁盘,就不会丢失数据。但是在高性能的要求下每次都sync不现实,一般都使用定时sync,比如1s1次,这个时候最多就会丢失1s的数据。
fork和cow(写时复制)。fork是指redis通过创建子进程来进行bgsave操作,cow指的是copy on write,子进程创建后,父子进程共享数据段,父进程继续提供读写服务,写脏的页面数据会逐渐和子进程分离开来。
可以将多次IO往返的时间缩减为一次,前提是pipeline执行的指令之间没有因果相关性。使用redis-benchmark进行压测的时候可以发现影响redis的QPS峰值的一个重要因素是pipeline批次指令的数目。
Redis可以使用主从同步,从从同步。第一次同步时,主节点做一次bgsave,并同时将后续修改操作记录到内存buffer,待完成后将rdb文件全量同步到复制节点,复制节点接收完成后将rdb镜像加载到内存。加载完成后,再通知主节点将期间修改的操作记录同步到复制节点,这样就完成了同步过程。
注意,Redis的复制都是异步执行的。Redis有2个参数:min-slaves-to-write和min-slaves-max-lag,二者表示的含义为:如果至少有 min-slaves-to-write 个从服务器, 并且这些服务器的延迟值都少于 min-slaves-max-lag 秒, 那么主服务器就会执行客户端请求的写操作,否则拒绝写操作。这在一定程度上减小主从不同步的时间间隔,但是没有从根本上解决同步一致性问题。
Redis Sentinal着眼于高可用,在master宕机时会自动将slave提升为master,继续提供服务。Redis Cluster着眼于扩展性,在单个redis内存不足时,使用Cluster进行分片存储。
(这里可能涉及到一致性哈希,后续分析下一致性哈希原理,一致性哈希原理可参考https://crossoverjie.top/2018/01/08/Consistent-Hash/,一致性哈希实现可参考https://www.cnblogs.com/xrq730/p/5186728.html)
过期策略通常有以下三种:
Redis中同时使用了惰性过期和定期过期两种过期策略。
serverCron是由redis的事件框架驱动的定位任务,这个定时任务中会调用activeExpireCycle函数,针对每个db在限制的时间REDIS_EXPIRELOOKUPS_TIME_LIMIT内迟可能多的删除过期key,之所以要限制时间是为了防止过长时间 的阻塞影响redis的正常运行。这种主动删除策略弥补了被动删除策略在内存上的不友好。
因此,Redis会周期性的随机测试一批设置了过期时间的key并进行处理。测试到的已过期的key将被删除。典型的方式为,Redis每秒做10次如下的步骤:
Redis的内存淘汰策略是指在Redis的用于缓存的内存不足时,怎么处理需要新写入且需要申请额外空间的数据。
对于LRU(Least Recent Used),淘汰掉最不经常使用的,对于LRU的实现hashMap + 双向链表来实现,如果Redis也基于hashMap + 双向链表实现,显然要对目前的数据结构做较大改动,为了追求空间的利用率,Redis采用权衡的实现方案:Redis会基于server.maxmemory_samples配置选取固定数目的key,然后比较它们的lru访问时间,然后淘汰最近最久没有访问的key,maxmemory_samples的值越大,Redis的近似LRU算法就越接近于严格LRU算法,但是相应消耗也变高,对性能有一定影响,样本值默认为5。
关于LRU算法和Redis中的LRU实现可参考:https://zhuanlan.zhihu.com/p/34133067、https://www.cnblogs.com/Lifehacker/p/redis_random_lru.html和https://zhuanlan.zhihu.com/p/34133067。
Redis通过MULTI、EXEC、WATCH命令来实现事务,Reids在事务执行期间,服务器不会中断事务去执行其他命令,而是在事务的所有命令执行完毕后再执行其他命令,因为Redis是单线程处理模型。
Redis事务和传统的数据库事务最大的区别就是:Reids事务不具有回滚机制,即使事务中有部分命令执行错误,事务也会继续执行之后的命令直到结束,并且之前执行的命令不受任何影响。Redis为什么不支持回滚机制呢,其作者解释道,不支持事务回滚是因为这种复杂的功能和Reids追求的简单高效设计主旨不符,并且他认为,Reids事务的执行中的错误通常是由程序错误导致的,这种错误在实际生产环境中较少出现,因此没必要为Reids开发事务回滚功能。
构造一个长度为2^32的整数环(这个环称为一致性hash环),根据节点名称的Hash值(其分布为[0, 2^32-1])将服务器节点放置在这个Hash环上,然后根据数据的Key值计算得到其Hash值(其分布也为[0, 2^32-1]),接着在Hash环按照顺时针(或者逆时针)查找距离这个Key值的Hash值最近的服务器节点,完成Key到服务器的映射查找。
一致性hash算法解决了节点伸缩(比如服务器上下线)时尽量将更多的请求命中原来路由到的服务器,减小由于节点伸缩对于原来映射关系的影响。一致性hash算法有两种实现,一种是未添加虚拟节点,另一种是添加了虚拟节点。后者由于使用了多个虚拟节点进行映射,理论上hash分散效果更好。
未添加虚拟节点的一致性hash算法实现:
private static int hash(String str) {
final int p = 16777619;
int hash = (int)2166136261L;
for (int i = 0; i < str.length(); i++)
hash = (hash ^ str.charAt(i)) * p;
hash += hash << 13;
hash ^= hash >> 7;
hash += hash << 3;
hash ^= hash >> 17;
hash += hash << 5;
// 如果算出来的值为负数则取其绝对值
if (hash < 0)
hash = Math.abs(hash);
return hash;
}
private static TreeMap<Integer, String> treeMap = new TreeMap<>();
static {
List<String> list = Arrays.asList("192.168.2.11", "192.168.2.12", "192.168.2.13");
list.forEach(item -> treeMap.put(hash(item), item));
}
private static String route(String data) {
Integer key = treeMap.higherKey(hash(data));
if (key == null) {
key = treeMap.firstKey();
}
return treeMap.get(key);
}
public static void main(String[] args) {
List<String> list = Arrays.asList("aaa", "bbb", "ccc");
list.forEach(item -> System.out.println(item + " 路由到了 " + route(item)));
}
添加虚拟节点的一致性hash算法实现:
private static TreeMap<Integer, String> treeMap = new TreeMap<>();
static {
// 每个节点有10个虚拟节点
List<Integer> vNode = Arrays.asList(0, 1, 2, 3, 4, 5, 6, 7, 8, 9);
List<String> list = Arrays.asList("192.168.2.11", "192.168.2.12", "192.168.2.13");
list.forEach(item -> vNode.forEach(node -> treeMap.put(hash(item + node), item)));
treeMap.forEach((key, value) -> System.out.println(key + ": " + value));
}