创建客户成功了,响应却没送到页面。用户重试时沿用requestId,服务端返回上次结果,这条路径没有问题。可如果用户顺手改了姓名,再用同一个requestId提交,继续返回旧结果就会制造另一种误解:页面显示成功,刚改的姓名根本没保存。
本文补的是重复请求里的"输入绑定"这一层。下面的Java程序用内存Map演示判定规则,不提供跨进程、多实例或持久化幂等保证。
请求号要对应一份业务输入
可以先把已经完成的请求分成两类:相同范围、相同请求号、相同输入,返回原结果;相同范围、相同请求号、不同输入,拒绝复用。
这里的范围包含经过认证的用户和操作类型。Alice创建客户用的r1,不应与Bob创建客户的r1相撞,也不应复用成Alice删除客户的结果。

java
record Key(String user, String operation, String requestId) {}
record Entry(String fingerprint, String result) {}
user 在业务接口中来自可信身份上下文,不接受前端随意指定;演示程序里的alice、bob是教学夹具。
不直接对随手序列化的JSON求摘要
字段顺序变化、空白差异、默认值填充,都可能让原始JSON不同,而业务输入相同。先固定规范化规则,再计算稳定摘要。
本例只收email和name两个字段:邮箱去首尾空白并转小写,姓名只去首尾空白。这个邮箱规则是示例选择,不是适用于所有邮箱业务的普遍保证。字段顺序固定,并采用长度前缀,避免简单分隔符拼接产生歧义;字符串前面的v1标记规范化规则版本。
计算摘要是为了识别输入,不是为了加密存储个人信息,也不替代访问控制。若数据库还保留原始输入,仍要按其敏感程度处理。
已完成请求的处理分支
java
Entry prior = done.get(key);
if (prior != null) {
if (!prior.fingerprint().equals(fp)) {
throw new IllegalArgumentException("REQUEST_KEY_REUSED_WITH_DIFFERENT_INPUT");
}
return prior.result();
}
发现输入不同后,不要悄悄覆盖旧摘要,也不要替用户自动换号重做。给出明确的冲突结果,等用户决定是否发起新操作。新请求号只代表新的逻辑请求,业务唯一性依然可能拒绝它。
本地实际结果是:同号同内容返回customer-1;同号改姓名被拒绝,写入次数仍是1;不同用户同号独立处理;新请求号进入新的处理分支。完整程序中这些结果都有断言。
哪些能力不在这张Map里
synchronized 仅串行化当前Java对象的方法调用,程序退出后Map就没了。真实系统至少还需要持久化的范围唯一约束、处理中/成功/失败状态、原结果保存及恢复规则。业务写入与请求记录如何一起提交,应结合事务边界设计。
特别要检查"业务已提交、结果记录没保存"这段窗口。不能因为查不到成功记录就断定从未执行过;也不能用一段内存锁代码宣称端到端Exactly Once。
如果沿用现有requestId实现,可以先只补本文这层:保存规范化输入摘要,对同号改参数加一个拒绝测试,再逐项检查持久化与并发。一次修改解决一个已知缺口,比重新堆一套名词更容易核对。
运行完整例子
下面的类只用JDK标准库,不需要Maven。保存为 ReplayLab.java,从该文件所在目录运行:
shell
javac -encoding UTF-8 -d out ReplayLab.java
java -cp out demo.ReplayLab
预期:
text
sameInput=customer-1; changedInput=rejected; separateUser=customer-2; newKey=customer-3; checks=6
java
package demo;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.*;
public class ReplayLab {
record Key(String user,String operation,String requestId) {}
record Entry(String fingerprint,String result) {}
final Map<Key,Entry> done=new HashMap<>();
int writes;
static String fingerprint(String email,String name) throws Exception {
// 按示例业务规则:邮箱去首尾空白并小写,姓名去首尾空白;保留姓名大小写。
String a=email.strip().toLowerCase(Locale.ROOT),b=name.strip();
// 长度前缀避免字段拼接歧义;v1固定规范化规则的版本。
String canonical="v1|"+a.length()+":"+a+"|"+b.length()+":"+b;
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(canonical.getBytes(StandardCharsets.UTF_8)));
}
synchronized String submit(Key key,String email,String name) throws Exception {
String fp=fingerprint(email,name); Entry prior=done.get(key);
if(prior!=null){
if(!prior.fingerprint().equals(fp))throw new IllegalArgumentException("REQUEST_KEY_REUSED_WITH_DIFFERENT_INPUT");
return prior.result();
}
String result="customer-"+(++writes);done.put(key,new Entry(fp,result));return result;
}
static void eq(Object e,Object a){if(!e.equals(a))throw new AssertionError(e+" != "+a);}
public static void main(String[] args) throws Exception {
var lab=new ReplayLab();var key=new Key("alice","create-customer","r1");
eq("customer-1",lab.submit(key,"A@example.com","Ada"));
eq("customer-1",lab.submit(key," a@example.com ","Ada"));
boolean conflict=false;try{lab.submit(key,"a@example.com","Bob");}catch(IllegalArgumentException e){conflict=true;}
eq(true,conflict);eq(1,lab.writes);
eq("customer-2",lab.submit(new Key("bob","create-customer","r1"),"b@example.com","Bob"));
eq("customer-3",lab.submit(new Key("alice","create-customer","r2"),"c@example.com","Cat"));
System.out.println("sameInput=customer-1; changedInput=rejected; separateUser=customer-2; newKey=customer-3; checks=6");
}
}
你们对"同一个请求号改了参数"现在怎么处理?如果直接返回旧结果,页面有没有办法知道这次的新内容没有生效?