使用Redis和Lua缓存聚合数据以实现可扩展的微服务架构? - itnext


具有大量增长数据的基于微服务的扩展应用程序在有效交付聚合数据(如顶级列表)方面面临挑战。
在本文中,我将向您展示如何使用 Redis 缓存聚合数据。而数据库将项目/行数据存储为“真实来源”并使用分片进行扩展。
单个 Redis 实例每秒可以处理大约 100,000 次操作
这里使用用户、帖子和类别的示例数据模型可以作为您自己用例的基础。
内容

  1. 示例用例和数据模型
  2. 设置 Redis 并实现顶级类别
  3. 热门用户、最新用户帖子和收件箱模式
  4. 原子性的 Lua 脚本
  5. 最后的想法和展望

 
1. 示例用例和数据模型
在示例微服务应用程序中,用户可以按类别撰写帖子。他们还可以按类别阅读帖子,包括作者姓名。最新的帖子在上面。类别是固定的,很少变化。
目前,存在一百万用户。每个用户每天写大约 10 个帖子。
将前 10 个类别如何显示在主页上?对于 MySql,这将需要这样的语句:
SELECT CategoryId, COUNT(PostId) FROM Post GROUP BY CategoryId ORDER BY COUNT(PostId) LIMIT 10;

为数百万行执行此语句将非常缓慢。并且在每次访问页面时,这都是不可能的。
由于数据量大,我也决定按类别分片。所以它需要合并来自多个数据库的顶级列表。
 
2.设置Redis并实现按类别分片
创建Redis容器:
docker run --name redis -d redis
连接到容器并启动 redis-cli:
docker exec -it redis redis-cli
 

  • 添加热门类别:

顶级类别(“ CategoriesByPostCount ”)使用Redis 排序集(ZSET)。
添加带有ZADD的第一个条目和类别“ Category5 ”的99 个帖子:

127.0.0.1:6379> ZADD CategoriesByPostCount GT 99“Category5”

添加更多条目:

> ZADD CategoriesByPostCount GT 1 "Category1"
(integer) 1
> ZADD CategoriesByPostCount GT 10
"Category2"
(integer) 1

更新类别 5:
> ZADD CategoriesByPostCount GT 100 "Category5"
(integer) 1
> ZADD CategoriesByPostCount GT 98
"Category5"
(integer) 0

最后一个命令给出的结果为零。发生这种情况是因为GT参数。该参数有助于处理更新无序到达的情况(帖子计数不会减少)。
 
  • 阅读热门类别

使用ZRANGE并阅读帖子数排名前 10 的类别:
> ZRANGE CategoriesByPostCount 0 9 WITHSCORES REV
1) "Category5"
2)
"100"
3)
"Category2"
4)
"10"
5)
"Category1"
6)
"1"

轻松检索第二页(条目 11-20)等:
ZRANGE CategoriesByPostCount 10 19 WITHSCORES REV
  • 先决条件

创建新帖子时,可以在 SQL 中计算每个类别的帖子:
BEGIN TRANSACTION 
INSERT INTO Post (...) 
UPDATE Categories SET PostCount = PostCount + 1 
COMMIT TRANSACTION

这是可能的,因为数据库是按类别分片的。一个类别的所有帖子都在同一个数据库中。
 
3. 热门用户、最新用户帖子和收件箱模式
用户帖子散布在所有分片服务器。因此无法使用UPDATE User SET PostCount = PostCount + 1然后更新Redis。
Redis 中的操作必须是“幂等的”。收件箱模式 使这成为可能。
  • 添加帖子(具有竞争条件)

在每个新帖子上添加一个条目到用户的PostsByTimestamp排序集:
> ZADD {User:5}:PostsByTimestamp 3455667878 '{Title: "MyPostTitle", Category: "Category5", PostId: 13}'
(integer) 1

然后在UsersByPostCount 中增加帖子计数:
> ZINCRBY UsersByPostCount 1 "5"
要使其具有幂等性,请检查将帖子添加到收件箱的结果。再次发出命令的结果为零(条目已存在):

> ZADD {User:5}:PostsByTimestamp 3455667878 '{Title: "MyPostTitle", Category: "Category5", PostId: 13}'
(integer) 0

到 PostsByTimestamp 的命令 ZADD 和到 UsersByPostCount 的命令 ZINCRBY 必须是原子的。我将向您展示如何使用 Redis Lua 脚本使其原子化。但首先,让我们阅读顶级用户和最新用户帖子。

  • 阅读热门用户和最新用户帖子

前 10 名用户:
> ZRANGE UsersByPostCount 0 9 WITHSCORES REV
1) "6" 
2) "10" 
3) "5" 
4) "8" 
5) "3" 
6) "4" 
7) "1" 
8) "3"

ID 6 的用户有 10 个帖子,ID 5 的用户有 8 个帖子,依此类推。
ID 为 5 的用户的热门帖子:

> ZRANGE {User:5}:PostsByTimestamp 0 9 WITHSCORES REV
1) "{Title: \"MyPostTitle2\", Category: \"Category1\", PostId: 14}" 
2)
"3455667999" 
3)
"{Title: \"MyPostTitle\", Category: \"Category5\", PostId: 13}" 
4)
"3455667878"

 
4. 原子性的 Lua 脚本
使用 Lua 脚本原子地添加帖子:Redis的Lua中可以使命令ZADD到PostsByTimestamp和命令ZINCRBY到UsersByPostCount原子。但是每个用户需要一个额外的计数器,以便所有关键参数都映射到相同的Redis 哈希标签
键“{User:5}:PostsByTimestamp”中的大括号是Redis哈希标签的符号。
这个 Lua 脚本尝试向一个有序集合添加一个键。如果它可以添加密钥,它也会增加一个计数器。如果键已经存在,则返回键的值:
if tonumber(redis.call('ZADD', KEYS[1], ARGV[1], ARGV[2])) == 1 then
  return redis.call('INCR', KEYS[2])  
else 
  return redis.call('GET', KEYS[2]) 
end

使用EVAL调用 Lua 脚本并传递“ {User:8}:PostsByTimestamp ”和“ {User:8}:PostCount ”作为键(命令行中的一行):
> EVAL "if tonumber(redis.call('ZADD', KEYS[1], ARGV[1], ARGV[2])) == 1 then return redis.call('INCR', KEYS[2]) else return redis.call('GET', KEYS[2]) end" 2 {User:8}:PostsByTimestamp {User:8}:PostCount 3455667999 "{Title: \"MyPostTitle2\", Category: \"Category1\", PostId: 14}"
(integer) 1

然后在UsersByPostCount 中设置用户 8 的计数:
ZADD UsersByPostCount GT 1 "8"

  • 将脚本存储在 Redis 中

出于性能原因,您可以将 sript 存储在 Redis 中:
> SCRIPT LOAD "if tonumber(redis.call('ZADD', KEYS[1], ARGV[1], ARGV[2])) == 1 then return redis.call('INCR', KEYS[2]) else return redis.call('GET', KEYS[2]) end"
"cd9222afab5eb8d579942016a8c22427eff99429"

使用哈希调用这上面脚本:

> EVALSHA "cd9222afab5eb8d579942016a8c22427eff99429" 2 {User:8}:PostsByTimestamp {User:8}:PostCount 4455667999 "{Title: \"MyPostTitle3\", Category: \"Category1\", PostId: 20}"
(integer) 2

 
5. 最后的想法和展望
在本文中,您设置了 Redis并从一个缓存聚合数据的简单用例开始。然后您使用收件箱模式和Lua 脚本来实现原子性。