假设你正在维护一个电商系统。最初业务不复杂,用户、商品、购物车、订单和操作日志都放在同一个关系型数据库里。这样的设计很省心:一个连接池、一套备份方案、一种查询语言,出了问题也知道先看哪里。
可业务一旦长起来,麻烦就会接连出现。促销开始时,购物车的高频读写挤占数据库连接;运营人员搜索商品时,模糊查询拖慢订单接口;推荐任务做多层关联分析,又把 CPU 和磁盘忙得团团转。最让人难受的是,这些负载的脾气完全不同:订单怕写错,购物车怕响应慢,搜索怕查不到,推荐怕关系绕得太深。让一套数据库同时把它们都照顾好,就像让同一辆车既跑城市通勤、又拉重货、还参加越野赛,能开不等于每件事都做得合适。
这时候,多存储持久化就派上用场了。它也常被叫作“多模型持久化”或“混合持久化”,意思不是把 SQL 数据库全部换成 NoSQL,更不是看到一种新数据库就接进系统,而是先弄清每类数据怎样被读取、怎样被修改、允许多旧、失败后怎样恢复,再把它交给合适的存储引擎。

多存储持久化的核心不是数据库数量,而是职责边界。一个系统即使接入了五种数据库,只要同一份关键数据被随意写入、服务之间互相直连数据库,它依然没有得到清晰的架构;反过来,一个系统只用关系型数据库加缓存,却把事实来源、缓存副本和更新路径划分得很清楚,也已经在运用这套思维。
不少团队的选型顺序恰好反了:先听说某个数据库很快,再努力寻找可以使用它的地方。更稳妥的做法,是先把业务负载写成一张“工作说明书”。我们至少要回答下面五组问题。
先描述最常见的访问路径。数据主要按主键读取,还是经常做范围查询、全文检索、聚合统计或多跳关系遍历?数据库的优势通常只在特定访问方式下成立,“快”如果不带查询条件,几乎没有实际意义。
再划出真正的事务边界。哪些字段必须一起成功或一起失败?哪些结果可以晚几秒出现?例如订单金额和订单明细不能对不上,但推荐列表晚一点吸收新购买记录,通常不会造成财务错误。
接着看数据形状。它是稳定的表格、经常一起读取的嵌套对象、简单的键值、不断延伸的关系网络,还是需要分词和相关度排序的文本?数据模型越贴近访问方式,应用层需要做的拼装工作通常越少。
然后估算增长和生命周期。数据量多快翻倍,读写峰值有多高,冷热数据比例怎样,是否需要自动过期、归档或删除?一个只存三十分钟的会话,与必须保存多年的订单凭证,不该使用同一套容量思路。
最后讨论故障和团队能力。存储不可用时业务能否降级,数据能否重建,团队是否具备监控、备份、恢复和升级这套数据库的能力?技术上适合却没人能稳定运维的方案,仍然不是好方案。

下面这个小工具把选型讨论压缩成几种典型负载。你可以用键盘 Tab 键移动焦点,再按回车或空格切换场景。它给出的不是“标准答案”,而是一种练习:先说出为什么,再说出选什么。
不同类型的存储没有绝对高下,只有是否贴合当前负载。下面这张表更像一份边界清单:它既告诉你擅长什么,也提醒你别让它承担什么。
这张表故意没有写“每秒能处理多少请求”。脱离数据规模、索引、硬件、分片方式和查询形状谈绝对性能,往往会误导选型。真正有价值的数据来自你的访问日志、容量估算和压测结果。
让我们跟着用户小林走一遍。小林打开商品页,修改购物车,搜索“轻便降噪耳机”,查看相关推荐,最后提交订单。表面上这只是一次购物,底层却包含了几种完全不同的数据动作。

购物车通常按用户标识整份读取,操作集中在增加、减少和删除商品,并且长时间不活跃后可以自动清理。键值存储很适合这种模式。以 Redis 哈希为例,我们可以把一个用户的商品数量放在同一个键下:
HSET cart:user-123 sku-88 2
HINCRBY cart:user-123 sku-88 1
HGETALL cart:user-123
EXPIRE cart:user-123 2592000这里最重要的不是命令短,而是业务边界要清楚。购物车里的价格只适合展示估算,提交订单时必须回到商品与库存服务重新校验。如果缓存数据丢失,用户体验会受影响;如果订单金额写错,系统会出现真实损失。这两类风险不能因为它们都叫“数据”就用同一等级处理。
过期和淘汰不是一回事。过期是我们主动给数据设置生命周期,淘汰通常是内存压力下的空间策略。若某份数据只能依靠“希望它还没被淘汰”来维持业务正确性,这份数据就不该只存在缓存里。
服装有尺码和面料,手机有芯片和存储容量,书籍有作者和 ISBN。把所有属性都做成关系表列,会产生大量空值;把每种属性都拆成通用键值表,读取一个商品又需要拼装许多行。文档模型允许我们把经常一起展示的字段放在一起:
{
"productId": "sku-88",
"title": "轻便降噪耳机",
"category": "数码配件",
"sale": {
"status": "ON_SALE",
"displayPrice": 39900,
"currency": "CNY"
},
"attributes": {
"颜色": ["雾灰", "湖蓝"],
"续航": "32 小时",
"重量"
“字段灵活”不代表“团队不用约定”。productId、销售状态、价格单位和结构版本依然应该稳定,写入端也要校验类型。真正适合自由扩展的是不同品类的属性,而不是所有字段都想叫什么就叫什么。
文档内嵌还要考虑增长边界。商品的几个规格适合与主体放在一起,几十万条评价却不适合塞入同一文档。一个简单判断是:这些数据是否总被一起读取、一起修改、一起归档?如果子数据可以独立存在、数量会持续增长或更新节奏完全不同,就应该拆成独立集合,用标识建立关联。
用户搜索时需要分词、拼写容错、筛选、排序和高亮,这些操作适合搜索引擎。但商品搜索索引不该成为修改售价的入口。商品服务才拥有商品事实,搜索索引只是为了查询而加工出来的副本。
这意味着系统要接受一个现实:商品刚改名时,详情页可能已经显示新名称,搜索结果还短暂保留旧名称。我们可以缩短延迟、监控积压,却不能假装异步传播完全没有时间差。对于“上架后几秒可搜到”这样的产品规则,最好把它写成明确指标,而不是交给用户自己猜。
“买过这件商品的人还买了什么”关心的不是某一行记录,而是用户、商品和购买行为之间的连接。图数据库把实体建成节点,把购买、收藏和同类关系建成边,多跳探索会更自然。
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
不过,看到“有关联”就上图数据库也会过度设计。如果你只需要按商品分类读取一次,普通索引已经足够;只有当查询经常沿关系走多步、关系类型本身承载业务含义,而且现有连接查询确实成为瓶颈时,引入图数据库才更有说服力。
用户点击“提交订单”后,系统要把商品、成交价、数量和应付金额固定下来。这个动作有清晰的事务边界,不能出现订单主表成功而明细缺失,也不能让搜索索引是否成功决定订单是否成立。
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',
这段事务同时写入订单和“待发布事件”。只要事务提交成功,系统就同时拥有两个事实:订单已经建立,并且这件事还需要通知其他服务。即使消息系统短暂不可用,后台发布器也能稍后继续扫描待发布事件,不会陷入“订单成功了,但通知永远丢了”的缝隙。
当系统接入多种数据库后,一个常见坏味道是:订单服务查自己的关系库,推荐服务又直接查订单表,运营工具也绕过接口改订单状态。短期看少写了一层 API,长期却把所有服务绑在订单表结构上。谁想改字段,都得先召集一屋子人开会。
更清楚的边界是“服务拥有数据”。订单服务可以选择关系型数据库,购物车服务可以选择键值存储,推荐服务可以选择图数据库;外部调用者只通过接口或事件使用它们提供的能力,不直接连接它们内部的数据库。

这样的边界带来三个直接好处:
代价也很真实。以前一条跨表连接能解决的查询,现在可能需要调用多个服务;以前一个本地事务能覆盖的修改,现在可能变成一串异步步骤。因此,服务边界不应按数据库类型随意切割,而应围绕业务职责和一致性边界来划分。
“每个服务有自己的数据库”不等于“每个服务必须部署一套独占服务器”。真正要隔离的是所有权和访问边界。早期系统完全可以让多个服务共享同一数据库实例,但使用独立账户、独立 Schema 或独立表集,禁止越权直连;等负载和风险值得时,再做物理拆分。
对外接口可以保持业务语义,而不是暴露底层查询语言:
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,消费者处理前先检查自己是否见过它。因为在“至少投递一次”的链路中,同一条事件出现两次是正常情况;我们追求的不是消息绝不重复,而是重复到达也不会产生第二份业务副作用。
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.
这里用“按订单标识更新或插入关系”,而不是每收到一次就盲目新增一条边。去重记录和业务修改放在消费者自己的本地事务中,才能避免刚改完数据、还没登记处理状态就崩溃带来的重复副作用。
下面的模拟器把“订单成立”和“副本更新”拆开。你可以故意让推荐服务失败,再观察重试怎样让系统收敛。按钮可用键盘操作,状态变化会在下方日志中说明。
分布式流程没有一个能包住所有数据库的本地事务,失败处理就必须成为业务设计的一部分。几种机制解决的问题并不相同:

收到失败后先判断操作是否真的没有执行。超时只说明调用方没有及时收到结果,不代表服务端一定失败;因此先用业务标识查询状态,比立刻重复写入更稳妥。
对确定可以重试的操作,使用同一个请求标识再次提交,让服务端返回同一业务结果。不要每次重试都生成新订单号,否则“技术重试”会变成“业务重复”。
对无法继续完成的跨服务流程,执行事先设计好的补偿动作,并把补偿本身也记录成可追踪事件。补偿失败同样需要重试和告警。
最后由定期对账核对事实来源与各个派生存储。自动修复低风险差异,把资金、库存等高风险差异送入人工队列,并保留完整操作轨迹。
最终一致仍然要有可验证的终点。团队必须说清楚目标状态、允许延迟、失败后的负责人和用户看到的中间状态。例如订单已经创建但推荐尚未更新时,可以正常展示订单成功;支付结果未确认时,则应该显示“处理中”,而不是猜测成功或失败。
可以为每条异步链路定义四个指标:待处理事件数量、最老事件等待时间、处理失败率和对账差异数量。只看消息队列有没有运行远远不够,因为队列健康并不代表业务副本已经正确。
多存储方案很容易在白板上看起来漂亮,真正的风险藏在所有权、重试和恢复细节里。勾选下面的设计现状,工具会给出即时体检。它不会替你做架构评审,但能提醒我们把“数据库能用”继续追问到“系统能恢复”。
看到这里,你可能会担心:现有系统已经把所有数据放在一个数据库里,难道要停机重构吗?通常不需要。多存储迁移更适合按一条明确的访问路径逐步拆出,并且每一步都保留回退能力。
先画出现状数据流,找出压力最大又容易验证的一类读请求。商品搜索常常比订单写入更适合作为第一步,因为搜索索引属于可重建副本,即使迁移失败也能切回原查询。
明确原数据库仍是事实来源,新存储先只承担派生查询。为历史数据做一次可重复的全量回填,再用事件或变更捕获持续同步新增变化。
在不影响用户的情况下做影子读取:新旧查询同时运行,只把旧结果返回给用户,并记录结果差异、延迟和错误。差异不一定表示新系统错误,也可能暴露旧查询长期存在的问题,因此要按业务规则判断。
先把少量流量切到新路径,观察延迟、错误率、资源消耗和数据新鲜度。出现异常时能够用开关迅速切回,而不是临时发布一版代码救火。
这个过程刻意从“读副本”开始,因为它的失败代价更低。订单、库存和资金这类关键写路径要更谨慎,需要额外的双写验证、幂等、审计和回滚设计。迁移顺序应该由风险决定,而不是由哪个模块代码最少决定。
多存储把每类负载交给擅长的引擎,同时也增加了连接管理、权限、监控、备份、升级和成本治理。真正成熟的方案会把下面这些问题写进设计,而不是等上线后再补:
如果团队还没有能力回答这些问题,减少数据库种类往往比继续拆分更专业。架构的目标是降低整个系统的复杂度,而不是让架构图看起来更丰富。
性能取决于访问模式。键值读取很快,不代表它擅长复杂事务;图遍历自然,不代表它适合账务汇总。先测真实查询,再决定迁移对象。
队列只能运输消息。事件是否会丢、是否会重复、消费者是否幂等、失败怎样补偿、积压怎样告警,仍然需要应用层和运维流程共同解决。
搜索索引通常为了查询而反规范化,字段可能已经被分词、合并或裁剪。让它反向成为写入口,会形成两个事实来源。正确方向是业务服务接受修改,再重新生成搜索副本。
最终一致必须带目标时间和异常处理。可以说“商品改名后 10 秒内可搜索”,也可以说“推荐更新允许 5 分钟延迟”,但不能只说“以后会好”。
服务独立不要求技术栈不同。两个服务都用 PostgreSQL 完全没有问题,只要它们的数据所有权清楚、访问边界独立。多存储是一种按需选择的能力,不是数据库集邮。
再想一个开放题:某在线学习平台把学习进度、登录会话、课程全文搜索、视频文件和“同学也在学”推荐都放在一个关系型数据库里。请你为每类数据写下事实来源、主要访问方式、可接受延迟和故障降级,再决定是否需要拆分。
多存储持久化解决的,不是“该学哪一种数据库”这样单独的产品问题,而是怎样让一个不断变化的系统保持清晰。我们从业务痛点出发,先分析访问模式和事务边界,再把购物车、商品文档、搜索、推荐、订单与对象文件交给不同的存储能力;接着用服务边界限制直接访问,用本地事务和待发事件保存事实,用幂等、重试、补偿与对账让各个副本最终收敛。
你可以把整章压缩成四句话:先问业务怎样读写,再决定数据放哪里;每份关键数据只有一个事实来源;跨存储更新默认会失败和重复;多一种数据库,就多承担一份恢复与运维责任。
好的多存储架构不是让每个请求经过更多组件,而是让关键路径更短、事实边界更稳、失败后的恢复更可预测。只要一项新存储不能清楚改善某种访问模式,也不能说明它的所有权、同步方式和恢复办法,我们就先不急着引入它。
新路径稳定后,再移除旧查询和不再需要的同步逻辑。最后更新数据目录、值班手册和恢复方案,避免代码已经迁走,团队脑中的事实来源仍停留在旧架构。