如何在 DynamoDb 中设计一个基于点赞的推荐系统

How to design a recommendation system in DynamoDb based on likes

考虑到性能是最重要的,在 DynamoDb 中构建推荐系统的最佳设计方法是什么?

系统需要存储 url 以及该主题被“”的次数,但是我的要求包括每天、每周、每月和每年的搜索需求,例如:

Give me the top 10 of the week
Give me the top 10 of the month

我想把日期和时间信息也包含进来,这样查询就可以通过这个字段来控制了,但是不知道这样对性能有没有好处。

如果你唯一的数据结构是哈希映射,你会如何解决这个问题?

如果在该约束之上,您每秒最多只能更新任何密钥 1000 次,并且每秒最多只能读取 3000 次密钥怎么办?

您希望您的商品多久被点赞一次?大概会有一些 热门 并且喜欢很多,而其他人几乎永远不会得到任何喜欢。

您的系统需要如何 real_time?系统能否最终保持一致(意思是,如果您只报告几分钟前的点赞情况是否可以)?


让我们试一试
免责声明:这在很大程度上是一种教学练习——在实践中,您可能想要探索一种分析产品,或者 DynamoDB 以外的其他技术来完成这项任务


第 1 部分。表示项目并更新点赞数

首先,让我们谈谈您的 aggregation/analytics 目标:您提到要查询“本周前 10 名”或“本月前 10 名”,但您没有具体说明是不是应该表示“日历周”/“日历月”或“过去 7 天”/“过去 30 天”。

我将按字面意思理解,并假设“本周前 10 名”是指从最近周一(或周日,如果你这样滚动)开始的本周前 10 项。月份相同:“本月前 10 名”表示“自本月初以来的前 10 项。

在这种情况下,您可能希望为每个项目存储:

  • 总点赞数
  • 本月初以来的点赞数
  • 自本周初以来的点赞数
  • 当前月份数字 - 需要确定我们是否需要重置
  • 当前周数 - 需要确定我们是否需要重置

并且每周重置当前周的计数;每个月都会重置当月的计数。

在 DynamoDB 中,这可能表示为:

{
   id: "<item-id>",
   likes_all: <numeric>,   // total likes of all time
   likes_wk: <numeric>,    // total likes for the current week
   likes_mo: <numeric>,    // total likes for the current month
   curr_wk: <numeric>,     // number of the current week of year, eg. 27
   curr_mo: <numeric>,     // number of the current month of year, eg. 6
}

现在,您可以使用 UpdateItem 操作更新点赞数,使用 UpdateExpression,如下所示:

dynamodb update-item \
    --table-name <your-table-name> \
    --key '{"id":{"S":"<item-id>"}}' \
    --update-expression "SET likes_all = likes_all + :lc, likes_wk = likes_wk + :lc, likes_mo = likes_mo + :lc" \
    --expression-attribute-values '{":lc": {"N":"1"}}' \
    --return-values ALL_NEW

这为您提供了一种简单的原子方法来增加计数并取回更新后的值。请注意 :lc 值可以是任何数字(不仅仅是 1)。这将在下面派上用场。

但是有一个问题。如果周或月结束,您还需要能够重置计数,为此,您可以将更新分为两个操作:

  1. 更新总计数(并取回最新值)
  2. 有条件地更新周数和月数

因此,我们的更新顺序变为:

第 1 步。更新总计数并读回更新的项目:

dynamodb update-item \
    --table-name <your-table-name> \
    --key '{"id":{"S":"<item-id>"}}' \
    --update-expression "SET likes_all = likes_all + :lc" \
    --expression-attribute-values '{":lc": {"N":"1"}}' \
    --return-values ALL_NEW

这会更新总计数并返回项目的状态。根据 curr_wk 和 curr_mo 的值,您必须决定更新的外观。您可以递增或设置绝对值。假设我们遇到的情况是在周结束后执行更新,而不是月份。假设上面的更新结果如下所示:

{
   id: "<item-id>",
   likes_all: 1000,  // total likes of all time
   likes_wk: 70,     // total likes for the current week
   likes_mo: 150,    // total likes for the current month
   curr_wk: 26,      // number of the week of last update
   curr_mo: 6,       // number of the month of year of last update
}

curr_wk是6,但是更新的时候,实际的当前周应该是7。

那么您的更新查询将如下所示:

dynamodb update-item \
    --table-name <your-table-name> \
    --key '{"id":{"S":"<item-id>"}}' \
    --update-expression "SET curr_wk = 27, likes_wk = :lc, likes_mo = likes_mo + :lc" \
    --condition-expression "curr_wk = :wk AND curr_mo = :mo" \
    --expression-attribute-values '{":lc": {"N":"1"}, ":wk": {"N":"26"}, ":lc": {"N":"6"},}' \
    --return-values ALL_NEW

ConditionExpression 确保如果同时发生两个相互冲突的更新,我们不会将点赞重置两次。在这种情况下,其中一个更新会失败,您必须将更新切换回增量。


第 2 部分 - 跟踪统计数据

为了管理您的统计数据,您需要跟踪每周和每月的最多点赞数。

您可以保留每周和每月 最热门 项目的排序列表。您还可以将这些列表存储在 Dynamo 中。

例如,假设您想跟踪前 3 名。您可以存储如下内容:

{
   id: "item-stats",
   week_top: ["item3:4000", "item2:2000", "item9:700"],
   month_top: ["item2:100000", "item4:50000", "item3:12000"],
   curr_wk: 26,
   curr_mo: 6,
   sequence: <optimistic-lock-token>
}

每当您对项目执行更新时,您也会更新统计信息。

更新统计信息的算法类似于更新项目,只是不能只使用更新表达式。相反,您必须使用 GetItem、PutItem 和 ConditionExpression.

实现您自己的读取-修改-写入序列

首先,您读取 item-stats 特殊项目的当前值,包括当前 sequence 的值(这对于检测破坏很重要)

然后,您确定您刚刚更新计数的项目是否会进入 Top-N 每周或每月列表。如果是这样,您将更新 week_top and/or month-top 属性并准备有条件的 PutItem 请求。

PutItem 请求必须包含条件检查,以验证 item-statssequeuce 是否与您之前阅读的内容相同。如果没有,则需要重新阅读该项目并重新计算top-N列表,然后再次尝试放置。

此外,与重置项目计数的方式类似,当发生更新时,您需要检查并查看每周或每月的最高排名是否需要作为更新的一部分进行重置。

发出 PutItem 请求时,请确保生成新的 sequence 值。


第 3 部分 - 整合

第 1 部分第 2 部分 中,我们弄清楚了如何跟踪点赞数和跟踪静态数据,但存在大问题使用我们的方法:对于任何一种现实生活规模,性能都会非常糟糕;热的东西会给我们带来麻烦;更新 Top-N 统计数据将是一个重大瓶颈。

为了提高性能并实现一定的可扩展性,我们希望避免更新每个项目和每个“喜欢”的项目统计信息。

我们可以使用队列+dynamodb+计算资源的组合来实现性能和可伸缩性的良好平衡。

  1. 创建一个队列来存储待处理的点赞
  2. let "likes API" 会将标记 post 的消息加入队列,而不是在它们出现时应用它们
  3. 实施队列消费者(可以是 Lambda,或其他一些周期性 运行 进程)从队列中拉出消息并聚合每个项目的喜欢,然后更新项目和 item-stats

通过批处理更新,我们可以以 latency/eventual 一致性为代价来控制并发性(和成本)。

我们最终可能会得到数量有限的队列消费者,每个消费者都分批处理项目。在每个批次中,将汇总多个项目喜欢的内容,并对每个项目应用一次更新。同样,每个批处理器将应用一个 item-stats 更新。

根据收到的点赞量,您可能需要启动更多处理器。