背景

最近在做一个在线文档协作系统,用户可以在平台上编辑文档,编辑完成后触发一系列业务流程——生成版本、数字签名、归档等。文档编辑器选用了 ONLYOFFICE,功能强大,协作体验也好。

看起来是个挺常规的需求,对吧?

我当初也是这么想的。直到系统上线后,用户反馈"数字签名偶尔会失效",我才意识到自己踩进了一个不大不小的坑里。

问题出在哪?

ONLYOFFICE 的架构中,文档编辑和文件存储是分离的。用户在编辑器里修改文档,改动先停留在 ONLYOFFICE Document Server 的内存中,并不会立即写入你的存储后端。

ONLYOFFICE 提供了 forcesave 接口,可以强制保存文档。我最初的实现是这样的:

// 用户点击"提交"按钮时触发
public void submitDocument(String documentId) {
    // 1. 调用 forcesave
    onlyofficeService.forceSave(documentId);
    
    // 2. 获取文件内容
    byte[] fileContent = storageService.getFile(documentId);
    
    // 3. 生成数字签名
    String signature = signService.sign(fileContent);
    
    // 4. 保存签名记录
    recordService.saveSignature(documentId, signature);
}

看起来逻辑清晰,没什么问题。但上线后,用户反馈:某些文档的数字签名校验失败

排查后发现:当用户打开文档验证时,文件内容和签名时不一致——明明签名后没人再编辑过,怎么文件变了?

根本原因:forcesave 是异步的

翻了 ONLYOFFICE 的文档,才发现 forcesave 的调用流程是这样的:

后端调用 forcesave API
        ↓
ONLYOFFICE Document Server 接收请求
        ↓
Document Server 在内部调度保存任务(异步)
        ↓
保存完成后,Document Server 回调后端的 Callback Handler
        ↓
后端在回调中收到最新的文件 URL

也就是说,forcesave() 返回时,文件还没保存完成。真正的文件更新是在回调里发生的。

我的代码里,forcesave() 调用后立即去取文件,拿到的其实是旧文件。然后在旧文件上计算了数字签名。等 ONLYOFFICE 真正把新文件写回去,签名自然就失效了。

时间线:
────────────────────────────────────────────────────────→
    │                    │                      │
    │                    │                      │
forcesave()        sign(旧文件)            回调写入新文件
    │                    │                      │
    └────────────────────┴──────────────────────┘
              签名基于旧文件              文件已更新
                   ↓                         ↓
                   └─────→ 签名验证失败 ←─────┘

这是一个典型的时序问题:业务逻辑依赖了异步操作的结果,但没有等待操作完成。

解决思路

要让业务逻辑正确,必须在 forcesave 真正完成后才能继续。也就是说,需要把异步的 forcesave 变成同步等待。

用户触发动作
    │
    ↓
调用 forcesave API
    │
    ↓
───────── 阻塞等待 ─────────
    │
    │   ONLYOFFICE 处理
    │       ↓
    │   回调后端接口
    │       ↓
    │   countDown() 触发
    │       ↓
───────── 阻塞解除 ─────────
    │
    ↓
获取最新文件
    │
    ↓
继续业务逻辑

由于我们的服务是多实例部署的,调用 forcesave 的实例和接收回调的实例可能不是同一个,所以需要一个分布式的同步机制。

Redisson 提供的 RCountDownLatch 正好满足这个需求。

实现方案

1. 定义 Latch Key

每个 forcesave 操作需要一个唯一的标识,用来关联等待线程和回调。

String latchKey = "forcesave:" + documentId + ":" + System.currentTimeMillis();

使用时间戳确保每次 forcesave 都有独立的 latch,避免并发场景下的混乱。

2. 调用方:阻塞等待

@Service
public class DocumentService {
    
    @Autowired
    private RedissonClient redisson;
    
    @Autowired
    private OnlyOfficeService onlyOfficeService;
    
    public void submitDocument(String documentId) {
        // 生成唯一的 latch key
        String latchKey = "forcesave:" + documentId + ":" + System.currentTimeMillis();
        
        // 初始化 CountDownLatch,计数为 1
        RCountDownLatch latch = redisson.getCountDownLatch(latchKey);
        latch.trySetCount(1);
        
        try {
            // 调用 forcesave,传递 latchKey 用于回调时识别
            onlyOfficeService.forceSave(documentId, latchKey);
            
            // 等待回调完成,设置超时避免死锁
            boolean completed = latch.await(30, TimeUnit.SECONDS);
            
            if (!completed) {
                throw new RuntimeException("forcesave 超时,文档保存失败");
            }
            
            // 此时文件已经是最新的了
            byte[] fileContent = storageService.getFile(documentId);
            String signature = signService.sign(fileContent);
            recordService.saveSignature(documentId, signature);
            
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new RuntimeException("forcesave 被中断", e);
        } finally {
            // 清理 latch
            latch.delete();
        }
    }
}

3. 调用 ONLYOFFICE forcesave API

在调用 forcesave 时,需要把 latchKey 传过去。ONLYOFFICE 的 forcesave 请求本身不支持自定义参数,但可以通过回调 URL 携带。

一种做法是在文档打开时设置回调 URL 时带上占位符,forcesave 时替换:

public void forceSave(String documentId, String latchKey) {
    // 构造 forcesave 请求
    JSONObject request = new JSONObject();
    request.put("c", "forcesave");
    request.put("key", documentId);
    
    // 告诉 ONLYOFFICE 回调时带上这个 key
    // 注意:实际实现中,回调 URL 通常在编辑器初始化时就固定了
    // 这里需要在回调处理时能够关联到对应的 latchKey
    // 可以在 Redis 中维护 documentId -> latchKey 的映射
    
    redisTemplate.opsForValue().set(
        "forcesave:latchKey:" + documentId, 
        latchKey, 
        5, TimeUnit.MINUTES
    );
    
    // 发送 HTTP 请求到 ONLYOFFICE Command Service
    // POST to http://<documentserver>/coauthoring/CommandService.ashx
    String response = httpClient.post(
        onlyofficeConfig.getCommandUrl(),
        request.toString()
    );
    
    log.info("forcesave response: {}", response);
}

4. 回调处理:解除阻塞

ONLYOFFICE 会在 forcesave 完成后调用我们配置的回调接口:

@RestController
@RequestMapping("/onlyoffice")
public class OnlyOfficeCallbackController {
    
    @Autowired
    private RedissonClient redisson;
    
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    
    @PostMapping("/callback")
    public String callback(@RequestBody JSONObject body) {
        log.info("Received callback: {}", body);
        
        String status = body.getString("status");
        String documentKey = body.getString("key");
        
        // status = 2 表示文档正在保存,forcesave 完成也是这个状态
        if ("2".equals(status)) {
            // 获取文件 URL,下载并保存到存储
            String downloadUrl = body.getString("url");
            byte[] fileContent = httpClient.download(downloadUrl);
            storageService.save(documentKey, fileContent);
            
            // 触发 CountDownLatch,解除阻塞
            String latchKey = redisTemplate.opsForValue()
                .get("forcesave:latchKey:" + documentKey);
            
            if (latchKey != null) {
                RCountDownLatch latch = redisson.getCountDownLatch(latchKey);
                latch.countDown();
                
                // 清理映射
                redisTemplate.delete("forcesave:latchKey:" + documentKey);
                
                log.info("forcesave completed, latch released: {}", latchKey);
            }
        }
        
        // 返回 {"error": 0} 表示成功
        return "{\"error\": 0}";
    }
}

5. 完整流程图

┌─────────────────────────────────────────────────────────────────┐
│                        用户点击"提交"                            │
└───────────────────────────┬─────────────────────────────────────┘
                            │
                            ▼
┌─────────────────────────────────────────────────────────────────┐
│  创建 Redisson CountDownLatch (key=forcesave:doc:timestamp)     │
└───────────────────────────┬─────────────────────────────────────┘
                            │
                            ▼
┌─────────────────────────────────────────────────────────────────┐
│  调用 ONLYOFFICE forcesave API                                   │
│  同时在 Redis 记录 docId -> latchKey 映射                        │
└───────────────────────────┬─────────────────────────────────────┘
                            │
                            ▼
┌─────────────────────────────────────────────────────────────────┐
│  latch.await(30, TimeUnit.SECONDS) ← 阻塞等待                   │
└───────────────────────────┬─────────────────────────────────────┘
                            │
        ┌───────────────────┴───────────────────┐
        │                                       │
        ▼                                       ▼
┌───────────────────┐              ┌─────────────────────────────┐
│  ONLYOFFICE       │              │  回调接口收到通知            │
│  处理保存(异步)  │   ──────►    │  下载文件 → 写入存储        │
└───────────────────┘              │  latch.countDown()          │
                                   └─────────────────────────────┘
        │                                       │
        └───────────────────┬───────────────────┘
                            │
                            ▼
┌─────────────────────────────────────────────────────────────────┐
│  await() 返回 true,阻塞解除                                     │
└───────────────────────────┬─────────────────────────────────────┘
                            │
                            ▼
┌─────────────────────────────────────────────────────────────────┐
│  获取最新文件 → 计算数字签名 → 保存记录                          │
└─────────────────────────────────────────────────────────────────┘

关键点总结

超时保护

必须设置合理的超时时间。ONLYOFFICE 可能在高负载时响应缓慢,或者回调因为网络问题失败。无限等待会造成线程堆积,最终拖垮服务。

boolean completed = latch.await(30, TimeUnit.SECONDS);
if (!completed) {
    // 超时处理:记录日志、告警、降级处理
}

幂等性

回调可能被重试。如果 ONLYOFFICE 没有收到 {"error": 0} 的响应,可能会重新发送回调。要确保 countDown() 多次调用不会出问题——幸好 RCountDownLatch.countDown() 本身就是幂等的。

Latch 生命周期管理

  • 使用后要 delete() 清理,避免 Redis 中堆积无用的 key
  • 设置合理的过期时间(Redisson 支持在 trySetCount 时配置)
  • 超时或异常情况下,要清理对应的 Redis 映射

日志与监控

这个方案引入了分布式协调,出问题时排查会比同步调用复杂。建议:

  1. 每次 forcesave 都记录 documentId、latchKey、开始时间
  2. 回调时记录收到的时间、处理耗时
  3. 监控 await 超时的频率,超时过多说明 ONLYOFFICE 压力大或网络不稳定

写在最后

这个问题的根源是对 ONLYOFFICE 机制理解不够深入。文档里其实写得很清楚:Command Service 是异步的,文件更新通过回调通知。但我一开始只看了接口怎么调,没理解完整的交互时序。

“先让它跑起来"的心态,让我跳过了对底层机制的探究。结果就是,代码在开发环境一切正常——开发时只有我一个人在用,forcesave 几乎瞬间完成,异步问题根本暴露不出来。到了生产环境,并发一上来,问题才浮出水面。

技术选型时,不仅要看"怎么用”,更要理解"它是怎么工作的"。异步变同步这种需求,看似简单,但在分布式环境下,需要考虑的点远比单机锁多得多。