当前位置:首页 > 问答 > 正文

Redis访问了已经失效的键,结果导致了一些意想不到的问题和困扰

在日常使用Redis的过程中,我们有时会遇到一种看似简单却可能引发连锁问题的情况,那就是访问那些已经失效或被删除的键,表面上看,Redis处理一个不存在的键时会返回一个空值(nil),逻辑上似乎很清晰,但问题往往不出在Redis本身的行为上,而出在应用程序的代码逻辑没有周全地考虑到这种“空”的状态,从而导致了各种意想不到的bug和困扰。

一个非常典型的场景出现在缓存穿透问题上,想象一下,我们有一个电商网站,商品信息缓存在Redis中,并设置了过期时间,当某个商品下架或从未存在时,其对应的键自然就是失效或根本不存在的,如果这时有一个恶意攻击者或者一个出了bug的爬虫程序,持续地、高并发地请求这个根本不存在的商品ID,会发生什么?每一次请求都会穿透Redis缓存,直接打到后端的数据库上,因为应用程序的逻辑可能是:先查缓存,如果缓存有就返回;如果缓存没有(即访问到失效键),就去数据库查,数据库里当然也查不到这个商品,所以应用程序可能不会将这次查询的结果写入缓存(因为觉得查无结果,没有可缓存的),或者只写入一个短暂的空值,这样一来,这个无效的请求就会一次次地绕过缓存,直接冲击数据库,在短时间内海量这样的请求面前,数据库很可能不堪重负,导致连接池被占满、响应变慢,进而影响到其他正常业务的数据库查询,最终导致整个网站的服务不可用,这就是因为处理“访问失效键”这个简单动作时,防御措施不足而引发的严重问题。

另一种困扰源于对数据状态的误判,在一个用户会话管理的场景中,我们可能用Redis来存储用户的登录状态,键是用户的令牌(Token),值是一些用户信息,我们设定这个Token在30分钟无活动后自动过期,假设一个用户正在填写一个复杂的表单,花了超过30分钟的时间,然后点击提交,提交时,应用程序会验证Token是否有效,Redis中的这个键已经因为过期而失效了,如果应用程序的代码只是简单地判断从Redis取到的值是否为null,然后就认为用户未登录,那么用户辛辛苦苦填了半天的表单提交后,可能只会看到一个冷冰冰的“请重新登录”的提示,所有输入的数据都可能丢失,这对用户体验来说是灾难性的,这里的问题在于,程序逻辑没有区分“用户根本未登录”和“用户登录会话已超时”这两种不同的情况,并对后者进行更友好的处理,例如提示会话超时并提供保存草稿或重新登录后继续操作的机会。

还有一种情况与分布式环境下的并发操作有关,考虑一个使用Redis实现简单分布式锁的场景(尽管生产环境更推荐用Redlock等更严谨的算法,但此处用来说明问题),假设一个进程获取锁后,由于某些原因(如执行时间过长)导致锁的过期时间到了,锁键在Redis中自动失效了,另一个进程发现这个键不存在(即锁已失效),于是它成功地获取了锁,但紧接着,第一个进程执行完了任务,它依然会按照既定流程去尝试释放锁,它可能会执行一个删除锁键的操作,悲剧就这样发生了:它删除的并不是它自己创建的那个已经过期的锁,而是第二个进程刚刚创建的新锁!这导致了锁的提前释放,破坏了互斥性,可能使得临界区的代码被多个进程同时执行,造成数据错乱,这个问题的根源就在于,进程在释放锁时,没有校验当前持有的锁标识是否仍然是Redis中的有效值,而仅仅因为曾经拥有过,就贸然执行了删除操作,访问那个已经失效的旧锁键的状态信息,并基于此做出决策,引发了严重的并发问题。

在一些数据统计和聚合的场景中,如果代码逻辑不够健壮,访问失效键也可能导致计算错误,一个用于计数器的键,可能因为过期被清理,当程序试图对其进行自增操作时,Redis会将其当作从0开始自增,如果程序期望的是延续之前的计数,那么这个计数就会从零开始,导致统计数据不准确,或者,在一个存储集合(Set)或列表(List)的键失效后,程序试图向其添加新元素,这会创建一个新的空集合或列表,之前的历史数据就彻底丢失了,而程序可能在没有察觉的情况下继续运行,产出错误的结果。

Redis访问失效键返回空值这个行为本身是明确且合理的,真正的“意想不到的问题和困扰”,几乎都源于应用程序的开发者未能充分预见到各种边界情况,并在代码逻辑中做出妥善的处理,这提醒我们,在使用缓存或任何外部数据源时,必须采用防御性编程的思想,对每一个可能为空的返回值进行仔细甄别,并设计相应的异常处理、降级策略或状态恢复机制,才能避免这些看似简单实则棘手的问题。

Redis访问了已经失效的键,结果导致了一些意想不到的问题和困扰