fhEVM Relayer 动态 Retry-After 设计解析:基于队列状态与处理阶段的智能轮询间隔计算
【免费下载链接】fhevmFHEVM, a full-stack framework for integrating Fully Homomorphic Encryption (FHE) with blockchain applications项目地址: https://gitcode.com/GitHub_Trending/fh/fhevm
本文基于 fhEVM 开源仓库中的 动态 Retry-After 设计文档,结合 relayer 的 Rust 源码实现,系统讲解 fhEVM relayer 如何根据队列状态、排空速率(drain rate)与请求处理阶段,为客户端动态计算Retry-After轮询间隔。读完本文,你将掌握单队列(Input Proof)与双队列(User/Public Decrypt)的 ETA 计算模型、全部配置参数的含义与校验规则、各类请求状态下的精确计算公式,以及如何通过管理端点进行运行时热更新。
一、设计目标:让轮询间隔"跟着系统负载走"
在 fhEVM 的架构中,relayer 扮演"网关适配层"的角色:客户端(SDK / dApp)向 relayer 提交 Input Proof(输入证明)、User Decrypt(用户解密)与 Public Decrypt(公共解密)等异步请求,relayer 在后台排队、执行就绪检查、构造交易并发送至 gateway,再等待 Coprocessor(Copro)/ KMS 的返回。
如果 relayer 一律返回固定轮询间隔(例如恒定的 4 秒),会出现两个问题:系统空闲时客户端空转浪费请求;系统高负载时(队列堆积上千条)客户端按过短间隔轮询会进一步放大压力。动态Retry-After的目标正是根据"队列有多长、每秒能消化多少、请求现在处于哪个处理阶段"这三个要素,为每个客户端计算出贴近真实剩余等待时间的轮询间隔,实现负载自适应。
该设计的核心计算逻辑实现在 relayer/src/http/retry_after/state.rs 与 relayer/src/http/retry_after/queue_info.rs,配置结构定义在 relayer/src/config/retry_after.rs。
二、队列架构:单队列与双队列
relayer 按请求类型维护不同的队列结构:
Input Proof(单队列)
[HTTP] → [TX Throttler Queue] → [Gateway TX] ↑ TPS-based drain (per_seconds)User Decrypt / Public Decrypt(双队列)
[HTTP] → [Readiness Queue] → [Readiness Check] → [TX Throttler Queue] → [Gateway TX] ↑ ↑ Concurrency-based drain TPS-based drain (max_concurrency) (per_seconds)两类队列的关键差异:
- TX Throttler(交易节流队列):基于 TPS(每秒令牌数)限速排空,使用 governor 实现。对应源码中的
TxQueueInfo(含size、drain_rate_tps、position字段,定义于 relayer/src/gateway/arbitrum/transaction/tx_throttler.rs)。 - Readiness Queue(就绪队列,仅解密请求):基于信号量做并发限制,最多同时执行
max_concurrency个并行任务。对应源码中的ReadinessQueueInfo,定义于 relayer/src/readiness/throttler.rs。
解密请求之所以要过两道队列,是因为它要先做"密文是否就绪"的就绪检查(并发受限、单次耗时较短),再进入交易发送队列(TPS 受限)。两类队列的等待时间模型不同,这直接决定了 ETA 公式的分段结构。两个队列的信息被组合进DecryptQueueInfo(见 relayer/src/http/retry_after/queue_info.rs):
pub struct DecryptQueueInfo { /// Readiness queue info (concurrency-limited) pub readiness: ReadinessQueueInfo, /// TX queue info (TPS-limited) pub tx: TxQueueInfo, }三、配置参数:从设计表到真实 YAML
3.1 核心参数
| 参数 | 含义 | 默认值 |
|---|---|---|
min_seconds | 最小轮询间隔(下限/floor) | 1 |
max_seconds | 最大轮询间隔(上限/ceiling) | 300 |
safety_margin | 作用于计算所得 ETA 的乘数(0.0~1.0) | 0.2 |
safety_margin的实际作用方式是final_eta = computed_eta × (1 + safety_margin),即 0.2 表示在估算值上额外预留 20% 的缓冲。
3.2 名义处理时间(Nominal Processing Times)
所有名义处理时间必须在配置中显式给出(配置必填,代码中无默认值)——源码中RetryAfterConfig的所有字段均为必填,且validate()会强制检查:
| 请求类型 | 由谁处理 | 配置字段 |
|---|---|---|
| Input Proof | Copro | input_proof_processing_seconds |
| User Decrypt | KMS | user_decrypt_processing_seconds |
| Public Decrypt | KMS | public_decrypt_processing_seconds |
| Readiness Check(仅解密) | — | readiness_check_seconds |
| TX 确认 | 区块链 | tx_confirmation_ms |
3.3 Copro/KMS Backoff 区间(仅用于ReceiptReceived状态)
由于 Copro/KMS 的响应时间本质上不可预测,设计上对ReceiptReceived状态采用"按已等待时长分级退避"的固定间隔表:
| 已等待时间 | Retry-After | 理由 |
|---|---|---|
| 0-60s | 4s | 预期很快返回 |
| 60s-2m | 10s | 比平时慢 |
| 2m-5m | 30s | 明显延迟 |
| 5m-15m | 60s | 重大延迟 |
| 15m+ | 300s | 可能卡住,最小化轮询频率 |
3.4 真实配置文件示例
仓库中的实际配置示例 relayer/config/local.yaml.example(第 180-221 行)给出了完整的http.retry_after配置块:
http: endpoint: "0.0.0.0:3000" api_retry_after_seconds: 4 # Default retry-after seconds for queued API responses # Dynamic retry-after configuration for V2 handlers # Computes Retry-After based on queue size, drain rate, and processing stage retry_after: # Minimum retry interval in seconds (floor) min_seconds: 1 # Maximum retry interval in seconds (ceiling/cap) # Use this to limit the maximum retry-after value (e.g., 60s or 120s for tighter bounds) max_seconds: 300 # Safety margin: multiplier applied to computed ETA # Formula: final_eta = computed_eta * (1 + safety_margin) # Range: 0.0 to 1.0 (0% to 100% buffer) # Example: 0.2 = 20% buffer, so 10s ETA becomes 12s safety_margin: 0.2 # Nominal processing times per stage (used for ETA computation) # These are admin-updatable at runtime via /admin/config # All fields are required - no defaults in code nominal_times: # Expected time for readiness check (user/public decrypt only) readiness_check_seconds: 4 # Expected processing time for input proof requests input_proof_processing_seconds: 2 # Expected processing time for user decrypt requests user_decrypt_processing_seconds: 6 # Expected processing time for public decrypt requests public_decrypt_processing_seconds: 6 # Expected time for blockchain TX confirmation (in milliseconds) tx_confirmation_ms: 250 # Backoff intervals for ReceiptReceived state ONLY # This is the only state where we can't compute a dynamic ETA because # Copro/KMS response time is unpredictable. # Format: [elapsed_threshold_seconds, retry_interval_seconds] # As time in ReceiptReceived increases, we back off polling frequency. # NOTE: Safety margin is NOT applied to backoff intervals copro_kms_backoff_intervals: - [0, 4] # 0-60s: retry every 4s (expect response soon) - [60, 10] # 60s-2m: retry every 10s - [120, 30] # 2-5m: retry every 30s - [300, 60] # 5-15m: retry every 60s - [900, 300] # 15m+: retry every 5m (likely stuck)注意 backoff 区间在 YAML 中采用[elapsed_threshold_seconds, retry_interval_seconds]二元组数组的紧凑写法,源码通过deserialize_vec_from_map_or_seq同时兼容"序列形式"与"map 形式"的反序列化(见 relayer/src/config/retry_after.rs 与 relayer/src/config/settings.rs)。
3.5 配置校验规则(源码级)
RetryAfterConfig::validate()(relayer/src/config/retry_after.rs)强制三类约束,任一不满足都会导致启动失败:
min_seconds必须严格小于max_seconds;safety_margin必须落在闭区间[0.0, 1.0];copro_kms_backoff_intervals必须按elapsed_threshold_secs严格递增排序,且不允许重复阈值。
对应的单元测试(同文件的tests模块)覆盖了 min≥max、margin 越界(负值与大于 1)、区间乱序、重复阈值、空区间等场景,例如test_validate_unsorted_backoff_intervals与test_validate_duplicate_thresholds。
四、公式变量定义
| 变量 | 含义 |
|---|---|
p | 请求在队列中的位置(0 起始) |
Q | TX 队列大小(用于新请求排到队尾的情形) |
D | TX 排空速率(tps) |
C | Readiness 最大并发数 |
R | 名义就绪检查时间(ms) |
P | 名义处理时间(ms),Input Proof 约 2s,Decrypt 约 4s(实际以配置值为准) |
T | 名义 TX 确认时间(ms) |
M | 安全边际(如 0.2) |
E | 在当前状态已流逝的时间(ms) |
B(E) | 基于已流逝时间的退避函数 |
在源码中,这些变量的载体是RetryAfterState(relayer/src/http/retry_after/state.rs),它把配置中的"秒"全部转换为毫秒存储:
pub struct RetryAfterState { min_seconds: RwLock<u32>, max_seconds: RwLock<u32>, safety_margin: RwLock<f32>, nominal_readiness_ms: RwLock<u32>, nominal_input_proof_ms: RwLock<u32>, nominal_user_decrypt_ms: RwLock<u32>, nominal_public_decrypt_ms: RwLock<u32>, nominal_tx_ms: RwLock<u32>, copro_kms_backoff_intervals: RwLock<Vec<BackoffInterval>>, }每个字段都用RwLock包裹,为后续管理端点的运行时热更新预留了入口(见第五节)。
五、ETA 计算公式
5.1 Input Proof
| 状态 | 公式 |
|---|---|
| Queued | clamp(⌈(p/D + P + T) × (1+M) / 1000⌉, min, max) |
| Processing | clamp(⌈(p/D + P + T) × (1+M) / 1000⌉, min, max) |
| TxInFlight | clamp(⌈P × (1+M) / 1000⌉, min, max) |
| ReceiptReceived | B(E) |
| Completed/TimedOut/Failure | 0 |
5.2 Decrypt(User 与 Public)
| 状态 | 队列位置 | 公式 |
|---|---|---|
| Queued | 在就绪队列中 | clamp(⌈(p/C + Q/D + P + T) × (1+M) / 1000⌉, min, max) |
| Processing | 已出就绪队列、尚未进入 TX 队列 | clamp(⌈(R + Q/D + P + T) × (1+M) / 1000⌉, min, max) |
| Processing | 在 TX 队列中 | clamp(⌈(p/D + P + T) × (1+M) / 1000⌉, min, max) |
| TxInFlight | — | clamp(⌈P × (1+M) / 1000⌉, min, max) |
| ReceiptReceived | — | B(E) |
| Completed/TimedOut/Failure | — | 0 |
5.3 关键要点(含源码印证)
- Queued 用请求真实位置
p,而非队列总大小:轮询一个已入队多时的请求时,若用队列总大小会高估等待时间——排在位置 5 的请求与刚入队排在位置 100 的请求不应拿到相同 ETA。 - Processing 状态需判定请求当前在哪条队列。源码用
get_decrypt_stage()(relayer/src/http/retry_after/state.rs)判定DecryptStage:readiness.position为Some(p)→InReadinessQueue,套用p/C公式;tx.position为Some(p)→InTxQueue,套用p/D公式;- 两者均为
None→ProcessingReadiness(正在做就绪检查),套用R + Q/D公式。
- TxInFlight 只算处理时间
P:交易已发出,接下来只剩等待 Copro/KMS 返回。 - ReceiptReceived 使用退避函数
B(E):Copro/KMS 响应时间不可预测。
5.4 源码中的实现细节(与文档公式的精确对应)
- TX 队列等待时间:
compute_tx_queue_wait_ms()(relayer/src/http/retry_after/state.rs)实现为position / drain_rate_tps × 1000,取整后向上取ceil;当position为None(新请求排队尾)时回退使用size。若drain_rate_tps为 0,返回兜底值300_000ms(即 300 秒),避免除零。 - 就绪队列等待时间:
compute_readiness_queue_wait_ms()(同文件第 411-432 行)实现为ceil(position / max_concurrency) × nominal_readiness_ms——即先算出需要等待几个"并发批次",再乘以单批就绪检查时间。注意:设计文档表格中 Queued 行简写为p/C + Q/D,实际源码实现是"批次数量 × 名义就绪检查时间 + TX 队列等待",即ceil(p/C) × R + Q/D × 1000,后者是更精确的建模。 - 最终换算:
to_retry_after_secs()(同文件第 379-382 行)先对raw_eta_ms × (1 + margin)做毫秒级向上取整(apply_safety_margin_ms,第 366-375 行),再div_ceil(1000)换算为秒并clamp(min, max)。 - 退避函数:
compute_copro_kms_backoff()(第 339-358 行)遍历区间表,取elapsed_secs >= threshold的最后一个区间值,最终同样clamp(min, max);空表时回退到min_seconds。
六、示例计算(完整推演)
以下沿用设计文档的示例参数:D=10, C=50, R=2s, P_input=2s, P_decrypt=4s, T=100ms, M=0.2, B(E)=3s。
6.1 Input Proof(P = 2000ms)
Queued / Processing:⌈(p/D × 1000 + P + T) × (1+M) / 1000⌉
| p | p/D (s) | + P + T (ms) | × 1.2 | 结果 |
|---|---|---|---|---|
| 0 | 0 | 2100 | 2520 | 3s |
| 1 | 0.1 | 2200 | 2640 | 3s |
| 10 | 1 | 3100 | 3720 | 4s |
| 100 | 10 | 12100 | 14520 | 15s |
| 1000 | 100 | 102100 | 122520 | 123s |
TxInFlight:⌈P × 1.2 / 1000⌉=⌈2000 × 1.2 / 1000⌉=3s(恒定值)
ReceiptReceived:B(E)=3s(恒定值,示例中退避函数恒取 3s)
6.2 Decrypt(P = 4000ms)
Queued(在就绪队列):⌈(p/C × 1000 + Q/D × 1000 + P + T) × (1+M) / 1000⌉,设 p = Q(两条队列条目数相同):
| p | p/C (s) | Q/D (s) | + P + T (ms) | × 1.2 | 结果 |
|---|---|---|---|---|---|
| 0 | 0 | 0 | 4100 | 4920 | 5s |
| 1 | 0.02 | 0.1 | 4220 | 5064 | 6s |
| 10 | 0.2 | 1 | 5300 | 6360 | 7s |
| 100 | 2 | 10 | 16100 | 19320 | 20s |
| 1000 | 20 | 100 | 124100 | 148920 | 149s |
Processing(已出就绪队列、未进 TX 队列):⌈(R + Q/D × 1000 + P + T) × (1+M) / 1000⌉
| Q | R (ms) | Q/D (s) | + P + T (ms) | × 1.2 | 结果 |
|---|---|---|---|---|---|
| 0 | 2000 | 0 | 6100 | 7320 | 8s |
| 1 | 2000 | 0.1 | 6200 | 7440 | 8s |
| 10 | 2000 | 1 | 7100 | 8520 | 9s |
| 100 | 2000 | 10 | 16100 | 19320 | 20s |
| 1000 | 2000 | 100 | 106100 | 127320 | 128s |
Processing(在 TX 队列):⌈(p/D × 1000 + P + T) × (1+M) / 1000⌉
| p | p/D (s) | + P + T (ms) | × 1.2 | 结果 |
|---|---|---|---|---|
| 0 | 0 | 4100 | 4920 | 5s |
| 1 | 0.1 | 4200 | 5040 | 6s |
| 10 | 1 | 5100 | 6120 | 7s |
| 100 | 10 | 14100 | 16920 | 17s |
| 1000 | 100 | 104100 | 124920 | 125s |
TxInFlight:⌈P × 1.2 / 1000⌉=⌈4000 × 1.2 / 1000⌉=5s(恒定值)
ReceiptReceived:B(E)=3s(恒定值)
6.3 汇总表(p=100, Q=100)
| 状态 | Input Proof | Decrypt |
|---|---|---|
| Queued | 15s | 20s |
| Processing(就绪队列中) | — | 20s |
| Processing(TX 队列中) | 15s | 17s |
| TxInFlight | 3s | 5s |
| ReceiptReceived | 3s | 3s |
6.4 源码测试对公式的印证
relayer/src/http/retry_after/state.rs 的测试模块中test_compute_for_input_proof_post直接验证了公式:size=100, drain_rate_tps=20时队列等待 5000ms,加上processing=2000ms, tx=250ms得raw_eta=7250ms,乘 1.2 后ceil(8700/1000)=9s。此外test_eta_clamped_to_min、test_eta_clamped_to_max、test_compute_copro_kms_backoff(验证 0s→4、60s→10、120s→30 的区间映射)分别覆盖了上下限钳制与退避表逻辑。
七、HTTP 响应格式
7.1 POST 响应(202 Accepted)
HTTP/1.1 202 Accepted Retry-After: 27 {"status": "queued", "job_id": "...", "eta_seconds": 27}7.2 GET 轮询响应(202 In Progress)
HTTP/1.1 202 Accepted Retry-After: 10 {"status": "queued", "state": "tx_in_flight", "eta_seconds": 10, "elapsed_seconds": 15}源码侧的实现位置:
- V2 的 Input Proof 处理端点 relayer/src/http/endpoints/v2/handlers/input_proof.rs 在 POST 入队时调用
compute_for_input_proof_post计算并设置Retry-After头(第 298-338 行),GET 轮询时调用compute_for_input_proof_get(第 518 行附近),OpenAPI 注释明确 202 语义为 "Still processing. Poll again after Retry-After."。 - 响应头的统一装配逻辑位于 relayer/src/http/utils/responses.rs(第 469-471 行附近):将计算出的秒数写入
Retry-After头;同时 relayer/src/http/endpoints/v2/types/error.rs 中定义V2StatusQueued响应体,携带eta_seconds字段。 - 每次 POST 还会调用
compute_raw_eta_ms_for_input_proof/compute_raw_eta_ms_for_decrypt输出"未加安全边际、未钳制"的原始 ETA,用于retry_after_raw_eta_histogram_bucket直方图监控(指标端点见 relayer/src/metrics/retry_after.rs,桶定义见 relayer/config/local.yaml.example 第 231 行)。
八、运行时热更新:Admin 配置端点
所有参数都支持通过管理端点运行时更新,无需重启进程:
- 各类请求的名义处理时间
- TX throttler 的 TPS(排空速率)
- Retry-After 上下限(min/max)
- 安全边际
- Copro/KMS 退避区间
实现层面:
- relayer/src/http/admin/handlers.rs 的
update_config(第 112 行起)接收{ "param": "...", "value": ... }请求体,其中is_retry_after_param()(relayer/src/http/admin/config_param.rs)负责识别retry_after_min_seconds、retry_after_max_seconds、retry_after_safety_margin、nominal_readiness_check_seconds、nominal_input_proof_processing_seconds、nominal_user_decrypt_processing_seconds、nominal_public_decrypt_processing_seconds、nominal_tx_confirmation_ms等参数,并写入RetryAfterState对应的 setter(如set_min_seconds、set_safety_margin、set_backoff_intervals,见 relayer/src/http/retry_after/state.rs)。 get_config(第 372 行起)返回当前生效的全部参数,便于运维核对。- 需要说明的是,
http.enable_admin_endpoint默认关闭(见 relayer/config/local.yaml.example 第 178 行),配置注释明确提示生产环境应由 Kong 等网关负责认证与限流。
RetryAfterState之所以用RwLock包裹每个字段而不是整体一个锁,正是为了支持"单参数热更新"——更新safety_margin不会阻塞正在读取其他参数的并发请求。
九、设计决策与取舍
9.1 为什么 ReceiptReceived 用固定退避(不乘安全边际)
- Copro/KMS 的响应时间从根本上不可预测,套用排队模型没有意义;
- 退避区间本身已按保守原则设计(4s→10s→30s→60s→300s 逐级放大);
- 额外叠加安全边际只会无谓地拉长轮询间隔,增加客户端感知延迟。
9.2 为什么内部一律用毫秒
- 所有内部计算以毫秒为单位,避免秒级取整引入的累积舍入误差;
- 仅在最终设置
Retry-After响应头时才换算为秒(div_ceil(1000)向上取整,确保告知客户端的值永远"足够宽裕")。
9.3 为什么基于位置(position)而非队列大小(size)
- 对轮询已有
Queued请求的 GET 而言,用队列总大小是错的:已在队内等到位置 5 的请求,ETA 应远短于刚入队排在第 100 的请求; - 源码中
compute_tx_queue_wait_ms/compute_readiness_queue_wait_ms均遵循"position 优先,size 仅作为新请求排队的回退"原则,测试test_position_overrides_size明确断言了这一点(size=10_000而position=Some(20)时等待按 20 计算); - 此外
get_position(id)还能让 ETA 随请求在队列中前进而动态收敛,提升估算精度。
9.4 多实例一致性(从测试推导的工程考量)
relayer/src/http/retry_after/state.rs 中test_get_eta_is_pod_independent的注释揭示了一个值得注意的工程细节:不持有 dispatcher 锁的被动 Pod 内存中的节流队列为空,若直接用内存size: 0计算,会把"600 条积压"误判成"空闲队列",导致 ETA 被钳制到min_seconds,而持有锁的 Pod 却给出真实估算,造成两个实例对同一请求返回不一致的轮询间隔。该设计通过从数据库req_status行集读取一致的队列深度来解决这一问题(Input Proof 端点中insert_result.tx_queue_size即来自 INSERT 返回值,见 relayer/src/http/endpoints/v2/handlers/input_proof.rs 第 291-297 行)。从源码结构看,这一"共享队列深度 + 位置优先"的建模同时服务了单实例准确性与多实例一致性两个目标。
十、延伸阅读
- 设计文档原文:relayer/docs/dynamic-retry-after-design.md
- 配置结构体与校验:relayer/src/config/retry_after.rs
- ETA 计算核心实现:relayer/src/http/retry_after/state.rs
- 队列信息聚合类型:relayer/src/http/retry_after/queue_info.rs
- 完整配置示例:relayer/config/local.yaml.example、relayer/config/local.testnet.yaml.example、relayer/config/local.mainnet.yaml.example
- 管理端点实现:relayer/src/http/admin/handlers.rs、relayer/src/http/admin/config_param.rs
- 监控指标:relayer/src/metrics/retry_after.rs、relayer/src/metrics/docs_and_dashboards/status_metrics.md
- 相关运行文档:relayer/docs/http-api-design.md、relayer/docs/idempotency-audit.md
【免费下载链接】fhevmFHEVM, a full-stack framework for integrating Fully Homomorphic Encryption (FHE) with blockchain applications项目地址: https://gitcode.com/GitHub_Trending/fh/fhevm
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考