NoSQL
局限:
Redis作为一款性能优异的内存数据库,在互联网公司有着多种应用场景,主要有以下几个方面:
总结1:
缓存
将热点数据放到内存中,设置内存的最大使用量以及淘汰策略来保证缓存的命中率。
会话缓存
可以使用 Redis 来统一存储多台应用服务器的会话信息。当应用服务器不再存储用户的会话信息,也就不再具有状态,一个用户可以请求任意一个应用服务器,从而更容易实现高可用性以及可伸缩性。
计数器:
可以对 String 进行自增自减运算,从而实现计数器功能。Redis 这种内存型数据库的读写性能非常高,很适合存储频繁读写的计数量。
全页缓存(FPC)
除基本的会话token之外,Redis还提供很简便的FPC平台。以Magento为例,Magento提供一个插件来使用Redis作为全页缓存后端。此外,对WordPress的用户来说,Pantheon有一个非常好的插件 wp-redis,这个插件能帮助你以最快速度加载你曾浏览过的页面。
查找表
例如 DNS 记录就很适合使用 Redis 进行存储。查找表和缓存类似,也是利用了 Redis 快速的查找特性。但是查找表的内容不能失效,而缓存的内容可以失效,因为缓存不作为可靠的数据来源。
消息队列(发布/订阅功能)
List 是一个双向链表,可以通过 lpush 和 rpop 写入和读取消息。不过最好使用 Kafka、RabbitMQ 等消息中间件。
分布式锁实现
在分布式场景下,无法使用单机环境下的锁来对多个节点上的进程进行同步。可以使用 Redis 自带的 SETNX
命令实现分布式锁(使用del key 命令释放锁),除此之外,还可以使用官方提供的 RedLock 分布式锁实现。
其它
Set 可以实现交集、并集等操作,从而实现共同好友等功能。ZSet 可以实现有序性操作,从而实现排行榜等功能。
总结2:
String类型应用场景:
hash类型应用场景:(String可以做的hash都可以做。)
List列表类型的应用场景:
set类型应用场景:抽奖(随机获取元素 spop)、点赞、签到、打卡
这条微博的id是t1001,用户ID是u2001
用like:t1001来维护t1001这条微博的所有的点赞用户
点赞:sadd like:t1001 u3001
取消点赞:srem like:t1001 u3001
是否点赞:sismember like:t1001 u3001
点赞的总人数: smembers like:t1001 u3001
点赞数:scard like:t1001
sadd tags:i5000 画质清晰细腻
sadd tags:i5000 真彩清晰显示屏
sadd tags:i5000 性能一流
获取差集:sdiff set1 set2
获取交集:sinter set1 set2
获取并集: sunion set1 set2
用户关注、推荐模型(相互关注、我关注的人也关注了他、可能认识的人)
zset类型的应用场景
Redis默认是快照RDB的持久化方式。对于主从同步来说,主从刚刚连接的时候,进行全量同步(RDB);全同步结束后,进行增量同步(AOF)。
Redis 中数据过期策略采用定期删除+惰性删除策略
结合起来,以及采用淘汰策略来兜底。
定时删除策略:
惰性删除策略:
定期删除
随机抽取一部分 key 进行检查
,这样就降低了对 CPU 资源的损耗,惰性删除策略互补了为检查到的key,基本上满足了所有要求。定期删除+惰性删除都没有删除过期的key?
当内存不够用的时候,就会走到redis的内存淘汰机制。
内存淘汰机制就保证了在redis的内存占用过多的时候,去进行内存淘汰,也就是删除一部分key,保证redis的内存占用率不会过高。
在redis.conf中有一行配置
maxmemory-policy volatile-lru
volatile-lru:
从已设置过期时间
的数据集(server.db[i].expires)中挑选最近最少使用的数据淘汰
volatile-ttl
:从已设置过期时间
的数据集(server.db[i].expires)中挑选将要过期的数据淘汰
volatile-random
:从已设置过期时间的数据集(server.db[i].expires)中随机移除key
allkeys-lru
:当内存不足以容纳新写入数据时,在键空间中,移除最近最少使用的key(这个是最常用的)
allkeys-random
:从数据集(server.db[i].dict)中任意选择数据淘汰
no-eviction
:当内存不足以容纳新写入数据时,新写入操作会报错,无法写入新数据,一般不采用
4.0版本后增加以下两种:
Redis每次按key获取一个值的时候,都会更新value中的lru字段为当前秒级别的时间戳。
Redis初始的实现算法很简单,随机从dict中取出五个key,淘汰一个lru字段值最小的。
在3.0的时候,又改进了一版算法:
1、Redis缓存雪崩
定义:redis缓存中大量的key同时失效,(缓存服务器宕机)此时又刚好有大量的请求打进来,直接打到数据库层,造成数据库阻塞甚至宕机。
场景: 把所有存入redis的所有数据设置相同过期的时间,过期时间失效后,就会大量请求数据库。
如何解决?
1、在缓存的时候我们给过期时间设置一个随机数,但是也要根据业务场景需求来设置
2、事发前:实现redis的高可用、主从架构+sentinel 或者 redis cluster ,避免全盘崩溃;
3、事发后:万一redis真的挂了,可以设置本地缓存ehcache+限流hystrix,避免数据库被打死;
4、事发后:redis持久化,重启后自动从磁盘上加载数据,快速恢复缓存数据
定义
:指查询一个缓存和数据库都不存在的数据,导致尽管数据不存在但是每次都会到数据库查询。在访问量大时可能DB就会挂掉。如果有人利用不存在的key频繁攻击,则这就形成了漏洞。
场景
:比如我们表的数据的id是从1开始的正数,如果在这里有黑客攻击我们的系统,会设置一些负数的id到数据库查询数据,查询出来返回的数据为null,在这里,由于缓存不命中,并且处于容错考虑,从数据库查询的数据为null就不写到redis,这将导致每次查询数据都会到数据库查询,失去了缓存的意义。这样数据库迟早也会挂掉
如何解决缓存穿透?
1、由于请求的参数是不合法(-1) 每次请求都是不存在的数据,于是我们可以使用布隆过滤器(BloomFilter) 或者 压缩filter提前拦截,不合法就不能访问数据库。
2、当我们从数据库查询出来的数据为null时,也把他设置到缓存redis中,下次请求的时候就到redis中查询了,在这里后台可以判断,如果为null,那么设置一个较短的过期时间,到期自动就失效,否则就是按正常操作。
什么是布隆过滤器?
二进制向量
和一系列随机映射函数
。布隆过滤器可以用于检索一个元素是否在一个集合中。它的优点是空间效率和查询时间都比一般的算法要好的多,缺点是有一定的误识别率和删除困难。设计概念
核心思想
优点:
缺点:
BloomFilter的应用
击穿与雪崩的不同在于缓存key失效的量级上。击穿是对于单个key值的缓存失效过期,雪崩则是大面积key同时失效。
若缓存数据基本不会发生更新,则可尝试将热点数据设置为永不过期。
若缓存的数据更新不频繁,且缓存刷新的整个流程耗时较少的情况下,则可以采用基于 Redis、zookeeper 等分布式中间件的分布式互斥锁,或者本地互斥锁以保证仅少量的请求能请求数据库并重新构建缓存,其余线程则在锁释放后能访问到新缓存。
若缓存的数据更新频繁或者在缓存刷新的流程耗时较长的情况下,可以利用定时线程在缓存过期前主动地重新构建缓存或者延后缓存的过期时间,以保证所有的请求能一直访问到对应的缓存。
缓存预热是在系统上线后,将相关缓存数据直接加载到缓存系统中。这样可以避免用户请求时先查数据库,再更新缓存的问题。
方案:
当访问量剧增、服务出现问题(响应时间长或不响应)或非核心服务影响到核心流程的性能,仍然需要保证服务是可用的,即使有损服务。系统可以根据一些关键数据进行自动降级,也可以配置开关实现人工降级。
降级的目的:保证核心服务的可用性,即使是有损的,而且有些服务是不能降级的(购物车、支付等)
以参考日志级别设置预案:
(1)一般:比如有些服务偶尔因为网络抖动或者服务正在上线而超时,可以自动降级;
(2)警告:有些服务在一段时间内成功率有波动(如在95~100%之间),可以自动降级或人工降级,并发送告警;
(3)错误:比如可用率低于90%,或者数据库连接池被打爆了,或者访问量突然猛增到系统能承受的最大阀值,此时可以根据情况自动降级或者人工降级;
(4)严重错误:比如因为特殊原因数据错误了,此时需要紧急人工降级。
服务降级的目的,是为了防止Redis服务故障,导致数据库跟着一起发生雪崩问题。因此,对于不重要的缓存数据,可以采取服务降级策略,例如一个比较常见的做法就是,Redis出现问题,不去数据库查询,而是直接返回默认值给用户。
跳跃表(skiplist)是一种有序数据链表结构, 它通过在每个节点中维持多个指向其他节点的指针, 从而达到快速访问节点的目的。查询平均性能为O(logN),最坏的情况会出现O(N)情况,而redis中的zset在数据较多的时候底层就是采用跳跃表去实现的,元素较少的时候会进行小对象压缩采用压缩列表实现。
从上述图我们可以看出跳跃表有以下几个特点:
查找过程
跳跃表的查询,跳跃表有多层的情况下查询复杂度为O(logN),如果跳跃表就一层那么查询复杂度会上升为O(N),接下来我们就用图1的实例来模拟下查询score为70的节点的具体查询过程。
如图所示我们需要找到score为70的节点,查找首先从header开始,因为level为3我们先从L2开始往后开始遍历,查找到第一个节点,发现score比70小,继续往后遍历查找到第五个节点,发现score比70大,于是从当前节点往下一层进行查找,查找到节点3,以此类推,最终查询到score为70的节点。
插入过程
:跳跃表插入节点的时候,首先需要通过score找到自己的位置,也就是需要先走一步查找过程,找到新节点所处的位置的时候就创建一个新节点,并对新节点分配一个层数(这里层数的分配redis采用的是random随机机制,分配层数从1开始,每次晋升为上一层的概率为0.25),层数分配完了之后将前后指针进行赋值将新节点与旧节点串起来,如果层数大于当前的level还需要进行level的更新操作。
更新过程
:更新过程会稍微复杂一些,更新其实就是插入,只不过插入的时候发现value已经存在了,只是需要调整一下score值,如果更新的score值不会带来位置上的改变,那么直接更新score就行不需要进行调整位置,但是如果新score会导致排序改变,那么就需要调整位置了,redis采用的方式比较直接就是先删除这个元素然后再插入这个元素即可,前后需要两次路径搜索。
补充问题:
Redis使用跳表不用B+树的原因?
Redis使用跳表不用B+树的原因是:redis是内存数据库,而B+树纯粹是为了mysql这种IO数据库准备的。B+树的每个节点的数量都是一个mysql分区页的大小。
操作:先更新数据库,再删除缓存
正常的情况是这样的:
先操作数据库,成功
再删除缓存,也成功
如果原子性被破坏了:第一步成功(操作数据库),第二步失败(删除缓存),会导致数据库里是新数据,而缓存里是旧数据。
如果第一步(操作数据库)就失败了,我们可以直接返回错误(Exception),不会出现数据不一致。
如果在高并发的场景下,出现数据库与缓存数据不一致的概率特别低,也不是没有:缓存刚好失效//线程A查询数据库,得一个旧值//线程B将新值写入数据库//线程B删除缓存//线程A将查到的旧值写入缓存
要达成上述情况,还是说一句概率特别低,因为这个条件需要发生在读缓存时缓存失效,而且并发着有一个写操作。而实际上数据库的写操作会比读操作慢得多,而且还要锁表,而读操作必需在写操作前进入数据库操作,而又要晚于写操作更新缓存,所有的这些条件都具备的概率基本并不大。对于这种策略,其实是一种设计模式:Cache Aside Pattern
解决删除缓存失败的解决思路
:将需要删除的key发送到消息队列中—自己消费消息,获得需要删除的key—不断重试删除操作,直到成功
操作:先删除缓存,再更新数据库
正常情况是这样的:
1、先删除缓存,成功;
2、再更新数据库,也成功;
3、如果原子性被破坏了
第一步成功(删除缓存),第二步失败(更新数据库),数据库和缓存的数据还是一致的。
如果第一步(删除缓存)就失败了,我们可以直接返回错误(Exception),数据库和缓存的数据还是一致的。
看起来是很美好,但是我们在并发场景下分析一下,就知道还是有问题的了:线程A删除了缓存///线程B查询,发现缓存已不存在
///线程B去数据库查询得到旧值///线程B将旧值写入缓存///线程A将新值写入数据库-------->所以也会导致数据库和缓存不一致的问题。
解决并发下解决数据库与缓存不一致的思路:将删除缓存、修改数据库、读取缓存等的操作积压到队列里边,实现串行化。
对比两种策略:
先删除缓存、再更新数据库----->在高并发下表现的不如意,在原子性被破环时表现优异
先更新数据库,再删除缓存(Cache Aside Pattern设计模式)) ----->在高并发先表现优异,在原子性被破坏时表现不如意
参考文档
redis-cli -h 182.208.x.x -a 密码
故障转移时,判断一个 master node 是否宕机了,需要大部分的哨兵都同意才行,涉及到了分布式选举的问题。
即使部分哨兵节点挂掉了,哨兵集群还是能正常工作的,因为如果一个作为高可用机制重要组成部分的故障转移系统本身是单点的,那就很坑爹了。
通过哈希的方式,将数据分片,每个节点均分存储一定哈希槽(哈希值)区间的数据,默认分配了16384 个槽位
每份数据分片会存储在多个互为主从的多节点上
数据写入先写主节点,再同步到从节点(支持配置为阻塞同步)
同一分片多个节点间的数据不保持一致性
读取数据时,当客户端操作的key没有分配在该节点上时,redis会返回转向指令,指向正确的节点
扩容时时需要需要把旧节点的数据迁移一部分到新节点
在 redis cluster 架构下,每个 redis 要放开两个端口号,比如一个是 6379,另外一个就是 加1w 的端口号,比如 16379。16379 端口号是用来进行节点间通信的,也就是 cluster bus 的东西,cluster bus 的通信,用来进行故障检测、配置更新、故障转移授权。cluster bus 用了另外一种二进制的协议,gossip 协议,用于节点间进行高效的数据交换,占用更少的网络带宽和处理时间。
节点间的内部通信机制
基本通信原理
集群元数据的维护有两种方式:集中式、Gossip 协议。redis cluster 节点间采用 gossip 协议进行通信。
hash 算法(大量缓存重建)
一致性 hash 算法(自动缓存迁移)+ 虚拟节点(自动负载均衡)
redis cluster 的 hash slot 算法
优点
无中心架构,支持动态扩容,对业务透明
具备Sentinel的监控和自动Failover(故障转移)能力
客户端不需要连接集群所有节点,连接集群中任何一个可用节点即可
高性能,客户端直连redis服务,免去了proxy代理的损耗
缺点
运维也很复杂,数据迁移需要人工干预
只能使用0号数据库
不支持批量操作(pipeline管道操作)
分布式逻辑和存储模块耦合等
Redis Sharding是Redis Cluster出来之前,业界普遍使用的多Redis实例集群方法。其主要思想是采用哈希算法将Redis数据的key进行散列,通过hash函数,特定的key会映射到特定的Redis节点上。Java redis客户端驱动jedis,支持Redis Sharding功能,即ShardedJedis以及结合缓存池的ShardedJedisPool
简介
客户端发送请求到一个代理组件,代理解析客户端的数据,并将请求转发至正确的节点,最后将结果回复给客户端
特征
透明接入,业务程序不用关心后端Redis实例,切换成本低
Proxy 的逻辑和存储的逻辑是隔离的
代理层多了一次转发,性能有所损耗
业界开源方案
Twtter开源的Twemproxy
豌豆荚开源的Codis
高可用首先需要解决集群部分失败的场景:当集群内少量节点出现故障时通过自动故障转移保证集群可以正常对外提供服务。主要针对Redis Cluster来分析:
详细的知识见:https://blog.csdn.net/xiaofeng10330111/article/details/90384502
redis的各个节点通过ping/pong进行消息通信,不需要Sentinel,转播槽的信息和节点状态信息,故障发现也是通过这个动作发现的,跟Sentinel一样,有主观下线和客观下线。
故障节点变为客观下线后,如果下线节点是持有槽的主节点则需要在它的从节点中选出一个替换它,从而保证集群的高可用。下线主节点的所有从节点承担故障恢复的义务,当从节点通过内部定时任务发现自身复制的主节 点进入客观下线时,将会触发故障恢复流程:资格检查–>准备选举时间–>发起选举–>选举投票–>替换主节点。
具体还是9上的回答,更为具体的见:https://blog.csdn.net/xiaofeng10330111/article/details/90384502
可以从以下三个方面分析:
当一个从节点发现自己正在复制的主节点进入已下线状态时,从节点将开始对下线主节点进行故障转移
复制下线主节点的所有从节点里面,会有一个从节点被选为新的主节点
新的主节点从撤销所有已下线主节点的槽位指派,并将这些槽位指给自己
新的主节点会向集群广播一条PONG消息,告知其他节点自己已由从节点转为主节点,并接管相应的槽位
故障转移完成
集群的纪元时一个自增的计数器,初始值为0
当集群里的某个节点开始一次故障转移操作时,集群的纪元会加一
对于每个纪元,集群里每个复制处理槽位的主节点都有一次投票机会,而第一次向主节点要求投票的从节点将获得主节点的投票
什么时候开始投票? 当从节点发现自己正在复制的主节点进入已下线状态,从节点会向集群广播一条CLUSTERMSG_TYPE_FAILOVER_AUTH_REQUEST消息,收到该消息的主节点可以开始投票
主节点投票后返回CLUSTERMSG_TYPE_FAILOVER_AUTH_ACK消息
当某个从节点的票数大于投票总节点数的一半时,被选为新的主节点
若每个获得足够的票数,进入下一轮投票
缓存收益:加速读写、降低后端存储负载;
缓存成本:缓存和存储数据不一致性、代码维护成本、运维成本;
对于读操作流程:先到redis缓存中查询数据,如果为null,那么再到数据库查询出来再设置到redis中去,最后将数据返回给请求。
定义: 如果只是简单查询,缓存数据和数据库数据没有什么问题,当我们更新的时候就可能导致数据库数据和缓存数据不一致了。 数据库库存为 999 缓存数据为1000 这就是更新导致的情况。
Master 最好不要做任何持久化工作,如 RDB 内存快照和 AOF 日志文件
如果数据比较重要,某个 Slave 开启 AOF 备份数据,策略设置为每秒同步一次
为了主从复制的速度和连接的稳定性, Master 和 Slave 最好在同一个局域网内
尽量避免在压力很大的主库上增加从库
主从复制不要用图状结构,用单向链表结构更为稳定,即:Master <- Slave1 <- Slave2 <-Slave3…
答:使用 keys 指令可以扫出指定模式的 key 列表。
对方接着追问:如果这个 redis 正在给线上的业务提供服务,那使用 keys 指令会有什么问题?
这个时候你要回答 redis 关键的一个特性:redis 的单线程的。keys 指令会导致线程阻塞一段时间,线上服务会停顿,直到指令执行完毕,服务才能恢复。这个时候可以使用 scan 指令,scan 指令可以无阻塞的提取出指定模式的 key 列表,但是会有一定的重复概率,在客户端做一次去重就可以了,但是整体所花费的时间会比直接用 keys 指令长。
先拿setnx来争抢锁,抢到之后,再用expire给锁加一个过期时间防止锁忘记了释放。
这时候对方会告诉你说你回答得不错,然后接着问如果在setnx之后执行expire之前进程意外crash或者要重启维护了,那会怎么样?
这时候你要给予惊讶的反馈:唉,是喔,这个锁就永远得不到释放了。紧接着你需要抓一抓自己得脑袋,故作思考片刻,好像接下来的结果是你主动思考出来的,然后回答:我记得set指令有非常复杂的参数,这个应该是可以同时把setnx和expire合成一条指令来用的!
Redis事务功能是通过MULTI、EXEC、DISCARD和WATCH
四个原语实现的
Redis会将一个事务中的所有命令序列化,然后按顺序执行。其他客户端提交的命令不会被插入到事务执行命令的序列中。(一次性、顺序性、排他性)
1、redis 不支持回滚 Redis 在事务失败时不进行回滚,而是继续执行余下的命令
, 所以 Redis 的内部可以保持简单且快速。
2、如果在一个事务中的命令出现错误,那么所有的命令都不会执行;
3、如果在一个事务中出现运行错误,那么正确的命令会被执行。
(1)MULTI命令用于开启一个事务,它总是返回OK。MULTI执行之后,客户端可以继续向服务器发送任意多条命令,这些命令不会立即被执行,而是被放到一个队列中,当EXEC命令被调用时,所有队列中的命令才会被执行。
(2)EXEC:执行所有事务块内的命令。返回事务块内所有命令的返回值,按命令执行的先后顺序排列。当操作被打断时,返回空值 nil 。
(3)通过调用DISCARD,客户端可以清空事务队列,并放弃执行事务, 并且客户端会从事务状态中退出。
(4)WATCH 命令可以为 Redis 事务提供 check-and-set (CAS)行为。可以监控一个或多个键,一旦其中有一个键被修改(或删除),之后的事务就不会执行,监控一直持续到EXEC命令。
Redis 是单进程程序,并且它保证在执行事务时,不会对事务进行中断,事务可以运行直到执行完所有事务队列中的命令为止。因此,Redis 的事务是总是带有隔离性的。
Redis基于Reactor模式开发了网络事件处理器,这个处理器被称为文件事件处理器(file event handler)。它的组成结构为4部分:多个套接字、IO多路复用程序、文件事件分派器、事件处理器。因为文件事件分派器队列的消费是单线程的,所以Redis才叫单线程模型。
虽然文件事件处理器以单线程方式运行, 但通过使用 I/O 多路复用程序来监听多个套接字, 文件事件处理器既实现了高性能的网络通信模型, 又可以很好地与 redis 服务器中其他同样以单线程方式运行的模块进行对接, 这保持了 Redis 内部单线程设计的简单性。
对于单链表来说,我们查找某个数据,只能从头到尾遍历链表,此时时间复杂度是 ○(n)。
提高单链表的查找效率呢?对链表建立一级索引,每两个节点提取一个结点到上一级,被抽出来的这级叫做索引或索引层。 所以要找到13,就不需要将16前的结点全遍历一遍,只需要遍历索引,找到13,然后发现下一个结点是17,那么16一定是在 [13,17] 之间的,此时在13位置下降到原始链表层,找到16,加上一层索引后,查找一个结点需要遍历的结点个数减少了,也就是说查找效率提高了。
建立一级索引的方式相似,我们在第一级索引的基础上,每两个结点就抽出一个结点到第二级索引。此时再查找16,只需要遍历 6 个结点了,需要遍历的结点数量又减少了。
当结点数量多的时候,这种添加索引的方式,会使查询效率提高的非常明显,这种链表加多级索引的结构,就是跳表。
在一个单链表中,查询某个数据的时间复杂度是 ○(n),那在一个具有多级索引的跳表中,查询某个数据的时间复杂度就是 ○(㏒n) 。
根据上图得知,每级遍历 3 个结点即可,而跳表的高度为 h ,所以每次查找一个结点时,需要遍历的结点数为 3*跳表高度 ,所以忽略低阶项和系数后的时间复杂度就是 ○(㏒n) 。
来分析一下跳表的空间复杂度为O(n)。
实际上,在实际开发中,我们不需要太在意索引占据的额外空间,在学习数据结构与算法时,我们习惯的将待处理数据看成整数,但是实际开发中,原始链表中存储的很可能是很大的对象,而索引结点只需要存储关键值(用来比较的值)和几个指针(找到下级索引的指针),并不需要存储原始链表中完整的对象,所以当对象比索引结点大很多时,那索引占用的额外空间就可以忽略了。
跳表这个动态数据结构,不仅支持查找操作,还支持动态的插入、删除操作,而且插入、删除操作的时间复杂度也是 ○(㏒n)。
对于单纯的单链表,需要遍历每个结点来找到插入的位置。但是对于跳表来说,因为其查找某个结点的时间复杂度是 ○(㏒n),所以这里查找某个数据应该插入的位置,时间复杂度也是 ○(㏒n)。
当我们不停的往跳表中插入数据时,如果我们不更新索引,就可能出现某 2 个索引结点之间数据非常多的情况。极端情况下,跳表会退化成单链表。
作为一种动态数据结构,我们需要某种手段来维护索引与原始链表大小之间的平滑,也就是说如果链表中结点多了,索引结点就相应地增加一些,避免复杂度退化,以及查找、插入、删除操作性能下降。
跳表是通过随机函数来维护前面提到的平衡性。
我们往跳表中插入数据的时候,可以选择同时将这个数据插入到第几级索引中,比如随机函数生成了值 K,那我们就将这个结点添加到第一级到第 K 级这 K 级索引中。 随机函数可以保证跳表的索引大小和数据大小的平衡性,不至于性能过度退化。
参考书籍、文献和资料
1、https://blog.csdn.net/xiaofeng10330111/article/details/105360939