功能说明
客户端在网络超时后重试支付、下单或创建资源时,服务端必须保证同一操作只执行一次。幂等键存储把请求分为三态:键不存在时开始处理并标记进行中;键已存在且仍在处理中,告知调用方稍后重试;键已有完成结果且在有效期内,直接重放当时的响应。被放弃或超时的处理需要显式释放,否则键会永远停留在进行中。
本例用互斥锁保护哈希表,条目要么是进行中,要么是带状态码、响应体和完成时间的已完成响应。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");
}