本应是bug的.join()

Hacker News Top 产品

摘要

Kronotop 解释了其架构:通过将连接管理(使用 Netty 事件循环)与工作执行(使用 Java 虚拟线程)分离,使用阻塞 I/O 处理数千个并发连接,从而像 Redis 一样扩展,同时支持 FoundationDB 调用等阻塞操作。

暂无内容
查看原文
查看缓存全文

缓存时间: 2026/07/04 18:41

# 本应是 Bug 的 `.join()` 来源:https://kronotop.com/blog/the-join-that-should-be-a-bug/ ### 在每次调用都阻塞的后端上处理数千个连接 在数据库系统中,实现连接管理有两种广为人知的方法。每种方法都有其代价。Kronotop(https://github.com/kronotop/kronotop)采用了第三种方式。我们先从面临的问题说起。 Kronotop 将其元数据存储在 FoundationDB(https://github.com/apple/foundationdb)中,同时将文档内容存储在本地文件系统上。因此,客户端请求的几乎每个操作都会转化为一次对 FDB 的网络调用和一次磁盘读取。网络调用耗时毫秒级,而非纳秒级。读取文档、提交事务、查找索引:所有这些都在等待 I/O。我们的命令路径并非内存操作,而是以等待为主导。 ## 两种经典模型 Redis1个线程1个线程✓低成本处理数千个连接✕任何操作都不能阻塞vsPostgres每个连接一个进程进程进程✓任何命令都可以阻塞✕每个连接一个进程,成本高昂 Redis(6.0 之前)运行在单个线程上。该线程监听所有连接,并逐个处理命令。这种方式能很好地应对大量连接,但禁止阻塞。命令路径中没有任何操作可以等待。如果一个命令等待,所有其他客户端都必须随之等待。当所有数据都在内存中时,这没问题。但当命令需要做更多工作时就行不通了。我们的命令通过远程调用事务存储,并将数据追加到磁盘文件后调用 fsync。这些步骤的每一步都会阻塞。 Postgres 则走了另一条路。它为每个连接分配一个独立的进程。这样阻塞便不再是问题。每个连接独立运行,因此可以编写简单的顺序代码。但代价也很明显:连接变成了操作系统进程,非常沉重。几千个连接就会变得难以承受。这就是为什么 Postgres 部署通常都位于独立的连接池之后。 一种模型能扩展连接量但不能等待;另一种能等待但无法以低成本扩展大量空闲/开放连接。Kronotop 两者都需要。 ## 将连接与工作分离 关键在于不再将“连接”和“工作”视为同一回事。 连接端的工作方式与 Redis 类似。我们基于 Netty(https://netty.io/)构建。少量事件循环线程监听大量套接字。它们响应任何就绪的套接字,解析传入的命令并将回复写回。这部分从未阻塞,也从不等待。因此,少数几个线程就能维持数千个连接存活。 工作端则负责磁盘和网络 I/O。它在别处运行,使用虚拟线程(https://docs.oracle.com/en/java/javase/21/core/virtual-threads.html)。虚拟线程让我们能够以普通、阻塞、从上到下的代码风格编写 I/O 调用。这是 Postgres 的风格,但没有 Postgres 的成本。虚拟线程在等待 FoundationDB 或磁盘时阻塞。当它阻塞时,Java 运行时会将其卸载,释放底层的载体线程供其他工作使用。数千个虚拟线程可以同时等待 I/O,而真正工作的线程只有寥寥几个。当 I/O 完成后,虚拟线程从停下的地方继续执行。 这样,网络线程便保持空闲,可以服务所有其他连接。慢速的部分在侧边进行,等待的成本很低。结果再交回连接线程,由其编写回复。我们有意将写操作保留在此处。这种交接是我们严格遵循的一条规则。 连接端工作端客户端...Netty 事件循环少量线程服务于数千个套接字。解析和写入。从不阻塞,从不等待。回复始终在此处写入。虚拟线程`tr.get(...).join()`阻塞于 FDB 或磁盘。运行时将其挂起。载体线程被释放用于其他工作。1`offload` `supplyAsync(..., vtExecutor)` 2`result` `thenAcceptAsync(..., nettyExecutor)` 在代码中,整个卸载过程归结为两个移动的部分: ```java CompletableFuture .supplyAsync(supplier, context.getVirtualThreadPerTaskExecutor()) // 在虚拟线程上运行 .thenAcceptAsync(action, response.getCtx().executor()); // 在 Netty 线程上恢复 ``` 两个执行器,两个阶段。`supplier` 是慢速部分。它在虚拟线程执行器上运行,允许在 FoundationDB 上阻塞。`action` 是回复部分。它在 `response.getCtx().executor()` 上运行,即拥有该连接的 Netty 事件循环。这样,等待的工作和必须不离位置的写操作分别位于不同的线程上。第二阶段只有在第一阶段完成后才会开始。 一个读取命令正是按照这种结构编写的。第一个块获取值,第二个块发送它: ```java public void execute(Request request, Response response) { AsyncCommandExecutor.supplyAsync(context, response, () -> { // 虚拟线程:打开事务并等待 FDB Session session = request.getSession(); Transaction tr = TransactionUtil.getOrCreateTransaction(context, session); DirectorySubspace subspace = openZMapSubspace(tr, session); return tr.get(subspace.pack(message.getKey())).join(); }, (value) -> { // Netty 线程:写入回复 response.writeFullBulkString(toMessage(value)); }); } ``` 中间的 `.join()` 是值得注意的一行。在普通线程上,这会是浪费——线程会坐在那里,阻塞且无用。但在虚拟线程上,这正是关键所在。运行时挂起该调用,释放真正的线程。当 FDB 响应时,它再恢复该调用。处理器从上到下读取,就像普通的阻塞代码,而等待的成本消失了。一旦 supplier 返回,执行器便进入回复阶段。如果命令是自动提交的,它会清理事务。如果抛出了异常,它会将错误写回客户端。 你可以从以下链接查看相关实现部分: - AsyncCommandExecutor(https://github.com/kronotop/kronotop/blob/main/kronotop/src/main/java/com/kronotop/AsyncCommandExecutor.java) - ZGetRangeHandler(https://github.com/kronotop/kronotop/blob/main/kronotop/src/main/java/com/kronotop/zmap/handlers/ZGetRangeHandler.java) - BucketQueryHandler(https://github.com/kronotop/kronotop/blob/main/kronotop/src/main/java/com/kronotop/bucket/handlers/BucketQueryHandler.java) ## 连接上承载了什么 连接在打开期间携带少量状态。它知道自己所对应的客户端、打开了哪些命名空间、以及是否已认证。它还持有任何进行中的事务的细节。 最后这一点很重要。在 Kronotop 中,事务属于连接。你开始事务,在其中运行若干命令,然后提交或回滚。在整个过程中,该事务与你的会话绑定。如果你的连接在中间断开,打开的事务会被取消并清理。不会留下任何悬空状态。你也可以重置会话状态而无需断开连接。这会一次性清除游标、监视的键以及任何半完成的事务。这种重置也使得连接易于在客户端侧池中重用:你归还连接,重置它,然后再重新分配出去。 一个连接就是一个会话客户端身份打开的命名空间认证状态事务进行中连接断开打开的事务被取消并清理,不留下悬空内容RESET清除游标、监视的键以及任何半完成的事务 如果你读完了这篇文章,为何不试试 Kronotop? Kronotop 是一个基于 FoundationDB 构建的分布式、事务性文档数据库。请参阅快速入门(https://kronotop.com/docs/quickstart)指南,只需几分钟即可启动一个集群。

相似文章

@danshipper: 非常酷

X AI KOLs Timeline

Guinness Chen 宣布 Codex 现在可以管理自己的线程,包括创建、搜索、组织、固定以及为并行任务启动工作树。