HTTP请求当中若用户还没接收到返回数据就离线但服务器资源已经删除的情况

令牌方式:

在 HTTP 请求中,若客户端在处理过程中断开连接,可能导致用户未收到数据而服务器已删除数据的风险。以下是分阶段解决方案及具体实现:


问题本质

核心矛盾:HTTP 是无状态协议,服务器无法感知客户端是否真正接收到了响应数据。
业务风险:数据被删除后,客户端无法重新获取,导致业务中断。


解决方案架构

方案 1:异步确认 + 延迟删除(推荐)

通过「客户端确认」和「延迟删除」机制保证数据安全,流程如下:

Client Server DB 1. 发起获取数据请求 2. 查询数据并标记为「待删除」 3. 返回数据 + 唯一令牌(token) 4. 发送确认请求(携带 token) 5. 物理删除数据 6. 定时任务清除过期数据 alt [超时未确认] Client Server DB
关键实现步骤(Spring Boot)
  1. 标记数据状态

    @Entity
    public class DataEntity {
          
          
        @Id
        private String id;
        private boolean pendingDelete; // 标记是否待删除
        private LocalDateTime expiryTime; // 过期时间
    }
    
  2. 返回数据并生成令牌

    @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));
    }
    
  3. 客户端确认接口

    @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();
    }
    
  4. 定时清理任务

    @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:预生成下载链接 + 软删除

将数据存储为临时文件,客户端通过一次性链接下载,确保删除前数据可达。

@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);
}

方案对比

方案 优点 缺点 适用场景
异步确认 + 延迟删除 数据安全,支持客户端重试 需要维护状态和定时任务 对数据可靠性要求高的场景(如支付凭据)
预生成下载链接 + 软删除 实现简单,无状态依赖 文件存储可能占用磁盘空间 临时文件下载(如报表导出)
消息队列延迟删除 解耦业务逻辑,扩展性强 依赖消息中间件,增加系统复杂度 高并发、分布式系统

关键设计原则

  1. 幂等性:客户端重试请求时,确保不会重复删除数据(通过唯一 token 或请求 ID 实现)。
  2. 状态跟踪:通过数据库标记或 Redis 记录数据生命周期。
  3. 超时机制:避免资源泄漏,设置合理的过期时间。
  4. 异常回滚:若客户端确认失败,保留数据直到超时。

扩展建议

监控与告警:记录客户端确认率和删除失败次数,及时报警人工介入。
客户端重试策略:在 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. 数据库设计(添加删除标记)
@Entity
public class DataEntity {
    
    
    @Id
    private String id;
    private boolean scheduledDelete; // 标记是否计划删除
    private LocalDateTime deleteTime; // 计划删除时间
    // 其他字段...
}
2. 数据查询接口
@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. 延迟队列实现(示例)
@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. 客户端静默重试(前端示例)
// 前端获取数据后,自动发起后台确认(无需用户感知)
fetch(`/data/${
      
      id}`)
  .then(response => {
    
    
    // 成功获取数据后,静默发送HEAD请求确认
    fetch(`/data/${
      
      id}`, {
    
     method: 'HEAD' })
      .catch(() => {
    
    }); // 忽略确认结果
  });

关键机制说明

机制 说明
延迟删除窗口 数据删除前保留5分钟(可配置),为客户端重试提供时间窗口
请求关联 通过数据ID自动关联请求,无需业务层令牌
自动取消任务 客户端重新请求同一数据时,自动取消之前的删除计划
静默确认 前端二次请求(HEAD方法)用于重置删除计时,不影响用户体验

适用场景扩展

  1. 高并发请求
    • 使用 ConcurrentHashMapScheduledFuture 确保线程安全
  2. 分布式系统
    • 将 DelayDeleteQueue 替换为 Redis 的 Redisson Delayed Queue
  3. 数据量较大
    • 使用数据库的定时任务(如 MySQL 事件调度器)替代内存队列

性能优化建议

  1. 延迟队列持久化
    使用数据库或 Redis 存储任务,防止服务重启导致任务丢失

    CREATE TABLE delay_tasks (
      id VARCHAR(64) PRIMARY KEY,
      execute_time DATETIME NOT NULL
    );
    
  2. 批量删除处理
    通过 SQL 语句批量清理过期数据

    @Scheduled(fixedRate = 60000)
    public void cleanExpiredData() {
          
          
        dataRepository.deleteByScheduledDeleteTrueAndDeleteTimeBefore(LocalDateTime.now());
    }
    
  3. 客户端指数退避重试
    前端在断开时自动重试,逐步增加重试间隔

    function fetchWithRetry(id, retries = 3) {
          
          
      return fetch(`/data/${
            
            id}`)
        .catch(err => {
          
          
          if (retries > 0) {
          
          
            setTimeout(() => fetchWithRetry(id, retries - 1), 1000 * (4 - retries));
          }
        });
    }
    

该方案通过 延迟删除请求自动关联 实现了无令牌的通用保障,平衡了系统复杂性和数据安全性,适用于大多数 HTTP 接口场景。