在为 Ariga 的运营数据图查询引擎工作时,我们看到通过构建一个稳健的缓存库,可以显著提升许多用例的性能。作为 Ent 的重度用户,自然会将此层实现为 Ent 的扩展。在本文中,我将简要说明缓存是什么、它们如何融入软件架构,并介绍 entcache——Ent 的缓存驱动。
缓存是提升应用性能的流行策略。它基于这样一个观察:使用不同介质检索数据的速度可能相差几个数量级。Jeff Dean 在关于《大型分布式系统构建经验的软工程建议》的一场演讲中,著名地展示了以下数字:

这些数字展示了有经验的软件工程师直觉上了解的事实:内存读取比磁盘读取更快,从同一数据中心检索数据比通过互联网获取更快。再者,某些计算既昂贵又慢,获取预计算结果往往比每次重新计算更快(且成本更低)。
Wikipedia 的集体智慧告诉我们,缓存是“硬件或软件组件,用于存储数据,以便未来对该数据的请求能更快得到响应”。换句话说,如果我们能把查询结果存储在 RAM 中,就能比通过网络访问数据库、读取磁盘、执行计算,再将结果返回给我们(通过网络)更快地满足依赖它的请求。
然而,作为软件工程师,我们必须记住缓存是一个臭名昭著的复杂主题。正如早期 Netscape 工程师 Phil Karlton 所说:'计算机科学中只有两件难事:缓存失效和命名问题'。举例来说,在依赖强一致性的系统中,缓存条目可能会变为陈旧,导致系统行为错误。因此,在将缓存设计到系统架构中时必须格外小心,关注细节。
介绍 entcache
entcache 包为用户提供了一个新的 Ent 驱动,可以包装现有的任何 SQL 驱动。总体而言,它会装饰所给驱动的 Query 方法,并在每一次调用时:
根据其参数(即语句和参数)生成缓存键(即哈希值)。
检查缓存中是否已有此查询的结果。如果已有(即缓存命中),则跳过数据库,直接从内存返回结果。
若缓存中不存在此查询条目,则将查询传递给数据库。
查询执行后,驱动记录返回行的原始值(
sql.Rows),并使用生成的缓存键将其存储到缓存。
该包提供多种选项来配置缓存条目的 TTL、控制哈希函数、提供自定义及多级缓存存储、驱逐以及跳过缓存条目。完整文档请参阅 https://pkg.go.dev/ariga.io/entcache。
正如之前提到的,正确配置应用的缓存是一项微妙的任务,entcache 为开发者提供了可使用的不同缓存层级:
- 基于
context.Context的缓存。通常附加到一次请求中,无法与其他缓存层级共同使用。它用于消除同一请求执行的重复查询。 ent.Client使用的驱动级缓存。应用通常为每个数据库创建一个驱动,因此我们将其视为进程级缓存。- 远程缓存。例如,一个 Redis 数据库提供持久化层,用于在多个进程之间存储和共享缓存条目。远程缓存层对应用部署变更或故障具有弹性,并能减少不同进程在数据库上执行的相同查询次数。
- 缓存层次结构,或多级缓存,允许以分层方式构建缓存。缓存存储的层次结构主要基于访问速度和缓存容量。例如,一个两层缓存,第一层是应用内存中的 LRU 缓存,第二层是 backed by a Redis 数据库的远程缓存。
让我们通过解释基于 context.Context 的缓存来展示这一点。
上下文级缓存
ContextLevel 选项将驱动配置为使用基于 context.Context 的缓存。该上下文通常随请求(如 *http.Request)附加,并在多级模式下不可用。当此选项被用作缓存存储时,附加的 context.Context 载有一个 LRU 缓存(可按需不同配置),驱动在执行查询时将条目存储并在 LRU 缓存中搜索。
此选项非常适合需要强一致性的应用,但又想避免在同一请求中执行重复数据库查询的场景。例如,给定以下 GraphQL 查询:
query($ids: [ID!]!) {
nodes(ids: $ids) {
... on User {
id
name
todos {
id
owner {
id
name
}
}
}
}
}
对上述查询的朴素解决方案会执行:1 次获取 N 个用户,另外 N 次获取每个用户的待办事项,再为每个待办事项查询一次获取其所有者(详细见 N+1 问题)。
然而,Ent 为此类查询提供了独特的解决方案(详见 Ent 官方网站),因此此例中只会执行 3 次查询:1 次获取 N 个用户,1 次获取所有用户的待办事项,和 1 次获取所有待办事项的所有者。
使用 entcache,查询次数可进一步减少到 2 次,因为首尾查询相同(参见代码示例)。

各层级的详细解释请参阅仓库 README。
入门
如果你还不熟悉如何设置新的 Ent 项目,请先完成 Ent 设置教程。
首先,使用以下命令 go get 获取该包。
go get ariga.io/entcache
安装完 entcache 后,你可以轻松地将其添加到项目中,示例如下:
// 打开数据库连接。
db, err := sql.Open(dialect.SQLite, "file:ent?mode=memory&cache=shared&_fk=1")
if err != nil {
log.Fatal("打开数据库", err)
}
// 用 entcache.Driver 装饰 sql.Driver。
drv := entcache.NewDriver(db)
// 创建一个 ent.Client。
client := ent.NewClient(ent.Driver(drv))
// 告诉 entcache.Driver 在运行模式迁移时跳过缓存层
// 运行模式迁移时。
if client.Schema.Create(entcache.Skip(ctx)); err != nil {
log.Fatal("运行模式迁移", err)
}
// 运行查询。
if u, err := client.User.Get(ctx, id); err != nil {
log.Fatal("查询用户", err)
}
// 以下查询已被缓存。
if u, err := client.User.Get(ctx, id); err != nil {
log.Fatal("查询用户", err)
}
要查看更多高级示例,请前往仓库的 examples 目录。
结束语
在本文中,我介绍了 “entcache”,一种新的 Ent 缓存驱动,我在 Ariga 的运营数据图查询引擎工作时开发。我们先简要提到了在软件系统中实现缓存的动机,随后描述了 entcache 的特性与能力,并给出了一个简短示例,说明如何在你的应用中设置它。
我们正在开发一些功能,并希望继续完善,但需要社区帮助来正确设计它们(缓存失效的解决方案,谁来帮忙? ;))。如果你有兴趣贡献,欢迎在 Ent Slack 频道与我联系。
- 订阅我们的 Newsletter
- 在 Twitter 上关注我们
- 加入 Gophers Slack 的 #ent 频道 (Gophers Slack)
- 加入 Ent Discord 服务器 (Ent Discord Server)