Redis相关知识点

Redis

1. 基本概念

Redis 是一个使用 C 语言写成的数据库,与传统数据库不同的是 Redis 的数据是存在内存中的,它是一个开源的高性能key-value非关系缓存数据库。它支持存储的value类型包括string(字符串)、list(链表)、set(集合)、zset(sorted set --有序集合)和hash(哈希类型)。

  • 完全基于内存
  • 数据结构简单
  • 单线程模型,避免了不必要的上下文切换和竞争
  • 使用多路 I/O 复用模型,非阻塞 IO;

Redis的数据结构的应用场景:

  • String:一般常用在需要计数的场景,比如用户的访问次数
  • List:本质是一个双向列表,可用于做消息队列
  • hash:对象数据的存储
  • set:存放的数据不能重复,以及需要获取多个数据源交集和并集的场景
  • zset:需要根据某个权重进行排序的场景

Redis的应用场景

  • 缓存:将热点数据放到内存中,设置内存的最大使用量以及淘汰策略来保证缓存的命中率
  • 消息队列(发布/订阅功能):List 是一个双向链表,可以通过 lpush 和 rpop 写入和读取消息
  • 分布式锁
  • Set 可以实现交集、并集等操作,从而实现共同好友等功能。
  • ZSet 可以实现有序性操作,从而实现排行榜等功能。

使用缓存的好处

  1. 高性能:假如用户第一次访问数据库中的某些数据。这个过程会比较慢,因为是从硬盘上读取的。将该用户访问的数据存在数缓存中,这样下一次再访问这些数据的时候就可以直接从缓存中获取了。操作缓存就是直接操作内存,所以速度相当快

  2. 高并发:直接操作缓存能够承受的请求是远远大于直接访问数据库的,所以我们可以考虑把数据库中的部分数据转移到缓存中去

优点

  • 读写性能优异
  • 支持数据持久化
  • 支持事务
  • 数据结构丰富

缺点

  • 数据库容量受到物理内存的限制
  • 不具备自动容错和恢复功能

2. Redis持久化

当客户端请求Redis服务端将数据写入Redis数据库的时候,数据将被存放在内存中。如果Redis数据库启用了持久化功能,那么数据将被持久化到持久化设备(磁盘)上。从客户端请求服务端写入数据到数据被持久化到磁盘上,整个过程需要经历如下几个阶段:

  1. 客户端向服务端发起写命令。
  2. 服务端接收到客户端请求,执行写命令将数据写入内存。
  3. 服务端调用write()系统调用(Unix环境)将内存中的数据写入内核缓冲区。
  4. 调用fsync()将内核缓冲区的数据写入磁盘控制器的缓存中。
  5. 磁盘控制器将缓存中的数据写入到磁盘的物理介质上。

第1步到第3步数据都在内存中存放,一旦服务宕机,那么数据将永久性的丢失了。在第4步和第5步中,数据已经从内存转移到了磁盘设备上,不过在第4步中数据是被写入到了磁盘的控制器缓冲区(为了解决磁盘设备和内存设备访问延迟的差异,通过缓冲区技术来提高单位时间内设备的吞吐量)中,所以一旦服务器掉电宕机,在缓存中的这部分数据也可能会丢失(取决于物理存储设备)。只有当数据经过第5步被写入磁盘物理介质以后,数据才算真正地被保存了下来,不会因为服务器掉电而丢失数据。

实际在实现持久化机制的时候,将会面临一些现实的约束。首先,完成上述五个步骤涉及到Redis服务、操作系统以及底层存储硬件的紧密配合,对于操作系统之上的Redis服务实现者来说,要实现持久化功能,只能通过调用操作系统提供的功能(系统调用System call)来完成对底层存储硬件的访问,所以实现者能控制的只有上述的第1-4这四步,至于最后一步则可能不受Redis服务的实现者控制,由各个硬件设备自己实现(至少不能保证能提供对应的内核驱动API供操作系统访问)。所以在持久化这件事上,实现者能做的是保证第1-4步能顺利完成。第3步和第4步都需要调用系统调用,调用系统调用会导致进程用户态和内核态的切换,这个过程是有性能损失的。频繁的调用系统调用将会降低服务的性能

Redis的实现者在实现持久化的时候,为了兼顾性能和数据安全性,引入了两种持久化方案:

2.1 RDB持久化

RDB持久化是Redis引入的一种数据安全性相对弱的持久化方案,通过异步将内存数据库的快照写入持久化文件来实现数据的持久化。由于生成快照是异步进行的,所以快照不会实时反应内存中的数据库情况,因此它是一种数据安全性较弱的数据持久化方案。不过由于创建快照过程是异步进行的,在创建快照过程中基本不会对内存数据库的操作产生影响,所以RDB持久化方案是一种注重性能,但是在数据安全性方面做出妥协的持久化方案。

Redis提供了两个命令:SAVEBGSAVE来创建RDB快照文件。这两个命令的最大区别是:SAVE命令在生成RDB文件的时候会阻塞Redis的进程;而BGSAVE会创建一个子进程来生成RDB文件,不会阻塞服务器的进程。由于SAVE命令是通过阻塞服务器的进程来进行快照生成的,所以在生成快照期间服务器将拒绝来自服务器外部的请求。而BGSAVE命令通过创建子进程实现快照的生成,所以在生成快照期间Redis服务可以继续执行客户端的请求。不过需要注意的一点是:在BGSAVE命令执行期间,BGSAVESAVEBGREWRITEAOF命令的执行将会受到限制。在BGSAVE命令执行期间,SAVE命令会被拒绝执行;其次,在BGSAVE命令执行完成前,新的BGSAVE命令也会被拒绝执行。对于AOF重新命令BGREWRITEAOF,在BGSAVE命令执行期间,该命令将会被延后执行。如果在BGSAVE命令执行之前有BGREWRITEAOF命令正在执行,则BGSAVE命令也需要等到AOF重新命令完成以后才能被执行。

自动生成快照和BGSAVE命令执行的效果类似,也是通过创建子进程的方式生成RDB快照文件。用户可以通过在配置文件中设置save选项的值来控制自动生成快照的频率。如果存在多个save选项配置,则任意一个配置满足条件都会触发生成RDB快照。

save 900 1
save 300 10
save 60 10000

上述配置的三个条件,只要满足下面任意一个条件,快照就会被创建:

  1. 服务器在900秒内,内存数据库发生了至少1次修改。
  2. 服务器在300秒内,内存数据库发生了至少10次修改。
  3. 服务器在60秒内,内存数据库发送了至少10000次修改。

当Redis服务器启动的时候如果发现存在RDB快照文件,则会进行RDB快照文件的载入。在RDB快照载入期间,Redis服务将会处于阻塞状态,直到快照载入完成。

优点

  1. 生成RDB快照文件的过程是异步的,所以在持久化过程中对服务器性能影响小。
  2. RDB文件存储的是内存数据库的快照,采用紧凑的二进制文件存储。通过RDB文件进行数据库恢复的时候速度快。

缺点

  1. 由于RDB文件是异步进行备份的,所以存在数据安全性弱的弊端:当系统发生故障导致内存数据库数据丢失的时候,从RDB文件中只能恢复创建RDB快照那一刻的数据,在最近一次创建RDB快照那一刻到服务器宕机之间的数据将永久性的丢失了。数据恢复的完整程度依赖于RDB快照创建的频率。
  2. 由于RDB快照是将整个内存数据库备份下来,所以当内存数据库很大的时候创建RDB文件需要耗费更久的时间。

2.2 AOF持久化

不同于RDB持久化方案通过创建快照文件来持久化数据,AOF持久化方案通过持久化发送到Redis服务器的写命令来实现持久化功能。AOF持久化机制会把所有引起内存数据库数据变化的写命令都保存下来,通过文件追加(Append)的方式保存到AOF文件中。在通过AOF文件进行数据恢复的时候我们可以通过重放AOF文件中的命令来恢复出Redis内存数据库的内容,这就是AOF机制能进行持久化的原理。

当Redis服务器启用了AOF持久化选项以后,服务器会将接收到的写命令以追加的方式写入服务器的AOF缓冲区,然后由服务器按照不同的AOF持久化选项以不同的策略将缓冲区的命令写入AOF文件。

选项作用
always每执行一次命令就进行AOF文件同步
everyscr每隔一秒进行一次AOF文件同步
noRedis不主动进行AOF文件同步,而是交给操作系统定时进行文件同步

appendfsync选项的值被配置为always,那么数据安全性最高,但是服务的性能会受到影响;如果选项值设置为no,那么数据安全性的保证相对较弱,但是服务器的性能有所提高;而everysec则是这两种场景的一个折中。

和RDB快照文件直接包含数据库状态不同,AOF文件包含了Redis服务器收到的所有写命令,所以当服务器从AOF文件恢复数据库的时候,需要对AOF文件中的所有命令按序进行重放来还原出数据库状态。在Redis服务器通过AOF文件恢复数据库的时候,为AOF文件创建一个伪客户端,然后通过这个伪客户端来执行AOF文件中的命令,以此来重建数据库。

重写AOF文件

由于AOF文件恢复需要重放AOF文件中的所有命令,所以数据库的恢复时间和AOF文件的大小成正比。当AOF文件很大的时候恢复过程需要耗费很长一段时间才能完成,而RDB由于存储的是快照,所以没有这方面的困扰,不过RDB快照恢复的速度和数据库的大小正相关。

Redis为了解决AOF文件太大的情况,提供了AOF文件重写的功能。通过对AOF文件进行重写,将一些命令进行合并来达到缩减AOF文件的目的,最终实现减少AOF文件恢复时间的目的。

# 重写前
PUSH list "A"
PUSH list "B"

# 重写后
PUSH list "A" "B"

Redis提供了BGREWRITEAOF命令进行AOF重写操作。BGREWRITEAOF命令是一个后台执行的命令,通过创建一个子进程来完成AOF文件重写工作。

在AOF文件重写过程中Redis服务器还可以继续处理请求,所以AOF文件会继续追加命令,如果不对AOF重写和命令追加进行协调,那么将导致AOF文件数据不一致的情况。

为了解决这个问题,Redis服务器会在AOF文件重写开始以后创建一个AOF重写缓冲区。当服务器接收到写命令以后,会同步将这个命令写入AOF重写缓冲区和原先的AOF追加缓冲区。这保证了在AOF重写期间,Redis服务器可以继续进行AOF持久化,而且新接受到的命令也会被记录到AOF重写缓冲区中。

  1. 服务器进程会将AOF重写缓冲区中的命令写入重写后的新AOF文件中。
  2. 服务器会原子的将AOF文件修改为重写后的AOF文件,完成新旧AOF文件的替换。

优点

  1. 数据的安全性更高,当服务crash以后丢失的数据更少。
  2. AOF文件的可读性更好。
  3. 通过append方式追加命令,访问磁盘的效率高。

缺点

  1. 由于AOF持久化会在一定程度上进行磁盘同步处理操作,这个过程是阻塞的(虽然很短),所以对服务器处理命令的性能会产生影响。
  2. AOF文件记录的是写命令,恢复的时候需要重放命令来得到内存数据库的状态,即使有AOF重写机制,恢复速度上和RDB相比也会有差距。

3. Redis缓存扩容

如果Redis被当做缓存使用,使用一致性哈希实现动态扩容缩容。

4. Redis的过期键的删除策略

Redis是key-value数据库,我们可以设置Redis中缓存的key的过期时间。Redis的过期策略就是指当Redis中缓存的key过期,Redis做出相应的处理。Redis 会保存Key的过期的日期。

定时删除

每个设置过期时间的key都需要创建一个定时器,到过期时间就会立即清除。该策略可以立即清除过期的数据,对内存很友好;但是会占用大量的CPU资源去处理过期的数据,从而影响缓存的响应时间和吞吐量。

惰性删除

只有当访问一个key时,才会判断该key是否已过期,过期则清除。该策略可以最大化地节省CPU资源,却对内存非常不友好。极端情况可能出现大量的过期key没有再次被访问,从而不会被清除,占用大量内存。

定期删除

每隔一定的时间,会扫描一定数量的数据库的expires字典中一定数量的key,并清除其中已过期的key。

Redis如何判断数据过期

Redis 通过一个叫做过期字典(可以看作是 hash 表)来保存数据过期的时间。该字典的键指向 Redis 数据库中的某个 key,值保存了 key 所指向的数据库键的过期时间(毫秒精度的 UNIX 时间戳), 是一个 long 类型的整数。

5. 内存淘汰策略

Redis的内存淘汰策略是指在Redis的用于缓存的内存不足时,怎么处理需要新写入且需要申请额外空间的数据。

5.1 全局的键选择性移除

  • no-eviction(禁止驱逐):当内存不足以容纳新写入数据时,新写入操作会报错。
  • allkeys-lru(Least Recently used):当内存不足以容纳新写入数据时,在键空间中,移除最近最少使用的key。(这个是最常用的)
  • allkeys-random:当内存不足以容纳新写入数据时,在键空间中,随机移除某个key。

5.2 设置过期时间的键选择性移除

  • volatile-lru:在设置了过期时间的键空间中,移除最近最少使用的key。
  • volatile-random:在设置了过期时间的键空间中,随机移除某个key。
  • volatile-ttl:在设置了过期时间的键空间中,挑选将要过期的数据淘汰。

5.3 新增

  1. volatile-lfu(least frequently used):从已设置过期时间的数据(server.db[i].expires)中挑选最不经常使用的数据淘汰
  2. allkeys-lfu(least frequently used):在键空间中,移除最不经常使用的 key

如果Redis中的key不设置过期时间,数据是否会过期?

Redis无论有没有设置expire,他都会遵循redis的配置好的删除机制,在配置文件里设置:
redis最大内存不足"时,数据清除策略,默认为volatile-lru
volatile-lru :对设置过期时间的键空间中的数据采取LRU(近期最少使用)算法.如果对key使用"expire"指令指定了过期时间,那么此key将会被添加到键空间中。将已经设置过期时间的数据优先移除.如果过期时间的键空间中数据全部移除仍不能满足内存需求,将out of memory。

Redis如何做内存优化

利用Hash,list,sorted set,set等集合类型数据,尽可能使用散列表(hashes),散列表(是说散列表里面存储的数少)使用的内存非常小,所以应该尽可能的将数据模型抽象到一个散列表里面,比如有一个用户对象,不要为这个用户的名称,邮箱,密码设置单独的key,而是应该把这个用户的所有信息存储到一张散列表里面。

6. Redis的线程模型

Redis基于Reactor模式开发了网络事件处理器,这个处理器被称为文件事件处理器(file event handler)。它的组成结构为4部分:多个套接字、IO多路复用程序、文件事件分派器、事件处理器。因为文件事件分派器队列的消费是单线程的,所以Redis才叫单线程模型。

  • 文件事件处理器使用 I/O 多路复用(multiplexing)程序来同时监听多个套接字, 并根据套接字目前执行的任务来为套接字关联不同的事件处理器。
  • 当被监听的套接字准备好执行连接应答(accept)、读取(read)、写入(write)、关闭(close)等操作时, 与操作相对应的文件事件就会产生, 这时文件事件处理器就会调用套接字之前关联好的事件处理器来处理这些事件。

虽然文件事件处理器以单线程方式运行, 但通过使用 I/O 多路复用程序来监听多个套接字, 文件事件处理器既实现了高性能的网络通信模型, 又可以很好地与 Redis 服务器中其他同样以单线程方式运行的模块进行对接, 这保持了 Redis 内部单线程设计的简单性。

Redis为何不使用多线程:

  • 单线程编程容易并且更容易维护;
  • Redis 的性能瓶颈不在 CPU ,主要在内存和网络;
  • 多线程就会存在死锁、线程上下文切换等问题,甚至会影响性能。

7. Redis的事务

什么是事务:

  • 事务是隔离操作:事务中的所有命令都会序列化、按序地执行,在执行过程中,不会被其他客户端发送来的命令请求所打断
  • 事务是一个原子操作:事务中的命令要么全部被执行,要么全部都不执行。

Redis事务理解:

  • Redis 事务的本质是通过MULTI、EXEC、WATCH等一组命令的集合。事务支持一次执行多个命令,一个事务中所有命令都会被序列化。在事务执行过程,会按照顺序串行化执行队列中的命令,其他客户端提交的命令请求不会插入到事务执行命令序列中。

Redis事务的三个阶段:

  1. 事务开始 MULTI
  2. 命令入队
  3. 事务执行 EXEC

Redis事务的相关命令

Redis事务功能是通过MULTI、EXEC、DISCARD和WATCH 四个原语实现的。Redis会将一个事务中的所有命令序列化,然后按顺序执行。

  • WATCH 命令可以监控一个或多个键,一旦其中有一个键被修改(或删除),之后的事务就不会执行
  • MULTI命令用于开启一个事务,它总是返回OK。 MULTI执行之后,客户端可以继续向服务器发送任意多条命令,这些命令不会立即被执行,而是被放到一个队列中,当EXEC命令被调用时,所有队列中的命令才会被执行。
  • EXEC:执行所有事务块内的命令。
  • 通过调用DISCARD,客户端可以清空事务队列,并放弃执行事务, 并且客户端会从事务状态中退出。
  • UNWATCH命令可以取消watch对所有key的监控。

Redis事务的特点

Redis 是单线程模型,它保证在执行事务时,不会对事务进行中断,事务可以运行直到执行完所有事务队列中的命令为止。因此,Redis 的事务是总是带有隔离性的,Redis中,单条命令是原子性执行的,但事务不保证原子性,且没有回滚。事务中任意命令执行失败,其余的命令仍会被执行。Redis的事务总是具有ACID中的一致性和隔离性,其他特性是不支持的。当服务器运行在_AOF_持久化模式下,并且appendfsync选项的值为always时,事务也具有持久性。

8. 数据库和Redis缓存一致性

旁路缓存模式 Cache Aside Pattern

写:先更新DB,然后直接删除cache

读:从cache中读取数据,读到就直接返回,cache中读取不到的话,就从DB中读取数据返回,再把数据放到cache。

  • 适合场景:读请求较多**,应用最广泛

在写数据的过程中,可以先删除 cache ,后更新 DB 么?

不可以,会造成数据不一致的情况,请求1先把cache中的A数据删除 -> 请求2从DB中读取A数据->请求1再把DB中的A数据更新,请求2读取后的A数据又被写入到缓存中,导致数据不一致。

在写数据的过程中,先更新DB,后删除cache就没有问题了么?

理论上来说还是可能会出现数据不一致性的问题,不过概率非常小,因为缓存的写入速度是比数据库的写入速度快很多。请求1从DB读数据A->请求2写更新数据 A 到数据库并把删除cache中的A数据->请求1将数据A写入cache。

缺点:

  • 首次请求数据一定不在 cache 的问题,解决办法:可以将热点数据可以提前放入cache 中
  • 写操作比较频繁的话导致cache中的数据会被频繁被删除 。解决办法:更新DB的时候,同样更新cache,需要加一个锁来保证更新cache的时候不存在线程安全问题。或者给缓存加一个比较短的过期时间。

读写穿透模式 Read/Write Through Pattern

写操作:同步更新Cache和DB。相当于请求线程只需更新Cache,或DB,剩下的操作由Cache服务完成。
如果Cache中存在,先更新Cache,再由Cache服务自己更新DB;如果Cache中不存在,则直接更新DB。

读操作:和旁路缓存模式的读操作类似,只是从缓存中读不到时,由Cache服务自己将从DB读到的数据写入缓存

异步缓存写入模式 Write Behind Pattern

写操作:只更新缓存,再异步更新DB

读操作步骤:和读写穿透模式的读操作类似。

  • 适用场景:写请求较多,对一致性要求较低

9. Redis常见的问题

9.1 缓存穿透

用户请求透过Redis。去直接访问Mysql,导致Mysql压力过大。

解决办法:

  • 从缓存和数据库中没有没有取到的数据,也缓存为key-null
  • 接口层增加校验
  • 采用布隆过滤器

9.2 缓存雪崩

缓存在同一时间大面积的失效,后面的请求都直接落到了数据库上,造成数据库短时间内承受大量请求。

解决办法:

  • 采用 Redis 集群
  • 限流,避免同时处理大量的请求
  • 设置不同的失效时间,比如随机设置缓存的失效时间。

10. Redis vs Memcached

共同点

  1. 都是基于内存的数据库,一般都用来当做缓存使用。
  2. 都有过期策略。
  3. 两者的性能都非常高。

区别:

  1. Redis 支持更丰富的数据类型(支持更复杂的应用场景)。Redis 不仅仅支持简单的 k/v 类型的数据,同时还提供 list,set,zset,hash 等数据结构的存储。Memcached 只支持最简单的 k/v 数据类型。
  2. Redis 支持数据的持久化,可以将内存中的数据保持在磁盘中,重启的时候可以再次加载进行使用,而 Memecache 支持数据持久化
  3. Redis 有灾难恢复机制。 因为可以把缓存中的数据持久化到磁盘上。
  4. Redis 在服务器内存使用完之后,可以将不用的数据放到磁盘上。但是,Memcached 在服务器内存使用完之后,就会直接报异常。
  5. Memcached 是多线程,非阻塞 IO 复用的网络模型;Redis 使用单线程的多路 IO 复用模型。
  6. Redis 支持发布订阅模型、事务等功能,而 Memcached 不支持。
  7. Memcached 过期数据的删除策略只用了惰性删除,而 Redis 同时使用了惰性删除与定期删除。

11. 补充内容

11.1 bitmap 位图

BitMap 是用一个比特位来映射某个元素的状态。由于一个比特位只能表示 0 和 1 两种状态,所以 BitMap 能映射的状态有限,但是使用比特位的优势是能大量的节省内存空间。Redis 其实只支持 5 种数据类型,并没有 BitMap 这种类型,BitMap 底层是基于 Redis 的字符串类型实现的。

  • 使用场景

    • 用户签到:key = 月份:用户id, offset = (今天是一月中的第几天) % (本月的天数)

    • 活跃用户统计:日期作为 key,然后用户 id 为 offset,如果当日活跃过就设置为1。

    • 统计用户是否在线:只需要一个 key,然后用户 id 为 offset,如果在线就设置为 1

案例描述-用户登录签到

如果有一个上亿用户的系统,需要我们去统计每一天的用户登录情况,我们应该如何去解决?
前提条件:设置在9月19号有下标为100、101、102、103四个用户都登录了系统
设置在9月20号有下标为100、101、102三个用户都登录了系统
提出问题:
1、取出9月19号登录系统的有多少人?
答:直接获取即可。
2、取出9月19号和9月20号连续登录系统的有多少人?
答:两天的数据取&运算。
3、取出9月19号与9月20号任意一天登录的有多少人?
答:两天的数据取|运算。

解决方案

使用bitmap, 使用bitcount来统计

# 设置在9月19号有下标为100、101、102、103四个用户都登录了系统
127.0.0.1:6379> setbit login_09_19 100 1
(integer) 0
127.0.0.1:6379> setbit login_09_19 101 1
(integer) 0
127.0.0.1:6379> setbit login_09_19 102 1
(integer) 0
127.0.0.1:6379> setbit login_09_19 103 1
(integer) 0
# 设置在9月20号有下标为100、101、102三个用户都登录了系统
127.0.0.1:6379> setbit login_09_20 100 1
(integer) 0
127.0.0.1:6379> setbit login_09_20 101 1
(integer) 0
127.0.0.1:6379> setbit login_09_20 102 1
(integer) 0
# 1. 取出9月19号登录系统的人的个数
127.0.0.1:6379> bitcount login_09_19
(integer) 4
# 2. 取出9月19号和9月20号连续登录系统的有多少人?
127.0.0.1:6379> bitop and login_in_09_19_20:and login_09_19 login_09_20
(integer) 13
127.0.0.1:6379> bitcount login_in_09_19_20:and
(integer) 3
# 3. 取出9月19号与9月20号任意一天登录的有多少人
127.0.0.1:6379> bitop or login_in_09_19_20:or login_09_19 login_09_20
(integer) 13
127.0.0.1:6379> bitcount login_in_09_19_20:or
(integer) 4

11.2 GeoHash 地理位置距离排序算法

适合场景: 附近的人

Redis 在 3.2 版本以后增加了地理位置 GEO 模块,意味着我们可以使用 Redis 来实现摩拜单车「附近的 Mobike」、美团和饿了么附近的餐馆这样的功能了。

地图元素的位置数据使用二维的经纬度表示,经度范围 (-180, 180],纬度范围 (-90, 90],纬度正负以赤道为界,北正南负,经度正负以本初子午线 (英国格林尼治天文台) 为界,东正西负。比如掘金办公室在望京 SOHO,它的经纬度坐标是 (116.48105,39.996794),都是正数,因为中国位于东北半球。

当两个元素的距离不是很远时,可以直接使用勾股定理就能算得元素之间的距离。我们平时使用的附近的人的功能,元素距离都不是很大,勾股定理算距离足矣。不过需要注意的是,经纬度坐标的密度不一样 (经度总共 360 度,纬度总共 180 度),勾股定律计算平方差时之后再求和时,需要按一定的系数比加权求和。

如果给定一个元素的坐标,然后计算这个坐标附近的其它元素,按照距离进行排序,该如何下手?

一般的方法都是通过矩形区域来限定元素的数量,然后对区域内的元素进行全量距离计算再排序。这样可以明显减少计算量。如何划分矩形区域呢?可以指定一个半径 r,使用一条 SQL 就可以圈出来。当用户对筛出来的结果不满意,那就扩大半径继续筛选。

select id from positions where x0-r < x < x0+r and y0-r < y < y0+r

为了满足高性能的矩形区域算法,数据表需要在经纬度坐标加上双向复合索引 (x, y),这样可以最大优化查询性能。

但是数据库查询性能毕竟有限,如果「附近的人」查询请求非常多,在高并发场合,这可能并不是一个很好的方案。

GeoHash算法

Redis中实现的GeoHash算法是将显示中的地点信息转化成一个长度为52的整数。然后将其存放在zset里面(其底层是使用zset进行实现的。 我们可以使用zrem 进行数据的删除。),zset的value值就是用户的ID ,score值就是GeoHash的52位整数值,在redis中使用的时候,通过zset的score排序就可以得到附近的元素,然后在将score值还原成经纬度坐标信息即可。

在使用 Redis 进行 Geo 查询时,我们要时刻想到它的内部结构实际上只是一个zset(skiplist)。通过 zset 的 score 排序就可以得到坐标附近的其它元素 (实际情况要复杂一些,不过这样理解足够了),通过将 score 还原成坐标值就可以得到元素的原始坐标。

Redis 的 Geo 指令基本使用

Redis 提供的 Geo 指令只有 6 个

# 1. 添加 | geoadd 集合名称 经度 维度 ID
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
# 2. 计算两个元素之间的距离 | geodist 集合名称 a_ID b_ID 距离单位
127.0.0.1:6379> geodist company juejin ireader km
"10.5501"
# 3. 获取集合中指定元素的经纬度坐标 | geopos 集合名称 ID
127.0.0.1:6379> geopos company ireader
1) 1) "116.51420205831528"
   2) "39.905409186624944"
# 4. 获取元素的 hash 值 | geohash 集合名称 ID
127.0.0.1:6379> geohash company juejin
1) "wx4gd94yjn0"
127.0.0.1:6379> geohash company ireader
1) "wx4g52e1ce0"
  1. 💡 附近的元素 | georadiusbymember

georadiusbymember 指令是最为关键的指令,它可以用来查询指定元素附近的其它元素。

# 查找 `ireader`附近20km最近的点按照升序排列, 只显示前3个点
127.0.0.1:6379> georadiusbymember company ireader 20 km count 3 asc
1) "ireader"
2) "juejin"
3) "meituan"
#  查找 `ireader`附近20km最近的点按照降序排列, 只显示前3个点
127.0.0.1:6379> georadiusbymember company ireader 20 km count 3 desc
1) "jd"
2) "meituan"
3) "juejin"
# 指定返回的点带参数
127.0.0.1:6379> georadiusbymember company ireader 20 km withcoord withdist withhash count 3 desc
# withdist 用来显示距离
# 
1) 1) "jd" # 地点名称
   2) "13.7269" # 距离
   3) (integer) 4069154033428715
   4) 1) "116.56210631132126"  # 经度
      2) "39.787602951302354"  # 维度
2) 1) "meituan"
   2) "11.5748"
   3) (integer) 4069887179083478
   4) 1) "116.48903220891953"
      2) "40.00766997707732"
3) 1) "juejin"
   2) "10.5501"
   3) (integer) 4069887154388167
   4) 1) "116.4810499548912"
      2) "39.996793488582597"

6.​ 💡 附近的元素 | georadius

根据定位(经度,纬度)查找附近的其他点

127.0.0.1:6379> georadius company 116.514202 39.905409 20 km withdist count 3 desc
1) 1) "jd"
   2) "13.7269" # 距离
2) 1) "meituan"
   2) "11.5748"
3) 1) "juejin"
   2) "10.5501"

12. 跳表

跳表(SkipList,全称跳跃表)是用于有序元素序列快速搜索查找的一个数据结构,实质就是一种可以进行二分查找的有序链表。跳表在原有的有序链表基础上增加了多级索引,通过索引来实现快速查找。


先设置跳表节点结构

class SkipNode<T>
{
    int key;
    T value;
    SkipNode right;//右指针
    SkipNode down;//下指针
    public SkipNode (int key,T value) {
        this.key=key;
        this.value=value;
    }
}
public class SkipList <T> {
    
    SkipNode headNode;//头节点,入口
    int highLevel;//当前跳表索引层数
    Random random;// 用于投掷硬币
    final int MAX_LEVEL = 32;//最大的层

    SkipList(){
        random=new Random();
        headNode=new SkipNode(Integer.MIN_VALUE,null);
        highLevel=0;
    }
    //其他方法
}

12.1 查询操作

设置一个临时节点team=head,如果teme不为空,则执行如下:

(1) 从team节点出发,如果当前节点的key与查询的key相等,那么返回当前节点 (如果是修改操作那么一直向下进行修改值即可)

(2) 如果key不相等,且右侧为null,那么证明只能向下(结果可能出现在下右方向),此时team=team.down

(3) 如果key不相等,且右侧不为null,且右侧节点key小于待查询的key。那么说明同级还可向右,此时team=team.right

(4)(否则的情况)如果key不相等,且右侧不为null,且右侧节点key大于待查询的key 。那么说明如果有结果的话就在这个索引和下个索引之间,此时team=team.down。

public SkipNode search(int key) {
    SkipNode team=headNode;
    while (team!=null) {
        if(team.key==key){
            return  team;
        }else if(team.right==null){
            //右侧没有了,只能下降
            team=team.down;
        }else if(team.right.key>key){
            //需要下降去寻找
            team=team.down;
        }else{
             //右侧比较小向右
            team=team.right;
        }
    }
    return null;
}

12.2 删除操作

删除需要改变链表结构,所以需要处理好节点之间的联系

  • 删除当前节点和这个节点的前后节点都有关系

  • 删除当前层节点之后,下一层该key的节点也要删除,一直删除到最底层

设置一个临时节点team=head,当team不为null具体循环流程为:

(1) 如果team右侧为null,那么team=team.down

(2) 如果team右侧不 为null,并且右侧的key等于待删除的key,那么先删除节点,再team向下team=team.down为了删除下层节点。

(3) 如果team右侧不 为null,并且右侧key小于待删除的key,那么team向右team=team.right。

(4) 如果team右侧不 为null,并且右侧key大于待删除的key,那么team向下team=team.down,在下层继续查找删除节点。

public void delete(int key)//删除不需要考虑层数
{
    SkipNode team=headNode;
    while (team!=null) {
        if (team.right == null) {//右侧没有了,说明这一层找到,没有只能下降
            team=team.down;
        }
        else if(team.right.key==key)//找到节点,右侧即为待删除节点
        {
            team.right=team.right.right;//删除右侧节点
            team=team.down;//向下继续查找删除
        }
        else if(team.right.key>key)//右侧已经不可能了,向下
        {
            team=team.down;
        }
        else { //节点还在右侧
            team=team.right;
        }
    }
}

12.3 插入操作

(1)首先通过查找的方式,找到待插入的左节点。插入是最底层先插入,然后再向上层走

(2)插入完这一层,需要考虑上一层是否插入,首先判断当前索引层级,如果大于最大值那么就停止(比如已经到最高索引层了)。否则设置一个随机数1/2的概率向上插入一层索引。

(3)继续(2)的操作,直到概率退出或者索引层数大于最大索引层。
使用栈来保存每层向下走的节点,如果上次索引需要增加节点,元素出栈就行

public void add(SkipNode node)
{
    int key=node.key;
    SkipNode findNode=search(key);
    if(findNode!=null)//如果存在这个key的节点
    {
        findNode.value=node.value;
        return;
    }
    Stack<SkipNode>stack=new Stack<SkipNode>();//存储向下的节点,这些节点可能在右侧插入节点
    SkipNode team=headNode;//查找待插入的节点   找到最底层的哪个节点。
    while (team!=null) {//进行查找操作 
        if(team.right==null)//右侧没有了,只能下降
        {
            stack.add(team);//将曾经向下的节点记录一下
            team=team.down;
        }
        else if(team.right.key>key)//需要下降去寻找
        {
            stack.add(team);//将曾经向下的节点记录一下
            team=team.down;
        }
        else //向右
        {
            team=team.right;
        }
    }
    int level=1;//当前层数,从第一层添加(第一层必须添加,先添加再判断)
    SkipNode downNode=null;//保持前驱节点(即down的指向,初始为null)
    while (!stack.isEmpty()) {
        //在该层插入node
        team=stack.pop();//抛出待插入的左侧节点
        SkipNode nodeTeam=new SkipNode(node.key, node.value);//节点需要重新创建
        nodeTeam.down=downNode;//处理竖方向
        downNode=nodeTeam;//标记新的节点下次使用
        if(team.right==null) {//右侧为null 说明插入在末尾
            team.right=nodeTeam;
        }
        //水平方向处理
        else {//右侧还有节点,插入在两者之间
            nodeTeam.right=team.right;
            team.right=nodeTeam;
        }
        //考虑是否需要向上
        if(level>=MAX_LEVEL)//已经到达最高级的节点啦
            break;
        double num=random.nextDouble();//[0-1]随机数
        if(num>0.5){//运气不好结束
            break;
        }
        level++;
        if(level>highLevel)//比当前最大高度要高但是依然在允许范围内 需要改变head节点
        {
            highLevel=level;
            //需要创建一个新的节点
            SkipNode highHeadNode=new SkipNode(Integer.MIN_VALUE, null);
            highHeadNode.down=headNode;
            headNode=highHeadNode;//改变head
            SkipNode nodeTeam=new SkipNode(node.key,node.value);
            nodeTeam.down=downNode;
            highHeadNode.right=nodeTeam;
            break;
        }
    }
}

☑️为什么Redis选择使用跳表而不是红黑树来实现有序集合:

Redis 中的有序集合(zset) 支持的操作:

  1. 插入一个元素
  2. 删除一个元素
  3. 查找一个元素
  4. 有序输出所有元素
  5. 按照范围区间查找元素

其中,前四个操作红黑树也可以完成,且时间复杂度跟跳表是一样的。但是,按照区间来查找数据这个操作,红黑树的效率没有跳表高。按照区间查找数据时,跳表可以做到 O(logn) 的时间复杂度定位区间的起点,然后在原始链表中顺序往后遍历就可以了。

13. 分布式锁

在单服务器系统我们常用本地锁来避免并发带来的问题,然而,当服务采用集群方式部署时,本地锁无法在多个服务器之间生效,这时候保证数据的一致性就需要分布式锁来实现

实现

Redis 锁主要利用 Redis 的 setnx 命令。

  • 加锁命令:SETNX key value,当键不存在时,对键进行设置操作并返回成功,否则返回失败。KEY 是锁的唯一标识,一般按业务来决定命名。
  • 解锁命令:DEL key,通过删除键值对释放锁,以便其他线程可以通过 SETNX 命令来获取锁。
  • 锁超时:EXPIRE key timeout, 设置 key 的超时时间,以保证即使锁没有被显式释放,锁也可以在一定时间后自动释放,避免资源被永远锁住。

加锁的伪代码如下:

if (setnx(key, 1) == 1){
    expire(key, 30)
    try {
        //TODO 业务逻辑
    } finally {
        del(key)
    }
}

存在哪些问题

  • SETNX和EXPIRE非原子性

    如果 SETNX 成功, EXPIRE 命令没有失败,锁没有设置超时时间可能导致死锁。(有一些开源的解决方案,比如lua脚本)

  • 错误解除

    如果线程 A 成功获取到了锁,并且设置了过期时间 30 秒,但线程 A 执行时间超过了 30 秒,锁过期自动释放,此时线程 B 获取到了锁;随后 A 执行完成,线程 A 使用 DEL 命令来释放锁,但此时线程 B 加的锁还没有执行完成,线程 A 实际释放的线程 B 加的锁。(解决:value 中设置当前线程加锁的标识)

  • 超时解锁导致并发

    如果线程 A 成功获取锁并设置过期时间 30 秒,但线程 A 执行时间超过了 30 秒,锁过期自动释放,此时线程 B 获取到了锁,线程 A 和线程 B 并发执行。(将过期时间设置的足够长)

  • 当线程在持有锁的情况下再次请求加锁,如果一个锁支持一个线程多次加锁,那么这个锁就是可重入的。如果一个不可重入锁被再次加锁,由于该锁已经被持有,再次加锁会失败。Redis 可通过对锁进行重入计数,加锁时加 1,解锁时减 1,当计数归 0 时释放锁。(在本地记录记录重入次数,如 Java 中使用 ThreadLocal 进行重入次数统计,实现 Redis Map 数据结构来实现分布式锁,对锁的标识和重入次数都进行记录)

  • 无法等待锁释放(通过客户端轮询的方式解决该问题,使用 Redis 的发布订阅功能,当获取锁失败时,订阅锁释放消息,获取锁成功后释放时,发送锁释放消息)

【参考】

  1. https://blog.csdn.net/weixin_43741711/article/details/123795242
  2. 位图
  3. Redis 相关指令文档
  4. Redis Gep地理位置模块
  5. Reids官方操作手册文档
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

企鹅宝儿

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

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

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

打赏作者

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

抵扣说明:

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

余额充值