本文目录导读:

这是一个涉及面较广的问题,实现云笔记同步的核心在于数据冲突处理和离线编辑,PHP 通常作为后端 API 服务(数据存储与同步逻辑),而客户端(Web、App)负责发起同步。
下面我将从一个中级开发者的角度,介绍 PHP 后端实现云笔记同步的完整方案,包含技术选型、核心算法和关键代码示例。
整体架构设计
客户端 (Web/App) <--> API 网关 (Nginx) <--> PHP 后端 (Laravel/Symfony) <--> 数据库 (MySQL/PostgreSQL) + 缓存 (Redis)
|
|--> 全文搜索引擎 (Elasticsearch,可选)
- 客户端:负责本地存储(IndexedDB/SQLite)和发起差异同步。
- PHP 后端:负责鉴权、存储、版本控制和冲突解决。
- 数据库:存储笔记内容、版本历史和元数据。
- Redis:用于锁、临时缓存和实时推送(WebSocket 配合)。
核心数据结构设计
数据库表是同步的基础,关键表设计如下:
-- 1. 笔记主表 (只存储最新版本快照,有利于快速查询)
CREATE TABLE notes (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
uuid CHAR(36) NOT NULL UNIQUE, -- 客户端的全局唯一 ID,设备离线也能创建
user_id BIGINT UNSIGNED NOT NULL,VARCHAR(255) DEFAULT '',
content LONGTEXT, -- 最新内容
content_hash VARCHAR(64) NOT NULL, -- 内容的 SHA256,快速比对是否相同
version INT UNSIGNED NOT NULL DEFAULT 1, -- 乐观锁,每次更新 +1
is_deleted TINYINT(1) DEFAULT 0, -- 伪删除
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_user_updated (user_id, updated_at DESC),
UNIQUE KEY uk_uuid (uuid)
);
-- 2. 操作日志表 (实现增量同步和历史追溯)
CREATE TABLE note_operations (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
note_uuid CHAR(36) NOT NULL,
user_id BIGINT UNSIGNED NOT NULL,
operation_type ENUM('create', 'update_title', 'update_content', 'delete', 'undelete') NOT NULL,
-- 增量操作数据 (JSON 格式,
-- update_title: {"old_title":"a","new_title":"b"}
-- update_content: 可使用 OT/CRDT 的 OP 或 Diff 数据
operation_data JSON,
operation_version INT UNSIGNED NOT NULL, -- 操作发生时的笔记版本
client_timestamp BIGINT UNSIGNED NOT NULL, -- 客户端的时间戳 (用于冲突排序)
server_timestamp BIGINT UNSIGNED NOT NULL, -- 服务器接收的时间戳 (最终仲裁)
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_note_ops (note_uuid, operation_version DESC),
INDEX idx_user_sync (user_id, operation_version) -- 用于拉取增量
);
为什么需要 note_operations 表?
- 增量同步:客户端只上传自己没有的
operation。 - 离线编辑:客户端将编辑操作序列化为
operation队列。 - 冲突解决:服务器可以重放操作日志。
同步流程:拉取与推送
同步通常是客户端主动发起,分两步:
客户端拉取 (Fetch Changes)
客户端请求:GET /api/sync/pull?last_sync_version=100
PHP 处理逻辑:
// 伪代码:SyncController@pull
public function pull(Request $request)
{
$user = auth()->user();
$lastVersion = $request->input('last_sync_version', 0);
// 1. 获取新增的操作日志
$operations = NoteOperation::where('user_id', $user->id)
->where('operation_version', '>', $lastVersion)
->orderBy('operation_version', 'asc')
->limit(100) // 分页批量获取
->get();
// 2. 获取被其他人修改/删除且客户端可能不知道的笔记 ID
$changedNotes = Note::where('user_id', $user->id)
->where('updated_at', '>', $request->input('last_sync_time'))
->pluck('uuid');
return response()->json([
'operations' => $operations->map(function ($op) {
// 可以在这里合并数据,返回客户端易于消费的格式
return [
'note_uuid' => $op->note_uuid,
'type' => $op->operation_type,
'data' => $op->operation_data,
'version' => $op->operation_version,
];
}),
'changed_notes' => $changedNotes, // 提示客户端需要全量拉取
'new_last_version' => $operations->last()->operation_version ?? $lastVersion,
'server_time' => now()->timestamp,
]);
}
客户端推送 (Push Changes)
客户端请求:POST /api/sync/push
请求体示例:
{
"operations": [
{
"note_uuid": "xxx-yyy",
"client_timestamp": 123456789,
"operation_type": "update_content",
"operation_data": { "type": "text-diff", "data": "@@ -1,7 +1,8 @@\n +hello\n world!" },
"base_version": 5 // 客户端编辑基于的服务器版本
}
]
}
PHP 处理逻辑(核心冲突处理在这里):
// 伪代码:SyncController@push
public function push(Request $request)
{
$user = auth()->user();
$results = [];
foreach ($request->input('operations', []) as $op) {
$note = Note::where('uuid', $op['note_uuid'])->first();
// 1. 乐观锁检查
if ($note && $op['base_version'] != $note->version) {
// 冲突发生了!无法直接应用
$results[] = [
'note_uuid' => $op['note_uuid'],
'status' => 'conflict',
'server_version' => $note->version, // 告诉客户端最新版本
'server_content' => $note->content // 返回服务器端内容让用户手动合并
];
continue;
}
// 2. 无冲突,应用操作
DB::beginTransaction();
try {
$newVersion = ($note->version ?? 0) + 1;
// 处理不同类型的操作
switch ($op['operation_type']) {
case 'update_content':
$newContent = $this->applyOperation($note->content ?? '', $op['operation_data']);
$note->content = $newContent;
$note->content_hash = hash('sha256', $newContent);
break;
case 'delete':
$note->is_deleted = 1;
break;
case 'create':
// 创建新笔记记录
$note = new Note();
$note->uuid = $op['note_uuid'];
$note->user_id = $user->id;
$note->content = $op['operation_data']['content'] ?? '';
break;
}
$note->version = $newVersion;
$note->save();
// 3. 记录操作日志 (对于原子性很重要)
NoteOperation::create([
'note_uuid' => $op['note_uuid'],
'user_id' => $user->id,
'operation_type' => $op['operation_type'],
'operation_data' => $op['operation_data'],
'operation_version' => $newVersion,
'client_timestamp' => $op['client_timestamp'],
'server_timestamp' => now()->timestamp,
// ... 其他字段
]);
DB::commit();
$results[] = [
'note_uuid' => $op['note_uuid'],
'status' => 'ok',
'new_version' => $newVersion
];
} catch (\Exception $e) {
DB::rollBack();
$results[] = ['note_uuid' => $op['note_uuid'], 'status' => 'error', 'message' => $e->getMessage()];
}
}
return response()->json(['results' => $results]);
}
冲突解决策略
冲突是分布式系统中最难的部分,以下是三种常用策略:
| 策略 | 适用场景 | PHP 实现难度 | 用户体验 |
|---|---|---|---|
| Last Write Wins (LWW) | 简单场景,适合“标题”或“标签” | 低 | 可能丢失数据 |
| OT (Operational Transformation) | 无结构文本(类似协同写作) | 极高(需完整数学基础) | 较好 |
| CRDT (Conflict-free Replicated Data Types) | 结构化数据(如 JSON/富文本) | 高(可使用 PHP CRDT 库,如 yphp/yphp) |
非常好,无需解决 |
| 手动合并 (Git 风格) | 复杂文档,用户需要控制权 | 中 | 给用户选择 |
对于“个人云笔记”或“小团队”:
推荐使用 手动合并 + LWW:
- 当检测到冲突(base_version 不匹配),服务器不自动应用。
- 返回
status: conflict和服务器端内容。 - 客户端弹窗让用户选择:保留本地 / 保留服务器 / 查看差异并手动合并。
- 一旦用户做出选择,客户端会再次发起
push,base_version设置为服务器的server_version。
数据库与性能优化
- 索引:
note_operations表一定要有(user_id, operation_version)的联合索引,否则拉取增量会全表扫描。 - 分页:不要一次性返回所有操作,使用
limit和cursor分页。 - 缓存:用户最近同步的
last_sync_version可以缓存在 Redis,减少重复计算。// 获取用户上一次同步的版本号 $lastVersion = Redis::get("user:sync:version:{$userId}") ?? 0; - 异步处理:如果笔记包含附件或大内容,
push接口收到后先将操作写入消息队列(RabbitMQ / Redis Stream),PHP worker 后去处理,客户端通过轮询或 WebSocket 获取结果。
安全与验证
- 请求验证:验证
note_uuid是否属于当前用户。 - 速率限制:每个用户每分钟最多 10 次 push 请求。
- 内容校验:使用
content_hash防止重复存储,如果客户端推送的内容 hash 和服务器一致,直接返回ok,不写入。 - 客户端时间戳校验:防止恶意构造时间戳,可以设定
client_timestamp与server_timestamp相差不超过 5 分钟。
示例框架选型
| 组件 | 推荐方案 |
|---|---|
| PHP 框架 | Laravel (内置队列、缓存、Eloquent ORM) 或 Hyperf (协程高性能,适合 API) |
| 数据库 | PostgreSQL (支持 JSONB,存储 operation_data 性能好) |
| 队列 | Redis (简单场景) / RabbitMQ (复杂场景) |
| HTTP 加速 | PHP-FPM 或 Swoole/Hyperf 常驻内存 |
| 实时推送 | WebSocket (通过 Laravel Echo Server / Swoole WebSocket Server) |
总结与建议
- 不要试图一次性实现完美的 OT/CRDT:对于个人笔记,基于版本号的乐观锁 + 冲突回传给用户手动解决 是性价比最高的方案。
- 操作日志是王道:即使你现在用 LWW,也请记录
note_operations表,未来需要升级到 CRDT 时,这些历史数据就是基础。 - 客户端也很重要:PHP 后端决定了下限,但实际体验(特别是离线编辑和冲突提示)由前端(JS/App)决定,前端需要维护一个“操作栈”和“未同步版本号”。
- 测试是关键:写单元测试模拟多设备同时编辑同一篇笔记的场景。
如果你只是需要一个立即能用的方案,可以先实现 updated_at 时间戳比对:客户端上传 updated_at,服务器判断是否被其他设备提前修改,如果已被修改则返回最新内容,由客户端处理,这是最简单、不用写冲突算法的方式。