数据库选型这件事,做后端的人几乎躲不开。关系型数据库和非关系型数据库这两个词天天挂在嘴边,可真让人三句话说清楚"到底差在哪、什么时候该用哪个",多数人还是会卡壳,或者只能背出"一个用SQL、一个不用SQL"这种表层答案。我这些年参与过几个从零搭起的数据存储方案,也接手过几个前期选错、后期硬扛的项目,慢慢发现一个规律:选型出问题,往往不是因为技术不够新,而是因为一开始就没搞清楚数据本身长什么样、业务到底要什么保证。这篇内容就是把这件事从头捋一遍——关系型和非关系型的本质区别在哪、底层的存储和事务机制怎么运作、建模和索引怎么落地、容量怎么算、坑在哪。不管你是刚接触数据库的新手,还是已经写过几百条查询但没系统梳理过机制的老手,都能在里面找到能直接拿去用的东西。
1. 先把概念掰开:两种数据库的本质差异
1.1 关系型数据库的"关系",指的是表这个结构本身
很多人第一次听到"关系型数据库",下意识会理解成"表之间有关系、有外键"。这个理解不算错,但没抓到根上。"关系"这个词来自数学里的关系模型,落到工程实现上,它指的就是一张二维表:行是一条记录,列是一个属性,整张表就是一个"关系"。所以关系型数据库的核心特征不是外键,而是所有数据都必须装进预先定义好的、结构一致的表格里。
这个约束带来三个直接后果,也是它几十年不倒的根本原因。
第一是强 schema。建表那一刻,字段名、类型、长度、是否可空就定死了。插入一条数据,类型不对直接拒绝。这在早期看着像束缚,实际上是最便宜的数据质量防线——脏数据根本进不来,不需要写一堆校验逻辑在应用层兜底。
第二是SQL 作为统一入口。查询、聚合、连接、排序,全都用一种声明式语言表达。你告诉数据库"我要什么",优化器负责决定"怎么拿"。这意味着同一份数据,运营可以自己写查询拉报表,不用每次都找开发改代码。
第三是事务保证。转账、扣库存、下订单这类操作,要么全成功要么全失败,中间状态不能被别人看到。关系型数据库把这套保证做进了引擎层,应用代码不需要操心。
我用一个类比解释这三点的关系:关系型数据库像一家管理严格的仓库,货架编号固定、每类货只能放指定区域、出入库都要登记。前期建货架麻烦,但一旦建好,找货、盘点、对账都快,也不会出现"这箱货不知道该放哪"的情况。
1.2 非关系型数据库不是"没有关系",而是"不靠关系"
非关系型数据库这个叫法其实是个历史包袱。它对应的英文是 Not Only SQL,原意是"不只是 SQL",而不是"不要 SQL"。它真正放弃的是**"所有数据必须塞进同一张固定表格"**这个前提,换来的是更灵活的数据模型和更容易的水平扩展。
按数据模型分,常见的非关系型数据库有四类,各自解决的问题完全不同:
| 类型 | 数据组织方式 | 典型能力 | 适合的数据形态 |
|---|---|---|---|
| 键值型 | 一个 key 对应一个 value | 极高吞吐的读写、简单过期策略 | 会话、计数、缓存、配置 |
| 文档型 | 一个 key 对应一个结构化文档 | 嵌套结构、按字段建索引 | 商品详情、内容、日志、表单 |
| 宽列型 | 按行键分布、列可动态扩展 | 海量写入、按行键范围扫描 | 时序数据、消息、行为埋点 |
| 图型 | 节点与边 | 多跳关系遍历 | 社交关系、推荐路径、权限树 |
这四类的共同点是:不强制统一 schema、不强调多表连接、把水平扩展当成一等公民。代价也很明确——跨记录的强事务保证通常很弱,甚至完全没有;复杂关联查询要么做不了,要么得在应用层自己拼。
举个具体场景对比。同样存一个商品,关系型数据库会拆成商品主表、属性表、图片表、SKU 表,查询时用 JOIN 拼起来。文档型数据库会直接把整个商品塞成一个文档,属性、图片、SKU 全是嵌套结构,一次读取全拿到。前者改一个属性类型要改表结构,后者不同商品可以有完全不同的字段。但如果业务要问"全站有多少商品包含某个属性",关系型一条 SQL,文档型可能得全表扫或者额外维护索引。
1.3 一张对照表看清选型的底层逻辑
概念讲完,落到选型上,别背结论,看维度。下面这张表是我自己在做方案评审时常用的判断框架,左边的每一条都对应一个真实会卡住的问题:
| 判断维度 | 偏向关系型 | 偏非关系型 |
|---|---|---|
| 数据之间是否需要多表关联 | 强关联,靠 JOIN 解决 | 弱关联,数据尽量内聚在一个文档/一行里 |
| 是否需要跨记录事务 | 需要,且是硬性要求 | 可以接受最终一致或单记录原子 |
| 数据结构是否稳定 | 结构稳定,改动少 | 结构多变,字段经常增删 |
| 写入量级 | 单机可承载,靠纵向扩展 | 写入量极大,需要横向分摊 |
| 查询方式 | 多维组合条件、聚合统计 | 基本都是按主键或少量固定维度取 |
| 团队熟悉度 | 团队 SQL 功底扎实 | 团队有对应组件的运维经验 |
我最想强调的是最后一行。见过不止一个项目,业务量根本不大,但因为"非关系型更时髦"选了文档型数据库,结果团队没人会调它的索引和分片策略,一个慢查询查了三天,最后加机器硬扛。这不是技术选错,是人的因素没算进去。
反面例子也有。一个日均写入量在千万级的埋点系统,早期用关系型数据库,单表撑到几亿行,加索引加不动、删数据删不掉,最后迁移到宽列型才缓过来。那个场景里数据几乎没有关联查询需求,全是按时间范围扫,关系型的优势一点没用上,劣势全占了。
注意:选型的判断依据应该是"数据形态 + 访问模式 + 一致性要求",而不是"哪个更新"或者"哪个面试问得多"。先把这三件事写清楚,答案基本自己就浮出来了。
2. 核心机制拆解:数据怎么存、怎么查、怎么保证不出错
2.1 ACID 四条保证,每一条都在解决具体问题
关系型数据库最核心的卖点就是 ACID,但很多人只能背出四个单词。我按"如果不保证会出什么事"的角度重新讲一遍,这样记得住。
**原子性(Atomicity)**解决的是"做一半崩了怎么办"。转账场景里,A 账户扣了 100,B 账户还没加,进程挂了。没有原子性,这 100 就凭空消失了。实现上靠的是 undo 日志:每次修改前先把旧值记下来,回滚时按日志反向操作。
**一致性(Consistency)**是目的而非手段,指的是事务前后数据满足所有约束——余额不能为负、外键必须存在、唯一键不能重复。这条更多是应用和数据库共同保证的,数据库负责检查约束,业务语义的正确性还得自己把控。
**隔离性(Isolation)**解决的是"两个事务同时跑会不会互相看见中间状态"。这里有个绕不开的知识点:隔离级别不是越高越好,等级越高并发越差。四个标准级别对应的问题如下。
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 实现方式简述 |
|---|---|---|---|---|
| 读未提交 | 可能 | 可能 | 可能 | 基本不加读锁 |
| 读已提交 | 不可能 | 可能 | 可能 | 每条语句生成一次快照 |
| 可重复读 | 不可能 | 不可能 | 部分场景仍可能 | 事务开始时生成快照 |
| 串行化 | 不可能 | 不可能 | 不可能 | 加范围锁,串行执行 |
脏读是读到了别人还没提交的数据,那个数据随时可能被回滚掉,等于读了个假的。不可重复读是同一个事务里读两次,结果不一样,因为中间被别人改了。幻读是读两次,行数变了,中间被别人插了新行。三个问题严重程度依次递减,但都会让业务逻辑出错。
绝大多数项目用"读已提交"或"可重复读"就够了。真正需要串行化的场景非常少,因为串行化的吞吐下降很夸张。我的经验是:先在可重复读下跑,发现业务逻辑依赖"读到最新值"再往下调,而不是一上来就拉到最高级别图安心。
**持久性(Durability)**解决的是"提交成功之后断电了数据还在不在"。靠 redo 日志:修改先写日志并刷盘,再异步刷数据页。崩溃重启后按日志重放。这里有个实操中容易忽略的点——刷盘策略直接决定性能和安全的平衡。把每次提交都强制刷盘,最安全但最慢;放宽成批量刷盘,吞吐上去了,但意外掉电可能丢最后几百毫秒的数据。这个取舍必须提前和业务方对齐,不能等出事再讨论。
2.2 CAP 与最终一致:非关系型让掉的是什么
聊非关系型绕不开 CAP:一致性、可用性、分区容忍,三者最多同时满足两个。这句话被引用得太多,反而容易误解。实际工程里,分区容忍基本是必选项——网络抖动、机器故障是客观存在的,你没法选择"不要分区容忍"。所以真正的取舍发生在 C 和 A 之间:网络出问题时,是拒绝服务保证数据一致,还是继续服务但可能返回旧数据。
多数非关系型数据库选的是后者,也就是"最终一致"。这个词听着玄,说白了就是:允许一段时间内不同副本看到的数据不一样,但如果没有新的写入,最终它们会追平。
这个模型在什么场景下能接受?我拿两个例子对比。
点赞数、阅读量、商品浏览量这类计数,短暂显示旧值完全无所谓,用户刷新一下就对了。这类数据用最终一致,换来的是极高的写入吞吐和跨机房可用性。
库存扣减就不行。两个机房各扣一次,如果允许不一致,超卖就发生了。这类必须走强一致的路径,或者用某种集中式的分配机制。
有一个中间地带值得单独说:"读己之写"。用户改完昵称,跳转到个人主页,必须看到新昵称,否则会以为没保存成功。这个保证不需要全局强一致,只需要保证同一个用户的读请求路由到能读到最新写入的副本上。很多系统靠"写后一段时间内强制读主库"来实现,简单有效。
提示:把一个业务拆成"强一致部分"和"最终一致部分"再分别选存储,比给整个系统选一个数据库要靠谱得多。订单状态强一致,订单列表页的统计数字最终一致,这种混合方案在真实项目里非常常见。
2.3 存储结构差异:B+ 树与 LSM 树的取舍
这一层是很多教程跳过、但对理解性能表现极其关键的部分。
关系型数据库主流用的是B+ 树。特点是数据按主键有序组织在磁盘页里,每个页能放几百条记录,树高通常只有三到四层,所以按主键查一条记录只需要三次左右的磁盘 IO。范围查询更是它的强项——因为有序,扫一个区间就是顺序读。
代价在写入。插入一条新记录,可能要找到中间某个页、把页写满、触发页分裂、再调整父节点指针。这是典型的随机写。机械盘时代这个代价很大,固态盘普及后缓解了不少,但仍然是写性能的主要瓶颈之一。
非关系型里尤其是宽列型,大量使用LSM 树(日志结构合并树)。思路完全不同:写入先进内存表,内存表满了就顺序刷成一个不可变的有序文件,后台再定期把这些文件合并。所有写操作都变成了顺序追加,这是它写吞吐高的根本原因。
但 LSM 树有自己的代价,而且是三层递进的:
第一层是读放大。查一个 key,可能要依次检查内存表、最新的几个文件、较老的文件,最坏情况查很多次。所以它普遍靠布隆过滤器先做一次快速判断"这个文件里肯定没有",减少无效 IO。
第二层是写放大。同一个 key 在合并过程中会被反复读写多次。写入量越大、层数越多,放大倍数越高。有些实现里写放大能到十几倍,意味着写 1MB 数据实际产生十几 MB 的磁盘写入。
第三层是合并带来的抖动。后台合并任务吃掉大量 IO 和 CPU,如果配置不当,会周期性出现延迟尖刺,表现为"大部分时候很快,偶尔突然卡一下"。这个现象在监控上很典型,P99 延迟远高于 P50。
| 对比项 | B+ 树 | LSM 树 |
|---|---|---|
| 写入方式 | 随机写,可能页分裂 | 顺序追加写 |
| 点查性能 | 稳定,树高固定 | 依赖布隆过滤器和文件数量 |
| 范围扫描 | 天然有序,效率高 | 需要合并多个有序文件 |
| 空间放大 | 较低,有页内碎片 | 较高,旧版本数据延迟回收 |
| 典型调优点 | 缓冲池大小、页填充率 | 内存表大小、合并并发度、压缩策略 |
我的实际体会是:写多读少、按时间追加的数据,LSM 结构的存储优势明显;读多写少、查询模式复杂、需要多维条件的,B+ 树更省心。这个判断比"哪个数据库更先进"有用得多。
3. 实操过程:从建模到容量估算的完整落地
3.1 关系型建模:范式是起点,反范式是手段
建表这件事,新手最容易犯两个反向的错误:要么把所有字段堆进一张大表,要么过度拆表拆到查一条数据要 JOIN 七张表。我的做法是先按范式走一遍,再根据查询模式做有针对性的反范式。
先看一个订单场景的规范化建模:
CREATE TABLE order_main ( id BIGINT NOT NULL AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL, user_id BIGINT NOT NULL, total_amount DECIMAL(12,2) NOT NULL, status TINYINT NOT NULL DEFAULT 0, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), KEY idx_user_created (user_id, created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE order_item ( id BIGINT NOT NULL AUTO_INCREMENT, order_id BIGINT NOT NULL, sku_id BIGINT NOT NULL, quantity INT NOT NULL, unit_price DECIMAL(10,2) NOT NULL, PRIMARY KEY (id), KEY idx_order (order_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这里有几个细节值得说明,都是踩过坑之后才形成的习惯。
金额用 DECIMAL 不用 FLOAT。浮点数存金额会累积精度误差,0.1 + 0.2 不等于 0.3,对账时能差出几分钱,查起来极其痛苦。DECIMAL 是定点数,精度可控。
订单号设唯一索引而不是主键。主键用自增 BIGINT,保证插入是顺序的,避免随机主键导致的页分裂;订单号是业务标识,加唯一索引即可。如果反过来拿订单号当主键,插入会变成随机写,数据量大之后插入性能下降非常明显。
索引顺序按区分度从高到低排。(user_id, created_at)这个联合索引,先按用户过滤,再按时间排序,正好覆盖"查某用户最近的订单"这个最高频的查询。如果反过来写成(created_at, user_id),按用户查就完全用不上这个索引了。这就是最左前缀原则的实际含义:联合索引只能从最左边的列开始匹配。
什么时候该反范式?举一个真实例子:订单列表页需要显示商品名称。按范式应该 JOIN 商品表,但商品名称会变,改了之后历史订单显示的名称也跟着变,这在业务上往往是错的——用户希望看到下单那一刻的名称。这时候就该校验性地把商品名称冗余到order_item里。这不是为了性能牺牲规范,而是业务语义本来就要求快照。
判断标准我总结成一句话:冗余的字段,如果业务上需要"当时的值",就必须冗余;如果只是"为了少一次 JOIN",要慎重,因为多一份副本就多一处不一致的风险。
3.2 非关系型建模:把"怎么查"放在"怎么存"前面
非关系型的建模思路和关系型几乎是反的。关系型先想数据怎么规范地组织,再想怎么查;非关系型必须先想清楚查询模式,再倒推数据结构。因为它的存储结构一旦定了,临时改查询方式基本等于重建。
以文档型为例,同一个业务有两建模方式,选哪个取决于读取模式。
方式一,内嵌:
{ "orderNo": "A20240115001", "userId": 10086, "status": 1, "items": [ {"skuId": 301, "name": "商品甲", "qty": 2, "price": 39.90}, {"skuId": 302, "name": "商品乙", "qty": 1, "price": 128.00} ], "createdAt": "2024-01-15T10:23:41Z" }方式二,引用:
// 订单文档 {"orderNo": "A20240115001", "userId": 10086, "itemIds": [9001, 9002]} // 明细文档 {"id": 9001, "orderNo": "A20240115001", "skuId": 301, "qty": 2}选内嵌的判断标准是:子数据是否总是跟着父数据一起读、数量是否有边界、是否会单独查询。订单明细几乎总是跟着订单一起展示,一单最多几十条,有明确上限,所以内嵌合适,一次读全拿到,不用二次查询。反过来,如果明细会被单独按 SKU 统计,那内嵌就麻烦了,得扫所有订单文档。
有一个坑必须提前知道:文档有大小上限。主流实现的单文档上限普遍在十几 MB 级别。如果子数据数量没有上限(比如评论、消息),内嵌迟早会撞墙,而且撞墙的时候往往已经上线了。这种"一对多且多的一侧无上限"的场景,必须用引用。
键值型的建模更简单,但 key 的设计最考验功底。我一般按业务:对象类型:标识:字段这样的分段命名,好处是能按前缀批量扫描和批量清理:
# 用户会话 sess:u:10086:{token} # 商品库存计数 cnt:sku:301:stock # 限流计数器,按分钟分桶 rate:api:order:202401151023这种命名方式的额外价值在于可运维性。上线一段时间后想清理某类脏数据,SCAN前缀匹配就能定位,不用全库遍历。我在一个项目里就是因为早期 key 命名混乱(有的用下划线有的用冒号,有的带业务前缀有的不带),排查问题时只能一个个试,非常低效。
3.3 容量与并发估算:别凭感觉加机器
选型之后紧接着就是容量规划。这一步很多人跳过,结果上线两周磁盘满了。我按一个具体的埋点场景把算法走一遍。
假设业务日均产生 1000 万条行为记录,每条记录序列化后约 500 字节。
单日原始数据量 = 1000 万 × 500 字节 = 5 × 10⁹ 字节 ≈ 4.66 GB。
按保留 180 天算,原始数据 ≈ 838 GB。但这只是裸数据,实际还要加上三块:
- 索引开销:一般按数据量的 20% 到 50% 估,取 30% 则约 250 GB。
- 副本开销:三副本则整体乘以 3。
- 合并/压缩的临时空间:LSM 结构在合并期间会有额外占用,按 20% 预留。
合计 ≈ (838 + 250) × 3 × 1.2 ≈ 3.9 TB。这个数字和"800GB"差了近五倍,如果按裸数据采购磁盘,两个月就会告急。
再看写入并发。1000 万条/天,如果业务集中在 8 小时高峰,高峰占比按 70% 算:
平均峰值写入 QPS = 1000 万 × 0.7 ÷ (8 × 3600) ≈ 243 条/秒。
这个量级其实很温和,单机完全能扛。真正需要担心的是突发流量——比如某个活动瞬时涌入。所以容量规划要按峰值的 3 到 5 倍来配,也就是按 1000 QPS 设计。
连接数估算容易被忽略。假设单条查询平均耗时 5 毫秒,要达到 1000 QPS,按利特尔法则:
所需并发连接数 ≈ QPS × 平均耗时 = 1000 × 0.005 = 5 个。
这只是理论最小值,实际要乘上安全系数(一般取 3 到 5)应对长尾请求,也就是 15 到 25 个活跃连接。但如果查询里混着几百毫秒的慢查询,同样的 QPS 下连接需求会暴涨到几百,连接池瞬间打满——这就是为什么连接池打满的第一反应应该是查慢查询,而不是调大连接数。调大只是把问题往后推,慢查询照样拖垮数据库。
提示:所有容量估算都要按"峰值 + 安全系数"来,而不是按日均值。日均值是给报表看的,峰值才是决定你会不会被半夜叫起来的那个数。
4. 常见问题与排查技巧实录
4.1 关系型数据库的典型故障与处理思路
慢查询,八成是索引问题,但未必是"没建索引"。我遇到过的索引失效原因按频率排序如下,这张表可以直接拿去对照排查:
| 现象 | 可能原因 | 排查方式 |
|---|---|---|
| 走了全表扫但表上有索引 | 条件列上套了函数或运算 | 看执行计划里的 type 和 key |
| 联合索引只用上第一列 | 查询条件跳过了中间列 | 检查条件顺序是否符合最左前缀 |
| 隐式类型转换 | 字符串列传了数字,或反之 | 比对字段类型和参数类型 |
| 索引选择性差 | 列上只有几个不同值 | 算区分度,低于 10% 意义不大 |
| 范围查询后索引失效 | 范围条件之后的列无法再用于过滤 | 把等值条件放联合索引前面 |
其中"条件列上套函数"是最隐蔽的。比如WHERE DATE(created_at) = '2024-01-15',看着人畜无害,实际让created_at上的索引用不上。改成范围写法created_at >= '2024-01-15 00:00:00' AND created_at < '2024-01-16 00:00:00'就能命中索引。这类改动的收益通常是从秒级降到毫秒级。
深分页是另一个经典坑。LIMIT 1000000, 20这种写法,数据库要先把前一百万行读出来再丢掉,越往后越慢。解决办法是改成游标式分页:记住上一页最后一条的主键,下一页从那里开始。
-- 慢:偏移量越大越慢 SELECT * FROM order_main WHERE status = 1 ORDER BY id LIMIT 1000000, 20; -- 快:用游标定位,代价恒定 SELECT * FROM order_main WHERE status = 1 AND id > 1234567 ORDER BY id LIMIT 20;这个改法有个前提:排序字段必须能唯一定位。多字段排序时要写成(a, b) > (?, ?)的元组比较,否则会漏数据或重复。
死锁。两个事务互相持有对方需要的锁,形成环。日志里会明确告诉你哪两个事务、哪几条语句、锁了什么。处理死锁有两个层面:短期是识别出死锁后重试(多数业务可以安全重试),长期是统一加锁顺序——所有事务都按主键升序更新,环就形不成。我接手过一个批量更新任务,并发跑起来死锁率很高,改成按主键排序后再分批更新,死锁直接归零。
大事务。一个事务里更新几万行,会长时间持有大量锁、撑大回滚日志、主从延迟飙升。经验值是单事务影响行数控制在几千以内。批量操作要拆成多批,每批提交一次。
4.2 非关系型数据库的隐藏成本
写入热点。宽列型存储按行键分布数据,如果行键是单调递增的(比如时间戳前缀),所有新写入都会砸到同一台机器上,其他机器闲着。解决办法是在行键前加一个散列前缀,把写入打散,读取时按前缀枚举再聚合。
删除不等于释放。非关系型的删除通常是打个标记,数据要等后台合并时才真正回收。删掉 100GB 数据,磁盘占用可能一小时后才降下来,期间还可能有额外的 IO。如果磁盘已经快满,这个操作可能直接把系统拖死。我的做法是:大范围清理要限速,一批一批删,中间留间隔,同时盯着磁盘和延迟指标。
没有跨记录事务带来的隐性 bug。一个操作要改三个文档,第一个成功了,第二个失败,第三个没执行。代码里如果没有显式的补偿逻辑,数据就处于半完成状态。这类问题往往在量小的时候测不出来,量大了偶发,排查成本极高。应对方式有两种:一是把需要一起改的数据合并到一个文档里,用单文档原子性兜住;二是写好幂等的补偿任务定期对账。
缓存与存储不一致。这是混合架构里最高频的问题。我见过最多的错误顺序是"先删缓存再写库",中间窗口里读请求会把旧值重新写回缓存,导致长期不一致。稳妥的做法是"先写库、再删缓存",并且删除失败要重试。即便如此仍有极小概率不一致,所以关键业务必须配一个定期对账任务。
4.3 问题排查速查表
我把上面这些整理成一张排查顺序表,遇到问题时从上往下走,能省掉大量瞎猜的时间。
| 症状 | 第一检查项 | 第二检查项 | 第三检查项 |
|---|---|---|---|
| 接口整体变慢 | 数据库慢查询日志 | 连接池使用率 | 机器 IO 与 CPU |
| 偶发超时 | 慢查询长尾 | 后台合并/检查点任务 | 网络与中间件 |
| 写入失败率上升 | 磁盘剩余空间 | 主从同步延迟 | 唯一键冲突日志 |
| 数据不一致 | 事务边界是否合理 | 缓存更新顺序 | 是否有补偿任务缺失 |
| 从库延迟飙升 | 大事务/大批量写 | 从库单线程回放瓶颈 | 是否有全表更新语句 |
我自己最常用的一个方法:把问题按"持续性"和"偶发性"先分一类。持续性的一般是配置或索引问题,看监控曲线能定位;偶发性的多半和并发、后台任务、资源竞争有关,需要抓现场,比如临时打开详细日志或者抓一次堆栈。这两类的排查路径完全不同,先分类能少走一半弯路。
5. 选型决策与混合架构的落地方式
5.1 三个问题定方向,两个约束做收敛
选型讨论会上,最容易跑偏成"哪个技术更好"的空辩。我现在习惯先让所有人回答三个问题,答案写在白板上:
第一个问题:这份数据的正确性要求是什么级别?涉及钱、库存、额度、权限的,几乎没有商量余地,必须走支持强事务的路径。反过来,日志、埋点、内容浏览数、推荐结果,最终一致完全够用。这条一划,候选范围立刻缩小一半。
第二个问题:查询模式有几种?如果只有"按主键取"和一两种固定维度的范围查询,非关系型会很舒服。如果业务方随时会提"能不能按这三个条件组合查一下",那关系型的灵活查询能力就是刚需,强行用非关系型会陷入不断加索引、不断改数据结构的循环。
第三个问题:一年后的数据量级是多少?注意是"一年后"而不是"现在"。很多项目在数据量几百万时选型很随意,涨到几亿时才发现当初的选择根本扩展不了。按 3.3 节的方法算一遍,能避开大部分后期迁移的麻烦。
这三个问题答完,再用两个约束做收敛:团队会不会运维、出问题能不能兜住。技术方案再优雅,团队没人能定位故障,那就是个定时炸弹。
5.2 混合架构:让每种存储只做它最擅长的事
真实项目里,单一存储撑起全部需求的场景其实不多。更常见也更稳的做法是按数据特性分层,我把它叫"分层存储"思路。
一个典型的内容类系统大概是这样的结构:
| 数据类别 | 存储选择 | 理由 |
|---|---|---|
| 账户、订单、支付流水 | 关系型 | 强事务、多表关联、需要审计 |
| 商品/文章详情 | 文档型 | 结构不固定、整体读写、字段经常变 |
| 会话、验证码、热点计数 | 键值型 | 极高频读写、天然带过期 |
| 全文检索、多维筛选 | 检索引擎 | 倒排索引、相关性排序 |
| 行为埋点、时序指标 | 宽列/时序型 | 海量追加写、按时间范围扫 |
关键在于数据怎么在层之间流动,这里有三条经验。
第一,以关系型为事实来源,其他层都是派生。订单的最终状态只在关系型里,缓存和检索里的都是副本。这样即使副本全丢,也能从源头重建。
第二,同步走异步、带补偿。写库成功后发一条变更消息,下游各自消费更新自己的副本。消息可能丢,所以要配一个定时对账任务,扫最近一段时间的变更,把不一致的修回来。对账窗口一般取 1 小时到 1 天,看业务对延迟的容忍度。
第三,读路径要有降级。检索层挂了,至少还能按主键从关系型查出基本信息,页面不至于白屏。这个降级逻辑必须提前写好并演练过,不能等故障时临时加。
注意:混合架构的复杂度不是线性增长的。每多引入一种存储,就多一套监控、备份、扩容、故障预案。两三种存储的组合是合理的,五六种大概率是过度设计。判断标准很简单:新引入的存储解决的痛点,是否大于它带来的运维负担。
我参与过一个项目,早期为了"技术先进性"同时引入了四种存储,结果一个故障排查要跨四个系统看日志,定位时间从十分钟变成两小时。后来砍掉了两种,把功能合并回关系型和一种文档型,整体反而更稳。这件事给我的教训是:架构的复杂度要跟着团队规模走,而不是跟着技术潮流走。
最后分享一个我在实际项目里坚持的小习惯:无论选哪种数据库,上线前一定要做一次"故障演练"——手动停掉一个副本、手动塞一批超大查询、手动把磁盘填到 80%,看看系统的表现和告警是否正常。选型文档写得再漂亮,都不如一次真实的压力暴露来得直接。踩过几次坑之后我越来越确信,数据库这东西没有"最好的选择",只有"想清楚了代价的选择"。