Redis的脚本不以原子方式过期密钥

145 阅读4分钟

Redis scripts do not expire keys atomically

这篇由Aply工程团队成员撰写的短文描述了我们如何解决一个问题,这是我们每周面临的典型挑战。我们乐于解决艰难的分布式系统问题,这些问题大多与平台无关,而且是理论性的,这是关于我们最近学到的东西的长期系列文章的第一篇文章。

我们在Ably如何使用Redis

Ably是一个发布/分发消息的平台。发布是在指定的通道上进行的,订阅了某个通道的客户将该通道上的所有消息传递给他们。我们使用Redis,一个基于密钥存储的分布式内存数据库,来存储各种实体,如认证令牌和短暂的通道状态。它很适合在我们处理消息时临时存储。

我们在任何时候都有数十亿个活跃的Redis密钥,这些密钥在众多Redis实例中被分片存放。分片策略将相关的键放在同一个分片中,这样我们就可以执行原子式更新相关键的操作。我们广泛使用Lua Redis脚本来查询和更新键,并依靠脚本执行的原子性来保持相关键值的完整性。也就是说,要么脚本中的所有命令都运行,要么都不运行,没有其他命令在同一时间执行。

我们还广泛使用过期的密钥;Ably服务的性质是,一个频道的大部分状态是短暂的,只保留有限的时间(通常是2分钟)。我们将密钥设置为有一个TTL,所以它们会自动过期。

这个问题

一组相关钥匙的完整性要求所有的钥匙要么存在,要么不存在。我们曾假设脚本执行的原子性也适用于由脚本调用的过期操作,但事实上,在同一个脚本中天真地对多个密钥进行过期操作并不能保持这种完整性。

虽然过期操作在同一个脚本中原子化地执行(没有机会发生中间的操作),但与每个过期操作相关的时间戳不一定相同。

运行TIME ,可以看到两个不同的值。

-- time.lua       

local a = redis.call('time')       
local b = redis.call('time')       
return {a, b}       
$ ./redis-cli --eval /app/time.lua      

1) 1) "1638280442"     
   2) "996960"     
2) 1) "1638280442"     
   2) "996966"      

检查实际的过期时间。

-- expire_check.lua     

redis.call('set', 'foo', '1')     
redis.call('expire', 'foo', 1)     

-- slow calls...

redis.call('set', 'bar', '2')     
redis.call('expire', 'bar', 1)     

local fooExpiry = redis.call('PEXPIRETIME', 'foo')     
local barExpiry = redis.call('PEXPIRETIME', 'bar')     
return {fooExpiry, barExpiry}     
$ ./redis-cli --eval /app/expire_check.lua     

1) (integer) 1638280843717     
2) (integer) 1638280843730     

过期时间可能不是精确的,它可能在0到1毫秒之间。

其含义是,可能会有一些钥匙已经过期,但其他相关的钥匙却没有过期,这可能会导致不一致的状态。

我们的解决方案

解决方案是使用EXPIREAT ,为所有相关的密钥设置一个绝对的过期时间,而不是通过TTL依赖一个相对的过期时间。

Redis文档中没有明确说明,如果钥匙有相同的EXPIREAT 设置,是否保证多个钥匙的到期时间在同一时间发生。为了谨慎起见,我们对密钥过期进行了重新排序,以确保无论如何,我们都要避免不一致的情况。

-- expire_new.lua     

-- Unix time     

local now = redis.call('time')[1]     
local expiry = now + 1     
redis.call('set', 'foo', '1')     
redis.call('expireat', 'foo', expiry)     

-- slow calls...     

redis.call('set', 'bar', '2')     
redis.call('expireat', 'bar', expiry)     
local fooExpiry = redis.call('PEXPIRETIME', 'foo')     
local barExpiry = redis.call('PEXPIRETIME', 'bar')     
return {now, fooExpiry, barExpiry}     
$ ./redis-cli --eval /app/expire_new.lua

2) (integer) 1638281266000     
3) (integer) 1638281266000     

这是我们每周在Ably这里排除和解决的许多工程问题中的一个典型。

想和我们一起在实时领域工作吗?我们的工程师拥有一系列广泛的技术技能,包括基础设施、安全、分布式系统和其他方面。

你可以在TwitterLinkedIn上找到我们,并申请加入我们的一个开放职位


来自Ably Engineering的最新信息