Redis(八):Redis 典型应用—— 缓存 (cache)【缓存的更新策略、缓存预热、缓存穿透、缓存雪崩和缓存击穿】、Redis 典型应用——分布式锁、常见面试题

接上次博客:Redis(七)集群 (Cluster):基本概念、数据分片算法【哈希求余、一致性哈希算法、哈希槽分区算法】、集群搭建 (基于 docker)、故障处理——主节点宕机、集群扩容、集群缩容、代码连接集群-CSDN博客

目录

什么是缓存?

使用 Redis 作为缓存

缓存的更新策略

定期生成

实时生成

缓存预热、缓存穿透、缓存雪崩和缓存击穿

关于缓存预热 (Cache preheating)

关于缓存穿透 (Cache penetration)

关于缓存雪崩 (Cache avalanche)

关于缓存击穿 (Cache breakdown)

Redis 典型应用——分布式锁

什么是分布式锁?

分布式锁的基础实现

引入过期时间

引入校验 id

引入 Lua

引入 watch dog (看门狗)

引入 Redlock 算法

其他功能

常见面试题

介绍一下什么是 Redis, 有什么特点?

ZSet 为什么使用跳表,而不是使用红黑树来实现?

Redis 的常见应用场景有哪些?

怎样测试 Redis 服务器的连通性?

如何设置 key 的过期时间?

如果大量的 key 在同一时间点过期,会产生什么问题? 如何处理?

Redis 如果内存用完了,会出现什么情况?

如何使用 Redis 作为消息队列?

什么是热 key 问题? 如何解决?

如何实现 Redis 高可用?

Redis 和 MySQL 如何保证双写⼀致性?

什么是 "双写⼀致性"?

如何解决 

Redis 的常用管理命令有哪些?

Redis 用到的网络通讯协议是怎样的?

Redis 如何遍历 key?

Redis 如何实现 "查找附近的人" ?

什么是 Redis 的 "bigkey" 问题? 如何解决?


什么是缓存?

缓存(cache)是一种数据存储技术,旨在提高数据访问速度和性能。它通过将常用的数据存储在更接近 CPU 的位置,以减少对更慢存储介质(如硬盘或网络)的访问次数,从而加快数据的读取速度。

缓存的核心思想是利用相对较快的存储介质存储一部分数据副本,以便在需要时能够快速访问。

通常情况下,缓存层级与存储介质的速度密切相关。

例如,在计算机系统中,常见的缓存层级包括:

  1. CPU 寄存器缓存:位于 CPU 内部,速度最快,但容量有限。
  2. 内存缓存(内存):位于 CPU 与硬盘之间,比硬盘快速但比 CPU 寄存器慢,容量相对较大。
  3. 硬盘缓存:将热门数据存储在较快的磁盘驱动器上,以减少对更慢的存储介质(如网络存储)的访问。
  4. 网络缓存:将数据存储在网络中的某些节点上,以加速数据传输和访问。

其中速度快的设备可以作为速度慢的设备的缓存,从而提高数据访问的效率。

  • CPU 寄存器:速度最快的存储设备,用于临时存储 CPU 正在处理的数据和指令。
  • 内存(RAM):速度比硬盘快得多,用于存储操作系统和应用程序的运行时数据。
  • 硬盘:速度相对较慢,但存储容量大,用于持久化存储数据,如文件、数据库等。
  • 网络:用于通过网络传输数据,速度相对较慢,受到网络带宽和延迟的影响。

常见的缓存方式包括:

  • 使用内存作为硬盘的缓存:如 Redis 数据库,将热门数据存储在内存中,以加快数据读取速度。

  • 使用硬盘作为网络的缓存:如 CDN(内容分发网络),将静态资源(如图片、视频、音频等)缓存到离用户较近的服务器上,以提高访问速度。

  • 浏览器缓存:浏览器通过 HTTP/HTTPS 协议从服务器获取数据,并在本地缓存这些数据。这样,当用户再次访问相同的页面时,浏览器可以直接从本地缓存中获取数据,而无需重新从网络获取。

需要注意的是,浏览器缓存可能会导致一些问题,如忘记刷新页面可能会导致使用过期的缓存数据,从而引发 bug。因此,在开发过程中需要谨慎处理缓存机制,确保缓存数据的及时更新。

总之,通过将数据存储在更接近 CPU 的位置,缓存技术可以显著提高系统的整体性能和响应速度。在实际应用中,缓存被广泛应用于数据库查询、网页加载、文件系统访问等场景,以提高系统的吞吐量和用户体验。

让我举一个例子来说明:

假设你是一位经常出差的商务人士,经常需要乘坐高铁前往不同的城市。在每次乘坐高铁时,都需要频繁地出示身份证以完成各个环节的验证,包括进站检票、上车乘坐、车票查验以及到达目的地的出站验证等。

最初,你把身份证放在了你的家中的抽屉里,因为抽屉的空间很大,可以容纳大量的文件和物品。但是,每次出行前,你都需要打开抽屉找到身份证,然后再放入口袋或包包中。这种方式虽然可以保证身份证的安全,但是每次使用时都需要花费较多的时间和精力。

为了提高出行效率,你决定把身份证放在随身携带的钱包的专门位置,即钱包的内侧小袋子中。虽然小袋子的容量较小,但是它靠近你身体,非常便于你随时取用。这样一来,每次需要出示身份证时,你只需从钱包的小袋子中取出,无需再打开抽屉去找身份证。

通过这个例子,我们可以看出将身份证放入钱包的小袋子中相当于在抽屉和身份证之间增加了一个缓存层。这个缓存层不仅更加便于访问,而且能够显著提高我们的出行效率。

在计算机系统中,通常来说,访问速度越快的存储设备成本也就越高,而且容量也越小。因此,我们会将一部分频繁访问的数据放入缓存中,以便快速访问,提高系统性能和响应速度。

比如,对于一个网站来说,用户经常访问的首页内容、热门文章或商品等数据会被放入缓存中,而不是每次都从数据库或磁盘上读取。这样可以减少数据库或磁盘的访问次数,加快页面加载速度,提高用户体验。

因此,缓存不仅可以提高系统性能,而且可以有效利用有限的资源,使系统更加高效地运行。

著名的 "二八定律",也称为帕累托法则或80/20规则,表明,在许多情况下,大约80%的结果来自于约20%的原因,或者说80%的影响来自于约20%的原因。

在计算机系统中,这个定律也适用。大约20%的数据会被频繁地访问,而这些数据却占据了80%的访问量。因此,只需要将这少量的热点数据进行缓存,就能够应对大部分的访问场景,从而实现整体性能的明显提升。

这就是为什么在设计系统时,重点关注和优化这些热点数据的缓存,可以带来更好的性能和用户体验。

使用 Redis 作为缓存

在一个网站中,我们经常会使用关系型数据库(比如MySQL)来存储数据。尽管关系型数据库功能强大,但存在一个重要的缺陷,即性能不高。换句话说,进行一次查询操作消耗的系统资源较多。

关系型数据库被认为性能不高的原因有多个,其中包括:

  1. 数据存储在硬盘上:关系型数据库通常将数据存储在硬盘上,而硬盘的IO速度相对较慢,尤其是在进行随机访问时。这会导致数据检索和操作的延迟。

  2. 索引未命中:如果查询无法通过索引快速定位到所需的数据行,数据库就需要进行全表扫描或者索引遍历,这会增加硬盘IO次数,影响性能。

  3. SQL执行开销:关系型数据库对SQL语句的执行需要进行解析、校验和优化等操作,这些过程会消耗额外的系统资源和时间。

  4. 复杂查询的效率:对于一些复杂的查询,如联合查询,数据库可能需要执行笛卡尔积等操作,导致查询效率大幅降低。

  5. 事务管理与并发控制:关系型数据库通常支持事务管理功能,确保数据的一致性和完整性。然而,事务管理需要数据库引擎维护锁、日志和回滚段等数据结构,这会增加系统的负载和开销,降低性能。在多用户同时访问数据库的情况下,需要进行并发控制以确保数据的一致性,这也会对性能产生影响。

  6. 硬件资源限制:关系型数据库的性能还受到硬件资源的限制,包括CPU、内存、存储等。如果硬件资源不足,数据库就无法充分发挥其性能潜力。

  7. 优化策略与工具:针对关系型数据库性能不高的问题,可以采取一些优化策略和工具来提升性能,包括优化SQL语句、合理设计索引、分区表、使用缓存、数据库水平和垂直切分等。这些优化手段需要针对具体的业务场景和数据库特性来进行选择和实施。

当访问数据库的并发量增加时,数据库服务器需要处理更多的请求,每个请求都需要消耗一定的硬件资源,如CPU、内存、硬盘和网络带宽。由于服务器的硬件资源是有限的,如果并发请求过多,就会导致资源耗尽。当资源耗尽时,服务器无法正确处理后续的请求,造成请求超时、连接断开或者服务器崩溃等问题,从而导致数据库服务器宕机。

并发量高导致数据库服务器宕机的主要原因有:

  1. 资源耗尽:并发请求过多导致服务器的CPU、内存、硬盘和网络带宽等资源被耗尽,无法满足后续请求的处理需求。

  2. 性能瓶颈:数据库服务器在处理大量并发请求时可能会遇到性能瓶颈,如CPU负载过高、内存不足、磁盘IO瓶颈等,导致请求响应时间增加,甚至出现请求超时。

  3. 竞争条件:并发请求可能导致数据库中的竞争条件(Race Condition),例如同时对同一行数据进行读写操作,这可能导致数据一致性问题或者死锁等情况,进而影响数据库的稳定性。

  4. 程序错误:高并发环境下,程序中的潜在bug可能会被暴露出来,如内存泄漏、资源竞争等问题,进而导致程序崩溃或者异常终止。

因此,对于数据库服务器而言,合理控制并发量、优化数据库结构和查询性能、提高硬件配置以及实现高可用性和负载均衡等措施是保障数据库服务稳定性和可靠性的关键。

在实际开发中,为了让数据库能够承担更大的并发量,通常会采取以下两种核心思路:

  1. 横向扩展(开源):引入更多的机器,部署多个数据库实例,构建数据库集群。这种方法称为横向扩展或者水平扩展。通过数据库集群,可以实现主从复制、分库分表等技术手段,将负载分散到多个节点上,提高整个数据库系统的并发处理能力和吞吐量。

  2. 节流控制(引入缓存):引入缓存机制,将经常访问的热点数据保存在缓存中,从而降低直接访问数据库的请求数量。常用的缓存技术包括内存缓存、分布式缓存、反向代理缓存等。通过缓存技术,可以加速数据访问速度,减轻数据库的负载压力,提高系统的响应速度和吞吐量。

在实际开发中,这两种方案往往是相辅相成的。通过横向扩展数据库集群,可以提高数据库的整体处理能力和容错能力,从而支撑更大的并发量;同时,通过引入缓存技术,可以进一步减轻数据库的负载压力,提高系统的性能和稳定性。因此,综合考虑业务需求、系统架构和技术实现等因素,选择合适的扩展方案和缓存策略是保障系统性能和可靠性的关键。

当谈到作为数据库缓存的选择时,Redis是一个常见的解决方案。Redis不仅仅是一个缓存系统,它还可以用作数据库的主要数据存储,特别是在需要快速访问和高并发的场景下:

  1. 快速访问速度:Redis的访问速度远远快于MySQL。这是因为Redis的数据存储在内存中,而内存的访问速度比硬盘快得多。因此,无论是读取数据还是写入数据,Redis都能够以非常快的速度执行操作,从而提高了系统的性能和响应速度。与MySQL相比,Redis几乎可以立即响应请求,而不需要等待硬盘I/O操作完成。

  2. 较少的系统资源消耗:在处理相同的访问请求时,Redis消耗的系统资源比MySQL要少得多。由于数据存储在内存中,Redis的操作不需要像MySQL那样频繁地进行磁盘I/O操作。此外,Redis采用单线程模型,避免了多线程并发带来的线程切换开销,因此在相同硬件条件下,Redis的资源消耗更低,性能更高。

  3. 更大的并发支持:由于快速的访问速度和较少的系统资源消耗,Redis能够支持更大的并发量。这意味着Redis可以同时处理更多的请求,而不会因为系统资源的限制而导致性能下降。相比之下,MySQL在面对大量并发请求时可能会遇到性能瓶颈,因为它需要频繁地进行磁盘I/O操作,而磁盘I/O通常是数据库的瓶颈之一。

  4. 数据存储在内存中:Redis的数据存储在内存中,这意味着访问数据的速度非常快。与硬盘相比,内存的访问速度要快得多,因此Redis能够快速读取和写入数据,从而提高了系统的性能。但是,需要注意的是,由于数据存储在内存中,Redis的存储容量受到内存大小的限制,因此需要合理管理数据存储和内存使用。

  5. 简单的key-value存储模型:Redis主要支持简单的key-value存储模型,不涉及复杂的查询和规则。这使得Redis的操作相对简单,不受复杂查询的限制。相比之下,MySQL需要执行复杂的SQL查询语句,并且可能需要进行索引扫描或者表连接等操作,这些操作会增加系统的负载和响应时间。因此,Redis在存储和检索数据时更加高效和灵活。

  6. 类似于“护盾”的作用:将Redis视为MySQL的“护盾”,通过缓存热点数据,减轻MySQL的负载压力,提高系统的性能和稳定性。通过将热点数据缓存到Redis中,可以减少对MySQL的直接访问次数,从而降低数据库的负载压力。这种缓存机制使得系统能够更快地响应请求,并且能够处理更多的并发请求。

综上所述,Redis作为数据库缓存,不仅能够提高系统的性能和响应速度,还能够支持更大的并发量,减轻数据库的负载压力,是一种非常有效的优化方案。

  • 当客户端向业务服务器发送查询请求时,业务服务器首先会检查Redis,以确定所需数据是否已经存在于Redis中。
  • 如果所需数据已经缓存在Redis中,则业务服务器立即从Redis返回数据给客户端。由于Redis的高速读取特性,这样的操作可以迅速完成,而无需访问MySQL数据库。
  • 如果Redis中不存在所需数据,则业务服务器将继续执行查询MySQL的操作,以从数据库中检索数据。 

按照上述讨论的 "二八定律",只需在Redis中存储20%的热点数据,即可满足80%的请求,避免实际查询数据库的情况。 当然,在实际应用中,具体的比例可能会因业务场景的不同而有所变化,可能是 "一九"、"三七"等其他比例。但至少在绝大多数情况下,使用缓存都能显著提升整体的访问效率,降低数据库的压力。

注意:缓存主要用于加速读取操作的速度,而对于写操作,仍然需要直接操作数据库。缓存并不能提高写操作的性能,因为写入数据时需要确保数据的一致性,并且可能需要更新多个缓存副本,增加了复杂度和开销。因此,在写操作方面,仍然要依赖数据库来保证数据的完整性和一致性。

当涉及到写操作时,缓存的作用会受到限制:

  1. 数据一致性:写操作涉及更新数据,而缓存中的数据可能会滞后于数据库的变化。如果在写入数据库后未及时更新缓存,就会导致数据库和缓存中的数据不一致。这可能会引发数据的脏读或者脏写问题,破坏系统的数据完整性和一致性。

  2. 写操作的延迟:写操作通常需要更多的时间和资源,因为它们涉及到数据的修改和更新。在写入数据库后,如果立即更新缓存,会增加系统的响应时间和负载。另一方面,如果延迟更新缓存,那么在数据库中的写操作完成后,读取缓存中的数据可能会导致旧数据的返回,从而降低了系统的实时性和准确性。

  3. 数据库与缓存的同步:为了确保数据的一致性,写操作完成后需要同步更新缓存。这意味着在写入数据库之后,必须更新缓存中相应数据的副本。这一过程涉及到网络通信和数据同步,增加了系统的复杂度和开销。

  4. 缓存策略的选择:针对写操作,需要选择合适的缓存策略。常见的策略包括写穿透、写回和写后读。每种策略都有其优缺点,需要根据具体的业务场景和系统需求进行选择和配置。

  5. 缓存更新的原子性:在更新缓存时,需要确保操作的原子性,即保证缓存数据的更新是完整的、不可分割的。否则可能会导致缓存数据的不一致性和损坏。

缓存的更新策略

接下来还有一个重要的问题——到底哪些数据才是 "热点数据" 呢?

缓存的更新策略是指在一定的时间间隔内,对于缓存中存储的数据进行更新和优化,以保持其与最新数据的一致性和有效性。

定期生成

  1. 日志记录:将访问的数据以日志的形式记录下来,这可以通过日志文件、数据库或日志服务来实现。确保日志记录系统能够处理大量的并发请求,并能够将日志数据持久化存储。
  2. 数据统计:定期对访问数据进行统计分析,可以选择每天、每周或每月的周期。统计可以包括对访问频次、访问量、用户行为等指标的分析,以及对数据的处理情况进行监控和分析。
  3. 存储优化:由于数据量可能非常大,单台机器可能无法存储所有数据,因此需要使用分布式系统来存储日志数据。常见的选择包括使用分布式文件系统(如HDFS)来存储日志文件,或者使用基于HDFS的数据库(如HBase)来存储结构化数据
  4. 数据处理:使用Hadoop的MapReduce等分布式计算框架对存储在HDFS上的日志数据进行处理和分析。MapReduce可以并行处理大规模数据集,并生成统计报告或分析结果。或者使用基于HDFS的数据库(如HBase)写SQL统计
  5. 缓存更新:定期对访问频次最高的数据进行更新或重新加载到缓存中,以确保缓存中的数据始终保持与实际访问情况的一致性。可以根据统计结果选择更新策略,例如每天更新前N%的数据,或者根据实时访问情况动态调整更新频率。

另外,实现定时生成的机制通常涉及多个步骤,可以通过一套离线的流程来完成。这种流程通常通过使用脚本编程语言如Shell脚本或Python脚本,以及定时任务调度工具如cron(在Unix/Linux系统中)或Windows Task Scheduler(在Windows系统中)来触发。以下是实现定时生成机制的主要步骤:

a) 完成统计热词的过程: 这个过程涉及收集和处理数据,通常会涉及到从数据库或者日志文件中提取数据,然后进行统计分析,识别热词并生成相应的统计结果。可以使用Python等脚本语言编写脚本来实现数据提取、处理和分析的过程。

b) 根据热词找到搜索结果的数据(广告数据): 在识别了热词之后,需要根据这些热词去查找相应的搜索结果数据,这可能涉及到查询数据库或者调用搜索引擎API来获取相应的数据。可以编写脚本来执行这些查询操作,并将结果保存下来供后续使用。

c) 将得到的缓存数据同步到缓存服务器上: 一旦获取了搜索结果数据,需要将这些数据同步到缓存服务器上,以便后续的访问和使用。可以编写脚本来实现数据的同步操作,例如通过API或者直接写入缓存服务器的数据存储。

d) 控制缓存服务器自动重启: 为了确保缓存服务器的稳定运行,可能需要定期对其进行重启以释放资源或者应用新的配置。可以通过编写脚本来实现对缓存服务器的自动重启操作,例如通过SSH远程连接到服务器执行相应的重启命令。

总之,实现定时生成机制涉及到数据处理、数据查询、数据同步和服务器管理等多个方面,可以通过编写脚本和使用定时任务调度工具来完成。

回归正题,以搜索引擎为例,用户在搜索引擎中输入查询词进行搜索。有些词频率较高,即被大量用户频繁搜索,如“鲜花”、“蛋糕”、“同城交友”等;而有些词则属于低频词,很少有用户搜索。

搜索引擎的服务器会记录用户的搜索行为,包括用户在何时、何地、使用何种关键词进行了搜索,这些搜索行为会以日志的形式详细记录下来。然后,通过对这些日志数据进行分析和统计,可以得出一段时间内的搜索频率,进而生成一个“高频词表”。

定期生成配置文件的优点有以下几点:

  1. 自动化:定期生成配置文件可以实现自动化,减少手动操作的时间和工作量。通过脚本或自动化工具,可以在规定的时间间隔内生成配置文件,无需人工干预。

  2. 简化操作:生成配置文件的过程相对简单,只需编写一次脚本或配置模板,然后通过循环或其他方式生成多个配置文件。这样可以大大简化操作流程,提高效率。

  3. 可控性:定期生成配置文件使得整个过程更加可控。可以根据需求调整生成配置文件的频率和时间点,确保在适当的时间生成最新的配置文件,从而使系统保持更新和稳定。

  4. 可追溯性:定期生成的配置文件可以记录生成时间和版本信息,方便追溯和排查问题。当系统出现异常或配置错误时,可以通过查看生成的配置文件来定位问题,快速恢复系统正常运行状态。

  5. 减少人为错误:定期生成配置文件可以减少人为错误的发生。通过自动化生成,可以避免手动操作过程中可能出现的拼写错误、格式错误等问题,提高配置文件的准确性和稳定性。

然而,这种定期生成高频词表的方式虽然能够保持缓存数据的一致性,但实时性较低,无法及时应对一些突发情况。比如在春节期间,“春晚”这样的词可能会突然成为非常高频的搜索词,而平时则很少有人搜索。因此,如果仅仅依靠定期生成的高频词表,可能无法及时将春节期间的热门搜索词加载到缓存中,从而影响了搜索引擎的效率和用户体验。为了解决这个问题,可能需要采用更加实时的更新策略,例如结合实时数据流处理技术,及时捕捉和处理用户的实时搜索行为,以动态地更新缓存数据,保持其与实际搜索情况的一致性,提高搜索引擎的响应速度和搜索结果的准确性

实时生成

实时生成作为一种缓存更新策略,旨在根据实时的查询需求动态地生成和更新缓存数据,以确保缓存中的数据能够及时地反映用户的访问行为,并且保持与最新数据的一致性。

首先,需要设置缓存的容量上限,这一参数通常可以通过配置 Redis 的 maxmemory 参数来进行设定,以限制缓存数据的总大小。

在用户进行查询操作时,系统会首先在 Redis 缓存中查找是否存在相应的数据:

  • 如果在缓存中找到了对应的数据,系统会直接从缓存中返回结果,避免了对数据库的访问,提高了查询效率。
  • 如果缓存中不存在对应的数据,则系统会从数据库中进行查询,并将查询结果写入到 Redis 缓存中,以便后续的快速访问。

当缓存达到容量上限时,即缓存空间已经被全部占满时,就需要触发缓存淘汰策略,以清理出空间来存储新的数据。这时候,系统会根据预先设定的淘汰策略,选择性地淘汰一些相对不那么热门的数据,以确保缓存中存储的是最有价值的数据,从而提高了缓存的效率和命中率。

通过实时生成的缓存更新策略,系统可以根据用户的实际查询行为动态地调整缓存数据,将热门数据保持在缓存中,从而提高了系统的响应速度和用户体验。同时,由于缓存数据的更新是基于实时的查询需求,因此可以更加精准地反映用户的访问模式,从而进一步优化了缓存的效果。

通用的淘汰策略如下:

  1. FIFO (First In First Out) 先进先出: 这种策略会淘汰最早进入缓存的数据,也就是存在时间最久的数据,即先来的数据先被淘汰掉。这种策略简单直观,符合队列的思想。

  2. LRU (Least Recently Used) 最近最少使用: LRU 算法会记录每个缓存数据项的访问时间,当需要淘汰数据时,会选择最久未被访问的数据进行淘汰,即淘汰最近访问时间最早的数据。这种策略认为最近被使用过的数据有更高的可能性再次被使用。

  3. LFU (Least Frequently Used) 最不经常使用: LFU 算法记录每个缓存数据项的访问次数,当需要淘汰数据时,会选择访问次数最少的数据进行淘汰,即淘汰访问频次最低的数据。这种策略认为被访问次数较少的数据可能不太重要。

  4. Random 随机淘汰: 这种策略简单地从所有缓存数据项中随机选择一个进行淘汰,不考虑数据的访问时间或访问频次。这种策略的优点是简单快速,但缺点是可能会淘汰掉重要的数据。

以上这些淘汰策略可以根据具体的需求和场景选择,每种策略都有其适用的场景和优缺点。例如,对于对缓存大小敏感的应用,可能更倾向于使用 FIFO 或 LRU 策略;而对于需要保证缓存数据的多样性和全面性的场景,可能会选择使用 Random 策略。

另外需要注意的是,上述记住淘汰策略适用于各种类型的缓存系统,不仅限于 Redis。

理解上述几种淘汰策略:

想象一下你是位皇帝,统治着拥有三千名佳丽的后宫。虽然你被誉为“真龙天子”,但你能够频繁宠幸的妃子却寥寥无几,毕竟你的精力是有限的。这三千名佳丽就如同数据库中的全部数据,而那些你经常宠爱的妃子则相当于数据库中的热点数据,它们被放置在缓存中,时刻备受你的关注。

今年又迎来了一批新选秀的小主入宫,其中有一位引起了你的兴趣。作为皇帝,你自然会对新人给予宠信,但这也意味着必须有旧人被冷落。那么问题来了,究竟是谁会被冷落呢?

  1. FIFO (First In First Out) 先进先出: 如果按照 FIFO 策略,那么最先被宠幸的皇后会被优先淘汰,因为她们已经在后宫中待了很久,而且可能已经年老色衰,失去了皇帝的宠爱。

  2. LRU (Least Recently Used) 最近最少使用: 在 LRU 策略下,皇帝会统计每位妃子最近一次被宠幸的时间。根据这个信息,如果有妃子已经很久没有被宠幸了,比如一个月前宠幸过的妃子,那么这位妃子可能会被淘汰。

  3. LFU (Least Frequently Used) 最不经常使用: LFU 策略会统计每位妃子在最近一个月内被宠幸的次数。如果有妃子的宠幸次数相对较少,比如只被宠幸了一次,那么这位妃子可能会被淘汰。

  4. Random 随机淘汰: 如果采用随机淘汰策略,那么皇帝会随机选择一位妃子进行淘汰,无论她是年轻貌美还是老迈疲惫。

综上所述,不同的淘汰策略会根据不同的标准来决定哪位妃子被淘汰。FIFO 策略会优先淘汰老妃,LRU 策略会优先淘汰最久未被宠幸的妃子,LFU 策略会优先淘汰宠幸次数最少的妃子,而随机淘汰策略则是完全随机选择。

在这里,我们可以根据业务需求和系统特点来自定义缓存的淘汰策略。当然,Redis也提供了一些内置的淘汰策略,我们可以直接利用这些内置策略来管理缓存。

自定义淘汰策略可以根据业务需求灵活调整缓存的存储和释放规则。例如,可以基于数据的访问频次、存储时长或者其他业务指标来决定哪些数据应该被保留在缓存中,哪些数据可以被淘汰出去。这种方式可以更好地适应特定业务场景下的需求,提高缓存的命中率和效率。

同时,Redis提供了一些内置的淘汰策略,这些策略可以根据数据的访问模式或者存储时间来自动淘汰缓存中的数据,减少内存占用并保持缓存的有效性。利用Redis提供的内置策略,我们可以快速实现缓存的淘汰管理,而无需自行实现复杂的算法和逻辑。

具体来说,Redis提供的内置淘汰策略与我们之前介绍的通用策略基本类似,只不过针对不同的情况和需求做了更加具体的处理:

  1. volatile-lru:当内存不足以容纳新写入数据时,从设置了过期时间的键中使用LRU(最近最少使用)算法进行淘汰。这个策略适用于那些需要定期过期的数据,例如缓存中的临时数据。

  2. allkeys-lru:当内存不足以容纳新写入数据时,从所有键中使用LRU算法进行淘汰。这个策略不考虑键是否设置了过期时间,而是对所有键都采用LRU算法进行淘汰,适用于对所有数据都有相同淘汰优先级的情况。

  3. volatile-lfu:在4.0版本中新增,当内存不足以容纳新写入数据时,在过期的键中使用LFU(最不经常使用)算法进行淘汰。这个策略会优先淘汰那些在过期时间内使用频率较低的键。

  4. allkeys-lfu:在4.0版本中新增,当内存不足以容纳新写入数据时,从所有键中使用LFU算法进行淘汰。这个策略不考虑过期时间,而是对所有键都采用LFU算法进行淘汰,适用于所有数据都有相同淘汰优先级的情况。

  5. volatile-random:当内存不足以容纳新写入数据时,从设置了过期时间的键中随机淘汰数据。

  6. allkeys-random:当内存不足以容纳新写入数据时,从所有键中随机淘汰数据。

  7. volatile-ttl:在设置了过期时间的键中,根据过期时间进行淘汰,越早过期的键优先被淘汰,相当于FIFO策略,但局限于过期键。

  8. noeviction:默认策略,当内存不足以容纳新写入数据时,新写入操作会报错,不会进行任何淘汰。很显然,这不适合实时更新缓存。

Redis 的淘汰策略是非常重要的,它决定了在内存不足时如何处理数据。

在上述策略中,通常默认策略是比较好的选择:

  • 完善的监控报警体系: 正常情况下,部署Redis的机器不应该消耗到内存耗尽才进行处理。应该建立完善的监控报警体系,当内存接近上限时提前通知开发人员,以便尽早进行扩容或其他处理。

  • 显式处理内存耗尽问题: 如果确实出现了内存耗尽的情况,相比于Redis在内存不足时偷偷删除一部分键,最好的方式是尽早将问题暴露出来,及时处理。虽然有些策略(如volatile前缀系列)在淘汰键时不会对关键数据造成损失,但通常建议尽早发现并解决内存不足的问题。

  • 避免使用allkeys策略: 除非Redis中的数据完全不关键,否则不应该使用allkeys系列的策略。这是因为allkeys策略会在内存不足时随机删除任何键,这可能会导致重要数据的丢失或不可预料的行为。

通过合理选择和管理淘汰策略,可以确保Redis在内存不足时以一种可控的方式处理数据,保障系统的稳定性和可靠性。

总的来说,Redis提供的缓存淘汰策略与我们之前介绍的通用策略基本一致,都是为了合理管理缓存,提高系统的性能和可靠性。然而,Redis在处理“过期key”和“全部key”的淘汰时会有一些特殊处理。

对于过期key,Redis采用的是TTL(Time To Live)策略,即设置每个缓存键值对的过期时间,在超过指定时间后自动将其淘汰。这样可以确保缓存数据的时效性,避免存储过期数据占用内存资源。

而对于全部key的淘汰,Redis提供了多种内置的淘汰策略,包括LRU(最近最少使用)、LFU(最不常用)、随机淘汰等。这些策略可以根据缓存数据的访问模式和存储特点,自动选择合适的数据进行淘汰,以保持缓存空间的有效利用。

此外,Redis还支持持久化功能,可以将缓存数据保存到硬盘上,以防止数据丢失。通过将数据持久化到硬盘,即使系统重启或者Redis服务宕机,数据依然可以被恢复,确保数据的安全性和持久性。

总之,Redis提供了丰富的缓存管理功能和灵活的淘汰策略,可以根据不同的业务需求和系统特点来选择合适的策略,从而实现高效的缓存管理和数据存储。

缓存预热、缓存穿透、缓存雪崩和缓存击穿

关于缓存预热 (Cache preheating)

我们才说过,缓存中的数据可以分为两种生成方式:

  1. 定期生成:在一定时间间隔内,系统会定时将数据库中的数据同步到缓存中,以保持缓存中的数据与数据库中的数据一致。这种情况下,不涉及“预热”,即数据是在需要时才生成到缓存中的。

  2. 实时生成:在系统运行时,当有新的数据产生或更新时,会立即将这些数据写入缓存,以保持缓存的实时性。

一种常见的情况是,当Redis服务器首次接入时,服务器中并没有数据。此时,客户端发送请求查询Redis,如果没有找到数据,则会再次查询MySQL。一旦从MySQL中获取到数据,客户端会将数据写入Redis中。随着时间的推移,Redis中的数据会不断积累,MySQL所承担的压力也会逐渐减小,因为越来越多的请求会直接从Redis中获取数据,而不必再访问MySQL数据库。

缓存预热是一种解决上述问题的方法,它结合了定期生成和实时生成的思路。在缓存预热中,系统会先通过离线的方式,利用一些统计的途径或者业务规则,提前找到一批热点数据,并将这些数据导入到Redis中。这批热点数据在导入到Redis之后,可以立即为MySQL数据库减轻一定的压力,因为很多请求可以直接从Redis中获取数据,而不必再访问MySQL。随着时间的推移,逐渐会有新的热点数据出现,旧的热点数据可能会逐渐被淘汰。因此,缓存预热能够有效地减轻数据库的负载,并提高系统的性能和响应速度。

什么是缓存预热?

缓存预热是在使用Redis作为MySQL缓存时的一种策略,用于在Redis刚刚启动或者Redis大量缓存数据失效后,提前将热点数据加载到Redis中。在Redis刚启动时或缓存失效时,Redis中可能没有任何缓存数据,这时如果直接访问MySQL会造成较大的压力,降低系统的性能和响应速度。因此,通过预先将热点数据写入Redis中,可以快速建立起缓存保护层,使得Redis能够尽快为MySQL提供缓存服务,减轻MySQL的负载压力。

预热的热点数据可以通过多种方式生成,其中一种常见的方式是基于之前介绍的统计方法。通过分析历史数据或者实时监控系统访问模式,可以找出访问频率较高的数据,将其作为热点数据提前加载到Redis中。这些热点数据并不一定需要非常准确,只要能够帮助MySQL抵挡大部分请求即可。随着程序的运行和数据访问模式的变化,缓存的热点数据会逐渐自动调整,以更好地适应当前的系统情况,保持高效的缓存服务。

关于缓存穿透 (Cache penetration)

什么是缓存穿透?

缓存穿透是指访问的key在缓存(如Redis)和数据库中都不存在的情况下,系统每次查询都必须直接访问数据库。这种情况会给数据库带来大量无效的查询请求,增加数据库的负载压力,降低系统的性能和稳定性。

缓存穿透可能由以下原因引起:

  1. 业务设计不合理: 在业务逻辑中可能缺少对请求参数的合法性校验,导致非法的key也被用于查询,例如缺少对参数的格式、长度等方面的检查。

  2. 开发/运维误操作: 可能因为开发人员或运维人员的失误,部分数据被意外地从数据库中删除,而缓存中却没有及时更新相应的数据。

  3. 恶意攻击: 黑客可能会利用恶意请求来频繁查询不存在的key,以此来消耗系统资源、增加数据库负载,甚至引发系统拒绝服务(DoS)攻击。

为了解决缓存穿透问题,可以采取以下措施:

  1. 参数校验: 对于要查询的参数进行严格的合法性校验,确保只有合法的key才会被用于查询。例如,对于手机号作为key,需要校验当前key是否满足合法的手机号格式。

  2. 默认值缓存: 针对数据库中也不存在的key,在缓存中也存储相应的数据,将其值设为一个默认值。这样即使查询的key不存在,也会有缓存返回一个默认的值,而不会直接访问数据库。

  3. 布隆过滤器: 使用布隆过滤器等技术,先判断key是否可能存在于缓存或数据库中,再进行真正的查询操作。布隆过滤器可以快速判断一个key是否可能存在,从而避免了对数据库的无效查询。

通过以上措施,我们可以有效地预防和解决缓存穿透问题,提高系统的性能和安全性,保障系统的稳定运行。

布隆过滤器是一种数据结构,结合了哈希函数和位图(bitmap)的思想,可以用较少的空间来判断某个元素是否存在于一个集合中。

它的基本原理是使用多个哈希函数将输入的元素映射到一个位数组中的多个位置上,然后将这些位置的位都设置为1。当检查某个元素是否存在时,我们对输入的元素再次应用相同的哈希函数,检查相应的位是否都为1。如果所有位都为1,则说明该元素可能存在于集合中;如果有任何一个位为0,则可以确定该元素肯定不在集合中。

由于布隆过滤器只使用了位数组和一组哈希函数,所以它的存储空间要比其他数据结构(如哈希表)要小得多,但是也存在一定的误判率。这是因为多个不同的元素可能被映射到相同的位上,导致误判。但是通过适当选择哈希函数的个数和位数组的大小,可以在空间和误判率之间取得平衡。

布隆过滤器通常用于需要快速判断某个元素是否存在的场景,例如网络爬虫中的URL去重、黑名单过滤、缓存击穿防护等。它在空间效率和查询效率方面具有很大的优势,但需要注意控制误判率,避免影响业务逻辑的准确性。

关于缓存雪崩 (Cache avalanche)

什么是缓存雪崩?

缓存雪崩是一种严重的系统故障,可能导致整个系统的不可用。它通常发生在缓存系统(比如Redis)中,当大量缓存键在短时间失效时,会导致缓存命中率陡然下降,从而导致大量的请求直接访问数据库,造成数据库压力骤增,甚至导致数据库宕机的情况。

产生缓存雪崩的原因可能多种多样,但其中最常见的情况包括:

  1. Redis宕机: 如果Redis服务器出现故障、宕机或不可用,所有数据都无法从缓存中获取,系统将不得不直接访问数据库,从而引发雪崩效应。

  2. 大量缓存键同时过期: 在某些情况下,会出现大量的缓存键在相同的时间点失效,例如,设置了相同的过期时间且在这个时间点进行了大量数据的缓存。当这些缓存键同时失效时,会导致大量请求直接访问数据库,增加数据库的负载。

为了应对缓存雪崩问题,可以采取以下措施:

  • 高可用性的Redis集群: 部署高可用性的Redis集群,通过主从复制、分片等方式确保Redis的高可用性,并建立完善的监控报警体系,及时发现并处理Redis服务器的异常情况,以降低系统故障的风险。

  • 设置随机过期时间: 在设置缓存键的过期时间时,避免统一的过期时间,可以为缓存键设置随机的过期时间因子,使得缓存键的过期时间分散在一段时间内,减少大量键同时失效的可能性,从而分散缓存失效的时间点,降低缓存雪崩的风险。

  • 限流与降级: 在缓存失效时,可以通过限流或者降级等方式,控制请求的流量,保护数据库不受过大的请求压力,以防止系统的崩溃。

关于缓存击穿 (Cache breakdown)

什么是缓存击穿?

缓存击穿是一种缓存系统中的严重故障,类似于缓存雪崩,但更加局部化。它发生在某个热点缓存键突然失效时,导致大量请求直接访问数据库,甚至可能导致数据库宕机的情况。

与缓存雪崩不同,缓存击穿是针对某个特定的热点缓存键的失效。这个热点缓存键通常是系统中的关键数据,被大量请求频繁访问。当这个缓存键突然失效时,所有的请求都会直接访问数据库,造成数据库的压力骤增,可能导致数据库宕机,从而引发系统的瘫痪或崩溃。

为了解决缓存击穿问题,可以采取以下措施:

  • 设置永不过期的热点数据: 对于系统中的热点数据,可以设置永不过期,确保这些关键数据一直可用,不受缓存失效的影响。

  • 服务降级: 在缓存击穿发生时,可以通过服务降级来减少对数据库的压力。例如,可以使用分布式锁来限制同时访问数据库的并发数,从而降低数据库的负载,保护系统的稳定性。

服务降级是一种在系统出现异常或者压力过大时,为了保障核心功能的稳定性和可用性而主动降低一些非核心功能或者服务的质量或者功能性的过程。服务降级可以通过减少某些功能、延迟处理、返回默认值等方式来实现。

主要目的是保障核心服务的稳定性,即使在异常情况下系统也能够继续提供核心功能。这样做的好处是可以避免系统的崩溃和性能下降,提高系统的整体可用性和稳定性。服务降级可以通过预先制定好的策略或者自动化的机制来实现,例如设置阈值来监控系统的负载情况,一旦超过阈值就触发服务降级策略。

虽然服务降级可以确保系统的稳定性,但也需要权衡考虑。降级过多或者降级过早可能会影响用户体验,因此需要根据实际情况和业务需求来确定降级的策略和条件。

通过以上措施,可以有效地解决缓存击穿问题,保障系统的可用性和稳定性。

Redis 典型应用——分布式锁

什么是分布式锁?

在分布式系统中,当多个节点需要访问同一个公共资源时,为了避免出现竞态条件和数据不一致等问题,通常需要使用锁来实现对资源的互斥控制。然而,传统的同步锁(如Java中的synchronized关键字或C++中的std::mutex)只能在单个进程内部生效,无法应对分布式环境下的多进程多主机的情况。

因此,为了在分布式环境中实现对共享资源的安全访问,需要使用分布式锁。

分布式锁是一种跨越多个进程和主机的锁机制,确保在分布式系统中的不同节点之间实现互斥访问共享资源。它可以有效地解决分布式环境下的并发访问问题,并确保数据的一致性和完整性。

分布式锁的实现通常基于一些分布式存储系统或协调服务,如ZooKeeper、Redis等。这些系统提供了分布式环境下的一致性和可靠性保证,可以用来实现分布式锁的获取、释放和管理。通过使用这些工具,可以在分布式系统中实现高效、安全的资源互斥控制,确保系统的稳定性和可靠性。

分布式锁的本质就是利用一个公共的服务器或服务来记录锁的状态,以确保在分布式环境中实现资源的互斥访问。这个公共的服务器可以是各种分布式存储系统或协调服务,如Redis、ZooKeeper、Etcd等,也可以是自行开发的服务。

这个公共的服务器负责维护锁的状态信息,包括哪些资源被哪个节点加锁,加锁的时间戳等。当某个节点需要获取锁时,会向公共服务器发送请求,公共服务器会根据当前的锁状态进行判断,如果资源未被其他节点加锁,则允许当前节点加锁并更新锁状态;如果资源已被其他节点加锁,则当前节点需等待或重试。当节点释放锁时,会通知公共服务器进行解锁操作,释放资源。

通过这种方式,分布式锁可以有效地实现在分布式系统中对共享资源的安全访问和互斥控制,确保系统的一致性和可靠性。不同的分布式锁实现方式会有各自的特点和适用场景,开发者可以根据具体的需求选择合适的分布式锁实现方式。

分布式锁的基础实现

分布式锁的基础实现是通过一个键值对来标识锁的状态,其思路虽然简单,但在分布式环境下确保线程安全却至关重要。

举个例子来说,考虑一个买票的场景,假设车站提供了多个车次,每个车次的票数都是固定的。由于存在多个服务器节点,都可能需要处理买票的逻辑,即先查询指定车次的余票,如果余票大于0,则将余票值减去1。然而,假设客户端1先执行查询余票操作,在即将执行1->0过程之前,客户端2也执行查询余票操作,发现同样剩余1张票。此时,客户端2也会执行1->0的过程,导致超卖的情况发生,即将1张票卖给了两个人。显然,在这样的场景中存在线程安全问题,需要使用锁来控制,否则可能出现超卖的情况。

为了解决这一问题,我们可以引入 Redis 作为分布式锁的管理器。

当一个买票服务器1尝试购买票时,首先访问 Redis,并在 Redis 上设置一个键值对。例如,将 key 设置为车次编号,value 可以随便设置一个值(例如1)。如果这个操作设置成功,就意味着当前没有其他节点对该车次加锁,买票服务器1可以安全地进行数据库的读写操作。操作完成后,再将 Redis 上的该键值对删除。

如果在买票服务器1操作数据库的过程中,买票服务器2也想购买票,它也会尝试向 Redis 写入一个键值对,其中 key 仍然是车次编号。但是此时设置时发现该车次的 key 已经存在了,这意味着已经有其他服务器正在持有锁,此时服务器2就需要等待或者暂时放弃。

在这个场景中,Redis 中提供的 setnx(set if not exists)操作非常适合。该操作会检查指定的 key 是否存在,如果不存在则设置键值对,存在则直接失败,这正好满足了我们的需求。

这种基于 Redis 的分布式锁实现简单高效,能够确保在多个节点并发访问时的数据一致性和线程安全。

综上,当多个服务器节点同时进行买票操作时,保证只有一个节点能够成功获取到锁是至关重要的。我们可以整理一下思路,给出更具体的流程说明:

分布式锁的基本流程:

  1. 买票请求:假设有两个买票服务器,分别为服务器1和服务器2。它们同时接收到用户的买票请求,需要先检查余票并进行相应的处理。

  2. 访问Redis:买票服务器1首先尝试获取车次001的锁,它向Redis发送一个SETNX命令,尝试在Redis中设置一个键值对(例如,key为车次001,value为1)来表示锁的状态。如果设置成功(返回值为1),说明当前没有其他节点对该车次加锁,买票服务器1可以继续执行后续操作。

  3. 操作数据库:买票服务器1获取到锁后,可以安全地执行数据库的读写操作,例如查询车次001的余票并更新余票数量。

  4. 释放锁:操作完成后,买票服务器1需要将之前在Redis上设置的锁释放掉,通过DEL命令删除对应的键值对,这样其他服务器节点就可以获得对该车次的锁并执行相应的买票操作。

  5. 竞争情况处理:与此同时,买票服务器2也尝试获取车次001的锁,它也向Redis发送一个SETNX命令。然而,由于Redis的SETNX命令的特性,如果键已经存在,则设置失败,买票服务器2就会意识到有其他节点已经持有锁,需要等待或者暂时放弃买票操作。

通过以上流程,可以确保在多个节点同时访问公共资源时的安全性和一致性,避免了竞态条件和数据不一致等问题。在实际应用中,还可以对锁的超时时间进行设置,以防止节点在持有锁的过程中出现异常而导致锁无法释放的情况。

刚才买票场景,使用MySQL的事务机制也可以批量执行(査询 +修改操作)。但是在分布式系统中,要访问的共享资源不一定局限于MySQL,也可能是其他类型的存储介质,如NoSQL数据库、缓存系统等。此外,有些存储介质可能不支持事务操作,这就需要开发者采取其他的方式来确保数据的一致性和可靠性。在这种情况下,可能会采用分布式锁、分布式事务等技术来实现对共享资源的访问控制和操作。

另外,在某些情况下,需要执行一段特定的操作,可能会通过调用统一的服务器来完成执行动作。这种情况下,需要确保调用的服务器是可靠的,并且要考虑网络通信的延迟和可靠性等因素。通常会采用分布式消息队列、分布式任务调度等技术来实现对任务的调度和执行。

我们刚刚提到,某个服务器加锁可以使用(setnx ), 那么执行后续逻辑过程中,我们本来需要使用(del)来删除key。但是如果程序突然崩溃了导致没有执行到解锁这个时候又该怎么办? 

Java中为了保证解锁操作能够执行到,可以把解锁放到 finally 里面。但是这种做法只是针对进程内的锁有用,针对分布式锁无效!比如服务器直接掉电,进程直接异常终止。

进程异常退出,锁也就随之销毁了,这样情况就会导致 redis 上设置的 key 无人删除也就导致其他服务器无法获取到锁了……

这个时候我们就需要引入“过期时间”。

引入过期时间

当服务器1加锁后,开始处理买票过程时,如果服务器1由于意外宕机或其他原因未能及时释放锁,就会导致其他服务器无法获取到这个锁,从而影响系统的正常运行。这种情况下,即使服务器1的处理逻辑已经完成,其他服务器也无法继续执行相关操作,因为它们无法获得必要的锁。

为了解决这个问题,引入了过期时间的概念,即设置锁时同时指定一个过期时间,在一定时间内如果锁没有被主动释放,系统会自动将其释放。这样即使服务器1宕机,也能保证在一定时间后锁会自动释放,其他服务器就有机会获取到锁,继续执行相关操作。

在Redis中,可以使用SET key value EX seconds NX命令来实现带有过期时间的锁设置。这个命令会将键的值设为指定的字符串值,并设置键的过期时间,如果键已经存在则不做任何操作。通过将过期时间设置在同一个命令中,保证了锁和过期时间的原子性,避免了出现设置锁成功但设置过期时间失败的情况。

需要注意的是,设置过期时间的操作一定要和锁的设置操作放在一起,并且在同一个命令中完成。如果拆分成多个命令,即使使用事务也无法保证原子性,可能会出现设置锁成功但设置过期时间失败的情况。这种情况下,锁可能会永远存在,导致其他服务器无法获取到锁,造成系统的不稳定。因此,在设置键的过期时间时,绝对不能使用 setnx expire 这样的方式,因为这种方式无法保证原子性,可能会导致意外的结果。

在 Redis 中,命令之间的组合是不具备原子性的。

例如,如果使用以下命令:
SETNX key value
EXPIRE key seconds

在执行 SETNX 命令之后,如果在执行 EXPIRE 命令之前发生了某种意外(比如服务器崩溃),那么即使键已经被设置,但它仍然可能会永远保留,而不会过期。

因此,在 Redis 中,要实现原子性的设置键和设置过期时间,最好使用 SET 命令的 EX 选项,如下所示:
SET key value EX seconds NX

这种方式会在一次命令中完成设置键的值和设置过期时间,从而保证了原子性。

另外,再次强调,确保在设置锁的同时设置过期时间是保证分布式锁有效性的关键。

引入校验 id

在一个分布式系统中,Redis被广泛用于共享数据和实现分布式锁的场景。然而,在这样的环境下,对于写入的加锁键值对,存在着其他节点可能会误删除的风险。

例如,如果一个服务器在Redis中写入了一个键值对 "001": 1 来表示某个资源已被锁定,那么另一个服务器也可以在不经过任何验证的情况下将其删除。尽管我们希望其他节点不会故意执行这样的操作,但在一个复杂的分布式系统中,由于各种原因,如程序漏洞、错误的操作或网络故障,这种误操作的风险是存在的。

为了解决这个问题,引入了一个更为严格的验证机制,即校验 id。具体而言,不再将锁定键的值简单地设为1,而是将其设为写入的服务器的唯一标识,比如 "001": "服务器1"。这样,在执行删除操作之前,会先对当前删除键的服务器进行校验,以确保它与最初加锁的服务器相匹配。只有当校验通过,也就是当前服务器与加锁服务器一致时,才会允许真正删除键;否则,将拒绝删除操作。这种严格的校验机制有效地确保了只有持有锁的服务器才能释放锁,从而防止了其他节点对锁的误操作,保障了分布式系统的数据一致性和安全性。

通过引入校验id的机制,可以有效地防止其他节点误删除加锁的键值对,从而保障了分布式锁在多节点环境下的可靠性和稳定性。这种方法不仅能够提高系统的安全性,还能够避免由于节点误操作而导致的数据异常问题,从而增强了系统的健壮性。

逻辑可以用伪代码描述如下:

很明显,解锁操作是两步操作 "get" 和 "del",并非原子操作,由于 Redis 中的操作是原子性的,所以需要首先获取键对应的值进行比较,再进行删除操作。

String key = "要加锁的资源 id";
String serverId = "服务器的编号";

// 加锁, 设置过期时间为 10s
redis.set(key, serverId, "NX", "EX", "10s");

// 执行各种业务逻辑, 比如修改数据库数据
doSomeThing();

// 解锁, 删除 key. 但是删除前要检验下 serverId 是否匹配
// 注意:解锁操作并非原子操作,而是由两步操作组成:"get" 和 "del"
if (redis.get(key).equals(serverId)) {
    redis.del(key);
}

在分布式系统中,解锁操作通常涉及两个步骤:首先是获取锁的值(通过GET命令),然后是删除该锁(通过DEL命令)。由于这两个步骤不是原子操作,而Redis中的操作是原子性的,因此需要先获取键对应的值进行比较,然后再进行删除操作。

在一个服务器内部,可能存在多个线程同时执行解锁操作的情况。在这种情况下,如果多个线程同时执行DEL操作,可能会出现重复删除的情况。虽然看起来重复执行DEL操作似乎不会有太大问题,但实际情况并非如此。

问题的关键在于引入了新的服务器执行加锁操作。假设在服务器A中,线程A执行完DEL操作之后,线程B执行DEL操作之前(已经获取到了同一个有效的value),服务器2的线程C正好要执行加锁操作(使用SET命令)。此时由于线程A已经释放了锁,线程C的加锁操作会成功。但随后,线程B执行DEL操作,将服务器2刚刚加锁的操作给解锁了,导致线程C获取的锁实际上已经失效了。

因此,即使是在同一个服务器内部,多线程执行解锁操作也可能导致问题。更进一步地,在分布式系统中,多个服务器进行加锁操作时,由于锁的获取和释放不是原子操作,可能会出现竞争条件,导致锁的不正确使用。

使用redis的事务确实能够解决上述问题,因为虽然弱,却能够避免插队。

但是此处我们有更好的解决办法。

引入 Lua

Lua是一种编程语言,其名称来自葡萄牙语中的 "月亮"。

官方文档:Lua: about

Lua的语法类似于JavaScript,是一种动态弱类型的语言。Lua的解释器通常使用C语言实现。Lua语法简洁明了,执行速度快,解释器也相对轻量(Lua解释器的可执行程序体积仅约200KB)。因此,Lua经常被用作其他程序内部嵌入的脚本语言。

例如,Redis本身就支持Lua作为内嵌脚本。许多程序都支持内嵌脚本,比如MySQL 8支持JavaScript作为内嵌脚本,Vim支持VimScript和Python作为内嵌脚本,通过内嵌脚本可以实现更复杂的功能,提供更强的扩展性。除了与Redis搭配使用外,Lua在许多场景中也被用作内嵌脚本。例如,在游戏开发领域,Lua经常被用作编写游戏逻辑的语言,如《魔兽世界》、《大话西游》等游戏都采用Lua作为编写游戏逻辑的主要语言。

当我们谈到Lua时,它不仅是一种简洁高效的编程语言,还是一种功能强大的脚本语言。其简单的语法和快速的执行速度使其成为许多应用中的理想选择:

  1. 脚本语言特性: Lua是一种脚本语言,这意味着它不需要编译成机器代码,而是由解释器逐行解释执行。这种特性使得开发过程更加灵活,可以在运行时动态修改和调试代码。

  2. 动态弱类型: Lua是一种动态弱类型语言,这意味着变量的类型不需要事先声明,并且可以在运行时动态改变其类型。这种灵活性使得Lua适用于快速原型设计和迭代开发。

  3. C语言实现: Lua的解释器通常是用C语言实现的,这使得Lua具有良好的性能和跨平台的特性。同时,Lua解释器本身也非常轻量级,使其能够在资源受限的环境中运行。

  4. 内嵌脚本语言: Lua经常被用作其他程序的内嵌脚本语言,例如Redis、MySQL、Vim等。通过内嵌Lua脚本,可以实现对应用程序的功能扩展和定制,而无需重新编译和部署整个应用。

  5. 游戏开发: Lua在游戏开发领域有着广泛的应用。许多游戏引擎(如Unity、Cocos2d-x等)都提供了Lua作为编写游戏逻辑的选项。Lua的简洁性和易学性使其成为游戏开发者的首选,同时其与C/C++的高度集成也使得性能优化变得相对容易。

总的来说,Lua作为一种脚本语言具有许多优秀的特性,使其在各种领域都有着广泛的应用。无论是用于嵌入式系统、游戏开发还是其他类型的应用,Lua都展现出了强大的表现和巨大的潜力。

为了确保解锁操作的原子性,我们可以利用Redis的Lua脚本功能。Lua脚本可以在Redis服务器端以原子方式执行,因此非常适合用于需要原子操作的场景。就连redis的官方文档都明确说明,Lua就属于事务的替代方案,而且功能更强大,推荐使用。

使用Lua脚本完成解锁功能的具体步骤: 

1、编写Lua脚本

-- Lua脚本开始
if redis.call('get',KEYS[1]) == ARGV[1] then -- 使用Redis的GET命令获取键对应的值,并与传入的参数进行比较
    return redis.call('del',KEYS[1])  -- 如果值匹配,使用Redis的DEL命令删除该键,并返回1表示删除成功
else
    return 0 -- 如果值不匹配,则返回0表示删除失败
end;
-- Lua脚本结束

这段Lua脚本首先使用Redis的GET命令获取键KEYS[1]对应的值,并与传入的参数ARGV[1]进行比较。如果两者相等,则说明当前服务器确实是加锁的服务器,可以执行解锁操作。在这种情况下,脚本使用Redis的DEL命令删除键,并返回1表示删除成功。如果值不匹配,则说明当前服务器并非加锁的服务器,无法执行解锁操作,此时返回0表示删除失败。

这段Lua脚本可以保存为一个以.lua后缀的文件,然后由redis-cli或其他Redis客户端加载,并发送给Redis服务器执行。Redis服务器会以原子的方式执行Lua脚本,从而确保解锁操作的原子性。

至于如何使用redis-plus-plus和jedis等客户端调用Lua脚本,具体的API写法可以根据它们的文档进行研究和使用。

2、保存Lua脚本:将上述Lua脚本保存为一个以.lua为后缀的文件,以便后续加载和执行。

3、加载Lua脚本:通过redis-cli或其他Redis客户端加载Lua脚本,并发送给Redis服务器执行。在加载脚本时,需要传入需要操作的键(KEYS)和参数(ARGV)。

4、执行解锁操作:一旦Lua脚本被Redis服务器加载和执行,就可以使用对应的键和参数调用Lua脚本完成解锁操作。Redis服务器会确保Lua脚本以原子方式执行,从而保证了解锁操作的原子性和安全性。

通过以上步骤,我们可以利用Redis的Lua脚本功能实现解锁操作,并确保该操作在分布式环境中的原子性,避免了其他节点误操作带来的潜在风险。

引入 watch dog (看门狗)

上述方案的确存在一个重要问题:如何设置合适的过期时间?

如果设置过短,即当设置了键的过期时间后(例如10秒),任务还未执行完毕,键就提前过期了,导致锁提前失效。即便将过期时间设置得足够长,例如30秒,也不能完全解决这个问题。因为无法确定任务执行的时间,设置的时间再长也无法完全保证不会提前失效。

此外,如果设置的过期时间过长,例如几分钟甚至几小时,虽然可以降低锁提前失效的可能性,但会导致锁释放不及时,如果对应的服务器在执行任务期间发生了故障或者挂掉,其他服务器也会因为无法及时获取到锁而受影响,进而影响系统的整体性能和可用性。

因此,相较于设置一个固定的过期时间,动态调整过期时间可能更为合适。

所谓的“watch dog”(看门狗),本质上是在加锁的服务器上运行的一个单独的线程,通过这个线程来对锁的过期时间进行“续约”。需要注意的是,这个线程是运行在业务服务器上的,而不是 Redis 服务器上的。

举个具体的例子来说明这个概念:假设初始情况下设置锁的过期时间为10秒,并且设定看门狗线程每隔3秒检测一次。当3秒时间到达时,看门狗就会判断当前任务是否已经完成:

  • 如果任务已经完成,则通过 Lua 脚本的方式直接释放锁,即删除对应的键。
  • 如果任务尚未完成,则重新设置过期时间为10秒,即“续约”。

通过这样的机制,可以确保锁的过期时间不会提前失效,从而避免了因任务执行时间过长而导致的锁过期问题。同时,另一方面,如果该服务器挂了,看门狗线程也会随之停止运行,没有人来续约锁,这样锁就会迅速过期,让其他服务器能够及时获取到锁。这种方式有效地提高了分布式系统中锁的有效性和稳定性,保障了系统的正常运行。

引入 Redlock 算法

在实践中,Redis通常以集群的方式部署(至少是主从的形式,而不是单机)。在这种部署方式下,可能会出现一些极端的情况,其中一种情况堪称“冤屈”。

假设有服务器1向主节点(master)进行加锁操作。当这个写入key的过程刚刚完成时,主节点突然发生了故障,而从节点(slave)则升级成了新的主节点。然而,由于刚刚写入的这个key尚未来得及同步给从节点,这就导致了一个问题:虽然服务器1进行了加锁操作,但实际上这个加锁操作在新的主节点上并不存在。因此,其他服务器(比如服务器2)仍然可以进行加锁操作,即在新的主节点上写入新的key。这样,新的主节点就不包含旧的被加锁的key,也由此会导致先前加锁操作不受影响。

这种情况下,服务器1原本的加锁操作实际上形同虚设,因为新的主节点并不包含该key。这种情况的发生可能会导致系统出现不一致的状态,因此在设计分布式系统时,我们需要考虑如何应对这种情况,保障系统的稳定性和一致性。

为了解决在Redis集群中可能出现的极端情况,Redis的作者提出了Redlock算法。

Redlock算法引入了一组Redis节点,每组节点包含一个主节点和若干从节点。不同组之间的数据是一致的,并且相互之间是备份关系,而不是数据集合的一部分(与Redis集群不同)。在执行加锁操作时,按照一定的顺序向多个主节点写入锁定信息。在写锁定信息时需要设定操作的超时时间,例如50ms。即如果setnx操作超过了50ms仍未成功,就视为加锁失败。

在Redlock算法中,如果某个节点的加锁操作失败,系统会立即尝试下一个节点。只有当加锁成功的节点数量超过了总节点数的一半时,才被认为是加锁成功的。

举例来说,如果有五个节点,其中三个节点成功加锁而两个节点失败,则此时认为加锁成功。这种策略确保了即使部分节点发生故障,也不会影响锁的正确性,因为只有当多数节点都加锁成功时,整个加锁操作才被视为成功。

在释放锁时,Redlock算法要求对所有节点进行解锁操作,即使之前超时的节点也要尝试解锁。这样的设计有助于保证逻辑的严密性,即使在异常情况下,也能够正确地管理和释放锁。通过这种方式,Redlock算法确保了分布式系统在各种情况下都能维持稳定性和一致性,提高了系统的可靠性和鲁棒性。

那么是否可能出现上述节点都同时遇到了 "大冤种" 情况呢?

理论上,所有节点同时遇到“大冤种”情况的可能性是存在的,但在实际情况下,这种情况发生的概率非常小,可以忽略不计。在工程实践中,这种极端情况可以被认为是几乎不可能发生的。因此,对于这种情况,可以不予考虑,而专注于更常见和更实际的问题。

简而言之,Redlock算法的核心思想是通过在多个Redis节点上执行加锁操作来确保分布式系统中的锁机制的可靠性和稳定性。在分布式系统中,任何一个节点都可能出现故障或不可靠的情况,因此单纯地将加锁请求发送到单个Redis节点是不够安全可靠的。相反,Redlock算法要求将加锁请求发送到多个Redis节点,并且只有在大多数节点上加锁成功时才认为加锁操作成功,这便是“少数服从多数”的原则。

具体地,当一个节点需要加锁时,它会同时向多个Redis节点发送加锁请求。如果大部分节点都成功执行了加锁操作,那么加锁请求就被认为是成功的。这样设计的目的是为了应对分布式系统中任何一个节点出现故障或不可用的情况。尽管在实践中,分布式系统中的某些节点可能会出现故障,但通常情况下并不会出现大多数节点同时发生故障的情况,因此通过“少数服从多数”的原则,Redlock算法能够确保锁机制的可靠性。

与仅将加锁操作发送到单个Redis节点相比,Redlock算法提供了更高的可靠性和鲁棒性。通过向多个节点发送加锁请求,即使其中某些节点出现故障或不可用,也不会影响加锁操作的最终结果。因此,Redlock算法在分布式系统中得到了广泛的应用,并且被认为是一种有效保障分布式系统锁机制稳定性的方法。

其他功能

在上述描述中,我们已经解释了基于Redis的分布式锁的基本实现原理。这种锁仅仅是一个简单的互斥锁,能够确保在分布式环境中对共享资源的访问是串行的。然而,在实际应用中,除了互斥锁外,还存在一些特殊的锁,例如可重入锁、公平锁、读写锁等。这些锁提供了不同的功能和语义,以满足特定场景下的需求。

  1. 可重入锁: 可重入锁允许同一个线程或客户端多次获取同一把锁,而不会产生死锁或者其他问题。这意味着如果一个线程已经持有了锁,那么它可以再次获取这个锁而不会被阻塞,只有当它释放了相同次数的锁时,其他线程才能获取到该锁。

  2. 公平锁: 公平锁保证锁的获取是按照请求锁的顺序进行的,即先请求的线程会先获取到锁,而后请求的线程会进入等待队列。这种锁的实现方式可以防止某些线程饥饿的情况,即长时间等待锁的线程一直无法获取到锁的情况。

  3. 读写锁: 读写锁分为读锁和写锁两种类型,允许多个线程同时读取共享资源,但只允许一个线程写入共享资源。当没有线程持有写锁时,多个线程可以同时持有读锁,从而实现读操作的并发性;而当有线程持有写锁时,其他线程无法获取写锁或者读锁,保证了写操作的互斥性。

  4. 自旋锁: 自旋锁是一种轻量级的锁,它不会让线程进入睡眠状态,而是通过循环检测锁是否被释放。当线程尝试获取自旋锁时,如果锁已被其他线程占用,该线程会一直循环等待直到锁被释放。自旋锁适用于锁的持有时间很短且并发冲突不频繁的情况,避免了线程进入睡眠状态和唤醒的开销。

  5. 悲观锁: 悲观锁是一种悲观地认为并发环境下会发生冲突的锁机制。在使用悲观锁时,线程在访问共享资源之前会先获取锁,然后在使用完资源后再释放锁。悲观锁常常会导致线程阻塞,因为线程在获取锁时可能会被其他线程阻塞,直到获取到锁才能继续执行。悲观锁适用于对并发冲突持保守态度的场景,适用于并发冲突频繁的情况。

  6. 乐观锁: 乐观锁是一种乐观地认为并发环境下不会发生冲突的锁机制。在使用乐观锁时,线程在访问共享资源时不会立即获取锁,而是直接进行操作。然后再检查操作过程中是否发生了冲突,如果没有冲突则操作成功,否则需要重试或者执行其他的冲突处理策略。乐观锁通常会利用一些版本号或者时间戳等机制来检测冲突。乐观锁适用于对并发冲突持乐观态度的场景,适用于并发冲突较少的情况。

基于Redis的分布式锁同样可以实现上述特性,尽管相应的实现逻辑会更加复杂。例如,可重入锁需要记录每个线程或客户端对锁的获取次数,并且只有当锁被完全释放时才能被其他线程获取;公平锁则需要按照请求锁的顺序进行处理;而读写锁则需要同时支持读操作的并发性和写操作的互斥性。因此,实现这些特殊锁的分布式版本需要考虑更多的细节和边界情况,使得实现逻辑更加复杂。

然而,在实际开发中,我们往往不会自己去实现一个分布式锁,因为已经有很多现成的库封装好了,我们只需要直接使用即可。比如,在Java中,可以使用Redisson这样的库;而在C++中,则可以使用redis-plus-plus。此外,一些大型公司也会根据自己的需求和场景,开发和维护自己版本的分布式锁实现,以满足特定的业务需求和性能要求。因此,在选择分布式锁时,我们可以根据自己的需求和技术栈,选择合适的库或者自研方案,而不必从零开始实现分布式锁的逻辑。

常见面试题

介绍一下什么是 Redis, 有什么特点?

Redis是一种高性能的键值对内存数据库,与传统的关系型数据库(如MySQL)不同,它主要使用内存来存储数据,当然也支持将数据持久化到硬盘上。Redis不使用传统的“表”结构来组织数据,而是采用简单的键值对方式存储数据。

Redis具有以下特点:

  • 使用内存存储数据,从而实现高性能的读写操作。
  • 支持多种数据结构,包括字符串、哈希、列表、集合和有序集合等,使其适用于各种不同的应用场景。
  • 支持持久化,可以将数据持久化到硬盘上,保证数据的安全性和持久性。
  • 单线程处理请求,通过异步IO和事件驱动机制来实现高并发,避免了多线程带来的线程安全问题。
  • 支持主从复制,可以通过复制功能实现数据的备份和读写分离,提高系统的可用性和容灾能力。
  • 支持哨兵模式,用于监控主从节点的状态,实现自动故障转移和高可用性。
  • 支持集群模式,可以将多个Redis实例组成集群,实现数据的分片存储和负载均衡,提高了系统的扩展性和性能。
  • 支持事务,可以通过MULTI和EXEC指令实现事务操作,保证多个命令的原子性执行,确保数据的一致性。
  • 支持发布订阅功能,可以实现消息的发布和订阅,用于构建消息队列和实时通信系统。
  • 支持Lua脚本,可以通过Lua脚本扩展Redis的功能,实现复杂的业务逻辑处理。
  • 提供了丰富的客户端库,支持多种编程语言,如Python、Java、JavaScript等,便于开发者进行集成和使用。

总的来说,Redis以其简单易用、高性能、丰富的功能和可靠性,在缓存、消息队列、计数器、排行榜、会话管理等各种场景中得到了广泛的应用,成为了现代Web应用中不可或缺的组件之一。

ZSet 为什么使用跳表,而不是使用红黑树来实现?

ZSet(有序集合)使用跳表而不是红黑树来实现的原因有几点:

  1. 实现简单:跳表的实现相对于红黑树来说更加简单。跳表的结构比较清晰直观,易于理解和实现。相比之下,红黑树的实现涉及到更多的复杂性,需要处理旋转、颜色变换等操作。

  2. 无需重新平衡:红黑树在插入和删除节点后需要进行平衡操作,以保持树的平衡性质,这些操作可能会导致性能开销。而跳表不需要像红黑树那样进行重新平衡,因为它通过随机化的方式保持了平衡性质。

  3. 优化空间占用:跳表相对于红黑树来说,在存储上可能具有更好的空间利用率。红黑树节点需要额外的颜色标记来表示节点的性质,而跳表中节点之间的关系通过指针连接,不需要额外的标记。

  4. 并发性能更好:跳表的插入和删除操作不涉及全局性的重排操作,因此在并发环境下,跳表的性能可能更好。相比之下,红黑树的平衡操作可能需要涉及到全局的节点移动和颜色变换,容易受到并发访问的影响。

总的来说,虽然跳表和红黑树在时间复杂度上是一致的,但跳表在实现简单性、无需重新平衡、空间占用和并发性能等方面具有优势,因此在实际应用中被广泛选择来实现有序集合结构。

Redis 的常见应用场景有哪些?

Redis 的常见应用场景非常广泛,包括但不限于以下几个方面:

  1. 缓存:Redis作为内存数据库,常用于存储经常被访问到的热点数据,以减少对后端数据库的频繁访问,从而提升系统性能。

  2. 计数器:通过Redis的原子操作,可以方便地实现各种计数器功能,如统计点击次数、访问次数、收藏次数等。

  3. 排行榜:利用Redis的有序集合(ZSet)数据结构,可以轻松地构建排行榜功能,用于展示用户排名或者热门内容。

  4. 分布式会话:将用户会话信息存储在Redis中,可以实现分布式系统中的会话共享和管理,确保用户跨模块访问时保持会话一致性。

  5. 分布式锁:利用Redis的原子性操作和过期特性,可以实现分布式系统的并发访问控制,避免多个客户端同时修改共享资源造成的竞争问题。

  6. 消息队列:Redis支持Stream数据类型,可用作简单的消息队列,用于异步通信、事件驱动等场景。

  7. 发布/订阅系统:Redis的发布/订阅功能可用于构建实时消息系统,支持事件发布和订阅模式,用于实现实时通知、消息广播等功能。

  8. 分布式缓存:作为分布式缓存系统的组件,通过多个Redis节点构建缓存集群,提供高可用性和扩展性。

  9. 地理位置服务:利用Redis的地理位置数据类型,如GeoHash,可以构建地理位置服务,用于附近搜索、位置跟踪等功能。

  10. 实时分析:通过Redis的持久化特性和丰富的数据结构,可以构建实时数据分析系统,用于监控、日志分析、用户行为分析等场景。

  11. 任务队列:Redis可用作任务队列的存储后端,支持任务调度、异步处理等场景。

  12. 限流和防刷:利用Redis的计数器和限制访问频率的功能,可以实现限流和防刷,保护系统免受恶意攻击和过载。

  13. 实时数据存储:对于需要实时存储和查询的数据,如实时日志、传感器数据等,Redis可以作为快速存储引擎,提供高性能的实时数据访问能力。

综上所述,Redis具有广泛的应用场景,其灵活性和高性能使其成为构建各种应用的理想选择。

怎样测试 Redis 服务器的连通性?

要测试Redis服务器的连通性,可以通过使用Redis客户端提供的ping命令来执行。当向Redis服务器发送ping命令时,如果服务器处于可访问状态,则会返回一个pong响应。这种ping-pong的交互是一种简单而有效的方式来验证Redis服务器是否正常运行并且与客户端之间的连接是否畅通。

如何设置 key 的过期时间?

要设置Redis中键的过期时间,有两种常见的方法:

  1. 在执行SET命令时,可以通过使用EX选项来指定键的过期时间。通过EX选项,可以将过期时间以秒为单位传递给Redis服务器。例如,SET key value EX 3600将键key的过期时间设置为3600秒(1小时)。

  2. 另一种方法是使用EXPIRE命令单独指定键的过期时间。使用EXPIRE命令,可以在键已存在的情况下为其设置过期时间。例如,EXPIRE key 3600将现有键key的过期时间设置为3600秒(1小时)。

这两种方法都允许在Redis中为键设置过期时间,使得在一定时间后自动删除键,从而有效地管理内存和资源。

如果大量的 key 在同一时间点过期,会产生什么问题? 如何处理?

当大量的键在同一时间点过期时,会产生以下问题:

  1. 短暂的卡顿现象: 如果大量的键过期时间设置得过于集中,到达过期时间点时,Redis可能会出现短暂的卡顿现象。这是因为Redis对过期键的处理采用了定期采样删除和惰性删除的策略。

  2. 阻塞: Redis在处理过期键时,会根据预先统计好的过期键的数量来决定后续的删除策略。如果过期键的数量超过了所有键数量的25%,Redis会持续删除过期键,直到达到最大删除时间(默认为25毫秒)。然而,即使限制了最大删除时间,依然可能因为删除操作阻塞而导致性能下降,特别是对性能要求较高的场景。

为了解决这些问题,可以采取以下解决方案:

  1. 在过期时间上添加随机值: 可以在设置键的过期时间时,添加一个随机值,使过期时间分散在一个时间范围内,而不是集中在同一时间点。通过这种方式,可以避免大量键在同一时刻过期,降低触发25%阈值的可能性,减轻Redis因处理过期键而导致的卡顿现象和性能下降。

  2. 灵活设定过期时间: 在实践中,并不需要过于精确的过期时间,因此可以设定一个相对宽松的过期时间范围。例如,可以设置一个键在2秒后过期,但并不一定非要精确到2000毫秒,而是允许在一定范围内浮动,例如1999毫秒或2010毫秒等。

通过以上解决方案,可以有效地避免因大量键集中过期而导致的Redis性能问题和卡顿现象,保障系统的稳定性和可靠性。

Redis 如果内存用完了,会出现什么情况?

当Redis的内存用完时,系统会触发Redis的淘汰策略,以释放内存空间。这意味着Redis将会自动删除一些键,以便为新的数据腾出空间。淘汰的键通常是根据配置的淘汰策略来确定的,可能是基于LRU(最近最少使用)、TTL(生存时间)等。通过淘汰策略,Redis可以优雅地处理内存耗尽的情况,避免系统崩溃或性能下降。

如何使用 Redis 作为消息队列?

使用Redis作为消息队列有三种典型的方法:

  1. 使用List:Redis中提供了BLPOP和BRPOP命令,可以阻塞式地获取元素。生产者可以通过RPUSH或LPUSH将消息推入队列,而消费者则可以使用BLPOP或BRPOP从队列中阻塞式地弹出消息。

  2. 使用PUB/SUB命令:Redis的发布/订阅功能允许消费者订阅特定的频道,而生产者则可以向频道发布消息。这种方法适用于一对多的通信场景。

  3. 使用Redis 5 引入的Stream类型:Stream是Redis 5中新增的数据类型,提供了更丰富的消息队列功能,包括消息持久化、消息消费追踪等。生产者可以使用XADD命令向Stream中添加消息,而消费者则可以使用XREAD命令从Stream中读取消息。

总体来说,Stream类型提供的功能更加完整,但即使如此,对于更复杂的消息队列需求,引入专业的消息队列组件可能更合适。如果场景对消息队列的需求不高,且不希望引入额外的消息队列组件依赖,那么Redis也是一个不错的选择。

什么是热 key 问题? 如何解决?

热 key 是指在Redis中被频繁访问的一些key,其访问频率异常高,可能会对Redis服务器造成过度负载,甚至导致系统崩溃。

即使Redis通过集群部署方式可以分散请求的压力,但由于热 key 是同一个 key,对应的访问机器也是同一组机器。这就导致其他组的机器虽然硬件资源丰富,但也无法分担负载。因此,即使使用了集群部署,仍然可能出现因为热 key 导致的性能问题和服务不可用情况。

解决热 key 问题的方法包括:

  1. 进一步扩大Redis集群的规模:特别是针对热 key 所属的分片,可以部署更多的从节点来分担读操作的压力。通过增加节点数量,可以提高Redis集群的整体容量和吞吐量,从而更好地应对热 key 的访问需求。

  2. 应用服务器对热 key 进行识别,并进行二次哈希:应用服务器可以识别出热 key,并对其进行二次哈希,将一个 key 分散到多个Redis分片上进行存储。这样做可以将热 key 的访问压力均匀地分散到不同的节点上,降低单个节点的压力。

  3. 应用服务器对热 key 进行识别,并单独部署更多的Redis集群:将热 key 存储在单独的Redis集群中,并部署更多的机器来处理这些热 key 的访问请求。通过将热 key 从普通的Redis集群中分离出来,可以有效地保护其他非热门数据的访问性能。

  4. 使用应用程序本地缓存:应用程序可以使用本地缓存来存储热门数据,减轻Redis服务器的压力。通过在应用程序中缓存热 key 的数据,可以减少对Redis的频繁访问,提高系统的整体性能和稳定性。

如何实现 Redis 高可用?

实现 Redis 的高可用性通常采用主从复制(Master-Slave Replication)结合哨兵(Sentinel)和集群(Cluster)技术。

  1. 主从复制:通过配置主从复制,将主节点的数据复制到多个从节点上。主节点负责写入和读取,从节点负责复制主节点的数据,并在主节点不可用时提供读取服务。这样即使主节点出现故障,从节点可以升级为新的主节点,从而保证数据的可用性。

  2. 哨兵:哨兵是用于监控 Redis 实例的守护进程,它可以自动检测主节点的故障,并在主节点宕机时选举出一个新的主节点。哨兵还负责监控从节点的状态,当从节点出现故障时,可以将其重新配置为主节点。通过哨兵,可以实现 Redis 的自动故障转移和自动恢复,提高了系统的可用性。

  3. 集群:Redis 集群通过分片和复制来实现数据的高可用性和扩展性。集群将数据分片存储在多个节点上,并且每个节点都有多个副本,当节点出现故障时可以自动进行故障转移。通过集群,可以实现数据的水平扩展和负载均衡,提高了系统的性能和可扩展性。

综合使用主从复制、哨兵和集群等技术,可以构建一个高可用、可靠、稳定的 Redis 系统,保证数据的安全性和可用性。

Redis 和 MySQL 如何保证双写⼀致性?

什么是 "双写⼀致性"?

双写一致性是指在数据更新过程中,确保数据库和缓存同时被正确更新,以维护数据的一致性。当用户修改数据时,如果只更新数据库而不更新缓存,那么缓存中的数据就会变成“脏数据”,与数据库中的数据不一致。因此,为了保证数据的一致性和准确性,需要在更新数据库的同时更新缓存。

然而,要实现双写一致性并不容易,因为在写入数据库和写入缓存的过程中都可能发生错误或失败。如果直接写入数据库和 Redis,并且其中一方写入失败,就可能导致数据不一致的情况。例如,如果成功写入数据库但写入 Redis 失败,那么数据库中的数据已经被修改,但缓存中的数据仍然是旧的,造成数据不一致。

为了解决这个问题,通常采用以下方法:

  • 事务操作:将数据库更新和缓存更新放在同一个事务中执行,以保证它们的原子性。如果其中一个操作失败,则整个事务会被回滚,避免数据不一致。
  • 异步更新:在写入数据库后,异步地更新缓存。虽然这种方式可能会造成一段时间内的数据不一致,但可以减少对用户的影响,并提高系统的吞吐量。
  • 双写确认:在写入数据库和缓存后,需要确认两者都已成功写入。如果其中一个写入失败,则进行相应的回滚或补偿操作,以确保数据的一致性。
  • 版本控制:通过版本号或时间戳等方式,在缓存中保存数据的版本信息,以便在读取数据时与数据库中的版本进行比较,从而检测数据是否过期或失效,确保读取到的数据是最新的。

如何解决 

我们来看看具体的方案:

方案一:延时双删

延时双删是一种常见的解决方案,用于确保在更新数据时数据库和缓存的一致性。其基本思路是先删除缓存中的数据,然后再更新数据库,最后再次删除缓存数据。

双删的目的在于多加一层保证,即使第一次删除失败,第二次删除仍然能够作为备份机制。通过删除 Redis 中的数据,可以触发后续访问该数据时的自动从数据库读取,并将结果写入 Redis,从而保证 Redis 和数据库的一致性。

如果直接修改 Redis 而不进行双删操作,则存在并发写入导致数据不一致的风险。例如,在多个应用服务器并发执行时,服务器1和服务器2同时写入 Redis 和 MySQL,此时可能出现竞态条件,导致其中一个服务器的写入结果被另一个服务器的写入结果覆盖。这样就会导致数据的不一致性。

因此,将“数据一致性”问题转换为“能否成功删除 Redis 数据”的问题,通过延时双删的方式来确保数据的一致性。

问题来了, 是否可能第二次删除也失败了呢?

是的,虽然第二次删除也有可能失败,但是概率会大大降低。在正常情况下,如果第一次删除操作失败,可能是由于某种临时性的网络或系统故障引起的,而不太可能是因为数据本身的问题。因此,第二次删除往往能够顺利完成,从而实现数据的一致性。即使第二次删除失败,也可以通过监控和报警系统及时发现并采取手动干预措施,以保证数据的一致性和系统的稳定性。

方案二:删除缓存重试

方案二的思路是采用一种基于重试机制的删除策略,首先尝试删除缓存数据。如果删除操作失败,系统会将失败的键放入消息队列中,以便稍后进行重试。这样即使删除操作在第一次尝试时未成功,系统也会持续尝试直到成功删除为止,确保数据的一致性和可靠性。

采用消息队列和重试机制的方式能够更严格地保证删除操作的效果,因为即使在删除失败的情况下,系统也会不断尝试直到成功为止。这种方法能够有效应对一些异常情况,如网络故障、缓存服务异常等,从而确保数据不会出现不一致的情况。

然而,实现这种方案需要处理消息队列的相关逻辑,包括消息的发送、接收和处理,以及重试机制的设计和管理。因此,相较于简单的删除操作,这种方法在实现上可能会更加复杂,需要更多的代码和逻辑来处理消息队列和重试机制。

幸运的是,有一些开源工具可以简化这个过程,例如阿里巴巴提供的开源工具Canal。Canal可以方便地获取到MySQL的binlog(二进制日志),并且可以基于此实现上述逻辑。通过Canal,我们可以监控MySQL数据库的变更情况,当有数据变更时,将变更的键放入消息队列中,并通过重试机制确保数据的缓存与数据库的一致性。这样,我们可以更加轻松地实现双写一致性的方案,而不需要自己手动编写复杂的逻辑。

Redis 的常用管理命令有哪些?

Redis 的常用管理命令主要用于查看服务器状态、配置参数、监视命令执行情况以及执行一些危险的操作:

  1. dbsize: 返回当前数据库中的 key 数量。
  2. info: 返回关于 Redis 服务器的各种信息和统计数据。
  3. monitor: 实时监听并返回 Redis 服务器接收到的所有请求信息。
  4. shutdown: 将数据同步保存到磁盘上,并关闭 Redis 服务器。
  5. config get parameter: 获取 Redis 配置参数的值,例如获取服务器的端口号、数据库数量等信息。
  6. config set parameter value: 设置 Redis 配置参数的值,例如设置最大内存限制、修改日志级别等。
  7. debug object key: 获取指定 key 的调试信息,例如查看 key 的类型、占用内存大小等。
  8. debug segfault: 造成一次服务器崩溃,用于模拟并调试 Redis 的故障处理机制。
  9. flushdb: 删除当前数据库中的所有 key,谨慎使用,操作不可逆。
  10. flushall: 删除所有数据库中的所有 key,同样需要谨慎使用,操作不可逆。

除了上述命令,Redis 还提供了许多其他管理命令,用于监控服务器状态、调试问题、执行备份和恢复等操作。这些命令可以帮助管理员有效地管理和维护 Redis 服务器,确保其正常运行和高性能。

Redis 用到的网络通讯协议是怎样的?

Redis 使用的网络通讯协议是 Redis Serialization Protocol (RSP),这是 Redis 专门设计的应用层协议,用于客户端与服务器之间的通信。RSP 是一种纯文本协议,它的设计简单、高效,并且易于理解和实现。

RSP 协议的特点包括:

  1. 纯文本协议: RSP 使用纯文本格式进行通信,每条命令和响应都是可读的文本字符串,这使得它易于调试和理解。

  2. 简单高效: RSP 的设计简单清晰,没有复杂的数据结构和语法,这使得它在实现和解析时非常高效。

  3. 支持多种数据结构: RSP 支持多种数据结构的序列化和传输,包括字符串、列表、哈希表、集合等,这使得 Redis 可以灵活地存储和传输各种类型的数据。

  4. 易于扩展: RSP 协议的设计考虑了可扩展性,允许根据需要添加新的命令和数据类型,从而满足不断变化的需求。

协议规则参考:Redis serialization protocol specification | Redis

Redis 如何遍历 key?

使用 keys * 虽然可以一次性获取所有键,但是这个操作的开销可能非常大,会导致 Redis 服务器负载过重,甚至造成 Redis 服务卡死的情况。更可靠的方式是使用 scan 命令进行键的遍历。

scan 命令的基本语法为:

SCAN cursor [MATCH pattern] [COUNT count]

每次执行 scan 命令的时间复杂度是 O(1),它能够按批次返回一组键,并告知下次应该从哪个光标开始进行扫描。需要多次执行 scan 命令才能完成整个键的遍历过程。光标 cursor 从 0 开始,每次执行 scan 命令都会返回下一次扫描的光标位置。当返回结果的光标为 0 时,表示遍历结束。

通过 count 参数可以限制每次获取到的键的数量,这样可以控制每次扫描的开销,避免一次性获取大量键导致性能问题。

Redis 是按照哈希的方式来管理键的,因此在遍历键时得到的键序列并不是有序的。每次调用 scan 命令会返回下一次扫描的游标和本次扫描得到的键。下次再执行 scan 命令时,可以根据返回的游标继续遍历。

除了 scan 命令外,Redis 还提供了 hscan(哈希)、sscan(集合)、zscan(有序集合)等命令,用于遍历不同类型的数据结构。这些命令的用法类似于 scan 命令。

渐进式遍历解决了阻塞问题,但是如果在遍历过程中键发生变化,可能会导致重复遍历或遗漏。这是因为在遍历的过程中,如果某个键被删除或添加,可能会影响到后续的遍历结果,需要注意处理这种情况,确保遍历的准确性和完整性。

Redis 如何实现 "查找附近的人" ?

要实现 "查找附近的⼈" 功能,可以利用 Redis 的 Geospatial 类型来存储每个人的地理位置信息。首先使用 GEOADD 命令将每个人的经纬度坐标添加到一个地理位置的键中,然后通过 GEORADIUS 命令来查询指定位置附近的人。

具体步骤如下:

使用 GEOADD 命令将每个人的经纬度坐标添加到指定的地理位置键中。命令格式如下:

GEOADD key [经度1] [纬度1] member1 [经度2] [纬度2] member2 ......

其中,key 是存储地理位置信息的键,经度 和 纬度 是人员的地理坐标,member 是对应的人员标识。

使用 GEORADIUS 命令查询指定位置附近的人员。命令格式如下:

GEORADIUS key [经度] [纬度] [半径] [单位]

其中,key 是存储地理位置信息的键,经度 和 纬度 是查询的中心位置坐标,半径 是查询的距离范围,单位 是距离的单位(如 m、km)。

通过这种方式,可以方便地实现根据地理位置信息查找附近的人功能。

什么是 Redis 的 "bigkey" 问题? 如何解决?

Redis的"bigkey"问题指的是某个key对应的value占据较多的存储空间,例如,value是一个非常长的字符串,或者value是hash或set类型,里面的元素特别多。这样的bigkey会导致读写的时候性能下降,如果是集群分片部署,还会引起不同分片的数据倾斜。

解决方案的核心思路是拆分bigkey,将一个大的key拆成多个小的key,每个key对应value的一部分数据。可以使用redis-cli --bigkeys命令来查找bigkey,然后针对查找到的bigkey采取相应的处理措施。

常见的解决方案包括:

  • 使用unlink命令在后台删除bigkey,而不是直接使用del命令,这样可以避免阻塞Redis。
  • 对于大的字符串类型的value,可以考虑将其拆分成多个较小的字符串存储,然后根据需要进行合并。
  • 对于hash或set类型的value,可以根据业务逻辑将其拆分成多个小的hash或set,分别存储。

通过以上方法,可以有效地解决Redis的bigkey问题,提高系统的性能和稳定性。

  • 14
    点赞
  • 31
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值