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 会把这条边界变成具体的表结构。
建立模型
后端在链的两侧各有一条路径,它们的失败模式完全不同:
- 读路径 RPC 与索引库
- Cache 缓存
- API 聚合
- 前端
- 写路径 请求
- Idempotency 幂等查重
- Queue 入队
- Worker 上链
- Confirm 确认回执
- 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 配额):你自己的出口也要限,否则一次流量尖峰会把当天的配额打光,然后全站不可用。
上游限流的正确姿势不是丢弃,是排队加超时:请求进队列等待令牌,等不到就快速失败并返回缓存里的旧值——给一个稍旧的答案,好过给一个错误页。
它叫什么
面向你自己前端的接口。它不应该是 RPC 的透传,而应该按页面需要的形状返回数据——一次请求返回那个页面要的全部内容。
判断标准:前端为了渲染一屏,需要发几个请求?超过三个,说明这一层没做好。
把算过的答案存起来。Crypto 场景的特殊之处在于答案的有效期由区块高度定义,而不是由时间定义。
一条经验:缓存已经确定的东西,不要缓存还会变的东西。 历史区块、已终局的回执、代币元信息,随便缓存;最新余额,要么带高度,要么短 TTL。
把「接受请求」和「执行请求」解耦。它带来重试、削峰、优先级和失败隔离。
必须同时知道的两件事:绝大多数队列是至少投递一次,以及消息可能乱序。这两条决定了消费者必须幂等,而且不能依赖消息顺序。
由客户端生成、标识「同一次业务意图」的字符串。同一个键重复提交,服务端只执行一次,并返回相同的结果。
三个要求:由客户端生成(服务端生成的话,重试时就变成新键了)、在业务上唯一、有明确的有效期。
同一时刻对同一个键,只允许一个请求真的去回源,其余请求等待并共享它的结果。
它解决的是缓存失效瞬间的并发击穿。没有它的缓存,在最需要它的时候(流量高峰)失效得最彻底。
重试若干次仍然失败的消息被投到这里,等待人工处理,而不是被无限重试或被静默丢弃。
涉及资金的队列必须有死信队列。 静默丢弃一条转账消息,比报错严重得多——因为没人会知道。
动手
全程使用测试网,写路径发出的交易都是测试网交易。 需要私钥的部分用一个只有测试币的临时钱包。
目标是拿到一张你自己测出来的对比表,而不是相信任何人的经验值。
写一个裸接口,量一下基线。
做一个 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
分两步,第二步问的是链上,这是模型最容易失手的地方:
第一步:
为一个会发起链上交易的后端接口设计幂等方案。要求覆盖:
- 幂等键由谁生成、格式、有效期
- 存储结构与并发下的正确性保证
- 重复请求时返回什么
- 同一个键配不同请求体时怎么办
- 任务失败后的重试与终态判定
给出具体的表结构和 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。
规则很简单:幂等状态必须落在共享存储里(数据库的唯一索引是最可靠的那个)。缓存可以分层——本地缓存放不变的数据,共享缓存放会变的数据。
限速和配额的约束消失了,延迟也降低了。缓存的收益随之下降——但不会消失,因为聚合逻辑的计算成本还在。
新的问题接上来:节点的同步状态成了你要监控的东西。一个落后了几十个区块的节点,会安静地返回过时的数据而不报任何错。 必须主动检查节点高度是否跟得上,落后超过阈值就切换或告警。
T4 讲过这件事的另一面:自建不是万能解,它把「配额问题」换成了「运维问题」。
带走的问题
谁在支付?链上查询的成本由你承担,而不是用户。这解释了后端存在的一大半理由:每砍掉一次重复的上游调用,都是在直接降低你的边际成本。
谁承担风险?幂等没做好时,风险由用户承担——多扣的钱是他的。而且这类事故的特征是低频、偶发、难复现,往往要等到用户投诉才暴露。这就是为什么幂等必须用并发测试来验证,而不是靠 review。
它解决什么问题?后端在这个系统里不解决「信任」问题——那是链的事。它解决的是体验、成本与可靠性。搞混这两者,就会做出「用后端来决定什么是真的」这种架构。
本章自测
七件链做不了、前端做不好的事:聚合、缓存、重试、通知、限流、密钥保管、幂等。
它们的共同点是:都和「事实是什么」无关,都和「怎样高效可靠地把事实送到用户面前」有关。
这条边界要一直记着:链是事实来源,后端是投影层。后端给出的每个数字都要能回溯到链上的某个高度。
因为两个并发请求会同时查到「没有」,然后同时插入——检查和写入之间存在一个窗口,这就是典型的竞态。
可靠的做法是把两步合成一步原子操作:唯一索引 + 插入冲突处理。插入成功说明是第一次,冲突说明是重复。
判断一个幂等方案靠不靠谱,只要问一句:它在并发下还成立吗? 并且必须用并发测试来回答,不能靠推理。
因为后端的幂等保证的是「业务逻辑只执行一次」,而链上的唯一性由 nonce 决定。
典型场景:worker 广播了交易但还没拿到回执就崩溃,重启后恢复任务,重新构造交易并分配了一个新的 nonce——两笔交易都合法,都会上链,都会扣钱。
解法是把幂等键和 nonce 绑死:一个幂等键固定占用一个 nonce。重新构造出来的交易 nonce 相同,链上最多只会保留一笔。
看数据的性质:
- 永不变(代币精度、历史区块、已终局的回执):不带高度,长期缓存。
- 按高度确定(某地址的余额、某合约的状态):必须带高度,这样缓存永远不会给出过期数据。
- 一直在变(内存池、最新报价):短 TTL 或不缓存。
带高度的代价是命中率随高度更新而下降,所以它适合历史查询。查「最新」时通常是短 TTL 加单飞的组合更划算。
没有标准答案,检查这几件事:
- 读路径上,每一层缓存的键里有没有高度?没有的话,它什么时候会给出过期数据?
- 写路径上,从请求到上链,一共有几个地方可能产生重复?每个地方都挡住了吗?
- 队列的投递语义是什么?消费者幂等吗?有死信队列吗?
- 上游限流在哪一层?配额打光时,服务是返回旧数据还是直接报错?
- 有没有哪一份数据,是后端自己算出来、且无法从链上重新推导的?有的话,那就是你未来对不上账的地方。
第 5 条是这一章留给 T16 的引子。
一句话带走
后端负责链不擅长的事:聚合、缓存、重试、通知与限流。