redis时间环结构-时序特征

一.应用场景:

需要频繁获取用户的聚合信息的场景,比如风控场景实时获取用户最近5s访问次数,最近5分钟访问次数,最近5小时访问次数,最近5天访问次数和最近半年访问次数。

二.思考

1.采用大数据flink进行实时预计算后存储,暂且不论。
2.采用redis,基于hash实现。
我这里因环境限制所以讲一下redis基于hash实现。
在写入的时根据滚动窗口实时计算出结果进行存储。
为了减少计算数据量我们按照常用的获取方式将时序的数据分为
60秒+60分钟+24小时+365天
那我们一共会在redishash中存储数据的key有509个。
先将需要更新的key变少。

我们再来考虑将更新次数变少。
笨办法是每次写入就更新509个key,但是lua中每次循环比较耗费资源,所以此处我们可以采用一个最小时间窗口的缓存key。
同一个时间窗口写缓存key,下一个时间窗口到达后再更新509个key,然后将本次的值写入到缓存key中进行覆盖。如此则减少了更新次数。

而我们再读取到时候,读取最近5分钟到访问次数,根据当前时间获取跨度定位到读取分钟key,通过当前时间戳/6000%60 + 60 = 读取下标。
读取hash 该下标的聚合值 + 缓存值 = 返回最终实时结果 。

下面是lua实现,但是目前我只实现了单一时间单位的key,多时间单位的还待实现中。且附上了性能压测结果:

三.实现方式

按时间段进行数据分片:划分为若干个时间槽,每个时间槽代表一段时间段。这样的划分能够为维度对数据进行切片存储,并在后续查询时能够轻松地按时间范围进行数据检索。

时间序列写入逻辑:在当前时间大于上次写入时间的情况下,根据时间差和时间槽长度,我们会选择性地将数据写入到不同的时间槽中,以实现高效的时间序列数据存储。

数据过期处理:如果数据超过一个完整周期,则重置所有槽位,防止数据累积过多导致性能问题。

支持时间倒退:处理当前时间小于上次写入时间的情况,以保证数据的一致性。

缓存当前时间的数据:使用一个缓存键来存储当前时间的数据,减少频繁的Redis写操作。

四.具体实现

1.说明
初始化槽位:

当没有上次写入时间时,初始化所有槽位为0,并记录当前时间和缓存值。

顺序写入:

当前时间大于上次写入时间时,计算当前槽位。

如果超过一个完整周期,重置所有槽位。

否则,根据当前时间和上次写入时间,更新有效槽位和无效槽位。

将当前时间的数据写入缓存键,并更新上次写入时间。

时间倒退处理:

如果当前时间小于上次写入时间,但在有效时间范围内,更新有效槽位。

五.代码实现

  • 按时间段进行数据分片:划分为若干个时间槽,每个时间槽代表一段时间段。这样的划分能够为维度对数据进行切片存储,并在后续查询时能够轻松地按时间范围进行数据检索。
  • 时间序列写入逻辑:在当前时间大于上次写入时间的情况下,根据时间差和时间槽长度,我们会选择性地将数据写入到不同的时间槽中,以实现高效的时间序列数据存储。
    • 数据过期处理:如果数据超过一个完整周期,则重置所有槽位,防止数据累积过多导致性能问题。
    • 支持时间倒退:处理当前时间小于上次写入时间的情况,以保证数据的一致性。
    • 缓存当前时间的数据:使用一个缓存键来存储当前时间的数据,减少频繁的Redis写操作。

1.说明

  • 初始化槽位
    • 当没有上次写入时间时,初始化所有槽位为0,并记录当前时间和缓存值。
  • 顺序写入
    • 当前时间大于上次写入时间时,计算当前槽位。
    • 如果超过一个完整周期,重置所有槽位。
    • 否则,根据当前时间和上次写入时间,更新有效槽位和无效槽位。
    • 将当前时间的数据写入缓存键,并更新上次写入时间。
  • 时间倒退处理
    • 如果当前时间小于上次写入时间,但在有效时间范围内,更新有效槽位。

2.1核心代码

写:
-- 该Lua脚本用于将数据写入Redis的指定槽位,并根据当前时间和上次写入时间来更新槽位。槽位的更新逻辑包括顺序写入、时间倒退的处理,以及槽位的重置 -- 获取输入参数 local key = KEYS[1] local curTime = tonumber(ARGV[1]) local slotCount = tonumber(ARGV[2]) local slotTsLength = tonumber(ARGV[3]) local value = tonumber(ARGV[4]) --- 更新槽位 local function updateSlots(key, currentSlot, slotCount, validSlotCount, value, reset)    for i = 0, slotCount - 1 do        local ix = (currentSlot + i) % slotCount        if i < validSlotCount then            -- 在有效槽位范围内递增            redis.call("hincrby", key, tostring(ix), value)        elseif reset then            -- 在无效槽位范围内重置            redis.call("hset", key, tostring(ix), "0")        end    end end --- 特殊键,用于记录上次写入时间 local lastWriterTimeKey = "-1" --- 当前窗口缓存键 local cacheKey = "-2" -- 获取上次写入时间 local lastWriterTime = tonumber(redis.call("hget", key, lastWriterTimeKey)) -- 初始化槽位 local firstSlot = 0 local lastSlot = slotCount - 1 -- 计算当前槽位的下标 local currentSlot = math.floor(curTime / slotTsLength) % slotCount if not lastWriterTime then    -- 如果上次写入时间不存在,初始化槽位并写入当前值到缓存key    for i = firstSlot, lastSlot do        redis.call("hset", key, tostring(i), "0")    end    redis.call("hset", key, lastWriterTimeKey, tostring(curTime))    redis.call("hset", key, cacheKey, tostring(value))    return 1 end -- 顺序写入的情况 if curTime > lastWriterTime then    -- 超过一个完整周期,重置所有槽位    if curTime - lastWriterTime > slotTsLength * slotCount then        -- 如果当前时间超过了一个完整周期,重置所有槽位        for i = firstSlot, lastSlot do            redis.call("hset", key, tostring(i), "0")        end        redis.call("hset", key, cacheKey, tostring(value))    else        -- 失效范围和有效范围同时存在        local validSlotCount = slotCount - math.floor((curTime - lastWriterTime) / slotTsLength)        if validSlotCount == slotCount then            -- 当前时间的数据累加到缓存key中            redis.call("hincrby", key, cacheKey, value)        else            -- 获取之前窗口的缓存值            local cacheValue = redis.call("hget", key, cacheKey)            -- 更新槽位            updateSlots(key, currentSlot, slotCount, validSlotCount, cacheValue, true)            -- 当前时间的数据写入到缓存key中            redis.call("hset", key, cacheKey, tostring(value))        end    end    -- 更新上次写入时间    redis.call("hset", key, lastWriterTimeKey, tostring(curTime))    return 1 end -- 当前时间等于上次写入时间,累加到缓存key中 if curTime == lastWriterTime then    -- 当前时间等于上次写入时间,增加所有槽位    redis.call("hincrby", key, cacheKey, value)    redis.call("hset", key, lastWriterTimeKey, tostring(curTime))    return 1 end -- 处理时间倒退的情况 if curTime < lastWriterTime then    if curTime < lastWriterTime - slotTsLength * slotCount then        -- 数据过期,跳过写入        return 1    else        -- 如果在有效时间范围内,更新有效槽位        local validSlotCount = slotCount - math.floor((lastWriterTime - curTime) / slotTsLength)        local lastSlotIndex = math.floor(lastWriterTime / slotTsLength) % slotCount        updateSlots(key, lastSlotIndex, slotCount, validSlotCount, value, false)        return 1    end end return 0
读:
-- 获取输入参数 local key = KEYS[1] local curTime = tonumber(ARGV[1]) local slotCount = tonumber(ARGV[2]) local slotTsLength = tonumber(ARGV[3]) local hoursToRead = tonumber(ARGV[4]) -- 计算当前槽位 local currentSlot = math.floor(curTime / slotTsLength) % slotCount -- 获取上次写入时间 local lastWriterTime = tonumber(redis.call("hget", key, "-1")) -- 如果上次写入时间不存在或者当前时间超过上次写入时间一轮以上,返回0表示无数据 if not lastWriterTime or curTime > lastWriterTime + slotTsLength * slotCount then    return 0 end -- 计算目标槽位 local targetSlot = (currentSlot - hoursToRead + slotCount) % slotCount -- 如果当前时间超过上次写入时间,返回0表示无数据 if curTime > lastWriterTime then    return 0 end -- 获取目标槽位的值 local targetSlotValue = tonumber(redis.call("hget", key, tostring(targetSlot))) or 0 -- 获取缓存值 local cacheValue = tonumber(redis.call("hget", key, "-2")) or 0 -- 累加目标槽位值和缓存值 local totalValue = targetSlotValue + cacheValue return totalValue 

3.1演示

假设参数

  • 槽位数量:24
  • 槽位长度:3600秒(60分钟)

第一次写入

参数
  • 当前时间:1625889600(UTC 2021-07-10 12:00:00)
  • 写入值:1

调用脚本

EVAL "script" 1 "my_key" 1625889600 24 3600 1
结果
  • 初始化所有槽位为0
  • 设置缓存键为1
  • 设置上次写入时间为1625889600
Redis存储变化
{  "my_key": {    "0":0,"1":0,"2":0,"3":0,"4":0,"5":0,"6":0,"7":0,"8":0,    "9":0,"10":0,"11":0,"12":0,"13":0,"14":0,"15":0,    "16":0,"17":0,"18":0,"19":0,"20":0,"21":0,"22":0,"23":0    "-1": 1625889600,  // lastWriterTimeKey    "-2": 1  // cacheKey  } }

第二次写入

参数
  • 当前时间:1625893200(UTC 2021-07-10 13:00:00)
  • 写入值:8
调用脚本
EVAL "script" 1 "my_key" 1625893200 24 3600 8
结果
  • 当前槽位为 (1625893200 / 3600) % 24 = 13
  • 更新上次写入时间为 1625893200
  • 更新槽位 13 的值增加缓存键值 1,并设置缓存键为 8
{  "my_key": {    "0":1,"1":1,"2":1,"3":1,"4":1,"5":1,"6":1,"7":0,"8":1,"9":1,    "10":1,"11":1,"12":1,"13":1,"14":1,"15":1,"16":1,"17":1,    "18":1,"19":1,"20":1,"21":1,"22":1,"23":1,    "-1": 1625893200,  // lastWriterTimeKey    "-2": 8 // cacheKey  } }

第三次写入

参数
  • 当前时间:1625896800(UTC 2021-07-10 14:00:00)
  • 写入值:7

调用脚本

EVAL "script" 1 "my_key" 1625896800 24 3600 7
结果
  • 当前槽位为 (1625896800 / 3600) % 24 = 9
  • 更新上次写入时间为 1625896800
  • 更新槽位 1 的值增加缓存键值 8,并设置缓存键为 7
Redis存储变化
{  "my_key": {  "0":9,"1":9,"2":9,"3":9,"4":9,"5":9,"6":9,"7":8,"8":0,     "9":9,"10":9,"11":9,"12":9,"13":9,"14":9,"15":9,     "16":9,"17":9,"18":9,"19":9,"20":9,"21":9,"22":9,"23":9    "-1": 1625896800,  // lastWriterTimeKey    "-2": 7  // cacheKey  } }

第四次写入(时间倒退)

参数
  • 当前时间:1625893200(UTC 2021-07-10 13:00:00)
  • 写入值:2
调用脚本
EVAL "script" 1 "my_key" 1625893200 24 3600 2
结果
  • 当前槽位为 (1625893200 / 3600) % 24 = 13
  • 上次写入时间槽位为 (1625896800 / 3600) % 24 = 9
  • 有效槽位为 [9,10,11,12,13,14,15,16,17,18,19,20,21,22,23]
  • 更新槽位区间 以添加值 2
  • 保持其他槽位不变
Redis存储变化
{  "my_key": {  "0":11,"1":11,"2":11,"3":11,"4":11,"5":11,"6":11,"7":10,"10":0,     "9":9,"12":9,"13":9,"14":9,"15":9,"14":11,"15":11,     "16":11,"17":11,"18":11,"19":11,"20":11,"21":11,"22":11,"23":11    "-1": 1625896800,  // lastWriterTimeKey    "-2": 7  // cacheKey  } }

第五次写入(超过完整周期)

参数
  • 当前时间:1625976000(UTC 2021-07-11 12:00:00)
  • 写入值:4
调用脚本
EVAL "script" 1 "my_key" 1625976000 24 3600 4
结果
  • 当前槽位为 (1625976000 / 3600) % 24 = 7
  • 当前时间超过一个完整周期 (3600秒 * 24槽位 = 86400秒)
  • 重置所有槽位为0
  • 设置缓存键为 4
  • 更新上次写入时间为 1625976000
Redis存储变化
{  "my_key": { "0":0,"1":0,"2":0,"3":0,"4":0,"5":0,"6":0,"7":0,"8":0,    "9":0,"10":0,"11":0,"12":0,"13":0,"14":0,"15":0,    "16":0,"17":0,"18":0,"19":0,"20":0,"21":0,"22":0,"23":0     "-1": 1625976000,  // lastWriterTimeKey     "-2": 4  // cacheKey  } }

3.2读取示例:

假设:

当前时间:1626003600(2021-07-11 17:00:00),槽位数量 24,槽位长度 3600 秒(1小时)

数据:

{  "my_key": {  "0":45,"1":42,"2":34,"3":31,"4":24,"5":15,"6":0,"7":151,"8":135,     "9":128,"10":124,"11":116,"12":111,"13":108,"14":106,"15":98,     "16":92,"17":85,"18":83,"19":75,"20":72,"21":70,"22":61,"23":53     "-1": 1625895600,  // lastWriterTimeKey (UTC 2021-07-10 12:00:00)     "-2": 3  // cacheKey  } }
读取最近23小时的数据:

当前槽位 currentSlot13,目标槽位 targetSlot(13 - 23 + 24) % 24 = 14。因此,我们会从槽位 14 获取数据,并加上缓存值 3

读取最近5小时的数据:

当前槽位 currentSlot13,目标槽位 targetSlot(13 - 5 + 24) % 24 = 8。因此,我们会从槽位 8 获取数据,并加上缓存值 3

我们读取最近23小时和最近5小时的数据如下:

  • 读取最近23小时的数据
    • 当前槽位:13
    • 目标槽位:14
    • 读取槽位 14 的值,加上缓存值 -2 的值。
    • 结果:槽位 14 的值(106) + 缓存值 3 = 109
  • 读取最近5小时的数据
    • 当前槽位:13
    • 目标槽位:8
    • 读取槽位 8 的值,加上缓存值 -2 的值。
    • 结果:槽位 8 的值(135) + 缓存值 3 = 138

10万次初步测试结果:

性能测试报告

时间类型总耗时 (ms)最大耗时 (ms)最小耗时 (ms)平均耗时 (ms)总平均耗时 (ms)P99耗时 (ms)P95耗时 (ms)P90耗时 (ms)P80耗时 (ms)单cpu6核cpu内存备注
时间类型总耗时 (ms)最大耗时 (ms)最小耗时 (ms)平均耗时 (ms)总平均耗时 (ms)P99耗时 (ms)P95耗时 (ms)P90耗时 (ms)P80耗时 (ms)单cpu6核cpu内存备注
2024-07-18T14:02:26纯string结构实现21497526.3290421.7971252.1482392.149753.3180422.4833332.3528332.23983382%13.6%10MB普通方案性能较差,尤其在P99表现上。
2024-07-18T14:10:09基于hash+字符串拼接实现的结构pre_acc_cache_string_hash_optimize12748557.9230830.9571671.2744071.274851.8683341.5301671.4561671.38433476%12.6%优化后的性能整体上更稳定,极值表现良好。
2024-07-18T14:06:01hash结构实现8558623.3031250.1643750.8554130.855861.7031.4050421.3328331.26241665%10.8%5MB缓存Hash的方案性能最佳,极值较低。
2024-07-18T14:07:27hash结构多时间粒度实现3110011.767910.1512080.3106880.3110.542250.4227090.3891250.35712530%5%1MB多线程优化的缓存Hash方案效果显著。
2024-07-18T14:07:58hash结构算法采用hgetAll实现130775693.9555830.1809171.3073841.307752.5128342.2443752.1704172.08495975%12.6%7MB优化后的缓存Hash方案在极值上有波动。
2024-07-18T14:12:17基于string类型拼接实现12998228.2999580.949751.2994301.299822.0351661.570251.4852911.40570877%12.8%1MB优化后的字符串缓存方案性能稳定。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值