自在学

我们与你共同进步

  • 分类课程
  • 文章
  • 工作台
  • 订阅

  • 关于我们
  • 隐私政策
  • 使用条款

探索

  • 分类课程
  • 文章
  • 工作台
  • 订阅

网站信息

  • 关于我们
  • 隐私政策
  • 使用条款

加入社区

自在学学习社区微信二维码

微信扫码,交流学习

株洲市自在学教育科技有限公司© 2025 - 2026 版权所有

© 2025 - 2026 株洲市自在学教育科技有限公司 版权所有

湘公网安备43020302000292号|湘ICP备2025148919号-1
分类课程工作台文章订阅
分类课程工作台文章价格

NoSQL

  1. 01认识NoSQL
  2. 02聚合数据模型
  3. 03数据模型的深入探索
  4. 04数据分布模型
  5. 05数据一致性
  6. 06版本戳
  7. 07Map-Reduce
  8. 08键值数据库
  9. 09文档数据库
  10. 10列族存储
  11. 11图数据库
  12. 12数据库模式演进
  13. 13多存储持久化
正在加载课程章节内容
课程编程NoSQL多存储持久化

多存储持久化:让每类数据去合适的地方

假设你正在维护一个电商系统。最初业务不复杂,用户、商品、购物车、订单和操作日志都放在同一个关系型数据库里。这样的设计很省心:一个连接池、一套备份方案、一种查询语言,出了问题也知道先看哪里。

可业务一旦长起来,麻烦就会接连出现。促销开始时,购物车的高频读写挤占数据库连接;运营人员搜索商品时,模糊查询拖慢订单接口;推荐任务做多层关联分析,又把 CPU 和磁盘忙得团团转。最让人难受的是,这些负载的脾气完全不同:订单怕写错,购物车怕响应慢,搜索怕查不到,推荐怕关系绕得太深。让一套数据库同时把它们都照顾好,就像让同一辆车既跑城市通勤、又拉重货、还参加越野赛,能开不等于每件事都做得合适。

这时候,多存储持久化就派上用场了。它也常被叫作“多模型持久化”或“混合持久化”,意思不是把 SQL 数据库全部换成 NoSQL,更不是看到一种新数据库就接进系统,而是先弄清每类数据怎样被读取、怎样被修改、允许多旧、失败后怎样恢复,再把它交给合适的存储引擎。

从一库全扛到多存储分工

多存储持久化的核心不是数据库数量,而是职责边界。一个系统即使接入了五种数据库,只要同一份关键数据被随意写入、服务之间互相直连数据库,它依然没有得到清晰的架构;反过来,一个系统只用关系型数据库加缓存,却把事实来源、缓存副本和更新路径划分得很清楚,也已经在运用这套思维。

先别选产品,先把问题问对

不少团队的选型顺序恰好反了:先听说某个数据库很快,再努力寻找可以使用它的地方。更稳妥的做法,是先把业务负载写成一张“工作说明书”。我们至少要回答下面五组问题。

先描述最常见的访问路径。数据主要按主键读取,还是经常做范围查询、全文检索、聚合统计或多跳关系遍历?数据库的优势通常只在特定访问方式下成立,“快”如果不带查询条件,几乎没有实际意义。

再划出真正的事务边界。哪些字段必须一起成功或一起失败?哪些结果可以晚几秒出现?例如订单金额和订单明细不能对不上,但推荐列表晚一点吸收新购买记录,通常不会造成财务错误。

接着看数据形状。它是稳定的表格、经常一起读取的嵌套对象、简单的键值、不断延伸的关系网络,还是需要分词和相关度排序的文本?数据模型越贴近访问方式,应用层需要做的拼装工作通常越少。

然后估算增长和生命周期。数据量多快翻倍,读写峰值有多高,冷热数据比例怎样,是否需要自动过期、归档或删除?一个只存三十分钟的会话,与必须保存多年的订单凭证,不该使用同一套容量思路。

最后讨论故障和团队能力。存储不可用时业务能否降级,数据能否重建,团队是否具备监控、备份、恢复和升级这套数据库的能力?技术上适合却没人能稳定运维的方案,仍然不是好方案。

存储选型前的四问罗盘

下面这个小工具把选型讨论压缩成几种典型负载。你可以用键盘 Tab 键移动焦点,再按回车或空格切换场景。它给出的不是“标准答案”,而是一种练习:先说出为什么,再说出选什么。

把“适合”说得更具体

不同类型的存储没有绝对高下,只有是否贴合当前负载。下面这张表更像一份边界清单:它既告诉你擅长什么,也提醒你别让它承担什么。

存储类型擅长的访问方式常见职责需要警惕的边界
关系型数据库事务、约束、结构化查询、范围统计订单、支付记录、库存账本不要让缓存、搜索和海量日志争抢核心事务资源
键值存储按键读取、计数、集合操作、短生命周期数据会话、购物车、限流、热点缓存内存快不等于适合长期账本,过期和淘汰策略必须分清
文档数据库整体读取嵌套对象、字段随类型变化商品详情、内容配置、流程快照灵活结构仍要有核心字段规范,数组也不能无限增长
图数据库多跳关系遍历、路径和邻居查询推荐、权限关系、依赖网络简单主键查询或普通汇总未必能发挥优势
搜索引擎分词、模糊匹配、相关度、聚合筛选站内搜索、日志检索、可观测性搜索索引通常不是业务事实来源,并且存在可见性延迟
对象存储通过对象标识读写完整文件图片、视频、附件、归档包业务状态、访问权限和孤儿文件清理仍需数据库配合

这张表故意没有写“每秒能处理多少请求”。脱离数据规模、索引、硬件、分片方式和查询形状谈绝对性能,往往会误导选型。真正有价值的数据来自你的访问日志、容量估算和压测结果。

用一条购物链路理解多存储分工

让我们跟着用户小林走一遍。小林打开商品页,修改购物车,搜索“轻便降噪耳机”,查看相关推荐,最后提交订单。表面上这只是一次购物,底层却包含了几种完全不同的数据动作。

电商业务的数据分流地图

购物车:追求短路径,但别把它当订单

购物车通常按用户标识整份读取,操作集中在增加、减少和删除商品,并且长时间不活跃后可以自动清理。键值存储很适合这种模式。以 Redis 哈希为例,我们可以把一个用户的商品数量放在同一个键下:

shell
HSET cart:user-123 sku-88 2
HINCRBY cart:user-123 sku-88 1
HGETALL cart:user-123
EXPIRE cart:user-123 2592000

这里最重要的不是命令短,而是业务边界要清楚。购物车里的价格只适合展示估算,提交订单时必须回到商品与库存服务重新校验。如果缓存数据丢失,用户体验会受影响;如果订单金额写错,系统会出现真实损失。这两类风险不能因为它们都叫“数据”就用同一等级处理。

过期和淘汰不是一回事。过期是我们主动给数据设置生命周期,淘汰通常是内存压力下的空间策略。若某份数据只能依靠“希望它还没被淘汰”来维持业务正确性,这份数据就不该只存在缓存里。

商品详情:文档灵活,但不是随便堆字段

服装有尺码和面料,手机有芯片和存储容量,书籍有作者和 ISBN。把所有属性都做成关系表列,会产生大量空值;把每种属性都拆成通用键值表,读取一个商品又需要拼装许多行。文档模型允许我们把经常一起展示的字段放在一起:

json
{
  "productId": "sku-88",
  "title": "轻便降噪耳机",
  "category": "数码配件",
  "sale": {
    "status": "ON_SALE",
    "displayPrice": 39900,
    "currency": "CNY"
  },
  "attributes": {
    "颜色": ["雾灰", "湖蓝"],
    "续航": "32 小时",
    "重量": "196 克"
  },
  "schemaVersion": 3
}

“字段灵活”不代表“团队不用约定”。productId、销售状态、价格单位和结构版本依然应该稳定,写入端也要校验类型。真正适合自由扩展的是不同品类的属性,而不是所有字段都想叫什么就叫什么。

文档内嵌还要考虑增长边界。商品的几个规格适合与主体放在一起,几十万条评价却不适合塞入同一文档。一个简单判断是:这些数据是否总被一起读取、一起修改、一起归档?如果子数据可以独立存在、数量会持续增长或更新节奏完全不同,就应该拆成独立集合,用标识建立关联。

搜索:把业务事实加工成查询副本

用户搜索时需要分词、拼写容错、筛选、排序和高亮,这些操作适合搜索引擎。但商品搜索索引不该成为修改售价的入口。商品服务才拥有商品事实,搜索索引只是为了查询而加工出来的副本。

这意味着系统要接受一个现实:商品刚改名时,详情页可能已经显示新名称,搜索结果还短暂保留旧名称。我们可以缩短延迟、监控积压,却不能假装异步传播完全没有时间差。对于“上架后几秒可搜到”这样的产品规则,最好把它写成明确指标,而不是交给用户自己猜。

推荐:当问题的主角变成“关系”

“买过这件商品的人还买了什么”关心的不是某一行记录,而是用户、商品和购买行为之间的连接。图数据库把实体建成节点,把购买、收藏和同类关系建成边,多跳探索会更自然。

cypher
MATCH (me:User {id: $userId})-[:PURCHASED]->(:Product)
      <-[:PURCHASED]-(peer:User)-[:PURCHASED]->(candidate:Product)
WHERE NOT (me)-[:PURCHASED]->(candidate)
RETURN candidate.id AS productId, count(DISTINCT peer) AS score
ORDER BY score DESC
LIMIT 10

不过,看到“有关联”就上图数据库也会过度设计。如果你只需要按商品分类读取一次,普通索引已经足够;只有当查询经常沿关系走多步、关系类型本身承载业务含义,而且现有连接查询确实成为瓶颈时,引入图数据库才更有说服力。

订单:守住最重要的事实

用户点击“提交订单”后,系统要把商品、成交价、数量和应付金额固定下来。这个动作有清晰的事务边界,不能出现订单主表成功而明细缺失,也不能让搜索索引是否成功决定订单是否成立。

sql
BEGIN;
 
INSERT INTO orders(id, user_id, total_amount, status)
VALUES ('ord-20260811-001', 'user-123', 79800, 'CREATED');
 
INSERT INTO order_items(order_id, product_id, quantity, unit_price)
VALUES ('ord-20260811-001', 'sku-88', 2, 39900);
 
INSERT INTO outbox_events(event_id, event_type, aggregate_id, payload, status)
VALUES (
  'evt-20260811-901',
  'ORDER_CREATED',
  'ord-20260811-001',
  '{"userId":"user-123","cartId":"cart:user-123"}',
  'PENDING'
);
 
COMMIT;

这段事务同时写入订单和“待发布事件”。只要事务提交成功,系统就同时拥有两个事实:订单已经建立,并且这件事还需要通知其他服务。即使消息系统短暂不可用,后台发布器也能稍后继续扫描待发布事件,不会陷入“订单成功了,但通知永远丢了”的缝隙。

数据属于服务,不属于所有调用者

当系统接入多种数据库后,一个常见坏味道是:订单服务查自己的关系库,推荐服务又直接查订单表,运营工具也绕过接口改订单状态。短期看少写了一层 API,长期却把所有服务绑在订单表结构上。谁想改字段,都得先召集一屋子人开会。

更清楚的边界是“服务拥有数据”。订单服务可以选择关系型数据库,购物车服务可以选择键值存储,推荐服务可以选择图数据库;外部调用者只通过接口或事件使用它们提供的能力,不直接连接它们内部的数据库。

服务拥有数据的边界

这样的边界带来三个直接好处:

  • 接口比表结构稳定。 订单表可以拆分或迁移,只要对外返回的订单能力没有改变,调用者不必一起改。
  • 故障更容易隔离。 推荐数据库不可用时,可以降级为热门商品,不必让下单也跟着失败。
  • 存储可以独立扩展。 搜索负载上涨时扩搜索集群,没必要同时扩大订单数据库。

代价也很真实。以前一条跨表连接能解决的查询,现在可能需要调用多个服务;以前一个本地事务能覆盖的修改,现在可能变成一串异步步骤。因此,服务边界不应按数据库类型随意切割,而应围绕业务职责和一致性边界来划分。

“每个服务有自己的数据库”不等于“每个服务必须部署一套独占服务器”。真正要隔离的是所有权和访问边界。早期系统完全可以让多个服务共享同一数据库实例,但使用独立账户、独立 Schema 或独立表集,禁止越权直连;等负载和风险值得时,再做物理拆分。

对外接口可以保持业务语义,而不是暴露底层查询语言:

javascript
class RecommendationService {
  async relatedProducts(input) {
    const result = await this.graphRepository.findCandidates({
      userId: input.userId,
      productId: input.productId,
      limit: input.limit || 10
    });
 
    return result.map(function (item) {
      return {
        productId: item.productId,
        reason: "相似购买行为",
        score: item.score
      };
    });
  }
}

调用者只知道“获取相关商品”,不必知道里面使用图数据库、离线模型还是缓存结果。以后实现变化,对外契约仍可以稳定。

一次下单,怎样安全地传播到多个存储

现在来到多存储最难的部分。订单写入关系库后,我们还希望清空购物车、更新推荐关系、建立搜索索引、记录分析事件。若把它们写成四个同步调用,任何一个环节超时都可能拖垮下单;若只是随手发几条消息,又可能在进程崩溃时丢通知。

更可靠的思路是:先在单一事务边界内保存最重要的事实,再异步传播,并为重复、乱序和失败做好准备。

订单事实的事件传播路径

事件必须拥有稳定身份

每个业务事件都应该有唯一的 eventId,消费者处理前先检查自己是否见过它。因为在“至少投递一次”的链路中,同一条事件出现两次是正常情况;我们追求的不是消息绝不重复,而是重复到达也不会产生第二份业务副作用。

javascript
async function handleOrderCreated(event, db) {
  await db.transaction(async function (tx) {
    const done = await tx.processedEvents.exists(event.eventId);
    if (done) return;
 
    await tx.purchaseGraph.upsertEdge({
      userId: event.userId,
      productId: event.productId,
      orderId: event.orderId
    });
 
    await tx.processedEvents.insert({
      eventId: event.eventId,
      processedAt: new Date()
    });
  });
}

这里用“按订单标识更新或插入关系”,而不是每收到一次就盲目新增一条边。去重记录和业务修改放在消费者自己的本地事务中,才能避免刚改完数据、还没登记处理状态就崩溃带来的重复副作用。

亲手走一遍故障恢复

下面的模拟器把“订单成立”和“副本更新”拆开。你可以故意让推荐服务失败,再观察重试怎样让系统收敛。按钮可用键盘操作,状态变化会在下方日志中说明。

重试、补偿和对账各管一段

分布式流程没有一个能包住所有数据库的本地事务,失败处理就必须成为业务设计的一部分。几种机制解决的问题并不相同:

  • 重试处理暂时性失败,例如网络抖动、连接超时。前提是操作幂等,并且要使用退避和次数上限,避免故障时制造请求风暴。
  • 补偿处理已经成功但业务需要撤销的动作。它不是把时间倒回去,而是执行一个含义相反的新动作,例如释放预占库存、关闭订单或发起退款。
  • 对账发现长时间没有自动收敛的异常。它比较事实来源与派生副本,把漏消息、程序缺陷和人工操作造成的差异暴露出来。
  • 人工介入处理无法自动判断的高风险情况。支付和资金类异常往往需要冻结状态、保留证据,再由受控流程决定下一步。

多存储一致性的故障恢复工具箱

收到失败后先判断操作是否真的没有执行。超时只说明调用方没有及时收到结果,不代表服务端一定失败;因此先用业务标识查询状态,比立刻重复写入更稳妥。

对确定可以重试的操作,使用同一个请求标识再次提交,让服务端返回同一业务结果。不要每次重试都生成新订单号,否则“技术重试”会变成“业务重复”。

对无法继续完成的跨服务流程,执行事先设计好的补偿动作,并把补偿本身也记录成可追踪事件。补偿失败同样需要重试和告警。

最后由定期对账核对事实来源与各个派生存储。自动修复低风险差异,把资金、库存等高风险差异送入人工队列,并保留完整操作轨迹。

不要把“最终一致”理解成“最终随缘”

最终一致仍然要有可验证的终点。团队必须说清楚目标状态、允许延迟、失败后的负责人和用户看到的中间状态。例如订单已经创建但推荐尚未更新时,可以正常展示订单成功;支付结果未确认时,则应该显示“处理中”,而不是猜测成功或失败。

可以为每条异步链路定义四个指标:待处理事件数量、最老事件等待时间、处理失败率和对账差异数量。只看消息队列有没有运行远远不够,因为队列健康并不代表业务副本已经正确。

用架构体检器发现常见坏味道

多存储方案很容易在白板上看起来漂亮,真正的风险藏在所有权、重试和恢复细节里。勾选下面的设计现状,工具会给出即时体检。它不会替你做架构评审,但能提醒我们把“数据库能用”继续追问到“系统能恢复”。

从单一数据库迁移,不必一口气重写

看到这里,你可能会担心:现有系统已经把所有数据放在一个数据库里,难道要停机重构吗?通常不需要。多存储迁移更适合按一条明确的访问路径逐步拆出,并且每一步都保留回退能力。

先画出现状数据流,找出压力最大又容易验证的一类读请求。商品搜索常常比订单写入更适合作为第一步,因为搜索索引属于可重建副本,即使迁移失败也能切回原查询。

明确原数据库仍是事实来源,新存储先只承担派生查询。为历史数据做一次可重复的全量回填,再用事件或变更捕获持续同步新增变化。

在不影响用户的情况下做影子读取:新旧查询同时运行,只把旧结果返回给用户,并记录结果差异、延迟和错误。差异不一定表示新系统错误,也可能暴露旧查询长期存在的问题,因此要按业务规则判断。

先把少量流量切到新路径,观察延迟、错误率、资源消耗和数据新鲜度。出现异常时能够用开关迅速切回,而不是临时发布一版代码救火。

新路径稳定后,再移除旧查询和不再需要的同步逻辑。最后更新数据目录、值班手册和恢复方案,避免代码已经迁走,团队脑中的事实来源仍停留在旧架构。

这个过程刻意从“读副本”开始,因为它的失败代价更低。订单、库存和资金这类关键写路径要更谨慎,需要额外的双写验证、幂等、审计和回滚设计。迁移顺序应该由风险决定,而不是由哪个模块代码最少决定。

多一种数据库,就多一份运维责任

多存储把每类负载交给擅长的引擎,同时也增加了连接管理、权限、监控、备份、升级和成本治理。真正成熟的方案会把下面这些问题写进设计,而不是等上线后再补:

  • 每个存储的负责人是谁,谁有写权限,密钥如何轮换?
  • 容量、连接数、慢查询、复制延迟和事件积压分别怎样告警?
  • 备份保存多久,恢复点目标和恢复时间目标是什么?
  • 某个派生存储彻底丢失时,能否从事实来源重建?重建需要多久?
  • 新版本如何灰度,数据格式如何兼容,回滚时旧程序能否继续读取?
  • 一致性异常怎样被发现,自动修复与人工处理的边界在哪里?

如果团队还没有能力回答这些问题,减少数据库种类往往比继续拆分更专业。架构的目标是降低整个系统的复杂度,而不是让架构图看起来更丰富。

常见误区:看起来合理,实际上少了一半

“NoSQL 比关系型数据库快,所以都迁过去”

性能取决于访问模式。键值读取很快,不代表它擅长复杂事务;图遍历自然,不代表它适合账务汇总。先测真实查询,再决定迁移对象。

“用了消息队列,数据就会自动一致”

队列只能运输消息。事件是否会丢、是否会重复、消费者是否幂等、失败怎样补偿、积压怎样告警,仍然需要应用层和运维流程共同解决。

“搜索结果可以直接改回业务数据库”

搜索索引通常为了查询而反规范化,字段可能已经被分词、合并或裁剪。让它反向成为写入口,会形成两个事实来源。正确方向是业务服务接受修改,再重新生成搜索副本。

“最终一致就是不用关心什么时候一致”

最终一致必须带目标时间和异常处理。可以说“商品改名后 10 秒内可搜索”,也可以说“推荐更新允许 5 分钟延迟”,但不能只说“以后会好”。

“每个服务都要选不同的数据库”

服务独立不要求技术栈不同。两个服务都用 PostgreSQL 完全没有问题,只要它们的数据所有权清楚、访问边界独立。多存储是一种按需选择的能力,不是数据库集邮。

章节练习

1
商品价格已经在业务数据库更新,但搜索结果短暂显示旧价格。最合理的处理方式是什么?
2
为了让订单事件的重复投递不会造成重复副作用,下面哪些做法有效?
3
采用服务拥有数据后,多个服务绝对不能部署在同一个数据库实例中。

再想一个开放题:某在线学习平台把学习进度、登录会话、课程全文搜索、视频文件和“同学也在学”推荐都放在一个关系型数据库里。请你为每类数据写下事实来源、主要访问方式、可接受延迟和故障降级,再决定是否需要拆分。

一种可行思路是:学习进度继续由支持事务和约束的业务数据库保存;登录会话可使用带过期时间的键值存储;课程搜索建立可重建的搜索索引;视频进入对象存储,数据库只留元数据;关系型推荐可在真实多跳查询出现瓶颈后考虑图数据库。最重要的是,搜索、推荐和缓存都不反向覆盖学习进度与课程事实,并为异步更新准备幂等、重试和对账。

小结:分工之后,更要知道谁说了算

多存储持久化解决的,不是“该学哪一种数据库”这样单独的产品问题,而是怎样让一个不断变化的系统保持清晰。我们从业务痛点出发,先分析访问模式和事务边界,再把购物车、商品文档、搜索、推荐、订单与对象文件交给不同的存储能力;接着用服务边界限制直接访问,用本地事务和待发事件保存事实,用幂等、重试、补偿与对账让各个副本最终收敛。

你可以把整章压缩成四句话:先问业务怎样读写,再决定数据放哪里;每份关键数据只有一个事实来源;跨存储更新默认会失败和重复;多一种数据库,就多承担一份恢复与运维责任。

好的多存储架构不是让每个请求经过更多组件,而是让关键路径更短、事实边界更稳、失败后的恢复更可预测。只要一项新存储不能清楚改善某种访问模式,也不能说明它的所有权、同步方式和恢复办法,我们就先不急着引入它。

上一章数据库模式演进