高级Java程序员必问,Redis事务终极篇

Posted 一灯架构

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了高级Java程序员必问,Redis事务终极篇相关的知识,希望对你有一定的参考价值。

Redis事务(Transaction)通过将多个Redis操作封装为一个原子性的操作序列,确保在事务执行过程中,不会受到其他客户端的干扰。从而在保证数据一致性的同时,协调并发,提高数据操作的效率和性能

1. 简介

1.1 什么是Redis事务

Redis事务(Transaction)通过将多个Redis操作封装为一个原子性的操作序列,确保在事务执行过程中,不会受到其他客户端的干扰。从而在保证数据一致性的同时,协调并发,提高数据操作的效率和性能。

1.2 Redis事务的应用场景

在分布式系统和高并发场景下,事务处理具有重要意义。Redis事务可以确保数据的一致性,避免并发操作导致的数据不一致问题。以下是一些Redis事务的应用场景:

  1. 批量操作:Redis 事务可以将多个命令打包成一个单元来执行,可以减少与 Redis 服务器的通信次数,从而提高性能。
  2. 数据库迁移:在迁移数据时,需要保证数据一致性。通过Redis事务,可以确保数据在迁移过程中不会出现不一致的情况。
  3. 分布式锁:在分布式系统中,为了保证数据的一致性,需要实现分布式锁。通过Redis事务,可以在同一个事务中执行锁定、解锁等操作,确保锁的原子性。

这些应用场景展示了Redis事务在实际应用中的价值。接下来,我们将详细介绍Redis事务的基本命令、特性和实现原理。

2. Redis事务基本命令

在Redis中,事务的处理主要涉及以下五个基本命令:

2.1 MULTI

MULTI 命令用于标记一个事务块的开始。在执行 MULTI 之后,Redis将开始记录后续的命令,并将这些命令放入一个队列中,直到遇到 EXEC 命令。

2.2 EXEC

EXEC 命令用于触发事务块中的所有命令一起执行。当Redis收到 EXEC 命令后,它将按照FIFO(先进先出)的顺序执行事务队列中的所有命令。如果事务执行成功,Redis会返回一个数组,其中包含每个命令执行后的结果。如果事务执行失败,Redis将返回一个错误信息。

2.3 DISCARD

DISCARD 命令用于取消一个事务块。当执行 DISCARD 命令后,Redis将清空事务队列,并恢复到正常执行模式。任何在事务块中的命令都不会被执行。

2.4 WATCH

WATCH 命令用于监视一个或多个Key,以确保在事务执行期间,这些Key的值没有发生变化。如果在事务执行之前,有其他客户端修改了这些被监视的Key,那么事务将被中断,并返回一个错误。这种机制被称为乐观锁(Optimistic Locking)。

2.5 UNWATCH

UNWATCH 命令用于取消对所有Key的监视。执行 UNWATCH 后,Redis将不再监视任何Key的变化,事务将按照正常流程执行。

通过这五个基本命令,Redis实现了事务功能。接下来,我们将详细介绍Redis事务的特性、实现原理以及在实际应用中的案例。

3. Redis事务的使用

下面演示一个常见的电商购物场景,把更新订单状态和扣库存放在一个事务中。

# 开启事务
> MULTI
OK

# 执行命令
# 1. 设置订单状态为已完成
> SET order_status 1
QUEUED
# 2. 库存减一
> DECR stock
QUEUED
# 3. 查看库存
> GET stock
QUEUED

# 提交事务
> EXEC
1) OK
2) OK
3) 99

4. Redis事务的实现原理

4.1 事务队列

当客户端发送 MULTI 命令后,Redis开始记录后续的命令,并将这些命令放入一个队列中。当遇到 EXEC 命令时,Redis会按照FIFO(先进先出)的顺序执行队列中的所有命令。

4.2 错误处理

在事务执行过程中,可能会遇到命令执行失败的情况。对于错误的处理,Redis采用的策略是:即使某个命令执行失败,事务中的其他命令仍然会继续执行。然而,整个事务的返回结果会包含错误信息,以便客户端了解事务执行过程中发生的错误。

4.3 WATCH命令与乐观锁

WATCH 命令允许客户端监视一个或多个Key,以确保在事务执行期间,这些Key的值没有发生变化。这种机制被称为乐观锁(Optimistic Locking)。如果在事务执行之前,有其他客户端修改了这些被监视的Key,那么事务将被中断,并返回一个错误。乐观锁可以在一定程度上解决并发场景下的数据一致性问题。

5. Redis事务的注意事项与局限性

虽然Redis事务具有一定的功能,但在使用过程中需要注意以下事项:

5.1 无回滚机制

与传统关系型数据库不同,Redis事务不支持回滚(Rollback)。当事务中的某个命令执行失败时,Redis不会回滚已执行的命令。因此,在使用Redis事务时,需要确保事务中的每个命令都能正确执行,以避免数据不一致的问题。

5.2 事务内的命令不支持条件判断

Redis事务不支持在事务内进行条件判断。这意味着,事务中的所有命令都会被执行,无论前面的命令是否执行成功。这可能导致数据的不一致性。想要解决这个问题,可以使用Lua脚本来实现条件判断。

5.3 性能影响

由于Redis使用单线程模型来执行事务,因此,在事务执行期间,服务器无法处理其他客户端的请求。这可能对Redis的性能产生影响。为了降低事务对性能的影响,建议将事务中的命令数量控制在一个合理的范围内。

5.4 ACID特性

Redis事务并不能完全保证事务四大特性,使用的时候需要注意:

  • 原子性:Redis事务具有一定的原子性,但是不支持回滚。
  • 一致性:Redis事务保证一致性。
  • 隔离性:Redis事务保证隔离性。Redis是单线程,事务执行期间,禁止其他客户端发送命令给 Redis服务器。
  • 持久性:Redis事务不保证持久性。Redis持久化机制都是异步刷盘,存在数据丢失的情况。

6. 使用Lua脚本优化Redis事务

在某些场景下,Redis事务可能无法满足应用的需求,例如需要在事务中进行条件判断或循环。在这种情况下,可以使用Redis的Lua脚本功能来优化事务。Lua脚本可以在Redis服务器端原子性地执行一系列命令,并支持条件判断和循环,从而提供更强大的事务处理能力。

6.1 Lua脚本的基本使用

要在Redis中使用Lua脚本,可以使用EVAL命令执行脚本。例如,以下Lua脚本用于实现原子性地递增一个计数器:

EVAL "local current = redis.call(\'get\', KEYS[1]); current = current + 1; redis.call(\'set\', KEYS[1], current); return current;" counter

6.2 Lua脚本与Redis事务的比较

与Redis事务相比,Lua脚本具有以下优势:

  1. 更强大的逻辑处理能力:Lua脚本支持条件判断、循环等复杂逻辑,而Redis事务只能顺序执行命令。
  2. 更好的性能:由于Lua脚本在服务器端执行,避免了多次往返通信带来的延迟,因此性能通常优于Redis事务。
  3. 更高的可维护性:将业务逻辑封装在Lua脚本中,可以提高代码的可读性和可维护性。

然而,使用Lua脚本也有一些局限性:

  1. 学习成本:使用Lua脚本需要学习Lua语言及其在Redis中的使用方法。
  2. 脚本管理:当业务逻辑变得复杂时,需要对多个Lua脚本进行维护和管理。
  3. 脚本执行的限制:为了避免长时间执行的脚本阻塞Redis服务器,Redis对Lua脚本执行时间有一定的限制。如果脚本执行时间过长,可能会被强制终止。

7. 总结

本文主要介绍了Redis事务的概念、应用场景、基本命令、实现原理以及在实际应用中的案例。需要注意的是Redis事务并没有完全实现事务的ACID特性,无回滚机制、也不支持条件判断,可以使用Lua脚本优化Redis事务。

我是「一灯架构」,如果本文对你有帮助,欢迎各位小伙伴点赞、评论和关注,感谢各位老铁,我们下期见

Redis扫盲:浅谈Redis面试必问——工程架构篇

前言

接下来我们来一起研究下Redis工程架构相关的问题,这部分内容出现的概率相对大一些,因为并不是所有人都会去研究源码,如果面试一味问源码那么可能注定是一场尬聊。

面试时在不要求候选人对Redis非常熟练的前提下,工程问题将是不二之选

通过本文你将了解到以下内容:
1.Redis的内存回收详解
2.Redis的持久化机制

Q1:了解Redis的内存回收吗?讲讲你的理解

1.1 为什么要回收内存?

Redis作为内存型数据库,如果单纯的只进不出早晚就撑爆了,事实上很多把Redis当做主存储DB用的家伙们早晚会尝到这个苦果,当然除非你家厂子确实不差钱,数T级别的内存都毛毛雨,或者数据增长一定程度之后不再增长的场景,就另当别论了。

对于我们这种把节约成本当做KPI的普通厂子,还是把Redis当缓存用比较符合家里的经济条件,所以这么看面试官的问题还算是比较贴合实际,比起那些手撕RBTree好一些,如果问题刚好在你知识射程范围内,先给面试官点个赞再说!

为了让Redis服务安全稳定的运行,让使用内存保持在一定的阈值内是非常有必要的,因此我们就需要删除该删除的,清理该清理的,把内存留给需要的键值对,试想一条大河需要设置几个警戒水位来确保不决堤不枯竭,Redis也是一样的,只不过Redis只关心决堤即可,来一张图:

技术图片

图中设定机器内存为128GB,占用64GB算是比较安全的水平,如果内存接近80%也就是100GB左右,那么认为Redis目前承载能力已经比较大了,具体的比例可以根据公司和个人的业务经验来确定。

笔者只是想表达出于安全和稳定的考虑,不要觉得128GB的内存就意味着存储128GB的数据,都是要打折的。

1.2 内存从哪里回收?

Redis占用的内存是分为两部分:存储键值对消耗和本身运行消耗。显然后者我们无法回收,因此只能从键值对下手了,键值对可以分为几种:带过期的、不带过期的、热点数据、冷数据。对于带过期的键值是需要删除的,如果删除了所有的过期键值对之后内存仍然不足怎么办?那只能把部分数据给踢掉了。

人生无处不取舍,这个让笔者脑海浮现了《泰坦尼克》,邮轮撞到了冰山顷刻间海水涌入,面临数量不足的救生艇,人们做出了抉择:让女士和孩童先走,绅士们选择留下,海上逃生场景如图:

技术图片

技术图片

1.3 如何实施过期键值对的删除?

要实施对键值对的删除我们需要明白如下几点:

  • 带过期超时的键值对存储在哪里?
  • 如何判断带超时的键值对是否可以被删除了?
  • 删除机制有哪些以及如何选择

1.3.1 键值对的存储

老规矩来到github看下源码,src/server.h中给的redisDb结构体给出了答案:

typedef struct redisDb {
    dict *dict;                 /* The keyspace for this DB */
    dict *expires;              /* Timeout of keys with a timeout set */
    dict *blocking_keys;        /* Keys with clients waiting for data (BLPOP)*/
    dict *ready_keys;           /* Blocked keys that received a PUSH */
    dict *watched_keys;         /* WATCHED keys for MULTI/EXEC CAS */
    int id;                     /* Database ID */
    long long avg_ttl;          /* Average TTL, just for stats */
    unsigned long expires_cursor; /* Cursor of the active expire cycle. */
    list *defrag_later;         /* List of key names to attempt to defrag one by one, gradually. */
} redisDb;

Redis本质上就是一个大的key-value,key就是字符串,value有是几种对象:字符串、列表、有序列表、集合、哈希等,这些key-value都是存储在redisDb的dict中的,来看下黄健宏画的一张非常赞的图:

技术图片

看到这里,对于删除机制又清晰了一步,我们只要把redisDb中dict中的目标key-value删掉就行,不过貌似没有这么简单,Redis对于过期键值对肯定有自己的组织规则,让我们继续研究吧!

redisDb的expires成员的类型也是dict,和键值对是一样的,本质上expires是dict的子集,expires保存的是所有带过期的键值对,称之为过期字典吧,它才是我们研究的重点。

对于键,我们可以设置绝对和相对过期时间、以及查看剩余时间:

  • 使用EXPIRE和PEXPIRE来实现键值对的秒级和毫秒级生存时间设定,这是相对时长的过期设置
  • 使用EXPIREAT和EXPIREAT来实现键值对在某个秒级和毫秒级时间戳时进行过期删除,属于绝对过期设置
  • 通过TTL和PTTL来查看带有生存时间的键值对的剩余过期时间

上述三组命令在设计缓存时用处比较大,有心的读者可以留意。

过期字典expires和键值对空间dict存储的内容并不完全一样,过期字典expires的key是指向Redis对应对象的指针,其value是long long型的unix时间戳,前面的EXPIRE和PEXPIRE相对时长最终也会转换为时间戳,来看下过期字典expires的结构,笔者画了个图:

技术图片

1.3.2 键值对的过期删除判断

判断键是否过期可删除,需要先查过期字典是否存在该值,如果存在则进一步判断过期时间戳和当前时间戳的相对大小,做出删除判断,简单的流程如图:

技术图片

1.3.3 键值对的删除策略

经过前面的几个环节,我们知道了Redis的两种存储位置:键空间和过期字典,以及过期字典expires的结构、判断是否过期的方法,那么该如何实施删除呢?

先抛开Redis来想一下可能的几种删除策略:

  • 定时删除:在设置键的过期时间的同时,创建定时器,让定时器在键过期时间到来时,即刻执行键值对的删除;
  • 定期删除:每隔特定的时间对数据库进行一次扫描,检测并删除其中的过期键值对;
  • 惰性删除:键值对过期暂时不进行删除,至于删除的时机与键值对的使用有关,当获取键时先查看其是否过期,过期就删除,否则就保留;

在上述的三种策略中定时删除和定期删除属于不同时间粒度的主动删除惰性删除属于被动删除

三种策略都有各自的优缺点:

定时删除对内存使用率有优势,但是对CPU不友好,惰性删除对内存不友好,如果某些键值对一直不被使用,那么会造成一定量的内存浪费,定期删除是定时删除和惰性删除的折中。

Reids采用的是惰性删除和定时删除的结合,一般来说可以借助最小堆来实现定时器,不过Redis的设计考虑到时间事件的有限种类和数量,使用了无序链表存储时间事件,这样如果在此基础上实现定时删除,就意味着O(N)遍历获取最近需要删除的数据。

但是我觉得antirez如果非要使用定时删除,那么他肯定不会使用原来的无序链表机制,所以个人认为已存在的无序链表不能作为Redis不使用定时删除的根本理由冒昧猜测唯一可能的是antirez觉得没有必要使用定时删除

技术图片

1.3.4 定期删除的实现细节

定期删除听着很简单,但是如何控制执行的频率和时长呢?

试想一下如果执行频率太少就退化为惰性删除了,如果执行时间太长又和定时删除类似了,想想还确实是个难题!并且执行定期删除的时机也需要考虑,所以我们继续来看看Redis是如何实现定期删除的吧!笔者在src/expire.c文件中找到了activeExpireCycle函数,定期删除就是由此函数实现的,在代码中antirez做了比较详尽的注释,不过都是英文的,试着读了一下模模糊糊弄个大概所以学习英文并阅读外文资料是很重要的学习途径

由于笔者对Redis源码了解不多,只能做个模糊版本的解读,所以难免有问题,还是建议有条件的读者自行前往源码区阅读,抛砖引玉看下笔者的模糊版本:

  • 该算法是个自适应的过程,当过期的key比较少时那么就花费很少的cpu时间来处理,如果过期的key很多就采用激进的方式来处理,避免大量的内存消耗,可以理解为判断过期键多就多跑几次,少则少跑几次;
  • 由于Redis中有很多数据库db,该算法会逐个扫描,本次结束时继续向后面的db扫描,是个闭环的过程;
  • 定期删除有快速循环和慢速循环两种模式,主要采用慢速循环模式,其循环频率主要取决于server.hz,通常设置为10,也就是每秒执行10次慢循环定期删除,执行过程中如果耗时超过25%的CPU时间就停止;
  • 慢速循环的执行时间相对较长,会出现超时问题,快速循环模式的执行时间不超过1ms,也就是执行时间更短,但是执行的次数更多,在执行过程中发现某个db中抽样的key中过期key占比低于25%则跳过;

主体意思:定期删除是个自适应的闭环并且概率化的抽样扫描过程,过程中都有执行时间和cpu时间的限制,如果触发阈值就停止,可以说是尽量在不影响对客户端的响应下润物细无声地进行的。

1.3.5 DEL删除键值对

在Redis4.0之前执行del操作时如果key-value很大,那么可能导致阻塞,在新版本中引入了BIO线程以及一些新的命令,实现了del的延时懒删除,最后会有BIO线程来实现内存的清理回收。

1.4 内存淘汰机制

为了保证Redis的安全稳定运行,设置了一个max-memory的阈值,那么当内存用量到达阈值,新写入的键值对无法写入,此时就需要内存淘汰机制,在Redis的配置中有几种淘汰策略可以选择,详细如下:

  • noeviction: 当内存不足以容纳新写入数据时,新写入操作会报错;
  • allkeys-lru:当内存不足以容纳新写入数据时,在键空间中移除最近最少使用的 key;
  • allkeys-random:当内存不足以容纳新写入数据时,在键空间中随机移除某个 key;
  • volatile-lru:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,移除最近最少使用的 key;
  • volatile-random:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,随机移除某个 key;
  • volatile-ttl:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,有更早过期时间的 key 优先移除;

后三种策略都是针对过期字典的处理,但是在过期字典为空时会noeviction一样返回写入失败,毫无策略地随机删除也不太可取,所以一般选择第二种allkeys-lru基于LRU策略进行淘汰。

个人认为antirez一向都是工程化思维,善于使用概率化设计来做近似实现,LRU算法也不例外,Redis中实现了近似LRU算法,并且经过几个版本的迭代效果已经比较接近理论LRU算法的效果了,这个也是个不错的内容,由于篇幅限制,本文计划后续单独讲LRU算法时再进行详细讨论。

1.5 过期键删除和内存淘汰的关系

过期健删除策略强调的是对过期健的操作,如果有健过期而内存足够,Redis不会使用内存淘汰机制来腾退空间,这时会优先使用过期健删除策略删除过期健。

内存淘汰机制强调的是对内存数据的淘汰操作,当内存不足时,即使有的健没有到达过期时间或者根本没有设置过期也要根据一定的策略来删除一部分,腾退空间保证新数据的写入。

Q2:讲讲你对Redis持久化机制的理解。

个人认为Redis持久化既是数据库本身的亮点,也是面试的热点,主要考察的方向包括:RDB机制原理、AOF机制原理、各自的优缺点、工程上的对于RDB和AOF的取舍、新版本Redis混合持久化策略等,如能把握要点,持久化问题就过关了。

以上是关于高级Java程序员必问,Redis事务终极篇的主要内容,如果未能解决你的问题,请参考以下文章

Java面试必问,ThreadLocal终极篇

Java面试必问:ThreadLocal终极篇 淦!

中高级开发面试必问的Redis面试题,看这篇就够了!

Redis扫盲:浅谈Redis面试必问——工程架构篇

Redis扫盲:浅谈Redis面试必问——工程架构篇

字节跳动面试必问:大厂程序员35岁后的职业出路在哪?太香了