运行时中内置Redis服务器
速览
该技术举措将Redis服务器直接嵌入运行时环境,减少外部依赖,提升数据访问速度。此举可优化实时应用和微服务架构的性能,尤其对需要快速读写缓存的场景有显著帮助。
AI 深度解读
背景
Encore 是一个后端开发平台,它让开发者在本地开发、测试和生产环境中运行相同的后端代码。应用依赖的基础设施(如数据库、缓存、消息队列等)在代码中声明,Encore 会根据环境自动配置对应的基础设施。为了让本地开发真正有用,这些基础设施必须真实存在于开发者的机器上,并且行为要与生产环境一致。对于大多数基础设施(如数据库用 Docker 运行,发布/订阅用本地 NSQ 守护进程),本地搭建相对直接。但缓存(cache)比较棘手:要么在 Docker 中运行一个真实的 Redis,但需要额外安装和管理一个容器;要么用 mock 替代,但 mock 只在代码不依赖 Redis 的特殊行为时才有效,一旦代码用到 mock 实现不同的细节就会出问题。
核心内容
Encore 采取了一种不同的方案:将 Redis 服务器内嵌到运行时(runtime)中。在本地开发和测试时,运行时自动启动一个内存中的 Redis 服务器,该服务器与运行时运行在同一个进程内。本文介绍了这一方案的工作原理、为何将 Go 实现移植到 Rust,以及如何确保内存服务器与生产环境中应用实际连接的 Redis 行为一致。
Encore 的 Go 运行时长久以来都是这样工作的:Go 端本地开发使用 alicebob/miniredis(一个用 Go 实现的内存 Redis 服务器),因此本地运行应用无需外部 Redis。当 Encore 构建支持 TypeScript 应用的 Rust 运行时(runtime)时,也需要同样的能力。一种选择是保留 Go 实现,将其作为一个独立进程由运行时启动和停止,但这意味着要附带第二个二进制文件,并监督另一个进程(有自己的启动、关闭和失败模式)。团队希望内存服务器像其他基础设施层一样,直接位于运行时内部。
因此,他们将 miniredis 移植到了 Rust(PR #2300),使其作为库在运行时内运行。移植代码约 25,000 行 Rust,实现了应用实际使用的数据类型:字符串、哈希、列表、集合、有序集合、流、发布/订阅、事务和 Lua 脚本。这是一个真实的 Redis 服务器,监听 TCP 套接字并采用 Redis 有线协议(RESP),而不是模拟命令子集的桩(stub)。
移植时还继承了 Go 版本的操作行为:miniredis 维护自己的 mock 时钟,因此内嵌服务器运行一个后台任务,每秒将时钟向前推进一秒,以保证基于时间的过期在长时间会话中正常工作;同时每 15 秒将 key 数量修剪到上限(如 100 个),防止本地缓存无限增长。代码片段如下:
// runtimes/core/src/cache/miniredis.rs
// 每秒将时间快进1秒,每15秒修剪回100个key,与旧版Go miniredis的清理一致
async fn cleanup_task(server: Miniredis) {
let mut interval = tokio::time::interval(Duration::from_secs(1));
loop {
interval.tick().await;
server.fast_forward(Duration::from_secs(1));
// 每15秒,修剪到100个key
}
}
在 Encore 应用中,缓存的声明与其他资源一样,通过代码完成:
import { CacheCluster, IntKeyspace, expireIn } from "encore.dev/storage/cache";
const cluster = new CacheCluster("rate-limit", {
evictionPolicy: "allkeys-lru",
});
const requestsPerUser = new IntKeyspace<{ userId: string }>(cluster, {
keyPattern: "requests/:userId",
defaultExpiry: expireIn(10 * 1000),
});
这个声明就是运行时所需的全部信息。在部署环境中,Encore 会配置一个真实的 Redis,运行时连接到它;在本地开发和测试中,运行时在本地地址启动内置服务器并连接它。决策来自运行时配置:每个 Redis 集群带有一个 in_memory 标志,当该标志设置时,运行时启动内嵌服务器,而不是拨号配置的外部服务器(PR #2322)。在运行时中,这个决策逻辑很简单:当需要内嵌服务器时,运行时启动它,并将同样的 Redis 客户端(原本用于托管集群)指向本地 Redis 地址:
// runtimes/core/src/cache/manager.rs
// 在测试或任何集群设置了in_memory时使用miniredis
let needs_miniredis = self.testing || self.clusters.iter().any(|c| c.in_memory);
if needs_miniredis {
let server = self.runtime.block_on(MiniredisServer::start())?;
let url = format!("redis://{}", server.addr());
// 同样的redis客户端,指向本地服务器
let client = redis::Client::open(url)?;
// ...
}
应用代码在两种情况下完全相同:它持有一个 keyspace,并调用 get、set、increment 等方法操作 Redis 客户端。本地和生产之间唯一的变化是客户端连接的地址。由于内嵌服务器通过相同类型的套接字使用相同协议,客户端连接它的方式与连接托管 Redis 完全一样。
内嵌服务器的价值在于它表现得像它所替代的 Redis。一个命令在处理边界情况时的微小差异,可能导致本地测试通过但生产环境失败,这正是本地-生产一致性所要避免的。为了防范这一点,团队用从 Go 移植来的测试套件对 Rust 服务器进行验证。miniredis 自带一个 Go 集成测试套件,它针对真实服务器运行命令并检查响应。他们用同样的套件对 Rust 服务器运行,并将原始 RESP 响应逐字节比较。当字节匹配时,说明 Rust 服务器与参考实现的行为一致。
通过这种方式运行参考套件,发现了许多容易遗漏的差异。例如过期测试:套件会推进 mock 时钟来检查 key 是否按时过期,因此 Rust 服务器需要提供一个命令让测试调用以快进自己的时钟。另一个是 TLS:套件使用的证书链被 Go 的 TLS 实现接受,但被 Rust 拒绝,因此连接需要构建一个正确的证书层级供测试使用。这两个差异都不是手写 mock 能复现的,如果没有与参考实现比较,它们就会被忽略。
在本地开发和测试中,缓存是一种只需声明即可使用的资源,无需安装或运行任何额外组件。测试使用真实的 Redis 服务器而非 mock,因此测试依赖的命令行为就是生产环境中会遇到的行为。
内嵌服务器仅用于本地开发和测试。在生产环境中,Encore 会配置真实的托管 Redis,因为内嵌服务器只是一个开发工具,不具备扩展能力。将其构建在运行时内部,使得本地开发和测试与生产环境匹配,而无需任何人单独搭建缓存。
关键要点
- Encore 在运行时中内嵌了一个真实的内存 Redis 服务器(基于 miniredis 移植的 Rust 实现),用于本地开发和测试,无需外部 Redis 依赖。
- 移植的 Rust 代码约 25,000 行,实现了最常用的数据类型和命令,包括字符串、哈希、列表、集合、有序集合、流、发布/订阅、事务和 Lua 脚本,并通过 TCP 套接字使用 RESP 协议通信。
- 内嵌服务器通过后台任务模拟时间推进和 key 数量修剪,与 Go 版 miniredis 行为一致,确保基于时间的过期和内存限制正常工作。
- 应用代码在本地和生产环境中完全相同,唯一区别是客户端连接地址:本地指向内嵌服务器,生产指向托管 Redis。运行时通过
in_memory标志自动切换。 - 为确保行为一致,团队使用 Go 版 miniredis 的集成测试套件,将 Rust 服务器的原始 RESP 响应逐字节比较,发现了 mock 难以捕获的差异(如时钟推进命令、TLS 证书链问题)。
- 内嵌服务器仅用于开发/测试,不用于生产,生产环境中仍使用真实的托管 Redis。
意义与影响
这一做法从根本上解决了开发-生产环境不一致这一经典问题。传统上,开发者要么依赖 Docker 容器(增加复杂性),要么使用 mock(存在行为差异风险)。Encore 通过将完整 Redis 服务器集成进运行时,实现了“声明即用”的零配置本地开发体验,同时保证了命令行为的完全一致。这种“嵌入式基础设施”的思路对后端开发平台具有重要参考价值:它表明,对于某些关键基础设施(尤其是缓存),与其外部依赖或 mock,不如将轻量级但真实的实现内嵌到运行时中,从而在开发阶段获得与生产环境高度一致的反馈。此外,通过
