type
status
date
slug
summary
tags
category
icon
password
wechat_gate
notion image
CloudKit 迁移真正难的,不是把 savefetch 调通,而是让同步在服务端拒绝、用户 iCloud 空间耗尽、账号暂时不可用和离线删除之后仍然可恢复。本文基于 JingNote 把笔记文本与录音迁入 CloudKit 私有数据库的真实记录,完整复盘 CKError 15quotaExceeded、Production Schema 和离线删除补偿的处理方法。
真机第一次迁移时,控制台持续输出:
保存、按 ID 读取和查询全部失败,而且错误签名相同。继续重试没有解决问题,反而掩盖了真正的配置与状态边界。
这次迁移最后形成了四条结论:
  1. serverRejectedRequest 只是结果,不是根因。先补齐错误上下文,再检查签名、entitlements、容器环境、schema 和索引。
  1. quotaExceeded 是持久状态,不应套用网络错误的退避重试。
  1. “本地已经删除”不代表“云端最终一定删除”。离线优先架构必须给删除操作设计持久化补偿。
  1. CloudKit 私有数据库适合用户数据,不适合开发者必须可信裁决的计费或用量账本。

先确认 CloudKit 私有数据库的边界

在讨论错误处理前,必须先说清楚数据到底属于谁。
JingNote 使用的是 privateCloudDatabase。Apple 对私有数据库的定义很明确:记录默认只有所有者可读写,开发者不能通过 Developer Portal 查看其他用户的私有记录,存储占用计入记录所有者的 iCloud 配额。
这意味着:
  • CloudKit Console 中的 Record Type 和字段是容器级 schema,所有用户共享结构。
  • Console 的 Private Database 数据视图需要使用某个 iCloud 账号授权,看到的是该账号自己的数据,不是全体用户数据。
  • 用户量增加不会把所有私有记录堆到一个开发者可浏览的数据表里。
  • 录音以 CKAsset 写入私有记录后,占用的是用户自己的 iCloud 空间;空间不足时,写入会失败。
这里有一个容易被“零后端”口号掩盖的现实:开发者看不到用户私有数据,所以线上排障更依赖客户端日志、可恢复状态和用户可理解的提示。不能把“服务器替我保存了数据”误解成“服务器替我完成了同步产品设计”。

CloudKit 迁移遇到 CKError 15,先建立证据链

Apple 对 CKError.Code.serverRejectedRequest 的公开说明只有两点:CloudKit 拒绝了请求,而且这个错误本身属于不可恢复错误。它没有承诺错误码 15 或底层码 2000 与某个唯一根因一一对应。
因此,把 15/2000 直接翻译成“容器没初始化”并不严谨。正确做法是把它当成排查入口。

第一步:把 CKError 的信息打完整

只打印 localizedDescription 通常不够。至少要保留 CloudKit 错误码、服务端描述、底层错误和建议重试时间:
这段日志代码的价值不是“打印得更漂亮”,而是帮助你先回答三个问题:
  • CloudKit 是否建议稍后重试?
  • 是整批操作失败,还是某条记录的局部失败?
  • 错误来自请求内容、账号状态、限流,还是容器环境?
Apple 对节流错误的建议是尊重 retryAfter;反过来说,没有 retryAfterserverRejectedRequest 不应自动进入无限重试。

第二步:验证构建产物,不只看 Xcode 面板

Xcode 的 Signing & Capabilities 显示正确,不代表真机包中的签名信息一定正确。可以直接检查构建产物:
重点核对:
  • com.apple.developer.icloud-container-identifiers
  • com.apple.developer.icloud-services
  • com.apple.developer.icloud-container-environment
  • 如果项目同时使用 iCloud Documents,再核对 com.apple.developer.ubiquity-container-identifiers
在这次项目里,app 签名、embedded provisioning profile 和目标容器均一致;accountStatus 也返回 .available。这些证据排除了“设备没登录 iCloud”和“构建包没有容器权限”。

第三步:把 Development 与 Production 当成两个环境

这次故障发生在一个刚创建不久的容器。打开 CloudKit Console 的 Development 环境后,后续真机写入成功,Record Type 也随首次成功写入出现。
但这里必须保持因果克制:这是本项目的时间顺序与修复结果,不能据此断言所有 15/2000 都由“没有打开过 Console”造成。更稳妥的结论是:
  1. 新容器出现全请求拒绝时,应把容器 provisioning 状态列入排查清单。
  1. 如果签名、账号和请求内容都没有异常,进入 Console 检查容器与环境状态是低成本验证步骤。
  1. 若仍失败,应保留 Request UUID 和完整错误信息,继续核查 Apple 服务状态或提交反馈。
更危险的是 Production。Apple 官方文档明确要求:发布前必须把 Development 中的 record types、fields 和 indexes 部署到 Production。App Store 版本只能访问 Production;Production 不允许客户端临时添加新的 record type 或字段。
所以,Development 真机成功并不代表 TestFlight 一定成功。上线前至少要验证:
  • Record Type 已部署到 Production。
  • 查询需要的字段和系统字段索引已部署。
  • TRUEPREDICATE 查询能够分页拉取全部记录。
  • 真机使用 Production entitlement 跑过增、删、改、查。
素材中的全量查询使用:
Apple 文档支持 TRUEPREDICATE,并提醒使用 cursor 分批处理结果。查询依赖 schema 中的索引配置,因此不要只在 Development 自动索引下验证,再假设 Production 会保持完全相同的行为。

第四步:首次迁移先控制并发

项目记录显示,向新容器进行首次批量写入时,并发请求更容易与 schema 建立阶段的问题叠加。将首次迁移改为串行后,诊断更清晰,运行也更稳定。
这是一条工程经验,不是 CKError 15 的官方唯一解。串行的实际价值有两个:
  • 降低首次 schema 写入阶段的变量数量。
  • 让失败记录、错误顺序和重试边界可追踪。
容器和 schema 稳定后,再基于实际耗时逐步增加有限并发,比一开始把所有笔记同时写入更容易验证。
notion image

iCloud 配额满了,为什么不能继续重试

Apple 对 quotaExceeded 的定义同样明确:在私有数据库中,它表示用户没有足够的 iCloud 存储空间,并建议引导用户前往 iCloud 设置管理空间。
这类错误与网络超时有本质区别:
状态
是否可能自行恢复
客户端策略
网络暂时不可用
可能
按退避策略重试
服务限流且带 retryAfter
可能
尊重服务端时间再重试
iCloud 账号暂时不可用
可能
等待账号状态变化
quotaExceeded
通常不会立即恢复
停止写入,保留本地数据并提示用户
serverRejectedRequest
不能靠同一请求自动恢复
检查配置、schema、索引与请求合法性
JingNote 本身是 local-first:先在本地保存,再异步同步到云端。这给配额降级提供了前提。用户空间满时,本地数据仍然可以正常使用,云端写入进入“等待外部条件变化”的状态。

集中设置写入闸门

不要让笔记、录音、AI 历史和报告模块分别识别配额错误。所有云端写入最好汇聚到一层策略对象:
策略对象只表达状态,不负责替用户“解决”空间问题。上层需要完成三件事:
  1. canAttemptWrite == false 时不再发起新的云端写入,避免电量和流量浪费。
  1. UI 只提示一次“笔记仍保存在本机”,同时给出系统设置入口。
  1. 一次成功写入或账号变化后,清除配额耗尽标记,允许再次验证。
CKAccountChanged 通知可能来自任意队列。更新 UI 或主线程状态时,需要显式切回 Main Actor。

配额满时必须放行删除

这是本次排查中一个容易被忽略的反例。
如果把“不能写入”粗暴实现成 canPerformCloudSync == false,并让删除操作也经过同一个 guard,那么空间越满,用户越无法清理数据。删除与新增、更新不是同一类写操作:
  • 新增和更新会继续占用或改写云端资源。
  • 删除的目标恰恰是释放用户空间。
因此,配额写入闸门只应拦截保存和更新,不应拦截删除。即使删除请求暂时失败,也要进入独立的持久化删除补偿流程。

删除记录不难,保证最终删除才难

录音通过下面的方式与笔记记录关联:
Apple 将 CKAsset 定义为属于记录的外部文件。客户端不需要维护第二套“云端文件删除 API”:清空资产字段并保存记录后,资产会成为孤立资源,由 CloudKit 周期性清理;删除整条记录也会移除这段关联。
这里不要向用户承诺“点击删除后,底层二进制文件在同一毫秒物理消失”。客户端真正能验证的是:
  • 记录已经删除或服务器确认记录不存在。
  • app 不再能通过该记录访问资产。
  • 本地删除状态不会被旧云端记录覆盖。

真实漏洞一:账号不可用时,删除意图消失

原始逻辑只在账号可用时删除云端记录:
当用户退出 iCloud 或账号暂时不可用时,本地记录已经删除,但云端删除没有执行,也没有任何持久化证据。
下一次登录后,全量同步把云端旧记录重新拉回本地,于是出现“笔记复活”。这不是 CloudKit 自动恢复了数据,而是合并算法把“云端仍存在”误判为“本地应该恢复”。

真实漏洞二:普通重试队列会把删除丢掉

原来的离线同步队列最多重试 3 次。对于普通更新,这可能是可以接受的产品策略;对于删除,它会破坏最终一致性:
  1. 本地删除成功。
  1. 云端连续 3 次删除失败。
  1. 队列静默放弃。
  1. 云端记录永久残留。
  1. 后续全量合并把记录重新写回本地。
删除操作需要的不是“多试几次”,而是“在服务器确认之前,不允许忘记这次删除”。

用“待删标记”把删除变成可恢复流程

这里的“待删标记”,在同步系统中常称为 tombstone,中文技术资料有时直译成“墓碑”。它不是被删除笔记的副本,也不是回收站内容,而是一条持久化的删除凭证,表达的是:本地已经删除,但云端还没有确认删除完成。
只要待删标记仍然存在,同步合并时就不能把同一 recordName 的云端旧记录重新拉回本地;只有云端确认删除成功,或确认记录已经不存在后,才能清除这条标记。
它至少包含:
  • CloudKit recordName
  • 本地删除时间
  • 可选的错误与最近尝试时间
一个最小实现可以使用 UserDefaults 持久化。数据量很大时,再迁到 SQLite 或本地数据库:
本地删除流程必须先保存待删标记,再尝试云端删除:
顺序不能反过来。先删云端、再保存待删标记,会在 app 被杀进程或网络回调丢失时留下新的竞态。

清算时把“记录不存在”视为逻辑成功

CloudKit 的单次删除 API 可能返回记录不存在。客户端可以把 .unknownItem 视为目标状态已经达成,从而让自己的删除流程具备幂等语义:
注意:这里说的是“客户端删除流程具备幂等语义”,不是声称 CloudKit 的每次删除请求都无条件成功。

合并前先执行防复活规则

只清算待删标记还不够。网络和多设备状态可能让云端旧记录先被拉下来,因此合并层必须知道哪些 ID 已经在本地被删除:
待删标记的清算建议设置三个可靠触发点:
  1. app 启动并确认 iCloud 账号可用后。
  1. 收到账号状态变化并重新确认 .available 后。
  1. 每次全量拉取和合并云端数据之前。
推送通知只能视为“云端可能有变化”的提示,不能作为唯一可靠触发点。Apple 文档也提醒通知可能被合并,因此客户端仍需主动拉取变化。
notion image

真机验证:不要只测在线成功路径

这次项目使用了两条最关键的真机路径。
在线删除时,日志形成闭环:
飞行模式删除并重启 app 后,待删标记仍然存在;网络恢复且账号可用时触发清算:
日志中的“删除墓碑”是当时代码里的内部命名,对应的就是本文所说的“待删标记”。
完整测试矩阵至少应包含:
场景
预期结果
在线删除
本地立即消失,云端确认后移除待删标记
飞行模式删除
本地立即消失,待删标记跨重启保留
账号退出后删除
不复活,重新登录后补删
删除请求连续失败
待删标记不因普通重试次数耗尽而丢失
云端记录已不存在
视为目标状态完成,清除待删标记
配额耗尽时删除
删除不被写入闸门阻断
清算前触发全量拉取
待删标记对应的记录不参与本地合并
仅在模拟器里验证“点击删除后列表少一条”,无法覆盖这些状态。

哪些数据不应该迁入 CloudKit 私有库

把用户笔记、录音和个性化内容放入私有数据库是合理的;把开发者必须可信裁决的数据放进去,则会改变安全边界。
JingNote 迁移后,Supabase 剩余职责主要有三类:
职责
是否适合迁入 CloudKit 私有库
原因
笔记与录音
适合
数据属于用户,跨设备同步且占用户配额
用户侧偏好与提示词
通常适合
用户拥有并可以删除
ASR 每日用量与免费额度
不适合
涉及开发者成本,必须由开发者控制的服务端裁决
私有数据库中的数据由用户拥有,用户可以删除 app 数据,也可能更换账号。开发者又无法把它当作自己的可信后台账本查询。因此,ASR 用量、支付权益、防刷限制这类数据仍应保留在受控服务端。
这也是为什么迁移完成后不应立刻删除 Supabase。更稳妥的顺序是:
  1. 先让 CloudKit 用户数据同步稳定运行一个版本。
  1. 盘点 Supabase 的真实调用方,而不是按目录名判断依赖。
  1. 为可信用量服务设计新的后端接口和存储。
  1. 做连通性、失败策略和账本一致性验证后,再移除 SDK。

这次最重要的三个失败教训

失败一:把所有 CloudKit 错误都交给同一个重试器

网络错误、服务限流、配额耗尽和请求被拒的恢复条件完全不同。统一“重试 3 次”的实现看似简洁,实际上既会浪费资源,也会让删除操作在次数用完后永久丢失。
更好的抽象不是“重试队列”,而是“按恢复条件分类的状态机”。

失败二:只验证写入,不验证删除后的下一次同步

用户点击删除后,本地列表立即更新,最容易让开发者误以为功能已经完成。真正的验收点是:离线删除、退出账号、重启、恢复网络并再次全量同步后,数据仍然不复活。

失败三:把 Development 成功等同于上线安全

Development 自动产生的 schema 与索引会降低早期开发门槛,也容易制造错觉。上线前不部署 Production Schema、不用真机验证 Production 环境,正式版本仍可能全线失败。

CloudKit 迁移上线检查清单

app 签名、provisioning profile 和目标 iCloud container 一致。
真机账号状态为 .available,账号变化有明确处理。
CKError 日志包含 code、underlying error、server description 和 retryAfter。
批量迁移的并发度受控,失败记录可以单独追踪。
Development schema 的 record types、fields 和 indexes 已部署到 Production。
Production 环境的查询索引与 cursor 分页已经真机验证。
quotaExceeded 会降级到本地,不进入无限重试。
配额提示只出现一次,成功写入后允许再次提示。
配额写入闸门不会阻断删除。
删除前先保存持久化待删标记,云端确认后才清除。
合并云端记录前会过滤待删标记中的 recordName。
飞行模式删除、账号退出删除、重启清算均已验证。
用户私有数据与开发者可信账本的边界已经划清。

总结

CloudKit 可以显著降低用户数据后端的维护成本,但它不会自动替 app 完成错误分类、离线补偿和最终一致性。
这次 JingNote 迁移真正有价值的,不是“打开 Console 后请求成功”这一条偶然性较强的经验,而是形成了一套可复用的方法:
  • 用完整 CKError 信息和构建产物建立证据链。
  • 把 Development、Production、schema 和索引纳入发布流程。
  • 把配额耗尽建模为持久状态,保住 local-first 的用户体验。
  • 用待删标记保存用户的删除意图,在账号和网络恢复后继续清算。
  • 把用户私有数据与开发者必须可信的服务端账本分开。
同步系统的可靠性,不是成功路径有多短,而是失败之后还能不能回到正确状态。

参考资料

 
最后,欢迎朋友们下载使用 JingNote(鲸海语记)App,这是我每天都在使用的软件,捕捉灵感,随时随地记录脑海中想法,真的解决了我不少的问题。
 
2026.07.28 15:18 沪 · 赵巷
📌 声明:本文由 AI 辅助完成