背景
最近在做一个在线文档协作系统,用户可以在平台上编辑文档,编辑完成后触发一系列业务流程——生成版本、数字签名、归档等。文档编辑器选用了 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 映射
日志与监控
这个方案引入了分布式协调,出问题时排查会比同步调用复杂。建议:
- 每次 forcesave 都记录 documentId、latchKey、开始时间
- 回调时记录收到的时间、处理耗时
- 监控 await 超时的频率,超时过多说明 ONLYOFFICE 压力大或网络不稳定
写在最后
这个问题的根源是对 ONLYOFFICE 机制理解不够深入。文档里其实写得很清楚:Command Service 是异步的,文件更新通过回调通知。但我一开始只看了接口怎么调,没理解完整的交互时序。
“先让它跑起来"的心态,让我跳过了对底层机制的探究。结果就是,代码在开发环境一切正常——开发时只有我一个人在用,forcesave 几乎瞬间完成,异步问题根本暴露不出来。到了生产环境,并发一上来,问题才浮出水面。
技术选型时,不仅要看"怎么用”,更要理解"它是怎么工作的"。异步变同步这种需求,看似简单,但在分布式环境下,需要考虑的点远比单机锁多得多。