【问题标题】:How to efficiently store and read back a Hierarchy from cache如何有效地从缓存中存储和读回层次结构
【发布时间】:2026-01-22 17:50:01
【问题描述】:

我的情况是,我目前正在将一个层次结构存储在一个快速接近 15000 个节点(5000 个边)的 SQL 数据库中。此层次结构根据用户在树中的位置定义我的安全模型,授予对以下项目的访问权限。因此,当用户请求所有受保护项目的列表时,我使用 CTE 在 db 中对其进行递归(并展平所有项目),这开始显示其年龄(慢)。

层次结构不会经常变化,所以我尝试将其移入 RAM ( redis )。请记住,我有许多子系统需要它来进行安全调用,并且 UI 需要为 CRUD 操作构建树。

第一次尝试

我的第一次尝试是将关系存储为键值对 (这就是它在数据库中的存储方式)

乙 / \ F G / \ / \ 嗨JK 映射到: E - [F, G] F - [H,我] G - [J, K]

所以当我想要 E 和它的所有后代时,我使用键递归地获取它的孩子和他们的孩子,它允许我从任何节点开始向下移动。该解决方案提供了很好的速度提升,但是对于 15,000 个节点,在代码中重建我的树大约需要 5000 次缓存命中(更糟糕的情况......从 E 开始。性能基于起始节点的位置,导致超级用户看到性能最差)。这仍然很快,但似乎很健谈。我喜欢这样一个事实,即我可以随时通过从键列表中弹出一个节点来删除它,而无需重建我的整个缓存。在 UI 上可视化地构建树的速度也很快。

第二次尝试

我的另一个想法是从数据库中获取层次结构,构建树并将其存储在 RAM (redis) 中,然后将整个事物拉出内存(它的大小约为 2 MB,已序列化)。这给了我一个对 redis 的调用(不是很健谈)来拉出整个树,找到用户父节点,然后下降以获取所有子项。这些调用很频繁,在网络层向下传递 2 MB 似乎很大。这也意味着如果不拉下树并编辑并将其全部推回,我就无法轻松添加/删除和项目。此外,通过 HTTP 构建的按需树意味着每个请求必须拉下 2MB 才能获得直接子节点(使用第一个解决方案非常小)。


那么您认为哪种解决方案更好(从长期来看,它会继续增长)。两者都快得多,并且减轻了数据库的负担。或者他们是我没有想到的更好的方法来实现这一点?

谢谢

【问题讨论】:

  • 你是怎么解决这个问题的?

标签: c# performance caching redis hierarchical-data


【解决方案1】:

让我提供一个想法...

使用分层版本控制。当图中的一个节点被修改时,增加它的版本(数据库中的一个简单的 int 字段),但增加它所有祖先的版本。

  • 首次从数据库中获取子树时,将其缓存到 RAM。 (您可能可以通过递归 CTE 对此进行优化,并在单个数据库往返中完成。)
  • 但是,下次您需要检索相同的子树时,只检索根。然后将缓存的版本与您刚刚从数据库中获取的版本进行比较。
    • 如果它们匹配,很好,您可以停止获取并重用缓存。
    • 如果没有,请获取孩子并重复该过程,同时刷新缓存。

最终结果是,您通常会在很早的时候就剔除提取,通常只在一个节点之后,您甚至不需要缓存整个图。修改很昂贵,但这应该不是问题,因为它们很少见。

顺便说一句,类似的原理会在相反的方向上起作用 - 即当您从叶子开始并且需要找到到根的路径时。您需要以相反的方向更新版本控制层次结构,但其余部分应该以非常相似的方式工作。你甚至可以将两个方向结合起来。

--- 编辑---

如果您的数据库和 ADO.NET 驱动程序支持它,那么可能值得研究一下服务器通知,例如 MS SQL Server 的 SqlDependencyOracleDependency

本质上,您指示 DBMS 监控更改并在发生更改时通知您。这非常适合以有效的方式使您的客户端缓存保持最新。

【讨论】:

  • 与我的方法相比,当我们更新节点时需要更少的工作,而当我们从缓存中读取节点时需要更多的工作。我认为这取决于您何时想向用户展示性能影响。我认为使树更新请求更长以使后续读取请求更快而不是在后续读取中分散额外工作是最合乎逻辑的。
  • @mephisto123 不一定。在我的方法中,初始查询成本更高,但后续查询往往非常便宜,通常只有一行。在您的方法中,即使没有任何更改,后续查询仍需要获取整个子树。所以,如果有更多的重复阅读,我的方法会更好。顺便说一句,你扩大了数据库的大小——这对数据库级缓存不利,所以即使是第一个查询的性能也存在问题——在一个缓存良好的小型数据库上的递归 CTE 可能比获取一个未缓存的 BLOB。
  • 不,我不是想将整个子树保存在数据库中。我的意思是缓存所有后代节点的列表(只是简单的数组),因为通常不需要实际的树结构,大多数情况下我们只需要知道某个选定节点下方的节点列表即可。因此,如果所选节点的信息已经被缓存,我们只需从缓存中执行一个简单的请求即可完成。
  • @mephisto123 好的,也许我误解了你的回答。当您说“缓存”时,您是指客户端吗?如果是这样,OP 已经尝试过了。问题不在于最初构建缓存,问题在于检测缓存中的陈旧数据并避免重复从数据库中获取大量数据(即不断地从头开始重建缓存)。
  • @mephisto123 顺便说一句,缓存是树还是列表并不重要 - 两者都可以相当有效地遍历。不过我会坚持使用树,因为它本身包含更多信息,并且(除其他外)允许您在不同节点之间共享树的药水(并节省 RAM)。
【解决方案2】:

如果层次结构不经常更改,您可以为每个节点计算下面的整个项目列表(而不仅仅是直接子节点)。 这样,您将需要更多的 RAM,但对于任何用户来说,它都能以闪电般的速度运行,因为您将能够在单次读取中读取整个后代节点列表。

对于您的示例(我将使用 JSON 格式):

E - {"direct" : [F, G], "all" : [F, G, H, I, J, K]}
F - {"direct" : [H, I], "all" : [H, I]}
G - {"direct" : [J, K], "all" : [J, K]}

好吧,对于超级用户来说,您仍然需要在每个请求中传输大量数据,但我看不出有什么办法可以减少它。

【讨论】:

  • - 如果 RAM 有问题,可以将密钥设置为较短的 TTL,这会在用户注销后立即刷新非活动用户。
  • - 如果使用 redis 集而不是 JSON 或其他字符串来表示子节点,许多操作可以优化为简单的检查,例如 SISMEMBER 等,以保持较低的网络流量。 redis.io/commands#set
【解决方案3】:

我们做这样的事情。我们将树读入内存,将其存储在应用程序缓存中,然后从内存中访问它。由于我们的更改几乎从不更改,并且更改不必立即反映在 Web 应用程序中,我们甚至无需费心检测它们,只需让缓存老化并刷新即可。它对我们非常有效。

【讨论】: