XML上传接口在网关、ERP、电子回单、配置同步等系统中非常常见。客户端发出POST请求后,可能因为响应超时没有拿到结果,但请求已经到达服务端并完成了处理。此时客户端再次上传同一份XML,如果服务端没有防重机制,就会产生两条相同的业务记录。幂等性设计就是让多次相同请求只产生一次业务效果。针对XML上传,主要有两种实现路径:利用内容摘要生成ETag,或使用请求级唯一ID。下面从实际工程角度展开。

一、为什么XML上传需要单独设计幂等性
很多人认为数据库唯一约束可以解决重复数据问题,比如对订单号、报文编号建立唯一索引。但XML上传场景并不总是这么简单。客户端重试的请求体可能完全相同,也可能因为序列化差异导致字段顺序、空白符、编码格式略有不同,而业务含义却完全一致。此时数据库唯一索引无法识别这种语义层面的重复,仍然会插入两条记录。更麻烦的是,有些XML导入接口并不直接落库,而是先解析、校验、再调用下游服务。如果解析和下游调用不具备幂等性,重复请求会产生多次外部副作用。
另一个常见误区是依赖客户端保证不重试。真实网络环境里,超时重试是不可避免的。负载均衡、网关、消息队列重投都可能造成重复请求。服务端必须假设同一个请求可能被发送多次,并在自己这一侧建立防重屏障。过早依赖业务层判断,容易把幂等逻辑散落在各个Service里,维护成本高,而且容易遗漏边界条件。因此,将幂等控制前置到请求入口,用统一的过滤或拦截机制处理,是更可控的方案。
XML上传的幂等性设计需要回答两个问题:第一,如何识别重复请求;第二,识别后如何响应。识别依据可以是内容摘要,也可以是请求标识。响应方式则包括直接返回历史结果、返回成功标识、或者返回冲突提示。下面分别讨论ETag和请求ID两种主流做法。
二、基于ETag的版本比对方案
ETag原本是HTTP协议中用于资源版本控制的响应头,比如GET请求返回资源的ETag,客户端在后续更新时通过If-Match头带上这个值,服务端比对版本决定是否执行更新。借鉴这个思路,XML上传接口可以让客户端先计算XML文件的内容摘要,把摘要作为ETag。常用的摘要算法是SHA-256,这样可以避免文件内容相同但字节顺序不同导致的误判。客户端生成摘要后,放在If-Match头或自定义头X-Content-Hash中,随上传请求一起发送。
服务端收到请求后,先取出摘要值,然后查询去重存储。如果摘要已经存在,并且对应的处理状态是成功,则直接返回历史处理结果或204状态码,不再进入后续解析流程。如果摘要不存在,则进行正常的XML解析、校验和业务处理,处理成功后再将摘要写入去重存储。为了防止两个携带相同摘要的请求同时到达,写入去重记录必须使用原子操作。以数据库为例,可以创建一张表,对摘要字段建立唯一索引,插入时捕获DuplicateKeyException,发现冲突就直接走重复请求分支。
public class EtagDeduplicateService {
private final JdbcTemplate jdbcTemplate;
public EtagDeduplicateService(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
public boolean tryAcquire(String etag) {
try {
jdbcTemplate.update(
"INSERT INTO upload_etag_log(etag, status) VALUES (?, ?)",
etag, "PROCESSING"
);
return true;
} catch (DuplicateKeyException e) {
return false;
}
}
public void markSuccess(String etag) {
jdbcTemplate.update(
"UPDATE upload_etag_log SET status = ? WHERE etag = ?",
"SUCCESS", etag
);
}
}
上面的代码通过唯一索引保证了同一个ETag只被成功占位一次。如果两个请求同时携带相同ETag,只有一个能插入成功,另一个会拿到DuplicateKeyException,从而进入重复处理分支。实际项目中,还可以把ETag记录放在Redis中,用setIfAbsent实现同样的效果。但Redis需要考虑持久化和过期策略,数据库则更稳定,适合需要长期保留防重记录的场合。
ETag方案的最大优点是防重依据与文件内容强相关,客户端可以在离线状态下提前算出摘要,服务端也可以用它做内容级别的去重。例如消息队列重投时,不同消息ID但内容相同,ETag仍然能拦住重复处理。它的缺点也很明显:如果服务端对XML做了规范化处理,比如去除注释、排序节点、忽略空白,那么客户端提供的原始摘要就可能与规范化后的内容不一致,导致重复请求无法命中。因此,使用ETag方案时,需要明确约定摘要的计算范围,最好直接对原始字节流计算,不要对解析后的对象计算。
三、基于请求ID的防重方案
请求ID方案不关心文件内容是否相同,而是要求每次业务操作生成一个唯一标识。客户端在第一次上传时生成UUID,如果遇到超时重试,则沿用同一个UUID再次发送。服务端把这个UUID作为幂等键,记录请求的处理状态。常见的HTTP头名称有X-Request-Id和X-Idempotency-Key,选择其中一个作为约定即可。服务端在入口处先读取请求ID,然后尝试以该ID占位。占位成功后进入正常处理流程,占位失败则判断当前状态:如果是处理中,可以返回409或202并提示客户端稍后重试;如果是成功,则直接返回历史结果;如果是失败,则允许客户端用同一个请求ID重新处理。
数据库方案可以创建一张请求日志表,以request_id为主键。首次处理时插入一条状态为PROCESSING的记录,处理完成后更新为SUCCESS或FAILED。因为主键本身具有唯一约束,重复插入会失败,服务端据此识别重复请求。这种方式的优点是状态清晰,失败重试可精确控制。缺点是每次上传都需要读写数据库,对高频接口可能需要配合缓存。Redis方案则更轻量,使用SETNX原子占位,同时设置合理的过期时间,防止服务崩溃导致锁永远不释放。
String luaScript = "if redis.call('setnx', KEYS[1], ARGV[1]) == 1 then " +
"redis.call('expire', KEYS[1], tonumber(ARGV[2])) " +
"return 1 " +
"else " +
"return 0 " +
"end";
上面的Lua脚本把SETNX和EXPIRE两个命令组合成原子操作,避免占位后设置过期时间之前进程崩溃导致死锁。占位失败时,服务端可以进一步读取当前状态。如果状态是PROCESSING,说明有另一个请求正在处理,此时不应该直接返回成功,因为结果还没确定。可以根据业务容忍度选择等待、轮询或返回可重试错误。如果状态是SUCCESS,则可以查询之前保存的处理结果,直接返回给客户端。
请求ID方案最大的优势是通用性强。它不依赖XML内容,也不需要客户端计算摘要,只需要每次请求带上一个稳定且唯一的ID。对于不同内容的两次正常上传,客户端必须生成不同的请求ID,否则会被服务端误判为重复请求。这是该方案对客户端的一个基本约束。另外,请求ID通常与业务单据号解耦,网关或中间件可以统一注入,适合在微服务调用链中传递。
四、ETag与请求ID如何选型及落地细节
两种方案并不是互斥的。实际系统中,很多团队会同时使用请求ID和ETag,形成两层防重。请求ID保证同一次业务操作不会因为重试而重复处理,ETag则保证内容相同的文件即使通过不同渠道提交,也能被识别出来。例如客户端直接上传和消息队列异步导入,可能会产生不同的请求ID,但文件内容相同,此时ETag可以兜底。服务端可以在入口先按请求ID查一次,再按ETag查一次,两层都通过才进入业务流程。
| 对比维度 | ETag方案 | 请求ID方案 |
|---|---|---|
| 防重依据 | XML内容摘要 | 业务请求唯一标识 |
| 客户端配合 | 计算SHA-256摘要 | 生成UUID并保证重试不变 |
| 重复判定 | 内容相同即重复 | 同一请求ID即重复 |
| 失败重试 | 内容相同可能直接返回历史结果 | 根据状态决定是否允许重试 |
| 适用场景 | 文件内容不可变、重复导入 | 所有上传请求,尤其业务操作 |
| 存储结构 | ETag唯一索引 | 请求ID主键或Redis键 |
落地时还需要考虑防重记录的过期清理。XML上传请求通常不会无限期保留,防重记录也不需要永久保存。Redis方案可以设置24小时过期,数据库方案可以通过定时任务清理7天前的记录。过期时间不能太短,否则客户端在较长时间后重试会绕过防重;也不能太长,否则存储压力增大。具体时长可以根据业务允许的最大重试窗口来确定。
并发冲突处理同样关键。无论是ETag还是请求ID,都必须依赖原子占位,不能先查询再插入,否则两个请求可能同时读到不存在,然后都进入处理流程。数据库唯一索引加异常捕获是可靠的方案,Redis的SETNX或者Lua脚本也能保证原子性。如果处理流程较长,占位记录不能一直停留在PROCESSING状态,需要设置超时机制,超过一定时间未完成则允许后续请求重新抢占,同时旧请求需要在完成时检查自己是否仍然持有处理权,避免覆盖新请求的结果。
最后,重复请求的响应体设计也值得关注。对于已经处理成功的请求,服务端最好能够返回与首次请求一致的响应,包括成功标识、业务单据号等信息。如果响应体很大,可以只保存响应摘要或结果位置,客户端拿到后再通过查询接口获取完整数据。返回409冲突时,响应中应当携带明确的错误码和提示信息,告诉客户端这是重复请求,而不是普通的参数错误。清晰的错误语义能显著降低客户端对接成本,也方便线上排查问题。