一家社区店的光纤在周六下午被施工挖断,收银台的手持设备连不上中心库,店长照常把两个小时的订单全部开在了本地。网络在傍晚恢复,同步任务跑完,界面上写着同步成功,中心库的订单数与门店当天的小票张数对得上,财务对账也没出问题。这件事当时被当作一次成功的断网演练。
三个月后,中心节点的磁盘告警先于任何业务异常出现。运维把库拉出来看,发现订单文档的数量没有异常增长,但每个订单文档的修订链都比想象中长;一部分文档挂着冲突标记,没人处理过;被作废的订单虽然查不到,删除标记还留在库里;连续复制任务一直挂着长连接,变更馈送从没真正追平过零点。
这里的问题不在「能不能同步」。同步确实成功了,双向复制在断网两小时这个量级上从来不是难点。真正的难点是三件都不会显示在「同步成功」这个状态里的事:冲突修订会悄悄积累,删除与附件让存储单向变胖,复制本身是一个永不结束的常驻负载。这三件事在上线当天都看不见,它们按周、按月慢慢长,直到某天变成一条磁盘告警。
下面按这个顺序拆开讲:先说修订模型,再说冲突为什么必须被显式解决,然后说删除、压缩、复制、附件、分库这几笔各自的账,最后给一份能直接落到监控里的清单。以下为典型部署思路,并非特指某一真实客户。
离线优先(offline-first)不是一个数据库特性,而是一种写入顺序的约定:终端先写本地库,本地库立刻返回成功,联网之后再与中心库做双向复制。收银员在断网时按下的「确认下单」,写的是手持设备上的本地库,响应时间由本地磁盘决定,与广域网状况无关。
这个约定换来的是业务连续性。门店不会因为网络抖动就开不了单,外勤人员不会因为在电梯里就丢掉刚采集的表单。代价是把一致性问题从「写入时刻」推到了「同步时刻」,而且是推到两端都能写的场景里——中心库会改文档,门店库也会改同一个文档。
双向复制的意思是两端互为源和目标,不是「门店上传、中心下发」两条单向通道。同一个订单文档,门店改了配送状态,中心改了对账标记,两边各自保存成功,复制时才发现是同一文档的两个不同修订分支。这时候数据库不会帮你把字段并起来。
所以离线优先架构的设计对象不是「同步功能」,而是「同步之后的状态」。同步功能写一次就完成了,同步之后的状态会一直存在,直到有人处理它。后面几节讲的所有治理动作,都是围绕这个持续存在的状态展开的。
CouchDB 是面向文档的数据库,数据以 JSON 文档为单位,每个文档有一个 ID 和一个修订号(revision),形如「世代序号-哈希」的字符串,比如 3-a7c2… 这种。每次保存修改,文档获得一个新的修订号,世代序号加一。
关键点在于:修改是追加一个新修订,而不是把旧内容原地覆盖掉。你在文档里改了一个字段,数据库写入的是这份文档的新版本,旧版本仍然作为修订历史留在存储里。读取时默认拿到的是当前修订,旧修订不出现在普通查询结果中,但它们在物理上还在。
这个模型是为了复制服务的。双向复制需要知道「对端有哪些我没有的修订」,追加写让每个文档天然带一条版本链,复制器沿着链比对就能算出差异。代价是写入放大:一次字段级修改,对应的物理开销是一整份新修订,不是那个字段的字节数。
由此可以直接推出一条容量估算方法:一个文档的存储占用约等于「当前修订大小 × 自上次压缩以来的修订次数」的累加,而不是文档当前大小。文档越大、改动越频繁,这个乘积膨胀得越快。具体倍数需以实际版本文档与自有压测为准,但变量关系本身是确定的——它同时受文档体积和写频度两个因子影响,且是乘法关系。
这也解释了为什么「订单文档数量没涨、磁盘却涨了」。文档数不变,修订深度在涨,这两件事在普通的 COUNT 查询里完全看不出来,只有看修订链或者看库体积才发现。
复制以文档为单位。双向复制时,两端在断网期间各自独立修改了同一个文档,网络恢复后复制器发现:源端有修订链 A→B,目标端有修订链 A→C,B 和 C 都从同一个祖先 A 派生出来,谁也不是谁的后续。
这时候数据库做的是:在两个分支里挑一个作为获胜修订(winner),把另一个(或另几个)标记为冲突修订,然后把冲突信息保留在文档里。挑哪一个有确定规则,但规则的目标是「两端选出同一个」,不是「选出业务上正确的那个」。这一点非常容易被误解:获胜者是算法挑的,不是业务挑的。
最重要的是:数据库不会自动合并字段。门店分支改了配送状态,中心分支改了对账标记,冲突产生后,获胜修订里只有其中一方的改动,另一方的改动躺在冲突修订里,不会自动并进来。这不是缺陷,是设计取舍——数据库不知道「配送状态」和「对账标记」在业务上是不是可以并存,它只是没有足够的信息去做字段级合并。
冲突在单次断网里发生的概率不高,但它会随几个因素上升:断网时长越长,两端各自修改同一批文档的时间窗越大;文档边界划得越粗,两个不同业务的字段被塞进同一份文档的概率越高;门店越多,同一份共享文档(比如当日促销配置、商品主数据)被并发改写的机会越多。这三个因素都是可以设计的,后面会分别给手段。
这是全文最需要记住的一条:冲突是一个稳定的数据状态,不是一个会自愈的临时状态。冲突标记一旦产生,就会一直留在库里,直到有人显式读取它、做出决定、并写入结果。没有任何后台任务会替你清理它,压缩也不会把它压掉——压缩回收的是旧修订的物理空间,不是冲突这个语义状态。
显式解决冲突的标准动作是三步:读出文档的所有冲突分支;按业务规则决定保留哪一方,或者把两边需要保留的字段合成一个新修订;删除落选的分支(对落选修订执行删除)。三步做完,冲突标记才真正消失。只做前两步不做第三步,落选修订还是挂在那儿。
需要特别划清一件事:这里的「冲突解决」是数据一致性语义——同一份业务实体的两个真实版本,必须选出一个真相或合成一个真相,选错了就是账实不符。它和错误追踪里的「事件分组去重」完全是两回事:去重是把内容相似的多条告警归并成一条以便阅读,归并错了只是少看几条日志;冲突归并错了是会丢掉一次真实的业务修改。两者用的算法思路可能相近,但错误代价和治理流程完全不同,不能用同一套策略对付。
同样要划清的是:本文讲的存储增长来自文档修订历史与冲突分支,和时序数据库里「数据点按时间保留、到期批量降采样」不是同一个机制。时序库的账是「点数量 × 保留期」,本文的账是「文档体积 × 修订深度 + 未被解决的分支数」。前者靠保留策略调节,后者靠写频度、文档边界和显式冲突解决调节,治理动作不通用。
为什么冲突会累积到三个月后才被发现?因为冲突文档在常规查询里读到的就是获胜修订,业务功能完全正常。订单能查、能对账、能打印,没人有动机去看冲突数。只有当你主动去统计「存在冲突分支的文档数」这个指标时,它才浮出水面。所以它必须是一个被监控的指标,而不是一个被临时排查的动作。
解决冲突还要考虑时机。解决得太早,可能门店侧的修改还没全部同步上来,你在不完整的信息上做了决定;解决得太晚,冲突分支在库里躺了几个月,业务上下文已经丢失,没人记得当时哪一方是对的。实践中更可行的做法是给冲突分龄:新产生的冲突走自动规则(比如按时间戳取新、按字段来源取权威端),超过一定时间还没解决的转人工工单,并把这个工单数也纳入监控。
在 CouchDB 里删除一个文档,做的是写入一个带删除标记的新修订,这个标记通常被称为 tombstone。文档从此在查询结果中不可见,看起来「没了」。
但物理上发生的是一次追加写,不是一次释放。文档体积变小了,修订历史仍然在,删除标记本身也是一个修订,也要占空间。这和关系型数据库里 DELETE 之后空间进入可复用状态是很不一样的直觉,从 MySQL 或 PostgreSQL 转过来的人最容易在这里误判。
而且删除标记不能被随便清掉。它在复制语义里是有用的:对端需要知道「这个文档被删了」,否则下次复制会把已经删除的文档又当成新增同步回去。删除标记是复制系统里「负向信息」的载体,删掉它可能引发数据复活。
要真正把删除痕迹从库里移除,需要专门的清理操作(purge)。purge 是移除修订历史的动作,不是常规删除,它会带来复制语义上的后果,通常要作为一次受控的变更流程来做,而不是放进日常清理脚本里随手跑。
所以删除策略要先分类:有审计或合规要求的删除,保留删除标记,接受它占用的空间;纯粹为了腾空间的历史数据清理,走 purge,并且要在复制拓扑中所有节点都已同步到该删除标记之后再执行,同时记录操作日志。这两类不能混在一个「清理任务」里。
压缩(compaction)是回收旧修订占用空间的手段。它扫描数据库文件,把还需要的部分(当前修订、未解决的冲突分支、仍需保留的删除标记)重写到一个新文件,再替换掉旧文件,旧修订占用的空间随之被回收。
这里有两个很容易踩的点。第一个是压缩期间需要额外的临时空间:新文件在写完、切换之前,旧文件还不能删,峰值占用大约是当前库体积加上压缩后体积的量级。在磁盘已经接近告警线时才想起来压缩,是最容易出事的时机——压缩本身可能把剩余空间吃光。
第二个是压缩是一次性的 IO 与空间峰值操作,不是持续的小开销。它要扫描并重写整个数据库文件,期间产生集中的读和写,与在线的复制任务、终端同步请求抢同一份 IO 能力。把压缩安排在业务高峰、或者和一批门店的集中补同步撞在一起,会同时拉长压缩时间和同步延迟。
实践上的排期逻辑是:按库的活跃度分级,高写入库压缩周期短、低写入库周期长,并把压缩窗口错开到复制负载的低谷。压缩前的空间检查要算「峰值」而不是「压缩后体积」——判断依据是剩余空间是否大于当前库体积的一个可观比例,而不是压缩能省出多少。
还有一条:压缩回收的是旧修订,不回收未被解决的冲突分支,也不回收仍需保留的删除标记。所以「压缩跑完了,库体积没怎么降」是一个有信息量的信号,它通常说明库的增长主要来自冲突或删除标记,而不是普通修订历史。这时候该回头查冲突解决流程,而不是加大压缩频率。
增量同步的基础是变更馈送(_changes):复制器读取源库自某个位置以来的变更序列,把缺失的文档修订推到目标端。连续(continuous)复制在这个基础上常驻运行,保持一条长连接,变更一产生就被推走。
连续复制不是「跑完就结束」的批处理,它是一个常驻任务:每条任务持续占用一个连接、一份内存上下文和一部分 IO。当门店数量上到几十、上百,复制任务数就是同量级,中心节点上挂着的是上百条常驻连接,这与「每天凌晨跑一次同步」的资源画像完全不同。
积压量的关系是这样的:断网期间本地持续产生变更,这些变更在变更馈送里排队,重连后集中补同步。断网时长越长,积压的变更量越大,重连瞬间需要传输的修订数越多。复制是幂等的——重复跑不会造成错误数据——但幂等不等于瞬时:积压不会凭空消失,只能靠复制推进一点点消化。
这段时间内的冲突概率也随之上升。积压大意味着两端在更长的时间窗里各自独立演进,同一份文档被两边改到的机会变大。所以「断网越久,恢复越慢,冲突越多」这三件事是同一个因果链的三个表现,不是三个独立问题。
估算补同步量的方法:积压修订数约等于「断网时长 × 该终端的单位时间修订生成速率」,恢复所需时间约等于积压修订数除以复制链路的实际吞吐。这两个速率都要靠自有压测拿到,不同文档大小、不同网络条件下的差异可以很大。能确定的只有变量关系:断网时长与积压量线性相关,积压量与恢复时长线性相关,冲突概率随断网时长单调上升。
由此推出两条设计约束:一是限定单个中心节点承载的连续复制任务数,超出的部分要么加节点要么改成分批触发;二是给长时间断网的终端设计补同步的错峰策略,避免几十家门店在同一个网络恢复瞬间同时冲击中心节点。
附件(attachment)通常单独存储,不随文档修订走同一套版本管理策略。一张签名图、一份小票影像、一张巡检照片,挂在一个文档上,文档本身可能只有几百字节,附件是几百 KB 到几 MB 的量级。
在门店和外勤这类场景里,大量或大体积附件往往是存储增长的主要来源,而不是文档 JSON 本身。一个订单文档的修订链再长,单个修订也就几 KB;一个巡检工单挂了五张现场照片,一次就上 MB。算容量时把这两部分混在一起估,会低估一个数量级。
附件还有一层麻烦:删除文档不一定立刻回收附件占用的空间,附件与文档的版本管理策略不同步,导致「文档清了、附件还在」是一种常见状态。清理附件需要单独的动作和单独的核对,不能假设文档侧的操作会自动传导过去。
一个更稳的做法是分层存放:文档库只存结构化字段和对附件的引用,附件放到对象存储里,引用里存地址。这样文档库的体积可控、复制任务传输的是小修订而不是大二进制,压缩和备份的代价都降下来。代价是同步语义变成两层——文档同步成功不代表附件也到位,需要在应用层处理「文档到了但附件还没到」的中间状态。
不做分层的话,就要给附件设定明确的大小上限和保存期,并把附件占用单独作为一项监控指标,而不是埋在「库体积」这一个总数里。压缩之前先核对附件占比,能避免一次耗时很长却回收很少的操作。
每个数据库是独立的分片与文件集合。这个事实决定了分库粒度直接作用于三个维度:文件数、复制任务数、备份粒度。
每店一库的优点是隔离性好:一家店的数据出问题不影响别家,按店恢复和按店迁移都很直接,权限模型也简单。缺点是文件数与门店数成正比,几百家店就是几百套文件集合;复制任务数与门店数成正比,中心节点要维持几百条常驻连接;备份任务也要按库逐个跑,调度复杂度上升。
共享一库的优点是资源集中:文件数固定,复制任务数可以按终端数之外的维度控制,备份一次性完成。缺点是隔离性变差,一家店的异常写入可能影响整个库的压缩和复制节奏;按店恢复要从大库里筛,粒度变粗;删除和 purge 这类操作的影响面从一家店变成所有店。
折中做法是按业务属性和变更量分档:高活跃、数据敏感度高的门店单独建库;低活跃的门店按区域合并到一个库里,用文档里的门店字段做逻辑隔离。分档的标准应该是「该库的修订生成速率」和「该店的恢复时间要求」,而不是门店的地理位置或者编号顺序。
还有一个容易被忽略的点:库一旦建立,它的文件数与分片不会因为数据量下降而自动减少。合并低活跃门店库这类动作不会自动发生,需要显式做迁移。所以分库决策在上线前做的成本最低,上线后再调整要付出迁移代价。
复制任务的组织方式也和分库粒度绑定。每店一库时,复制是「店库 ↔ 中心库」的一对任务;共享一库时,需要用过滤条件限定每个终端只复制属于自己的文档,过滤本身要在复制配置里体现,配置错误会直接导致越权可见。这两种方式的运维复杂度来源不同,前者在连接数,后者在配置正确性。
以下为典型部署思路,并非特指某一真实客户。假设一家连锁品牌有若干门店,每家门店一台或多台收银终端,终端上运行本地库,中心有一套中心库,两者之间连续双向复制。
文档边界怎么划是关键决策。把一张订单做成单一文档,订单状态、支付信息、会员积分、配送备注全在里面,那么门店改状态、中心改对账标记、后台客服改备注,三方都在改同一份文档,冲突概率被人为放大。更合理的划分是按「谁会独立修改它」来切:订单主文档只放订单创建时确定的字段,状态、对账、备注各自独立成文档,用订单 ID 关联。
这样切之后,即使产生冲突,冲突也只发生在某个具体子文档上,而且子文档的业务语义清晰,自动解决规则容易写——比如支付结果以中心为准,配送状态以门店为准,规则可以按字段来源直接判定,不需要复杂的合并逻辑。
ID 策略也要定。用确定性 ID(比如「门店号+日期+流水号」)而不是随机 ID,可以让同一笔业务在两端生成同一个文档 ID,避免因为 ID 不同而把一份数据复制成两份。随机 ID 在离线场景下会造成重复文档,而且这种重复不会被识别为冲突——它是两个不同的文档,靠冲突解决流程查不出来。
商品主数据这类被所有门店共享的文档要单独考虑。它通常只允许中心侧修改,门店只读,通过单向复制下发。如果设计成双向可写,几百家门店都可能改它,冲突率会非常高。把「谁能写」写进架构约束,比事后写冲突解决规则有效得多。
以下为典型部署思路,并非特指某一真实客户。外勤采集的特点是终端长时间离线——半天、一天甚至更久——回到有网环境后一次性回传。这个场景的画像和门店 POS 很不一样。
门店 POS 是短断网、高频次、小批量;外勤采集是长断网、低频次、大批量。前者的瓶颈在冲突概率,后者的瓶颈在补同步的集中冲击。一批终端在下班时间集中连上网络,如果都走连续复制,中心节点会在一个很窄的时间窗内接收大量积压变更。
更合适的做法是分批触发:终端联网后先上报积压量,由调度侧决定何时启动复制,把回传打散到一个时间窗里。连续复制在这个场景里的价值不大——终端大部分时间离线,常驻连接的收益低而成本高,改成按需触发更划算。
附件在这个场景里是主要矛盾。巡检照片、签名、现场录音,一次采集产生多个附件,总量远大于结构化字段。如果附件挂在文档上进复制链路,补同步的传输量和耗时都由附件主导,结构化数据反而被拖慢。分层存放在这里几乎是必选项:附件走单独的上行通道,文档库只处理引用。
删除策略也要提前定。外勤终端本地存储有限,回传完成后往往会清理本地历史数据。本地的删除会产生删除标记,删除标记会参与下一次复制,如果清理动作在复制完成前执行,可能造成中心侧把已删除的文档又同步回来。本地清理的触发条件必须是「已确认中心侧收到」,而不是「本地点了清理」。
一万网络深耕 IDC 行业 19 年(成立于 2007 年),在华南、华东、华北及中国香港、海外多地整合自营节点与 BGP 多线资源,提供服务器租用、机柜托管与带宽接入的一体化交付;具体机型、带宽与报价以官网实时信息或咨询确认为准。
前面所有分析落到运维上,就是一份监控清单。这份清单的原则是:把「同步成功」这个二元状态,换成若干条会随时间变化的曲线。同步成功只能告诉你此刻没坏,曲线才能告诉你在变坏。
必备的第一类指标是冲突类:存在冲突分支的文档数(总量,以及按文档类型拆分)、最老冲突的存续时长、单位时间新产生的冲突数、超过阈值仍未解决的冲突数。第一类里最有用的是「最老冲突存续时长」,它直接反映冲突解决流程是不是真的在跑。
第二类指标是删除标记类:tombstone 的数量、tombstone 占文档总数的比例、单位时间的删除操作数。删除操作数的突增往往意味着上游有批量清理动作在跑,这类动作最好提前报备,而不是事后从监控曲线上发现。
第三类指标是容量类:单库体积、库体积的增长率、附件总容量及其占比、单个文档的修订深度分布、压缩前后的体积差。压缩前后的体积差特别有诊断价值:差值小说明增长来源不是普通修订历史,该去查冲突和删除标记。
第四类指标是复制类:复制任务的待处理变更数(积压量)、追平所需时长、连续复制任务的活跃数与失败重试次数、终端的断网时长分布。追平所需时长比「是否在线」有用得多,它把积压这件事量化成了时间。
| 增长项 | 产生原因 | 为什么不会被自动回收 | 治理动作 | 该进监控的指标 |
|---|---|---|---|---|
| 冲突修订 | 双向复制中两端各自修改同一文档,或同一文档被并发写入 | 冲突是正常数据状态,不会被自动合并或删除,只有显式解决才消失 | 定期扫描冲突分支,按业务规则选择胜出方或写入合并后的新修订,并删除落选分支 | 冲突文档总数、按文档类型分布、最老冲突存续时长、单位时间新增冲突数 |
| 旧修订历史 | 每次保存追加新修订,旧修订仍留在存储中直到压缩 | 追加写是复制模型的默认行为,压缩之前不做回收 | 控制单文档写频度、拆分高频改动字段、按库活跃度排期压缩 | 修订生成速率、单文档修订深度分布、压缩前后库体积差 |
| 删除标记(tombstone) | 删除写入一个带删除标记的新修订,而非物理移除 | 删除标记是复制的负向信息载体,清掉可能导致数据复活 | 区分需留痕的删除与可清理的删除,purge 作为受控变更流程执行 | tombstone 数量与占比、单位时间删除操作数 |
| 附件 | 图片、签名、影像等单独存储,不随文档修订走同一套版本策略 | 文档侧删除不一定同步回收附件空间,需单独核对 | 附件分层存放到对象存储,设定大小上限与保存期,压缩前先核对附件占比 | 附件总容量及其占比、单附件大小分布、附件增量速率 |
| 复制积压 | 断网期间本地变更持续堆积,重连后集中补同步 | 积压是变更馈送的未消费部分,只有复制推进才消化,幂等但不瞬时 | 按断网时长估算积压量、限定单节点连续复制任务数、错峰补同步 | 待处理变更数、追平所需时长、断网时长分布、复制失败重试次数 |
| 库数量与文件数 | 每店一库带来大量库,每个库是独立的分片与文件集合 | 库建立后文件数与分片不随数据量下降而自动减少 | 按修订生成速率与恢复时间要求分档决定分库粒度,合并低活跃库 | 库总数、单库平均体积、活跃复制任务数、单节点常驻连接数 |
第一项要算的是文档边界。把「谁会独立修改哪些字段」列成一张表,能各自独立修改的字段就不该放在同一份文档里。这一项在上线前改动成本极低,上线后改动要付出数据迁移的代价,是所有决策里最该早做的。
第二项要算的是冲突解决规则与责任人。规则要能覆盖每一类文档:哪些按时间戳取新,哪些按来源取权威端,哪些必须转人工。还要定一个时限——冲突存在超过多久就升级成工单。没有责任人的冲突规则等于没有规则,因为没人会去看它。
第三项要算的是容量模型。用「文档体积 × 修订深度 + 冲突分支 + 删除标记 + 附件」这个式子估一遍,把每一项的参数来源写清楚,标注哪些需要自有压测补齐。不要用「同步一次大概多少」这种单点估法,它覆盖不了持续累积的部分。
第四项要算的是压缩窗口与空间余量。压缩需要的峰值空间大于压缩后的体积,这个数值要提前算进去,并确认磁盘在压缩启动前有足够的余量。同时确认压缩窗口避开了复制负载的高峰。
第五项要算的是复制拓扑的规模上限。单节点能承载多少条常驻复制任务、断网多久之后的补同步需要分批、要不要引入中间层节点聚合,这些都要在门店数还没上量之前定好。等到几百家店都在线上再改拓扑,迁移成本会高得多。
把这五项算清楚,比把同步功能调通重要得多。同步功能调通一次就结束了,这五项决定了这套架构在半年、一年之后是不是还站得住。
本文涉及的原理性事实——追加写与修订号、双向复制产生冲突而非字段合并、冲突需显式解决、删除写入删除标记、压缩需额外临时空间与集中 IO、变更馈送与连续复制的常驻特性、附件单独存储、每个库独立分片与文件集合——均属于文档型数据库复制模型的公开设计原理,可在官方文档中核对到对应表述。
需要你自己验证的部分:具体版本的默认行为与参数名称。不同版本在压缩触发方式、冲突查询接口、purge 的语义约束上可能有差异,落地前需以实际版本文档为准。
需要自有压测才能确定的部分:单位时间修订生成速率、复制链路的实际吞吐、压缩耗时与空间峰值比例、附件占比。这些数值与文档大小、网络条件、磁盘类型强相关,本文只给出变量关系与估算方法,不给出具体数值,任何脱离自身数据规模的通用数字都不可靠。
还需要结合业务确认的部分:分库粒度取决于门店数量与各自的恢复时间要求;冲突解决规则取决于谁能改哪些字段;删除与清理策略取决于审计与留存要求。这三项没有技术上的标准答案,只有与你自己的业务约束匹配的答案。
最后再回到开篇那家店。它的问题不在于断网两小时没扛住,而在于上线时没人算过上面这五项。同步成功是一个可以立即验证的状态,冲突累积、存储增长、复制常驻负载是三个只能靠持续观测才能发现的状态。前者让人放心,后者决定这套架构能跑多久。
Copyright © 2013-2020 idc10000.net. All Rights Reserved. 一万网络 科技有限公司 版权所有 深圳市科技有限公司 粤ICP备07026347号
本网站的域名注册业务代理北京新网数码信息技术有限公司的产品