← 中文资源

简体中文版本

幂等键重放存储

按幂等键跟踪进行中与已完成的请求,在有效期内重放已存响应,并释放被放弃的任务。

阅读英文原文

功能说明

客户端在网络超时后重试支付、下单或创建资源时,服务端必须保证同一操作只执行一次。幂等键存储把请求分为三态:键不存在时开始处理并标记进行中;键已存在且仍在处理中,告知调用方稍后重试;键已有完成结果且在有效期内,直接重放当时的响应。被放弃或超时的处理需要显式释放,否则键会永远停留在进行中。

本例用互斥锁保护哈希表,条目要么是进行中,要么是带状态码、响应体和完成时间的已完成响应。begin 返回三种结果,complete 写入最终响应,abandon 删除条目以便重试。main 演示首次、并发重复、完成后重放三个分支。

关键代码

Entry 枚举区分进行中与完成,避免用空响应表示进行中造成歧义。begin 在锁内先取出可能需要重放的缓存副本再决定插入,借用在插入前结束,避免同时持有不可变与可变借用。已完成条目只有在存储时间未超过 TTL 时才重放,过期后重新开始。complete 覆盖进行中条目并记录当前时间。abandon 用 remove 删除键。所有锁中毒都通过 expect 暴露,防止在状态不确定时继续。

使用注意

该存储是进程内实现,多实例部署必须改用数据库或 Redis,并用原子插入保证同一键只有一个处理者。TTL 只控制重放窗口,进行中条目不会自动过期,需要额外的看门狗清理。缓存响应可能含敏感字段,存储前应脱敏并加密。幂等键应由客户端生成且长度受限,服务端要校验归属,防止跨用户重放。complete 与 abandon 必须覆盖所有成功和失败路径,包括 panic,否则键会卡住。

验证方式

运行脚本,依次输出 first request: fresh、duplicate while running: in progress、replay: 201 与响应体,说明三种状态转换正确。把 TTL 改为零后重新运行,完成后的 begin 应再次返回 fresh 而不是重放。调用 abandon 后再 begin 同一个键,也应得到 fresh。

代码示例

use std::collections::HashMap;
use std::sync::Mutex;
use std::time::{Duration, Instant};

#[derive(Clone)]
struct CachedResponse {
    status: u16,
    body: String,
    stored_at: Instant,
}

enum Entry {
    InProgress,
    Done(CachedResponse),
}

enum Outcome {
    Fresh,
    InProgress,
    Replay(CachedResponse),
}

struct IdempotencyStore {
    entries: Mutex<HashMap<String, Entry>>,
    ttl: Duration,
}

impl IdempotencyStore {
    fn new(ttl: Duration) -> Self {
        Self {
            entries: Mutex::new(HashMap::new()),
            ttl,
        }
    }

    fn begin(&self, key: &str) -> Outcome {
        let mut entries = self.entries.lock().expect("store poisoned");
        let replay = match entries.get(key) {
            Some(Entry::InProgress) => return Outcome::InProgress,
            Some(Entry::Done(cached)) if cached.stored_at.elapsed() < self.ttl => {
                Some((*cached).clone())
            }
            _ => None,
        };
        if let Some(cached) = replay {
            return Outcome::Replay(cached);
        }
        entries.insert(key.to_string(), Entry::InProgress);
        Outcome::Fresh
    }

    fn complete(&self, key: &str, status: u16, body: &str) {
        let mut entries = self.entries.lock().expect("store poisoned");
        entries.insert(
            key.to_string(),
            Entry::Done(CachedResponse {
                status,
                body: body.to_string(),
                stored_at: Instant::now(),
            }),
        );
    }

    fn abandon(&self, key: &str) {
        self.entries
            .lock()
            .expect("store poisoned")
            .remove(key);
    }
}

fn main() {
    let store = IdempotencyStore::new(Duration::from_secs(60));

    match store.begin("order-1") {
        Outcome::Fresh => println!("first request: fresh"),
        _ => println!("unexpected first outcome"),
    }
    match store.begin("order-1") {
        Outcome::InProgress => println!("duplicate while running: in progress"),
        _ => println!("unexpected duplicate outcome"),
    }
    store.complete("order-1", 201, "{\"id\":1}");
    match store.begin("order-1") {
        Outcome::Replay(cached) => println!("replay: {} {}", cached.status, cached.body),
        _ => println!("unexpected replay outcome"),
    }
    store.abandon("order-2");
}