byroot's blog About

Database Protocols Are Underwhelming

Mar 21, 2025

如果你在这个行业待了一段时间,你可能已经见过无数关于 SQL 作为关系数据库查询语言的优点和问题的辩论。作为一个 ORM 维护者,我对 SQL 有一些不满,但总的来说它还是可行的,而且无论如何,它有如此强大的惯性,以至于幻想一个替代品毫无意义。

然而,有一个与数据库相邻的话题,我认为我从未见过任何讨论,并且我认为可以改进的是这些数据库公开的用于执行查询的协议。关系数据库是非常令人印象深刻的技术,但它们的客户端协议让我怀疑它们是否考虑过被人类在 CLI 界面中输入命令之外的任何东西使用。

我恰好也维护着 Ruby 的 Redis 客户端,虽然 Redis 协议远非完美,但我认为它在某些方面比 PostgreSQL 和 MySQL 协议做得更好,我对这两个协议比较熟悉。

可变状态,大量可变状态

你可能从未见过它们,因为默认情况下它们不会被记录,但当 Active Record 连接到你的数据库时,它首先会执行几个特定于数据库的查询,我通常称之为“序曲”。

具体发送哪些查询取决于你如何配置 Active Record,但对于大多数人来说,它将是默认值。

在 MySQL 的情况下,它看起来像这样:

SET @@SESSION.sql_mode = CONCAT(@@sql_mode, ',STRICT_ALL_TABLES,NO_AUTO_VALUE_ON_ZERO'),
   @@SESSION.wait_timeout = 2147483

对于 PostgreSQL,则更多一些:

SET client_min_messages TO 'warning';
SET standard_conforming_strings = on;
SET intervalstyle = iso_8601;
SET SESSION timezone TO 'UTC'

在这两种情况下,其想法都是相同的,我们正在配置连接,使其表现不同。这种总体思路并没有错,随着数据库的老化,会引入新的模式和功能,因此出于向后兼容性的原因,你必须选择启用它们。

然而,我对这个问题的看法是,你可以随时设置这些配置。它们不局限于初始身份验证和配置步骤,因此,当作为一个框架或库,你将连接交给用户代码,稍后再取回它时,你无法确定他们是否更改了其中任何设置。同样,这意味着你既有已配置的连接,也有未配置的连接,必须小心永远不要使用未配置的连接。这不是世界末日,但显着地复杂化了连接管理代码。

这种状态性也使得从错误中恢复变得困难,甚至是不可能的。如果由于某种原因查询失败,很难判断连接处于哪种状态,唯一合理的事情是关闭它并从头开始使用新连接。

如果这些协议有一个显式的初始配置阶段,那么在发生错误后(或者在让用户代码运行未知查询后),发送某种“重置状态”消息,使连接恢复到已知的干净状态会更容易。

从 Ruby 客户端的角度来看,它看起来像这样:

connection = MyDB.new_connection
connection.authenticate(user, password)
connection.configure("SET ...")
connection.query("INSERT INTO ...")
connection.reset

你甚至可以在连接被检回连接池时廉价地重置状态。

我对数据库服务器面临的所有限制了解不多,但我无法想到为什么这样的协议功能特别难以实现的原因。

安全重试

数据库客户端,或者一般的网络客户端,最重要的工作之一是处理网络错误。

在底层,大多数(如果不是全部)客户端看起来像这样:

def query(command)
 packet = serialize(command)
 @socket.write(packet)
 response = @socket.read
 deserialize(response)
end

这非常简单,你将查询发送到服务器并读取服务器响应。然而,困难在于 writeread 操作都可能以数十种不同的方式失败。

也许服务器暂时无法访问,并且会在一两秒钟后再次工作。或者,也许它可以访问,但由于暂时过载而没有足够快地响应,因此达到了客户端超时。

这些错误应该很少见,但无法完全避免。无论何时你通过网络发送东西,都存在它可能无法工作的机会,这是一个生活中的事实。因此,客户端应尽可能优雅地处理此类错误,并且没有太多方法可以做到这一点。

处理此类错误最明显的方法是重试查询,问题是大多数情况下,从数据库客户端的角度来看,不清楚重试是否安全。

在我看来,HTTP 最好的特性是它明确的谓词规范。HTTP 规范明确指出,允许客户端甚至代理重试一些特定的谓词,例如 GETDELETE,因为它们是 幂等的

之所以这很重要,是因为无论何时 writeread 失败,在绝大多数情况下,你都不知道查询是否在服务器上执行。这就是为什么幂等性是如此有价值的属性,根据定义,幂等操作可以安全地执行两次,因此当你不确定它是否已执行时,你可以重试。

但是,使用 SQL 知道查询是否幂等并不容易。例如,一个简单的 DELETE 查询是幂等的:

DELETE
FROM articles
WHERE id = 42;

但是,可以完美地编写一个非幂等的 DELETE 查询:

DELETE
FROM articles
WHERE id IN (
 SELECT id
 FROM articles
 LIMIT 10
);

因此,在实践中,除非调用者指示它们这样做是安全的,否则数据库客户端无法安全地重试错误。你可以尝试编写一个客户端来解析查询以确定它们是否幂等,但这充满了危险,因此通常最好依靠调用者来告诉我们。

这就是我最近一直在慢慢重构 Active Record 的原因之一,以便在发生网络错误时逐渐使其更容易重试更多查询。但是,即使在我完成重构之后,仍然会存在许多非幂等查询,并且无论何时它们失败,Active Record 仍然无能为力。

幂等键

但是,有一些解决方案可以将非幂等操作转换为幂等操作,使用有时称为“幂等键”的东西。如果你使用过 the Stripe API,那么你可能已经熟悉它们。我怀疑他们不是第一个提出这种解决方案的人,但那是我第一次接触到它。

从概念上讲,它相当简单,当执行非幂等操作时,比如创建一个新的客户记录,你可以添加一个 Idempotency-Key HTTP 标头,其中包含一个随机生成的字符串。如果由于某种原因你需要重试该请求,你可以使用相同的幂等键来执行它,从而允许 Stripe API 检查初始请求是否成功,并执行或放弃重试。

他们甚至更进一步,当具有幂等键的请求成功时,他们会记录响应,以便在重试的情况下,他们会向你返回完全相同的原始响应。由于此功能,可以安全地重试对其 API 的所有 API 调用,无论它们是否幂等。

这是一个非常棒的功能,以至于去年在 Rails World 2024 上,当我看到有一个由 Kyle Davis 主持的 ValKey 展位时,我决定去和他聊聊,看看 ValKey 是否有兴趣解决这个相当常见的问题。

因为我说的一切关于 SQL 和幂等性的内容也适用于 Redis(因此也适用于 ValKey)。Redis 客户端也很难知道是否可以安全地重试查询,并且在几十年里,早在我成为维护者之前,Redis 客户端就会 默认重试所有查询

起初,它只会对 ECONNRESET 错误执行此操作,但随着时间的推移,越来越多的错误被添加到重试列表中。我必须承认,我对 TCP 并不是最了解的人,因此当返回此类错误时,可能确实可以安全地假设服务器从未收到查询,但随着时间的推移,越来越多的错误被添加到列表中,我非常怀疑所有这些错误都可以安全地重试。

这就是为什么当我后来编写 redis-client(一个更简单、更低级别的 Redis 客户端)时,我确保默认情况下不重试,以及通过同时拥有 callcall_once 方法来区分幂等查询的方法。

但是,当我 Mike Perham 在 Sidekiq 中用 redis-client 替换 redis gem 时,我得到的反馈是,很多用户开始注意到以前不会遇到的错误报告,显示了远程数据存储在实践中是多么不可靠,尤其是在云环境中。

因此,即使这些重试可能不安全,并且可能偶尔会导致数据丢失,但它们是用户所期望的。

这就是为什么我试图向 Kyle 推销一种幂等键功能,他鼓励我在 ValKey 仓库中 提交一个功能请求。经过几轮讨论后,ValKey 核心团队接受了该功能,虽然据我所知它尚未实现,但下一个版本的 ValKey 可能会有它。

从概念上讲,它再次非常简单:

MULTISTORE 699accd1-c7fa-4c40-bc85-5cfcd4d3d344 EX 10
INC counter
LPOP queue
EXEC

就像 Stripe 的 API 一样,你首先使用一个随机生成的密钥(在这种情况下是一个 UUID)和一个过期时间来启动事务。

在上面的示例中,我们要求 ValKey 记住此事务 10 秒,这就是我们可以安全重试的时间,之后 ValKey 可以放弃响应。

假设下一个版本的 ValKey 附带该功能,那么它最终应该提供一个安全地重试所有可能查询的解决方案。

我完全理解关系数据库比内存中的键值存储要大得多,因此实现起来可能更难,但如果有人问我 MySQL 或 PostgreSQL 可以添加什么功能来使它们更易于使用,那肯定会是这个功能。

在 ValKey 的情况下,鉴于它是一个文本协议,这意味着引入一个新命令,但 MySQL 和 PostgreSQL 都有二进制协议,具有不同的数据包类型,因此我认为可以在协议级别引入它,而无需更改它们各自的 SQL 语法,也无需担心向后兼容性。

Prepared Statements

我认为数据库协议的另一个重要组成部分,但使用起来不愉快的是 prepared statements。

Prepared statements 主要服务于两个功能,最重要的是分别提供查询及其参数,以消除 SQL 注入的风险。除此之外,它在某些情况下可以帮助提高性能,因为它节省了每次都必须解析查询以及通过线路发送查询的时间。一些数据库还会缓存关联的查询计划。

以下是如何使用 MySQL 协议使用 prepared statements:

现在,理想情况下,你相对频繁地执行相同的语句,因此你可以跟踪它们,并且在正常情况下,你可以通过直接发送一个带有已知 statement_idCOM_STMT_EXECUTE,从而在一次往返中执行参数化查询。

但是,一个主要的麻烦是这些 statement_id 的作用域是会话级的,这意味着它们仅在使用用于创建它们的连接时才有效。在现代 Web 应用程序中,你不仅仅有一个连接,而是一个连接池,并且每个进程都有一个连接池,因此你需要多次跟踪同一件事。

更糟糕的是,正如之前解释的那样,由于关闭并重新打开连接通常是从错误中恢复的唯一安全方法,因此无论何时发生这种情况,所有 prepared statements 都会丢失。

这些语句在服务器端也有成本。每个语句都需要数据库服务器中的一些内存。因此,你必须小心不要创建无限数量的语句,这对于 ORM 来说并不容易强制执行。

应用程序根据用户输入动态生成查询的情况并不少见,通常是一些高级搜索或过滤表单。

此外,Active Record 允许你提供 SQL 片段,并且它无法知道它们是静态字符串还是动态生成的字符串。例如,这不是一个好习惯,但用户可以完美地执行以下操作:

Article.where("published_at > '#{Time.now.to_s(db)}'")

此外,如果你有 Active Record query logs,那么大多数查询将是唯一的。

所有这些意味着像 Active Record 这样的库必须有大量的逻辑来跟踪 prepared statements 及其生命周期。你甚至可能需要某种最近最少使用 (Least Recently Used) 逻辑来修剪未使用的语句并释放服务器上的资源。

在许多情况下,当你没有理由相信会很快再次执行特定查询时,实际上不使用 prepared statements 是有利的。理想情况下,你仍然会使用参数化查询,但这意味着对数据库进行 2-3 次往返1 而不是只有一次。

因此,至少对于 MySQL 来说,当你将 Active Record 与作为字符串提供的 SQL 片段一起使用时,Active Record 会回退到不使用 prepared statements,而是将参数插入到查询中。

理想情况下,我们仍然会使用参数化查询,只是不是 prepared statement,但 MySQL 协议不提供此类功能。如果你想使用参数化查询,你必须使用 prepared statements,并且在许多情况下,这将意味着额外的往返。

我对 PostgreSQL 协议不太熟悉,但从浏览其规范来看,我认为它的工作方式大致相同。

那么如何改进它呢?

首先,我认为应该可以在没有 prepared statement 的情况下执行参数化查询,我无法想到为什么这还不可能的原因。

然后,我认为在这里也可以从 Redis 中汲取一些灵感。

EVALSHA

Redis 没有 prepared statements,这没有多大意义,但它确实有某种类似的东西,即 Lua 脚本

> EVAL "return ARGV[1] .. ARGV[2]" 0 "hello" "world!"
"helloworld!"

但就像 SQL 查询一样,Lua 代码需要解析,并且可能相对较大,因此缓存该操作对于性能来说是更可取的。但是,Redis 没有使用返回给定脚本的连接特定标识符的 PREPARE 命令,而是使用 SHA1 摘要。

你可以首先使用 SCRIPT LOAD 命令加载一个脚本:

> SCRIPT LOAD "return ARGV[1] .. ARGV[2]"
"702b19e4aa19aaa9858b9343630276d13af5822e"

然后,你可以通过仅引用其摘要来根据需要多次执行该脚本:

> EVALSHA "702b19e4aa19aaa9858b9343630276d13af5822e" 0 "hello" "world!"
"helloworld!"

并且该脚本注册表是全局的,因此即使你有 5000 个连接,它们都可以共享相同的脚本,你甚至可以假设脚本已经加载,并在重试时加载它们(如果它们没有加载):

require "redis-client"
require "digest/sha1"
class RedisScript
 def initialize(src)
  @src = src
  @digest = Digest::SHA1.hexdigest(src)
 end
 def execute(connection, *args)
  connection.call("EVALSHA", @digest, *args)
 rescue RedisClient::CommandError
  connection.call("SCRIPT", "LOAD", @src)
  connection.call("EVALSHA", @digest, *args)
 end
end
CONCAT_SCRIPT = RedisScript.new(<<~LUA)
 return ARGV[1] .. " " .. ARGV[2]
LUA
redis = RedisClient.new
p CONCAT_SCRIPT.execute(redis, 0, "Hello", "World!")

我不是数据库工程师,因此可能缺少一些很大的约束,但我认为 prepared statement 的标识符是某种可预测的摘要会更有意义,以便它们更容易在连接之间共享,并让服务器处理垃圾回收很长时间未使用的 prepared statements,或者使用某种引用计数策略。

结论

我可能会找到更多 MySQL 和 PostgreSQL 协议中不切实际的示例,但我认为我已经展示了足够的内容来分享我对它们的感受。

关系数据库是非常令人印象深刻的项目,显然是由非常聪明的人构建的,但感觉开发者体验在他们的优先级列表中并不高,甚至没有被考虑过。这也许解释了 2010 年代早期 NoSQL 的吸引力的一部分。但是,我认为有可能在不更改查询语言的情况下显着提高它们的可用性,只需改进查询协议即可。

  1. 总共 3 次往返,但理论上你可以异步执行 COM_STMT_CLOSE

byroot's blog

Various ramblings.