我们用只懂 get 和 put 的存储跑起了一整套认证系统
认证系统看上去像是想要一个关系型数据库。用户、角色、会话、OAuth 授权、刷新令牌,全都互相引用。我们的系统却没有用。它跑在 Azure Table Storage 上,这个存储只给你一个分区键、一个行键,此外几乎什么都没有。没有连接查询,没有像样的二级索引,没有自增操作符,也没有你习惯的那种多行事务。这是一个选择。下面讲讲这个选择换来了什么、让它跑起来的那些模式,以及那些换成 SQL 确实会更省事的地方。
这么做的理由是成本和运维上的简单。没有会被耗尽的连接池,没有需要选型、打补丁或做故障转移的数据库实例,还能免费获得按分区扩展。一张表几乎不花钱去维持,也没有什么会被用光。代价是一个没有查询规划器的存储,只有当你的访问模式可预测的程度配得上这个存储的笨时,它才划算。认证的访问模式恰恰如此,所以整个设计都顺着它来。
键承担了本该由表结构做的工作。 没有连接查询,你就设计键,让查询本身就是键。你刻意做反规范化,挑选分区好让热点读取都是点查,并把复合标识直接编码进行键,例如用 "{pk}|{rk}" 来表示那些在只提供一个行键槽位的存储里需要复合标识的条目。“我该怎么查到它”这个问题必须在你设计键的时候回答,而不是在查询的时候。对认证来说这没问题,因为访问模式是已知的,而且不会漂移:按 id 取用户、取某个用户的授权、消费一个 code。你不是在探索数据。你事先就知道每一个问题。
你自己维护一份变更日志。 每一行都带着一个由服务端管理的 Timestamp,于是很容易想在它上面做增量备份:把所有 Timestamp gt watermark 的行拉出来。这在演示里能跑,到了生产就会挂掉,因为这个存储只给分区键和行键建索引,别的都不建。Timestamp 不在任何索引里,所以按它过滤会扫描表里的每一行。这是一次全表扫描披着查询的外衣,而且表每长大一天,它就更慢一分。所以存储改成自己写一份变更日志。每一次改动,每一次 upsert 和每一次删除,都会往一张变更日志表里追加一行,以逻辑表名作为键,用复合的 "{pk}|{rk}" 作为行键,再加一个标记表示这行是被写入还是被删除。现在“自水位线以来改了什么”就变成了读取一个小而有索引的分区,而不是扫描整张表,备份也只会点读那些真正动过的行。你用普通的写入重建出了变更数据捕获,它随改动量扩展,而不是随表的大小扩展。
没有事务的并发。 这里没有 SELECT ... FOR UPDATE。你拿到的是唯一一个原子原语:条件写入,以 ETag 的形式交到你手里。只有当你读到的那份副本没有在你脚下被改动时,你才写入这一行。所有需要在并发访问下保证安全的东西,都表达成乐观并发加上冲突时重试。最清楚的例子是账户锁定计数器。AccessFailedCount 需要原子地自增,但这个存储没有自增。于是你读出这一行,把计数加一,再以你读到的 ETag 为条件写回去,如果有人抢在了你前面就重试。这个循环,就是你在一个没有计数器的存储上把计数器做成原子的方法。一口气打五十个错误密码,每一个都会被记上,因为冲突是被检测并重试,而不是被丢掉。
删除也可以是一把锁。 一次性消费,比如一个必须恰好兑换一次的授权码或一次性令牌,就是一次条件删除。它是一次基于 ETag 的条件删除:如果删除成功,你就赢了这场竞争,可以继续;如果因为这一行已经不在而失败,那就是别人先消费了它,你就停下。删除就是那把锁。同样的思路也能做领导者选举,建立在 blob 租约之上,一把用原子写入而不是一个单独的协调服务造出来的锁。当写入本身就是原子的时候,你几乎从不需要一个锁服务。
删除必须是一等公民,这也是变更日志存在的一半理由。键值存储没有删除标记:被删除的行就那么消失了,所以任何扫描变更的东西根本看不到它离开过。一份以行时间戳为键的备份,会悄无声息地把被删除的用户永远带下去。变更日志把删除记录为一次删除,于是恢复时会重放这次删除,而不是把它复活。那种失败模式,一份忠实地把你删掉的每一个人都带回来的备份,本身就是一个完整的故事,值得单独写一篇,但这个模式属于这份清单:在一个没有删除日志的存储里,你自己来维护删除日志。
现在看账本的另一面,你要放弃的东西。你放弃了即席查询:一个真正全新的访问模式,可能意味着一次新的键设计或者一次全表扫描,因为没有查询规划器来救你。你放弃了连接查询,于是你手工维护反规范化的副本,并自己对那份一致性负责。你放弃了多行事务,于是你把每一条不变量都设计成活在单个条目里,因为单条目原子性就是你所拥有的全部。如果你的数据高度关系化,或者你的访问模式还未知、还在变动,那这就是个错误的工具,而且它会让你难受。
而这恰恰是它适合认证的原因。关系型数据库的价值,在于回答那些你还没想到的问题。认证系统没有那些问题。你要问的事情就那么一组,取这个用户、消费这个 code、选出这个领导者、备份改动过的东西,小、已知、而且稳定。放弃那个你本来也用不上的查询规划器,作为交换,你得到一个几乎不花钱运维、也没有什么需要做故障转移的存储层。对大多数软件来说这是笔糟糕的交易。对这个来说,它是对的。
这些模式就是 Authagonal 底层的存储引擎:一套几乎不花钱运维的键值设计,也正是我们能把每一项功能放进每一个套餐的重要原因。