PostgreSQL、Cassandra、Elasticsearch 简单对比
机器人云平台项目里同时用到了 PostgreSQL、Cassandra 和 Elasticsearch:PostgreSQL 保存设备、租户、项目、网络等资产信息;Cassandra 接住持续写入的 metrics;Elasticsearch 存设备与服务日志,提供按关键词、时间和字段定位问题的入口。它们表面上都在“存数据”,但真正回答的是三类不同的问题。
这很容易引出一个问题:为什么资产、指标和日志要分给三个数据库?不能用“哪个性能更高”来比较,因为它们优化的并不是同一件事:PostgreSQL 希望让互相有关联的业务事实保持正确;Cassandra 希望让已知访问路径上的海量读写持续可用;Elasticsearch 希望把文本和文档变成可搜索、可排序、可聚合的索引。
因此,选型的第一问不该是数据有多大,而是:一次请求究竟如何定位数据?它是否需要跨记录保持原子性?它是在按主键取值,还是在从大量文档中找“最相关”的答案?
先看结论:三种“数据是什么”的定义
| 系统 | 数据模型 | 最自然的查询 | 不该期待它擅长的事 |
|---|---|---|---|
| PostgreSQL | 有关系的行、表与约束;也可有 jsonb | SQL 条件、join、事务更新、精确聚合 | 无边界地横向扩展写入,或搜索引擎级的相关性检索 |
| Cassandra | 按 partition key 分布的宽表;一张表服务一类查询 | 已知分区键下的范围读取、持续高并发读写 | join、任意字段筛选、临时分析查询 |
| Elasticsearch | 带 mapping 的 JSON 文档及其索引 | 全文检索、过滤、相关性排序、聚合 | 复杂跨文档事务、强一致的业务账本 |
| 云平台数据 | 典型读取方式 | 选择 |
|---|---|---|
| 资产、设备关系、归属与生命周期 | 按多条件组合查找,跨表关联,变更必须原子提交 | PostgreSQL |
| 设备指标点、容量/性能采样 | 按设备或资源、指标名与时间范围连续写入和读取 | Cassandra |
| 设备日志、异常信息、堆栈与审计文本 | 按词搜索、按时间过滤、聚合错误类型并排序 | Elasticsearch |
三个系统都能存“用户、订单、日志”之类的数据,但被问的问题不同。例如“把订单金额从 A 转到 B,并且绝不能只扣不加”是事务问题;“取出某个设备过去一小时的状态”是按分区键读取的问题;“在一亿条日志里找语义相关的报错并按时间聚合”则是搜索问题。
真正的分水岭:B-tree、LSM Tree 与倒排索引
三者最有辨识度的差异不在 SQL、CQL 或 JSON API,而在“写进去的数据,之后靠什么结构被找到”。
| 引擎核心 | PostgreSQL | Cassandra | Elasticsearch |
|---|---|---|---|
| 主结构 | B-tree 等行式数据库索引,配合 heap tuple | Memtable + 不可变 SSTable 的 LSM Tree | Lucene segment 中的倒排索引 |
| 最擅长定位 | 某个 key、范围或排序区间对应的行 | 某个 partition key 对应的分区及其范围 | 某个 term 对应的文档集合 |
| 写入的主要代价 | WAL、MVCC 新版本、表与索引页更新 | 顺序追加、flush,之后的 compaction | 分词建索引、refresh,之后的 segment merge |
| 读路径的主要代价 | 遍历索引找到 tuple,再按快照判断可见版本 | 合并 memtable / 多个 SSTable 的结果 | 合并 posting list、过滤、评分与 shard 结果 |
| 最自然的业务 | 资产关系与事务 | 按资源与时间读取的 metrics | 日志全文检索与聚合 |
PostgreSQL: key / range ──> B-tree ──> heap 中可见的行版本
Cassandra: partition key ──> memtable + SSTable ──> compaction 后的分区数据
Elasticsearch: term ──> inverted index / posting list ──> 匹配文档与相关性分数
这也是为什么三者看起来都有“索引”,却不能互相替代:B-tree 的目标是高效维护可更新的业务行;LSM Tree 的目标是把写放在前台做成顺序追加,再把整理工作交给后台;倒排索引的目标则是从一个词迅速反查到大量文档。
PostgreSQL:先维护业务事实,再让 SQL 组合它们
PostgreSQL 的中心是关系模型。云平台中的资产不是一串孤立 JSON:一台设备属于哪个租户和项目、挂在哪个网络、现在是什么状态、某次变更由谁发起,这些事实互相引用,也要一起演进。把它们拆成表,用主键、外键、唯一约束和检查约束描述规则;查询时再用 SQL 的 join、过滤和聚合把它们组合回来。这个模型的价值不只是写法整齐,而是让数据库能够在事务中维护多个事实之间的一致性。
订单创建:写 orders
扣减库存:写 inventory
记录支付:写 payments
↓
三步要么一起提交,要么一起回滚
B-tree 负责定位,WAL + MVCC 负责让更新正确
从引擎看,PostgreSQL 不是“只有一棵 B-tree 的 SQL 数据库”,但 B-tree 的确是最常用的访问入口:它把有序 key 组织为平衡页树,适合等值查找、范围扫描和 ORDER BY。例如按 asset_id 查设备、按创建时间翻页、按 (tenant_id, name) 做唯一约束,都是 B-tree 擅长的模式。

修改先受到 WAL 保护以应对崩溃恢复;表中保留 MVCC tuple 版本,让每个语句在事务快照中看到一致的数据;B-tree 等索引再把逻辑条件导向可能的行版本。普通读与写通常无需彼此阻塞,代价是更新会产生旧版本,需要由 vacuum 回收。它很适合 OLTP,但也意味着高频更新、长事务和索引设计会直接影响表膨胀与维护成本。
关系表不是“只能放固定列”
PostgreSQL 有丰富的原生类型,也可以使用 jsonb 保存半结构化字段;配合 GIN 索引,常见的 key / key-value 包含查询可以加速。因此,业务早期遇到“字段仍在演化”的情况,并不必立刻换到文档库。
但 jsonb 的灵活不是免建模:真正高频过滤、排序、关联或约束的字段,通常仍应提升为明确列并选对索引。PostgreSQL 的 B-tree 适合相等、范围与排序;GIN 适合 jsonb、数组和全文 token;BRIN 对按物理顺序相关的大表字段(例如持续增长的时间)可能很省空间。索引越多,读取路径越多,但每次写入与更新也要维护更多结构。
PostgreSQL 也能做全文检索,tsvector + GIN 是成熟方案。若只是站内文章、后台管理或中等规模的检索,它常常足够;问题变成复杂的多字段相关性、同义词/分词调优、大规模聚合和独立搜索容量时,专用搜索引擎会更顺手。
Cassandra:metrics 先按资源和时间切开,再决定表如何长出来
Cassandra 的 CQL 看起来像 SQL,却不鼓励先画实体关系图、以后再任意 join。metrics 写入的特点是持续、时间有序、访问路径却相对稳定:例如“查看 device-42 的 CPU 使用率在某个时间范围内的曲线”。Cassandra 要求从这种已知查询出发建表:一类高频查询常对应一张表,必要时把同一份数据复制到多个表中。这种反规范化不是偷懒,而是为了让一次查询尽量落在少量、最好是一个 partition 上。
CREATE TABLE metrics_by_resource_metric_day (
resource_id text,
metric_name text,
day date,
occurred_at timestamp,
value double,
PRIMARY KEY ((resource_id, metric_name, day), occurred_at)
) WITH CLUSTERING ORDER BY (occurred_at DESC);
上例的 (resource_id, metric_name, day) 是 partition key:它决定数据落到哪个副本集合;occurred_at 是 clustering key:它决定同一分区内的有序布局。下图的 keyspace、partition key、clustering key 可以把这种层次关系直观地画出来:先定位分区,再在分区内按时间范围读取。

查“某设备某天最近的事件”很自然,查“所有设备中包含某个词的事件”则不是这张表的职责。时间桶 day 也不是语法装饰,而是防止单个热门设备的 partition 无限变大、形成热点。对 metrics 而言,时间桶大小、每个资源的写入频率和保留期应一起估算;否则一个“看起来合理”的分区键也会因热点资源而失效。
LSM Tree:把写入变成追加,再在后台整理
每个节点上的 Cassandra 写路径接近 LSM Tree:先追加 commit log,再写入内存 memtable,之后 flush 为不可变 SSTable;后台 compaction 合并多个 SSTable,处理更新、删除和 tombstone。下图把这条持久化路径放在一起:commit log 保证崩溃后的恢复,memtable 吸收热写入,SSTable 负责落盘,compaction 则在后台完成“合并”的账。

追加写避开了每次写入都先读旧页再原地修改的成本,因而适合持续、高吞吐的指标写入;代价是 compaction 会带来写放大,读路径也可能需要查看多个 SSTable,并依赖 Bloom filter、稀疏索引和压缩减少无效读取。




指标数据通常是“按时间追加、过期后不再查询”。TTL 配合按时间窗口组织的 compaction,可以让完整过期的 SSTable 更容易被整体回收;但乱序写入会把新旧时间混进同一文件,破坏这种优势。现代 Cassandra 也提供更通用的 Unified Compaction Strategy;无论具体策略是什么,核心判断不变:compaction 是写入成本的一部分,不能只看前台写入 QPS 而忽略后台 I/O。
集群层面,partition key 经一致性哈希分布,副本散布到节点、机架和数据中心;任意副本可接受相应分区的写入。下图中的复制因子、token range 与 Last Write Wins,展示了每个分区为什么会落在多个副本上,以及冲突最终如何收敛。

读写的 consistency level 决定这次操作要等待多少副本确认:更低的级别更偏向延迟与可用性,更高的级别更偏向读到最新写入。它不是“天然最终一致”或“天然强一致”这么简单,而是将这部分取舍暴露给数据模型、复制因子和每次读写的配置。Gossip 负责传播成员与拓扑信息;节点短暂故障后,可借助 hinted handoff、read repair 和 repair 逐步恢复副本。

扩容也不是“加一台机器立即变快”:新节点先 bootstrap、接收属于其 token range 的数据,再逐渐承担读写。虚拟节点让每台机器承接多个较小的 token range,使负载与迁移更均匀。


这也解释了 Cassandra 的典型边界:它适合设备事件、用户行为、消息投递状态、按租户或用户键分区的巨大数据集;不适合把它当作可随意加 where 条件的关系库。ALLOW FILTERING、跨分区扫描和无界 partition 往往不是“再调一个参数”能解决的问题,而是在提醒 schema 没有从查询出发。
Elasticsearch:用倒排索引把设备日志拆成可发现的词项
设备日志与 metrics 的差别在于:它们同样有时间,却包含大量自由文本。排障时常见的问题不是“设备 42 在 10:00 的 CPU 值”,而是“过去半小时哪些设备出现 connection refused,集中在哪个服务版本、哪个机架”。这要求既能搜索消息和堆栈,又能按设备 ID、日志级别、时间和版本精确过滤并聚合。
倒排索引负责“从词找文档”,而不是“从主键找一行”
Elasticsearch 面向 JSON 文档,但核心不是“把 JSON 存起来”,而是为不同字段建立不同的索引。message、异常堆栈等 text 字段会经历 analyzer:字符过滤、分词、大小写归一化、词干或同义词处理,再写入倒排索引;device_id、level、service_version 等 keyword 字段保留整体值,适合精确过滤、聚合和排序;@timestamp 则是 date 字段,用来限制时间范围。
message = "Connection refused from payment service"
↓ analyzer
[connection, refused, payment, service]
↓ inverted index
connection → [doc 7, doc 42, ...]
refused → [doc 7, doc 18, ...]
查询“payment connection refused”时,查询语句也用相近规则分析,然后在倒排索引中合并 posting list,并按 BM25 等相关性模型评分。这和 PostgreSQL / Cassandra 的“按主键或条件找行”有本质不同:搜索引擎接受召回一批候选文档,再排序出更像用户想要的结果。日志的 device_id、level、@timestamp 同时作为过滤条件,可以把“像什么”与“发生在哪里、什么时候”结合起来。
Segment、refresh 与分片:为什么它是 near real-time
底层 Lucene 将索引组织成不可变 segment。写入先进入缓冲区,refresh 后生成可被搜索的新 segment;因此文档通常会在很短时间后可搜到,却不是每次写入立即对搜索可见。更新和删除也不是就地改写旧 segment,而是写入新版本、标记旧版本,随后由 merge 回收。这与 Cassandra 的 LSM 有相似的“不可变文件 + 后台合并”味道,但 Elasticsearch 合并的中心目标是维护倒排索引与搜索性能。
倒排索引解决“一个词出现在哪些文档”;聚合与排序反过来需要“一个文档在某字段上有什么值”。因此 Elasticsearch 还会为支持的字段构建列式的 doc values,让 level、device_id、@timestamp 这类字段能高效做 terms / date histogram 聚合与排序。这个双结构也说明了为什么 mapping 需要在写入前想清楚:把本应精确过滤的字段只映射成 text,或把所有字段都无差别索引,都会在查询能力与磁盘/写入成本上付出代价。
一个 Elasticsearch index 会切成 primary shard,并可有 replica shard。搜索通常向每个相关 shard 发起局部查询,再由协调节点合并候选结果、取回文档;聚合也是在 shard 局部计算后归并。分片让容量和吞吐可以横向扩展,但分片不是免费并行:过多小 shard 会带来更多元数据、文件句柄、查询协调和内存开销。
它最适合日志与可观测性检索、商品/内容搜索、知识库搜索、带筛选的文档发现和面向搜索的分析。它不应轻易成为订单、余额或权限的唯一事实来源:refresh 带来可见性延迟,mapping 与 analyzer 一旦进入生产后变更成本高,而跨文档事务也不是它的设计中心。
常见组合:一个系统存事实,另一个系统提供入口
这三者经常同时出现,而不是三选一:
PostgreSQL:资产、设备关系、租户与权限等强一致业务事实
│ (若需要按资产名称/描述搜索,可经 outbox / CDC 异步索引)
▼
Elasticsearch:可选的资产搜索索引
Cassandra:按资源、指标与时间分区的 metrics 持续写入
│
▼
监控查询与趋势展示(不要求 Elasticsearch 充当它的主存储)
设备与服务日志 ──────────────────────────────→ Elasticsearch:全文检索、过滤和聚合入口
关键是明确 source of truth。若 PostgreSQL 中的订单已提交、但异步索引尚未更新,搜索结果短暂滞后是可以设计与解释的;若反过来让搜索索引承担扣款结果的唯一事实来源,就把业务正确性押在了不适合它的语义上。跨系统同步还必须面对重复投递、乱序、失败重试与最终一致:outbox、幂等写入和可重放事件通常比“同步调用两个数据库”更可靠。
这三个系统的分工并不神秘:PostgreSQL 擅长让关联资产保持正确,Cassandra 擅长让按资源和时间的访问在规模下持续可用,Elasticsearch 擅长让人从大量日志中找到线索。把查询形状、正确性边界和运维成本说清楚,产品名往往就自己浮现出来了;而每多引入一个系统,也就多一份 schema、备份、权限、监控、迁移与数据一致性的责任。
References
Notion 相关零散笔记
- Cassandra
- elasticsearch
- DDIA
- InfluxDB