Jun 25, 2026

We put a Redis server inside our runtime

How we run an in-memory Redis inside the runtime for local development and tests, and how we keep it behaving the same as the Redis you run in production.

6 Min Read

Encore runs the same backend code in local development, in tests, and in production. The infrastructure an application depends on is declared in its code, and Encore provisions that infrastructure for each environment. For local development to be useful, the infrastructure has to actually be present on your machine, and it has to behave the way it does in production.Encore在本地开发、测试和生产环境中运行相同的后端代码。应用程序依赖的基础设施在代码中声明,Encore为每个环境配置该基础设施。要使本地开发有用,基础设施必须实际存在于你的机器上,并且其行为必须与生产环境一致。

Most of it is straightforward to stand up locally, with databases running in Docker and pub/sub running against a local NSQ daemon. A cache is harder, because the realistic options are to run a real Redis in Docker, which is another container to install and keep alive, or to replace it with a mock, which only behaves like Redis until your code relies on something the mock implements differently.大多数基础设施在本地启动都很简单,数据库在Docker中运行,发布/订阅则使用本地NSQ守护进程。缓存则更难,因为现实的选择要么是在Docker中运行真正的Redis(需要额外安装和保持运行的容器),要么用模拟替代,但模拟只有在代码不依赖其不同实现时才像Redis。

We took a different approach, where the runtime has an in-memory Redis server built into it that starts automatically in local development and tests, in the same process as the runtime. This post covers how that works, why we ported a Go implementation to Rust to get there, and how we make sure the in-memory server behaves the same as the Redis an application talks to in production.我们采取了不同的方法:运行时内置了一个内存Redis服务器,在本地开发和测试时自动启动,与运行时在同一进程中。本文介绍了其工作原理、为何将Go实现移植到Rust,以及如何确保内存服务器与应用程序在生产环境中使用的Redis行为一致。

An in-memory Redis, in the same process同一进程中的内存Redis

Encore's Go runtime has worked this way for a long time. On the Go side, local development uses alicebob/miniredis, an in-memory Redis server written in Go, so that running an application locally needs no external Redis.Encore的Go运行时长期以来一直以这种方式工作。在Go端,本地开发使用alicebob/miniredis(一个用Go编写的内存Redis服务器),因此本地运行应用程序无需外部Redis。

When we built the Rust runtime that powers TypeScript applications, we needed the same capability there. One option was to keep the Go implementation and run it as a separate process that the runtime starts and stops, but that means shipping a second binary and supervising another process alongside the runtime, with its own startup, shutdown, and failure modes. We wanted the in-memory server to live inside the runtime, the way the rest of the infrastructure layer does.当我们构建为TypeScript应用程序提供支持的Rust运行时,也需要同样的能力。一种选择是保留Go实现,将其作为独立进程由运行时启动和停止,但这意味着需要附带第二个二进制文件并监督另一个进程,带来额外的启动、关闭和故障模式。我们希望内存服务器像其他基础设施层一样存在于运行时内部。

So we ported miniredis to Rust (#2300), where it runs as a library inside the runtime. The port is about 25,000 lines of Rust and implements the data types applications actually use: strings, hashes, lists, sets, sorted sets, streams, pub/sub, transactions, and Lua scripting. It is a real Redis server that listens on a TCP socket and speaks the Redis wire protocol (RESP), rather than a stub that emulates a subset of commands.因此,我们将miniredis移植到了Rust(#2300),使其作为库在运行时内运行。移植代码约25,000行Rust,实现了应用程序实际使用的数据类型:字符串、哈希、列表、集合、有序集合、流、发布/订阅、事务和Lua脚本。它是一个真正的Redis服务器,监听TCP套接字并采用Redis有线协议(RESP),而非模拟部分命令的存根。

Porting it also meant carrying over the operational behavior the Go version had. miniredis keeps its own mock clock, so the embedded server runs a small background task that advances that clock once a second to keep time-based expiry working during a long session, and prunes back to a bounded number of keys so a local cache does not grow without limit:移植还继承了Go版本的操作行为。miniredis维护自己的模拟时钟,因此嵌入式服务器运行一个后台任务,每秒推进一次时钟,确保长时间会话中基于时间的过期正常工作,并修剪到有限数量的键,防止本地缓存无限增长:

// runtimes/core/src/cache/miniredis.rs // Fast-forward time by 1s every second, and prune back to 100 keys // every 15s, matching the old Go miniredis binary's cleanup. 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)); // every 15s, prune down to 100 keys } }

How the runtime chooses where to connect运行时如何选择连接目标

A cache in an Encore application is declared in code, the same way every other resource is: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), });

That declaration is all the runtime needs. In a deployed environment, Encore provisions a real Redis and the runtime connects to it, and in local development and tests the runtime starts the built-in server on a local address and connects to that instead. The decision comes from the runtime configuration, where each Redis cluster carries an in_memory flag, and when that flag is set the runtime starts the embedded server rather than dialing the configured servers (#2322).运行时仅需该声明。在部署环境中,Encore配置真正的Redis,运行时连接至该Redis;在本地开发和测试中,运行时启动内置服务器并连接至本地地址。该决策来自运行时配置,每个Redis集群都带有in_memory标志,当该标志设置时,运行时启动嵌入式服务器而非连接配置的服务器(#2322)。

In the runtime that decision is small. When the embedded server is needed, the runtime starts it and hands the same Redis client it would use for a managed cluster a redis:// address pointing at the local server:在运行时中,该决策很简单。当需要嵌入式服务器时,运行时启动它,并将用于托管集群的同一Redis客户端指向本地服务器的redis://地址:

// runtimes/core/src/cache/manager.rs // Use miniredis for testing or when any cluster has in_memory set. 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()); // The same redis client used for a managed cluster, pointed at the local server. let client = redis::Client::open(url)?; // ... }

The application code is identical in both cases. It holds a keyspace and calls get, set, increment, and the rest against a Redis client. The only thing that changes between local and production is the address the client connects to. Because the embedded server speaks the same protocol over the same kind of socket, the client connects to it exactly as it connects to a managed Redis.两种情况下应用程序代码完全相同。它持有一个键空间,并通过Redis客户端调用get、set、increment等操作。本地与生产环境之间唯一的变化是客户端连接的地址。由于嵌入式服务器通过相同类型的套接字使用相同协议,客户端连接它的方式与连接托管Redis完全相同。

Keeping it faithful to real Redis保持与真实Redis一致

An embedded server is only useful if it behaves like the Redis it stands in for. A small difference in how one command handles an edge case is the kind of thing that passes locally and fails in production, which is the situation local-production parity exists to avoid.嵌入式服务器只有在行为与它所替代的Redis一致时才有用。某个命令处理边缘情况的微小差异可能导致本地通过而生产环境失败,这正是本地-生产一致性要避免的情况。

To guard against that, we test the Rust server against the implementation we ported from. miniredis ships with a Go integration suite that runs commands against a live server and checks the responses. We run that same suite against our Rust server and compare the raw RESP responses byte for byte. When the bytes match, our server is answering the way the reference implementation does.为防止这种情况,我们针对移植的源实现测试Rust服务器。miniredis附带一个Go集成测试套件,针对实时服务器运行命令并检查响应。我们对该套件针对Rust服务器运行,并逐字节比较原始RESP响应。当字节匹配时,我们的服务器与参考实现响应一致。

Running the reference suite this way surfaced differences that would be easy to miss otherwise. One of them was in how expiry is tested, where the suite advances a mock clock to check that keys expire on schedule, so the Rust server needed a command the tests could call to fast-forward its own clock to the same effect. Another was TLS, where the certificate chain the suite used was accepted by Go's TLS implementation but rejected by Rust's, so connecting at all required building a proper certificate hierarchy for the tests. Neither difference is one a hand-written mock would reproduce, and both would have gone unnoticed without comparing against the reference.通过运行参考套件,我们发现了容易忽略的差异。其中之一是过期测试:套件推进模拟时钟以检查键是否按时过期,因此Rust服务器需要一条命令供测试调用以快进其时钟达到相同效果。另一个是TLS:套件使用的证书链被Go的TLS实现接受,但被Rust拒绝,因此连接需要为测试构建正确的证书层次结构。这些差异都不是手写模拟能复现的,若不与参考实现比较,两者都会被忽略。

Where this leaves local development这对本地开发意味着什么

In local development and tests, a cache is something you declare and use without installing or running anything alongside your application. Tests exercise a real Redis server rather than a mock, so the command behavior a test depends on is the behavior it will meet in production.在本地开发和测试中,缓存只需声明即可使用,无需安装或运行任何额外组件。测试使用真正的Redis服务器而非模拟,因此测试依赖的命令行为就是生产环境中会遇到的行为。

The embedded server is only for local development and tests. In production Encore provisions a real, managed Redis, because the embedded server is a development fixture and is not built to scale. Building it into the runtime is what lets local development and tests match production without anyone standing up a cache to get there.嵌入式服务器仅用于本地开发和测试。在生产环境中,Encore配置真正的托管Redis,因为嵌入式服务器是开发工具,并非为扩展而设计。将其构建到运行时中,使得本地开发和测试与生产环境匹配,而无需任何人搭建缓存。

If you want to go deeper, the docs cover how Encore provisions infrastructure from your code and the cache primitive itself.如需深入了解,文档介绍了Encore如何从代码中配置基础设施以及缓存原语本身。

This blog is presented by Encore, the backend framework for building robust type-safe distributed systems with declarative infrastructure.本博客由Encore呈现,Encore是用于构建健壮类型安全分布式系统并带有声明式基础设施的后端框架。

Like this article?
Get future ones straight to your mailbox.
喜欢这篇文章?订阅以获取未来文章。

You can unsubscribe at any time.你可以随时取消订阅。