Crypto OS
Technical Crypto OS第三阶段 · Full-stack Onchain Application

T15 · Backend for Crypto

链上已经有数据了,为什么还要后端?

练习的能力
Builder
动手
给一个链上查询加上缓存与队列,压测对比加与不加的延迟差异。
AI Lab
让 AI 设计一套幂等键方案,自己构造重复请求验证它是否真的幂等。

一个现实问题

一个「我的资产」页面。需求写得很简单:列出用户持有的代币,每种显示余额、当前价格、24 小时涨跌。

你算了一下:假设用户持有 20 种代币。

  • 20 次余额查询
  • 20 次精度查询(不同代币位数不同)
  • 20 次名称与符号查询
  • 20 个价格,来自另外一个数据源

80 多次网络请求,从用户的浏览器发出去。

首屏跑了 6 秒。用户以为页面坏了,刷新,又是 80 次。三个用户同时刷新,你的 RPC 配额开始报 429。而此时全站只有三个人在线。

再往下想一层,问题更尴尬:这 80 次请求的结果对所有用户都是一样的。代币的名称、符号、精度是永远不变的;价格是全站共享的;连余额,同一个地址在同一个区块高度上的余额也是确定的。

你让每一个用户,各自重新算了一遍全世界都一样的答案。

这时候「为什么还要后端」就不再是一个哲学问题了。它的答案很具体:因为有一堆事情,链不做,前端做不好,只能后端做

思想实验

把 RPC 想成一个只有一个窗口的档案馆

它的性质很特别:

  • 只读,而且极其严谨。 你问什么它答什么,一个字不多。它不会帮你汇总,不会帮你排序,不会告诉你「这个地址最近一个月的净流入」。
  • 不记得你。 每次问都从头开始,没有会话,没有上下文。
  • 有排队。 队伍是全站共用的,一个人问得太频繁,所有人一起等。
  • 按次收费。 问得越多越贵。

现在把你的产品需求摆在这个档案馆面前:

「列出这 20 个代币的余额并按美元价值排序」——档案馆不会排序,你得把 20 条都取出来自己排。 「这个地址一收到钱就通知我」——档案馆不会主动找你,你得一遍遍去问。 「这笔提现请求,用户重复点了三次,只执行一次」——档案馆不知道什么叫「一次」,你发三笔它就执行三笔。 「同一个人一分钟最多查十次」——档案馆不认识人。

这四句话,就是后端的四项职责:聚合、轮询与通知、幂等、限流。再加上一项档案馆本身的性质带来的:缓存——同一个问题,答案在一段时间内不会变,不必反复去问。

后端不是来替代链的,是来做链做不了的那部分。 记住这句话的反面同样重要:链能做的那部分,后端不要抢——尤其是「什么是真的」这件事。

你来决定

给上面那个资产页面加一层。你先加哪一层?

观察结果

四个选项不是四选一,而是按顺序叠上去的四层。但它们要解决的其实是同一件事的两面:

后端补的能力链为什么不做前端为什么做不好
聚合链只回答原子问题要发几十次请求,还要自己算
缓存链没有「上次你问过」的概念每个浏览器只能缓存自己那份
重试链不知道你失败了页面一关,重试就没了
通知链不会主动找你前端不在线时什么都收不到
限流与配额链不认识用户前端的限制可以被绕过
密钥保管与链无关前端的任何秘密都不是秘密
幂等链只认 nonce,不认你的业务 ID用户重复点击你控制不了

看完这张表,有一条边界要立刻立起来:

链是事实来源,后端是投影与节流层。

后端可以缓存、聚合、加工、预计算,但它给出的每一个数字,都必须能回溯到链上的某个高度。一旦你的后端开始产生链上没有的「事实」——比如自己记一份余额然后加加减减——你就有了两份真相,而它们迟早会分叉。T16 会把这条边界变成具体的表结构。

建立模型

后端在链的两侧各有一条路径,它们的失败模式完全不同:

  1. 读路径 RPC 与索引库
  2. Cache 缓存
  3. API 聚合
  4. 前端
读路径的目标是省:省延迟、省配额、省钱。读错了可以重来。
  1. 写路径 请求
  2. Idempotency 幂等查重
  3. Queue 入队
  4. Worker 上链
  5. Confirm 确认回执
  6. Notify 通知
写路径的目标是准:一次请求,最多一次上链。写错了不能重来。

读路径:缓存的三层

缓存键的设计比缓存本身重要。分三类:

类别例子缓存键策略
永不变代币名称、符号、精度;历史区块;已终局交易的回执不带高度长期缓存,几乎不失效
按高度定某地址在某高度的余额;某合约在某高度的状态必须带高度高度变则键变,天然正确
一直在变内存池、最新报价带一个短时间窗秒级 TTL,或干脆不缓存

第一类是最大的免费午餐。上面那个页面的 80 次请求里,有 40 次是查名称、符号、精度——这些值一辈子都不会变,缓存一次就够了。光这一项就能砍掉一半请求。

第二类是这一章最值得记住的技巧:把区块高度放进缓存键,缓存就永远不会给出过期数据。高度变了,键就变了,自然回源。代价是命中率随高度更新而下降,所以它适合「历史查询」而不适合「查最新」。

还有一个几乎所有人第一版都会漏的东西:单飞。一百个请求同时到达,缓存都没命中,于是一百个请求一起回源——缓存在这一刻完全没起作用,甚至更糟(缓存击穿)。正确做法是同一个键只允许一个请求真的去回源,其余的等它的结果。

写路径:幂等的三个层次

用户重复点击、网络重发、队列重投、客户端重试——重复请求不是异常,是常态。而链上操作一旦重复,就是真金白银的损失。

幂等要在三个层次上同时成立,缺一层就会漏:

第一层  请求幂等:同一个幂等键,只产生一个任务
        实现:唯一索引 + 插入冲突即返回原结果

第二层  任务幂等:同一个任务被消费多次,只执行一次副作用
        实现:任务状态机 + 状态转移用条件更新,不用读后写

第三层  链上幂等:同一个业务操作,最多只有一笔交易上链
        实现:把幂等键与 nonce 绑定,一个键固定占用一个 nonce

第三层是最容易被忽略、代价最大的一层。 后端只发了一次,不等于链上只有一笔——如果你的 worker 崩溃重启后重新构造了交易,用的是新的 nonce,那么两笔交易都会上链,两笔都会成功。

反过来,如果一个幂等键固定绑死一个 nonce,重复构造出来的交易会因为 nonce 相同而互相顶替,链上最多只有一笔。这是从链的规则里拿到的一层免费保险。

请求幂等的表结构(SQL 是稳定的,这里可以写死):

create table submissions (
  idempotency_key text        primary key,
  request_digest  text        not null,       -- 请求体的哈希,用来识别「同键不同内容」
  status          text        not null,       -- queued | broadcast | confirmed | failed
  nonce           bigint,                     -- 分配之后固定不变
  tx_hash         text,
  result          jsonb,
  created_at      timestamptz not null default now(),
  updated_at      timestamptz not null default now()
);

写入用一条语句解决,不要「先查再插」:

insert into submissions (idempotency_key, request_digest, status)
values ($1, $2, 'queued')
on conflict (idempotency_key) do nothing
returning idempotency_key;
  • 返回了一行:这是新请求,继续入队。
  • 返回零行:这是重复请求,去把原记录读出来返回给客户端。
  • 读出来发现 request_digest 不一样:同一个键被用在了不同的请求上,返回冲突错误——这通常意味着客户端的键生成有 bug,不要默默接受。

「先 SELECT 看看有没有,没有再 INSERT」是错的。 两个并发请求会同时查到「没有」,然后同时插入。唯一索引是唯一可靠的那道锁。

限流要有两套

对外和对内是两件不同的事,很多人只做了一半:

  • 对下游(你的用户):按用户、按地址、按 IP 限流,防滥用。超限返回 429 并带上重试时间。
  • 对上游(RPC 配额):你自己的出口也要限,否则一次流量尖峰会把当天的配额打光,然后全站不可用。

上游限流的正确姿势不是丢弃,是排队加超时:请求进队列等待令牌,等不到就快速失败并返回缓存里的旧值——给一个稍旧的答案,好过给一个错误页

它叫什么

API Layer接口层

面向你自己前端的接口。它不应该是 RPC 的透传,而应该按页面需要的形状返回数据——一次请求返回那个页面要的全部内容。

判断标准:前端为了渲染一屏,需要发几个请求?超过三个,说明这一层没做好。

Cache缓存

把算过的答案存起来。Crypto 场景的特殊之处在于答案的有效期由区块高度定义,而不是由时间定义。

一条经验:缓存已经确定的东西,不要缓存还会变的东西。 历史区块、已终局的回执、代币元信息,随便缓存;最新余额,要么带高度,要么短 TTL。

Queue队列

把「接受请求」和「执行请求」解耦。它带来重试、削峰、优先级和失败隔离。

必须同时知道的两件事:绝大多数队列是至少投递一次,以及消息可能乱序。这两条决定了消费者必须幂等,而且不能依赖消息顺序。

Idempotency Key幂等键

由客户端生成、标识「同一次业务意图」的字符串。同一个键重复提交,服务端只执行一次,并返回相同的结果。

三个要求:由客户端生成(服务端生成的话,重试时就变成新键了)、在业务上唯一、有明确的有效期。

Single Flight单飞

同一时刻对同一个键,只允许一个请求真的去回源,其余请求等待并共享它的结果。

它解决的是缓存失效瞬间的并发击穿。没有它的缓存,在最需要它的时候(流量高峰)失效得最彻底。

Dead Letter Queue死信队列

重试若干次仍然失败的消息被投到这里,等待人工处理,而不是被无限重试或被静默丢弃。

涉及资金的队列必须有死信队列。 静默丢弃一条转账消息,比报错严重得多——因为没人会知道。

动手

动手给一个链上查询加上缓存与队列,压测对比延迟差异任意后端框架 + 一个数据库 + 一个压测工具0 元,全程测试网

全程使用测试网,写路径发出的交易都是测试网交易。 需要私钥的部分用一个只有测试币的临时钱包。

目标是拿到一张你自己测出来的对比表,而不是相信任何人的经验值。

写一个裸接口,量一下基线。

做一个 GET /portfolio?address=...,内部老老实实发几十次 RPC 请求,聚合后返回。

用压测工具打 50 并发、30 秒。记下三个数:p50 延迟、p95 延迟、这段时间里发出的上游 RPC 调用总次数。

第三个数最重要,它直接对应你的账单。

先砍掉不该问的那一半。

把代币的名称、符号、精度缓存成永久条目。这类数据永不改变,第一次查到就可以一直用。

重新压测。上游调用次数应该掉掉一半左右——而你一行业务逻辑都没改

给会变的数据加上带高度的缓存键。

缓存键用 chainId:method:params:blockNumber 这样的组合。每轮请求先取一次最新高度,然后用它去查缓存。

重新压测,对比 p95。同时观察:高度每更新一次,命中率怎么变化。

加单飞,验证它真的生效。

把回源逻辑包一层:同一个键同时只有一个请求出去。

验证方法很直接:清空缓存,瞬间打 100 个相同请求,数一下上游实际被调用了几次。答案必须是 1。如果是 100,说明你的单飞没起作用。

把写操作改成异步。

做一个 POST /withdraw:校验参数,写入 submissions 表,入队,立刻返回 202 和一个任务 ID。

再做一个 GET /withdraw/:id 查状态。前端用它轮询——这正好接上 T14 的状态机。

加幂等键,然后用重复请求攻击它。

要求客户端在头里带幂等键,用上面那条 on conflict do nothing 的写法。

攻击一: 同一个键,串行提交 10 次。必须只有一个任务,后 9 次返回同一个任务 ID。 攻击二: 同一个键,并发提交 10 次。结果必须相同——这一步会打死「先查再插」的实现。 攻击三: 同一个键,但请求体不同。必须返回冲突错误,而不是默默按第一次的内容执行。

三个攻击都过了,再去链上数一下:只应该有一笔交易。

把 worker 杀掉一次。

在交易已经广播、但回执还没拿到的时候,直接 kill 掉 worker 进程,然后重启它。

它应该从 submissions 表里恢复出「这个任务已经广播过,哈希是 X」,继续等回执——而不是重新构造一笔新交易

如果链上出现了第二笔交易,说明你的第三层幂等(nonce 绑定)没做。这一步是整个 Lab 最值钱的一步。

产出对比表。

三行:无缓存、有缓存、有缓存加队列。三列:p50、p95、上游调用次数。

这张表是你的阶段作品的一部分,也是你以后跟任何人讨论架构时最有力的东西——因为它是你自己测的。

AI Lab

AI Lab让 AI 设计一套幂等键方案,再用重复请求验证它是否真的幂等Level 2 · AI Copilot

分两步,第二步问的是链上,这是模型最容易失手的地方:

第一步:
为一个会发起链上交易的后端接口设计幂等方案。要求覆盖:
- 幂等键由谁生成、格式、有效期
- 存储结构与并发下的正确性保证
- 重复请求时返回什么
- 同一个键配不同请求体时怎么办
- 任务失败后的重试与终态判定
给出具体的表结构和 SQL。

第二步:
现在假设这个接口的 worker 在「交易已广播、回执未返回」时崩溃并重启。
重启后它读到这条记录,应该做什么?
如果它重新构造并发送了一笔交易,链上会发生什么?
我应该怎样保证「一次业务请求最多只有一笔交易上链」?

第一步模型通常答得像模像样,但要盯紧它是不是写了「先 SELECT 判断是否存在」。这个写法在单线程下工作正常、在压测下必然出问题,是幂等实现里最常见的假幂等。

第二步才是真正的分水岭。绝大多数关于幂等的材料都停留在 Web 后端的层面,不涉及「链上还有一层 nonce」。如果模型答不出 nonce 绑定,你就亲眼见证了一次模型的知识边界:它知道幂等,但不知道这个领域的幂等多了一层。

写完之后自己跑一遍并发测试。 幂等方案不能靠读代码来验证,只能靠压测——这也是为什么这个 Lab 的 verify 里,前三条全都要求你实际跑。

AI 说完之后,你必须自己验证

  • 它的方案是「先查询再插入」还是「唯一索引加插入冲突」——前者在并发下必然失效,必须改
  • 两个并发请求同时到达时,它的方案能不能保证只有一个成功:自己写一个并发测试跑一遍
  • 重复请求返回的是第一次的结果,还是又执行了一遍业务逻辑
  • 它有没有处理「同一个幂等键配不同请求体」的情况——多数方案会直接忽略这一点
  • 它有没有把幂等键和交易 nonce 绑定:后端只发一次,不等于链上只有一笔
  • 任务失败后重试,它有没有区分「可重试的失败」和「终态失败」,键能不能复用
  • 它给出的数据库语法、锁语义、队列投递保证,你在自己用的那个版本的文档里逐条对一下

真实案例

队列重投导致重复发币

一个空投任务放在队列里。worker 处理完、交易已经上链,但在提交消费确认之前进程被重启了。

队列认为这条消息没被消费成功,重新投递。第二个 worker 拿到它,又发了一笔——同一个人拿了两份

根因是「至少投递一次」这个语义被当成了「恰好一次」。防御是消费端幂等,而不是换一个队列产品。

缓存了一个还没确定的状态

某个接口缓存了交易状态,TTL 设成 10 分钟。一笔交易在 pending 时被查了一次,状态进了缓存。

交易在 20 秒后确认了,但接下来的 10 分钟里,所有查询都返回 pending。用户盯着「处理中」,以为卡了,又发了一笔。

教训:缓存的 TTL 要和数据的变化速度匹配。 一个处在中间态的东西,要么不缓存,要么 TTL 短于它的预期变化时间。

一个爬虫打光了当天的配额

没有上游限流的服务,被一个抓取数据的脚本按着打了半小时,当天的 RPC 配额耗尽。

之后所有真实用户的请求全部失败,而且是在没有任何预警的情况下——配额是个悬崖,不是斜坡。

教训:上游限流不是为了省钱,是为了保证服务在最坏情况下仍然可用。 配额要留出安全余量,接近阈值时要告警。

后端成了第二个事实来源

一个团队为了性能,在后端自己维护了一份用户余额,每次操作后直接加减,不再回链核对。

跑了两个月,发现有几百个地址的余额和链上对不上——原因五花八门:漏处理一次失败交易、漏掉一次内部转账、有一次重组没回滚。

教训:后端可以缓存链上的事实,不能生产链上的事实。 任何派生数据都必须能从链上重新算出来,而且要有定期对账。T16 会把这条做成硬性的表设计规则。

改一个变量

如果只加缓存,不加队列

读性能问题基本解决了,成本也降下来了。大多数以展示为主的产品,到这里就够了。

但所有涉及写入的操作还留在同步请求里:用户等着页面转圈,你在后台等回执;一旦失败,除了报错没有别的办法;重试只能靠用户自己再点一次——而这又会带来重复提交

结论:只读产品可以不要队列,一旦要替用户发交易,队列和幂等必须一起上。

如果只加队列,不加缓存

写路径稳了,但读路径的成本一分没省。

更麻烦的是:队列本身会产生大量读请求(每个 worker 都要查状态、查 nonce、查回执),队列的引入反而增加了上游压力

两者的关系是互补的:缓存降成本,队列提可靠性。先做哪个,取决于你现在疼的是账单还是事故。

如果服务从一个实例变成多个实例

所有放在进程内存里的东西当场失效:内存缓存变成了 N 份互不相同的缓存,内存里的幂等表完全不起作用,单飞只在自己这个实例内生效。

这是单机方案到分布式的经典断崖,而且它不会报错,只会开始出现偶发的重复——最难查的那一类 bug。

规则很简单:幂等状态必须落在共享存储里(数据库的唯一索引是最可靠的那个)。缓存可以分层——本地缓存放不变的数据,共享缓存放会变的数据。

如果上游从公共 RPC 换成自建节点

限速和配额的约束消失了,延迟也降低了。缓存的收益随之下降——但不会消失,因为聚合逻辑的计算成本还在。

新的问题接上来:节点的同步状态成了你要监控的东西。一个落后了几十个区块的节点,会安静地返回过时的数据而不报任何错。 必须主动检查节点高度是否跟得上,落后超过阈值就切换或告警。

T4 讲过这件事的另一面:自建不是万能解,它把「配额问题」换成了「运维问题」。

带走的问题

5
谁在支付?

谁在支付?链上查询的成本由你承担,而不是用户。这解释了后端存在的一大半理由:每砍掉一次重复的上游调用,都是在直接降低你的边际成本。

9
谁承担风险?

谁承担风险?幂等没做好时,风险由用户承担——多扣的钱是他的。而且这类事故的特征是低频、偶发、难复现,往往要等到用户投诉才暴露。这就是为什么幂等必须用并发测试来验证,而不是靠 review。

1
它解决什么问题?

它解决什么问题?后端在这个系统里不解决「信任」问题——那是链的事。它解决的是体验、成本与可靠性。搞混这两者,就会做出「用后端来决定什么是真的」这种架构。

本章自测

一句话带走

后端负责链不擅长的事:聚合、缓存、重试、通知与限流。

做完这一章的动手环节了?勾上它查看全部进度

本页目录