令牌方式:
在 HTTP 请求中,若客户端在处理过程中断开连接,可能导致用户未收到数据而服务器已删除数据的风险。以下是分阶段解决方案及具体实现:
问题本质
• 核心矛盾 :HTTP 是无状态协议,服务器无法感知客户端是否真正接收到了响应数据。
• 业务风险:数据被删除后,客户端无法重新获取,导致业务中断。
解决方案架构
方案 1:异步确认 + 延迟删除(推荐)
通过「客户端确认」和「延迟删除」机制保证数据安全,流程如下:
Client Server DB 1. 发起获取数据请求 2. 查询数据并标记为「待删除」 3. 返回数据 + 唯一令牌(token) 4. 发送确认请求(携带 token) 5. 物理删除数据 6. 定时任务清除过期数据 alt [超时未确认] Client Server DB
关键实现步骤(Spring Boot)
-
标记数据状态:
java@Entity public class DataEntity { @Id private String id; private boolean pendingDelete; // 标记是否待删除 private LocalDateTime expiryTime; // 过期时间 }
-
返回数据并生成令牌:
java@GetMapping("/getData") public ResponseEntity<DataResponse> getData() { DataEntity data = dataRepository.findById(id).orElseThrow(); data.setPendingDelete(true); data.setExpiryTime(LocalDateTime.now().plusMinutes(5)); // 设置5分钟过期 dataRepository.save(data); String token = UUID.randomUUID().toString(); redisTemplate.opsForValue().set(token, data.getId(), Duration.ofMinutes(5)); return ResponseEntity.ok(new DataResponse(data.getContent(), token)); }
-
客户端确认接口:
java@PostMapping("/confirm") public ResponseEntity<Void> confirm(@RequestParam String token) { String dataId = redisTemplate.opsForValue().get(token); if (dataId != null) { dataRepository.deleteById(dataId); // 物理删除 redisTemplate.delete(token); } return ResponseEntity.ok().build(); }
-
定时清理任务:
java@Scheduled(fixedRate = 60000) // 每分钟检查一次 public void cleanupExpiredData() { List<DataEntity> expiredData = dataRepository.findByExpiryTimeBefore(LocalDateTime.now()); expiredData.forEach(data -> { dataRepository.delete(data); redisTemplate.delete(data.getId()); // 清理相关token }); }
方案 2:预生成下载链接 + 软删除
将数据存储为临时文件,客户端通过一次性链接下载,确保删除前数据可达。
java
@GetMapping("/generateDownload")
public ResponseEntity<DownloadResponse> generateDownload() {
String data = fetchData();
String fileId = UUID.randomUUID().toString();
Path tempFile = Files.write(Paths.get("/tmp/" + fileId), data.getBytes());
// 记录文件ID和过期时间
redisTemplate.opsForValue().set(fileId, "valid", Duration.ofHours(1));
return ResponseEntity.ok(new DownloadResponse("/download/" + fileId));
}
@GetMapping("/download/{fileId}")
public ResponseEntity<Resource> download(@PathVariable String fileId) {
if (!redisTemplate.hasKey(fileId)) {
throw new FileNotFoundException();
}
Path filePath = Paths.get("/tmp/" + fileId);
FileSystemResource resource = new FileSystemResource(filePath);
// 下载后立即删除文件
Files.deleteIfExists(filePath);
redisTemplate.delete(fileId);
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=data.txt")
.body(resource);
}
方案对比
方案 | 优点 | 缺点 | 适用场景 |
---|---|---|---|
异步确认 + 延迟删除 | 数据安全,支持客户端重试 | 需要维护状态和定时任务 | 对数据可靠性要求高的场景(如支付凭据) |
预生成下载链接 + 软删除 | 实现简单,无状态依赖 | 文件存储可能占用磁盘空间 | 临时文件下载(如报表导出) |
消息队列延迟删除 | 解耦业务逻辑,扩展性强 | 依赖消息中间件,增加系统复杂度 | 高并发、分布式系统 |
关键设计原则
- 幂等性:客户端重试请求时,确保不会重复删除数据(通过唯一 token 或请求 ID 实现)。
- 状态跟踪:通过数据库标记或 Redis 记录数据生命周期。
- 超时机制:避免资源泄漏,设置合理的过期时间。
- 异常回滚:若客户端确认失败,保留数据直到超时。
扩展建议
• 监控与告警 :记录客户端确认率和删除失败次数,及时报警人工介入。
• 客户端重试策略 :在 SDK 或前端实现自动重试逻辑(如指数退避)。
• 数据备份:删除前备份到归档存储,极端情况下可手动恢复。
通过以上设计,可有效解决「客户端断开导致数据丢失」的问题,平衡业务安全性和系统复杂度。
非令牌方式:
在无需引入额外令牌机制的情况下,可以通过 延迟删除 + 状态跟踪 的通用方案解决问题。以下是具体实现策略:
解决方案:延迟删除 + 请求关联
通过 请求唯一性标识 和 延迟任务 确保数据在客户端确认接收前保留,核心流程如下:
Client Server DB Queue 1. 发起数据请求 (GET /data/{id}) 2. 查询数据并标记删除状态 3. 返回数据 (HTTP 200) 4. 提交延迟删除任务 (延迟5分钟) 5. 正常接收数据 (前端静默确认) 6. 延迟到期后删除数据 7. 重新请求同一数据 (GET /data/{id}) 8. 检查删除标记,取消延迟任务 9. 再次返回数据 alt [客户端断开/未接收] [客户端重新请求] Client Server DB Queue
实现步骤(Spring Boot)
1. 数据库设计(添加删除标记)
java
@Entity
public class DataEntity {
@Id
private String id;
private boolean scheduledDelete; // 标记是否计划删除
private LocalDateTime deleteTime; // 计划删除时间
// 其他字段...
}
2. 数据查询接口
java
@GetMapping("/data/{id}")
public ResponseEntity<Data> getData(@PathVariable String id) {
DataEntity data = dataRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException());
// 首次请求时标记删除计划
if (!data.isScheduledDelete()) {
data.setScheduledDelete(true);
data.setDeleteTime(LocalDateTime.now().plusMinutes(5)); // 5分钟后删除
dataRepository.save(data);
// 提交延迟删除任务
delayDeleteQueue.submit(id, Duration.ofMinutes(5));
}
return ResponseEntity.ok(convertToDto(data));
}
3. 延迟队列实现(示例)
java
@Component
public class DelayDeleteQueue {
@Autowired
private DataRepository dataRepository;
private final Map<String, ScheduledFuture<?>> tasks = new ConcurrentHashMap<>();
public void submit(String dataId, Duration delay) {
ScheduledFuture<?> future = taskScheduler.schedule(
() -> executeDelete(dataId),
Instant.now().plusMillis(delay.toMillis())
);
tasks.put(dataId, future);
}
private void executeDelete(String dataId) {
dataRepository.findById(dataId).ifPresent(data -> {
if (data.getDeleteTime().isBefore(LocalDateTime.now())) {
dataRepository.delete(data);
tasks.remove(dataId);
}
});
}
// 取消删除任务(当客户端重新请求时调用)
public void cancel(String dataId) {
ScheduledFuture<?> future = tasks.get(dataId);
if (future != null && !future.isDone()) {
future.cancel(true);
dataRepository.updateScheduledDelete(dataId, false);
tasks.remove(dataId);
}
}
}
4. 客户端静默重试(前端示例)
javascript
// 前端获取数据后,自动发起后台确认(无需用户感知)
fetch(`/data/${id}`)
.then(response => {
// 成功获取数据后,静默发送HEAD请求确认
fetch(`/data/${id}`, { method: 'HEAD' })
.catch(() => {}); // 忽略确认结果
});
关键机制说明
机制 | 说明 |
---|---|
延迟删除窗口 | 数据删除前保留5分钟(可配置),为客户端重试提供时间窗口 |
请求关联 | 通过数据ID自动关联请求,无需业务层令牌 |
自动取消任务 | 客户端重新请求同一数据时,自动取消之前的删除计划 |
静默确认 | 前端二次请求(HEAD方法)用于重置删除计时,不影响用户体验 |
适用场景扩展
- 高并发请求
• 使用ConcurrentHashMap
和ScheduledFuture
确保线程安全 - 分布式系统
• 将DelayDeleteQueue
替换为 Redis 的Redisson Delayed Queue
- 数据量较大
• 使用数据库的定时任务(如 MySQL 事件调度器)替代内存队列
性能优化建议
-
延迟队列持久化
使用数据库或 Redis 存储任务,防止服务重启导致任务丢失
sqlCREATE TABLE delay_tasks ( id VARCHAR(64) PRIMARY KEY, execute_time DATETIME NOT NULL );
-
批量删除处理
通过 SQL 语句批量清理过期数据
java@Scheduled(fixedRate = 60000) public void cleanExpiredData() { dataRepository.deleteByScheduledDeleteTrueAndDeleteTimeBefore(LocalDateTime.now()); }
-
客户端指数退避重试
前端在断开时自动重试,逐步增加重试间隔
javascriptfunction fetchWithRetry(id, retries = 3) { return fetch(`/data/${id}`) .catch(err => { if (retries > 0) { setTimeout(() => fetchWithRetry(id, retries - 1), 1000 * (4 - retries)); } }); }
该方案通过 延迟删除 和 请求自动关联 实现了无令牌的通用保障,平衡了系统复杂性和数据安全性,适用于大多数 HTTP 接口场景。