- 引言 —— 问题不是每秒请求数,而是发布
- 稳态成本和启动成本是两个不同的问题
- 12 小时这个数字是从哪儿来的
- 为什么没有引入 Redis
- 怎么把滑动窗口装进对象存储
- 16 字节 —— 表示方式就是性能
- 平方复杂度的 worker 为什么够用
- 正确性在条件式 PUT 上,领导者选举只是优化
- 参考资料
引言 —— 问题不是每秒请求数,而是发布
会话处理通常这样优化:把会话信息加密塞进 cookie,网关就不必每个请求都去查一次数据库。相当于砍掉了每请求一次的网络往返,在每秒处理几十万请求的系统里是笔大收益。
可一旦发生登出或权限变更,就必须把已经签发的 cookie 吊销掉。于是每个网关都得在内存里揣一份「已吊销会话列表」。查询依然近乎免费。
Canva 在 2026 年 7 月 22 日发布的 Session revocations at scale 处理的是接下来的那个问题:这份内存列表最初是怎么被填满的。
按原文的说法,几百个网关 Pod 在启动时各自从 MySQL 拉取超过一百万条吊销记录,把「发布变成了对数据库的一场有组织的踩踏」。
稳态成本和启动成本是两个不同的问题
这件事的核心就在这里。我们评估缓存时看的指标,绝大多数是关于稳态的:命中率、查询延迟、内存占用。这些指标全都很漂亮,系统照样可能垮。
内存缓存还有第二笔不太容易被指标捕捉到的成本。
- 稳态成本 —— 每个请求的查询。在这里事实上是零。
- 启动成本 —— 进程起来时从某处把整个数据集取回来的成本。就单个 Pod 看不算什么,但发布重启的不是一个 Pod,而是整支舰队同时重启。
启动成本与 Pod 数量成正比,按发布频率反复发生,而且偏偏发生在发布过程这个最敏感的时刻。它和稳态负载完全是另一根轴。
Canva 临时采用的对策恰好体现了这个性质:靠大量增加只读副本硬扛过去。也就是说,那些只读副本存在的意义不是承载稳态查询量,而是承载发布那一刻的尖峰。换句话说,不发布的时间里它们大多闲着。
12 小时这个数字是从哪儿来的
吊销列表要在内存里揣多久?不是永远。
Canva 的会话 cookie 会定期刷新。刷新的时点反正要查数据库,所以经过刷新的令牌并不依赖内存缓存。于是内存里只放 12 小时的吊销记录,需要刷新的令牌就走慢速的 MySQL 查询去确认。
这个设计的好处在于,缓存大小的上界是从令牌寿命推导出来的。决定大小的是时间窗口,而不是用户数量。用户翻一倍,吊销发生率也翻一倍,但窗口的长度不变。
在缓存设计里,只要「什么东西可以丢」有了答案,大小问题通常也就解开了。这里刷新路径就是安全网,所以丢掉超过 12 小时的记录并不会破坏正确性。
为什么没有引入 Redis
扩展读取的标准解法,是在数据库和读取方之间加一层缓存。Canva 也评估过 Redis:网关启动时向 Redis 索要整个数据集,之后再定期轮询新的吊销记录。
否决它的理由有两条,而且两条都适用于一般的技术选型。
第一,Redis 通常不会以完整持久化的配置部署。吊销列表一旦丢失,已登出的会话就会复活。这不是一层性能缓存,而是一道安全边界。
第二,用原文的说法,那样做「只是把问题从一个数据存储搬到另一个数据存储,同时为了维持缓存一致性而添上相当的复杂度」。运维 Redis 集群本身的负担原封不动地留着。
再叠一层的解法,陷阱就在这里。新的层会连带新的失败模式、新的运维负担和新的一致性问题一起搬进来。原来那层的负担是减轻了,整个系统的负担却可能变重。
怎么把滑动窗口装进对象存储
于是他们选了 S3。强持久性保证和大文件的高效批量下载,正是这里需要的性质。
问题在于,S3 擅长处理的是静态的块,而这份数据是一个不断流动的窗口:永远是最近 12 小时,每一刻前面进来、后面出去。
解法是把窗口切成 30 分钟一段,一段对应一个 S3 对象。这个决定一口气带来了三件事。
- 不需要单独删除旧的吊销记录,网关只取最近的几段就够了。
- 更新的粒度变小了。要重写的不是整整 12 小时,而是 30 分钟的量。
- 只要把 30 分钟窗口的起始时刻放进段名里,就能按排序顺序遍历键,挑出截止点之后的那些段。
最后一条尤其实用。S3 没有「只把最近的给我」这样的查询,但只要把键命名成按时间排序的形式,光靠前缀列举就能得到同样的效果。
16 字节 —— 表示方式就是性能
这是整篇文章最值得学的部分。
一条吊销记录要承载的信息有两项:它作用于谁(principal),以及作用到哪一个登录时刻为止。把比特省着排一排,16 字节就装得下。一个段就是这些 16 字节元素构成的扁平数组。
再加上排序。按 principal 排好序,段内就能做二分查找。结果是网关把下载下来的字节原样拿来用,不转换成另一种表示。
之前的实现用好几个 Java 对象来跟踪一条吊销记录。换成紧凑的二进制表示之后,内存缓存的大小降到了八分之一,也就是减少了 87.5%。
实际上吊销的种类有好几种:有的不让用户登出,只把 cookie 里缓存的信息作废;有的针对的不是个人而是整个品牌。所以他们预留了几个比特作标志位;只要维持按 principal 排序,一个数组里就可以混放多种类型。
归纳起来是这样:把填缓存的成本降下来的不是新加的一层,而是一种不需要反序列化的表示。下载结束,缓存也就结束了。
平方复杂度的 worker 为什么够用
把段维持在最新状态的这一侧,有另一个问题:每产生一条吊销就重写一次段,成本根本扛不住。
于是由一个异步 worker 来做这件事。它持续扫描数据库,把还没上传到 S3 的吊销记录以大批次取出来,拿到最新的段,插进有序数组里,再传回去。
正如原文自己指出的,这乍看是扩展不了的。要往一个段里塞满 N 条,就得每个固定大小的批次都处理一遍整个段,对 N 而言接近平方时间。再加上更新丢失的问题,水平扩展也不容易。
可实测下来,即便是每批处理几百条的未优化实现,也跑出了每秒 2000 条以上的写入吞吐,超过了可预见未来的需求。原文的结论很准确:worker 的瓶颈不在计算,而在网络延迟。
理论复杂度说的只是输入变大时的斜率,它并不说明在你的输入区间里实际要花多少时间。对现代 CPU 来说,给几十万个元素的紧凑数组排序几乎是免费的。在常数占主导的区间里,就该去量常数。
正确性在条件式 PUT 上,领导者选举只是优化
worker 出于可用性和发布的考虑会跑多份。天真地实现,就会出现针对 S3 的竞态:在读取、修改、写回之间,另一个 worker 的更新被悄悄抹掉。
Canva 用了两样东西。
用条件式 PUT 实现乐观并发控制。每一次段更新都附带「自首次读取以来这个段没有变过」的前置条件;创建新段时也会确认是否已有别的进程创建了同名的段。这个前置条件把读-改-写变成了永远只做追加的操作,从而保证无论执行如何交错,数据都不会消失。
ZooKeeper 领导者选举则是一项优化,用来避免持续的冲突给系统带来负担。
这两句话的先后顺序很要紧。原文明确写道,正确性不能依赖领导者选举。一个节点可能在写入前的那一刻停顿任意长的时间,等它醒来时,别的节点也许早已成为领导者并写下了自己的变更。如果没有 PUT 的条件,睡醒的那个节点就会把它覆盖掉。
在分布式系统里,领导者选举几乎永远应该待在这个位置上:它是减少冲突的装置,不是让冲突不可能发生的装置。 正确性必须挂在存储层提供的原子条件操作上。
参考资料
- Session revocations at scale — Canva Engineering Blog, Llew Vallis, 2026-07-22
- Amazon S3 条件式请求文档
- Amazon S3 条件式 GET 文档
本文中的数字(超过一百万条吊销记录、12 小时窗口、30 分钟段、16 字节、内存减少 87.5%、每秒 2000 条以上、2 台只读副本)全部照搬自上面那篇 Canva 原文所写的值,并非我自己复现出来的测量结果。
현재 단락 (1/47)
会话处理通常这样优化:把会话信息加密塞进 cookie,网关就不必每个请求都去查一次数据库。相当于砍掉了每请求一次的网络往返,在每秒处理几十万请求的系统里是笔大收益。