一、踏坑事件
时间:2017年10月23日 凌晨
背景:个人赛中用户可以发起pk,pk即为两个开播主播进行限时收礼比拼,收礼数多的主播获得3分钟加速1.5倍buf,加速期间不可再发起pk。
事件:年度盛典活动个人赛 - 冲刺赛阶段(下文以‘个人赛’代替)上线,用户再次发起pk操作时,出现提示"加速buf期间,不能发起pk",但实际用户已经结束buf时间(3分钟)。
业务实现:使用Redis进行用户加速buf的判断,并给Redis用expire设置自动失效时间(3分钟)保证用户buf自然失效。
部署结构:Redis使用1主8从的部署方案。
数据表现:用户提示异常后查询主库Redis对应key值,发现key值确实已不存在。在不查询主库的情况下直接查询分库,数据存在且ttl为0。
初步结论:Redis主从同步中不进行自动失效的删除同步。
测试验证
主库操作:
HMSET z:pk:spd:100045:20171023:26586578 aaa 'test'
EXPIRE z:pk:spd:100045:20171023:26586578 30
分库1操作:
watch -n 1 'redis-cli -h ip -p port -a pass hgetall z:pk:spd:100045:20171023:26586578'
分库2操作:
watch -n 1 'redis-cli -h ip -p port -a pass ttl z:pk:spd:100045:20171023:26586578'
30秒后发现:
分库1数据存在、分库2ttl得到值为0
对主库进行一次hgetall 操作后,分库1数据为nil 分库2数据为-2
结论:主库自动失效的key并不对从库进行同步通过ttl检查返回结果为0.
二、Redis key的三种过期策略
- 惰性删除:当读/写一个已经过期的key时,会触发惰性删除策略,直接删除掉这个过期key,很明显,这是被动的!
- 定期删除:由于惰性删除策略无法保证冷数据被及时删掉,所以redis会定期主动淘汰一批已过期的key。(在第二节中会具体说明)
- 主动删除:当前已用内存超过maxmemory限定时,触发主动清理策略,该策略由启动参数的配置决定,可配置参数及说明如下:
volatile-lru:从已设置过期时间的数据集中根据LRU算法删除数据(redis3.0之前的默认策略)
volatile-ttl:从已设置过期时间的数据集中挑选过期时间最小的数据删除 volatile-random:从已设置过期时间的数据集中随机选择数据删除
allkeys-lru:从所有数据集中根据LRU算法删除数据
allkeys-random:从所有数据集中任意选择删除数据
noenviction:禁止从内存中删除数据(从redis3.0 开始默认策略) maxmemory-samples:删除数据的抽样样本数,redis3.0之前默认样本数为3,redis3.0开始默认样本数为5,该参数设置过小会导致主动删除策略不准确,过大会消耗多余的cpu
2.1 Redis过期key删除策略之定期删除
因为redis本身的定位为轻量、快速的内存数据库,所以如果为所有key都加上定时器,过期即删除的定时策略显然会消耗大量的性能,这与redis作者的价值观有着巨大差异;由于redis中key的过期删除只会在主库上进行,对于目前redis使用的组合策略来说,单位时间过期的数据量越多,越可能会带来key的过期延迟,对于做了读写分离的业务,很容易导致从库读取到过期的脏数据。
redis源码activeExpireCycle函数的解读结果请看下文(如果你懒得看,可以直接跳过本节):
- 相关参数默认值:
hz 10 :每秒执行10次activeExpireCycle 函数
- activeExpireCycle函数解析:
- 每次循环随机拿出的key的数量
- 正常过期模式最大cpu耗时率
- 过期模式:
1) “正常过期”模式 :执行时间限制:25ms;计算公式为
2) “快速过期”模式 :执行时间限制为1ms,触发条件为上次的执行时间超过了timelimit,之后函数会使timelimit_exit=1 为真,并从上次发生超时的db的下一个db开始继续处理。
过期策略:redis会遍历所有db,每次从db中随机拿出20个带有过期时间属性的key做过期判断。
循环检测:对随机拿出的20个key进行检测,如果在本次检测中发现有超过25%的key被判定为过期则持续执行过期检测循环,直到这批key中需要过期的key的比例低于25%或某次循环超过timelimit执行时间限制。
上文已经提到,过期删除行为只会在主库中进行。这是因为key的过期删除依赖于expireIfNeeded函数,这个函数在任何访问数据的操作中都会被调用并用来检测客户端访问的数据是否过期。
如果当前数据库实例角色是master,则不进行key过期的删除操作。反之,它会先调用另一个函数propagateExpire发送del key命令到aof和当前redis实例的所有slave,最后将该key从数据库中删除。此时,从库中的该key才真正意义上的过期/消失/你访问不到了!
所以一旦一个redis集群的内存没有触及maxmemory,而它每时每刻都有大量的key需要过期导致定期删除忙不过来,并且这些过期了的key不会再被访问到,那么你就很可能会在从库莫名其妙的读到了本应过期的key了。
三、从redis原码级别分析问题
查看redis对于ttl这个命令的源代码,代码如下:
代码中确实出现了TTL = 0 的情况,理论上对于存在过期时间的key,应该返回-2才对,而这个代码中,第一个if语句(应该返回-2)并没有执行,才导致调入了第二个循环里,而理 论上当前的key的过期时间一定小于当前时间戳(且不为-1),所以TTL应该是小于0,而在代码里,作者将TTL<0的情况处理成TTL=0,那 问题就在为什么第一个个if没有生效上了,既该条件的主要判断函数lookupKeyRead并没有返回NULL,再查看该函数的代码:
从这开始终于看出点端倪了,该函数之所以没有返回NULL,也是由于第一个if语句并没有return NULL,从代码的评论中可以看出,当redis作为slave的时候,是可能不返回NULL的。
从 expireIfNeeded函数的注释中可以看到,当当前的Redis为Slave时,为了保证主从数据的一致性,是并不会将当前key删除的,触发这 一句:if (server.masterhost != NULL) return now > when;当前的时间now一定是大于key存储的过期时间的,故该函数还是返回了1,这样又回到lookupKeyRead,函数中。下面的这段函数起 到决定性作用:
以下几个条件满足的时候,该函数才会Return NULL。
当前链接存在
当前链接不是master
当前链接的命令存在
当前链接的命令flags于REDIS_CMD_READONLY的与为True
前三个比较在测试过程中,一定是为True的,问题在第四个条件上,这里又引出了Redis Command的flags,在客户端,通过client list,可以查看到当前链接的flags:
可以看到,执行ttl命令的flags为N,而在下面的代码中可以看出flags=N时,表示flags=0,所以在上面的代码中,flags & REDIS_CMD_READONLY = 0 &2(REDIS_CMD_READONLY = 2,redis.h中定义),故这个if语句也没有进入,所以并没有返回NULL,因此导致ttlGenericCommand命令返回了TTL=0的结 果。(至于redis使用这些flags的原理以及上面的if语句的原理,还需要更加深入的分析,这里就不再阐述了)
所以,这种情况下,我们才知道,如果一个redis作为slave,且将slave-read-only设置为off,并写入了一个带有TTL的key时,当key过期后,该key是不会被Redis删除的,且TTL在过期后永远为0。
四、如何避免从库读取到脏数据
4.1. 通过scan命令扫库
当redis中的key被scan的时候,相当于访问了该key,同样也会做过期检测,充分发挥redis惰性删除的策略。这个方法能大大降低了脏数据读取的概率,但缺点也比较明显,会造成一定的数据库压力,谨慎合理使用,否则有可能影响线上业务的效率。
3.2. 升级redis到新的版本
在redis 3.2-rc1版本中,redis加入了一个新特性来解决主从不一致导致读取到过期数据的问题(好吧,虽然这个新特性我们一直觉得是个bug fix),在源码db.c文件中,作者对lookupKeyRead做了相应的修改,增加了key是否过期以及对主从库的判断(代码如下),如果key已过期,当前访问的是master则返回null;当前访问的是从库,且执行的是只读命令也返回null(老版本从库真实的返回该操作的结果,如果该key过期后主库没有删除),源码片段如下:
注:那么,不想通过自己写程序解决问题的同学,快快升级redis到新的版本吧。
进一步加深理解推荐地址:
http://www.cppblog.com/richbirdandy/archive/2011/11/29/161184.html