一.应用场景:
需要频繁获取用户的聚合信息的场景,比如风控场景实时获取用户最近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小时的数据:
当前槽位 currentSlot
为 13
,目标槽位 targetSlot
为 (13 - 23 + 24) % 24 = 14
。因此,我们会从槽位 14
获取数据,并加上缓存值 3
。
读取最近5小时的数据:
当前槽位 currentSlot
为 13
,目标槽位 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) | 单cpu | 6核cpu | 内存 | 备注 |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
时间 | 类型 | 总耗时 (ms) | 最大耗时 (ms) | 最小耗时 (ms) | 平均耗时 (ms) | 总平均耗时 (ms) | P99耗时 (ms) | P95耗时 (ms) | P90耗时 (ms) | P80耗时 (ms) | 单cpu | 6核cpu | 内存 | 备注 |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
2024-07-18T14:02:26 | 纯string结构实现 | 214975 | 26.329042 | 1.797125 | 2.148239 | 2.14975 | 3.318042 | 2.483333 | 2.352833 | 2.239833 | 82% | 13.6% | 10MB | 普通方案性能较差,尤其在P99表现上。 |
2024-07-18T14:10:09 | 基于hash+字符串拼接实现的结构pre_acc_cache_string_hash_optimize | 127485 | 57.923083 | 0.957167 | 1.274407 | 1.27485 | 1.868334 | 1.530167 | 1.456167 | 1.384334 | 76% | 12.6% | 优化后的性能整体上更稳定,极值表现良好。 | |
2024-07-18T14:06:01 | hash结构实现 | 85586 | 23.303125 | 0.164375 | 0.855413 | 0.85586 | 1.703 | 1.405042 | 1.332833 | 1.262416 | 65% | 10.8% | 5MB | 缓存Hash的方案性能最佳,极值较低。 |
2024-07-18T14:07:27 | hash结构多时间粒度实现 | 31100 | 11.76791 | 0.151208 | 0.310688 | 0.311 | 0.54225 | 0.422709 | 0.389125 | 0.357125 | 30% | 5% | 1MB | 多线程优化的缓存Hash方案效果显著。 |
2024-07-18T14:07:58 | hash结构算法采用hgetAll实现 | 130775 | 693.955583 | 0.180917 | 1.307384 | 1.30775 | 2.512834 | 2.244375 | 2.170417 | 2.084959 | 75% | 12.6% | 7MB | 优化后的缓存Hash方案在极值上有波动。 |
2024-07-18T14:12:17 | 基于string类型拼接实现 | 129982 | 28.299958 | 0.94975 | 1.299430 | 1.29982 | 2.035166 | 1.57025 | 1.485291 | 1.405708 | 77% | 12.8% | 1MB | 优化后的字符串缓存方案性能稳定。 |