本文聚焦系统架构师案例题中的数据库与缓存设计,重点说明关系型数据库设计、NoSQL 选型、分库分表、读写分离、缓存策略、缓存故障模式以及缓存与数据库一致性问题,并结合电商、支付、内容平台、搜索系统、推荐系统等真实软件行业场景说明这些技术为什么会出现、各自解决什么问题、工程上该如何取舍。
阅读时可以按三个层次把握:先理解数据为什么会成为瓶颈,再理解数据库和缓存分别解决哪一类问题,最后把题干中的业务信号翻译成卷面表达。
一、先建立整体认识
数据库与缓存设计的核心,不是"会不会背名词",而是看清系统到底在为什么付成本。
一个系统最早往往只有一套关系数据库,数据正确、事务清晰、开发也直接。但随着业务增长,问题会逐步出现:
- 读请求越来越多,数据库开始扛不住查询压力
- 写请求越来越集中,单库容量和吞吐接近上限
- 表越来越大,索引越来越重,慢 SQL 和锁冲突开始频繁出现
- 页面越来越复杂,一个接口需要拼很多表,查询响应时间持续升高
- 业务变化越来越快,某些数据结构已经不适合硬塞进固定表结构
所以,数据库与缓存设计本质上是在解决五类问题:
- 如何在数据正确和查询效率之间平衡
- 如何根据数据特点选择合适的存储模型
- 如何把单库单表的容量和性能瓶颈拆开
- 如何用缓存扛住热点流量
- 如何在引入缓存之后控制数据不一致和故障扩散
二、关系型数据库设计
1. 为什么要规范化
怎么理解: 规范化的目标不是为了考试好记,而是为了让数据结构更稳定、冗余更少、更新更不容易出错。
最典型的问题是:如果一份用户信息被冗余在订单表、地址表、营销表很多地方,那么用户改一次手机号,就可能有多处要一起改;如果漏改一处,系统就会出现"同一个用户在不同页面看到不同手机号"的异常。这就是典型的数据冗余和更新异常。
常见范式可以这样记:
| 范式 | 重点要求 | 直观理解 |
|---|---|---|
| 1NF | 字段原子化 | 一个字段里不要再塞列表、对象、组合值 |
| 2NF | 消除部分依赖 | 非主属性不能只依赖联合主键的一部分 |
| 3NF | 消除传递依赖 | 非主属性不要通过别的非主属性间接依赖主键 |
| BCNF | 决定因素都是候选键 | 比 3NF 更严格,进一步消除异常 |
行业里的理解方式: 用户、订单、商品、支付这些核心业务表之所以要认真建模,就是因为它们承载的是长期稳定的数据边界。关系库在这里最大的价值,是结构清晰、约束明确、事务能力强。
2. 为什么又会反规范化
如果规范化一直做到极致,查询就会越来越依赖多表关联。对高并发系统来说,这会带来新的问题:读链路变长、SQL 更复杂、索引更难维护、接口响应更慢。
所以工程上经常会走到第二步:在核心数据仍保持规范化的前提下,对读路径做反规范化。
怎么理解: 反规范化不是"设计变差了",而是在承认一个现实:很多业务更怕查询慢,而不是多存一份可控冗余。
常见做法有:
| 手段 | 在行业里的典型表现 |
|---|---|
| 冗余列 | 订单表直接冗余用户名、商品快照、收货地址快照 |
| 派生列 | 订单总金额、评论数、点赞数直接存结果,不每次实时聚合 |
| 预聚合表 | 报表系统提前汇总日活、GMV、转化率 |
| 拆大字段 | 把正文、图片、扩展 JSON 从主表拆到扩展表 |
什么时候适合做: 读多写少、查询性能敏感、页面拼装复杂的场景最常见。例如电商订单详情页、内容平台文章页、BI 看板、运营后台统计页。
代价是什么: 一旦引入冗余,就必须接受同步更新、补偿修复和一致性校验的成本。所以规范化解决"数据对不对",反规范化解决"数据查得快不快"。
三、NoSQL 与搜索系统
1. 为什么关系数据库不够用
很多人第一次接触 NoSQL,会误以为它是在"替代 MySQL"。其实更准确的理解是:NoSQL 是在补关系库不擅长的那一部分能力。
关系型数据库最擅长的是:
- 结构清晰的数据建模
- 事务一致性
- 复杂条件查询
- 强约束和强关系
但它不一定擅长:
- 极高并发热点访问
- 灵活多变的半结构化数据
- 海量时序写入
- 复杂图关系遍历
- 全文搜索和多维检索
所以 NoSQL 的本质,不是"更先进",而是"更偏科"。选型时要先看你的问题是什么。
2. 常见类型怎么选
| 类型 | 解决什么问题 | 代表产品 | 软件行业里的典型场景 |
|---|---|---|---|
| 键值型 | 极快读写、简单查找 | Redis、Memcached | 缓存、Session、验证码、排行榜 |
| 文档型 | 结构灵活、对象聚合存储 | MongoDB、CouchDB | 内容管理、画像标签、表单系统 |
| 列族型 | 海量写入、宽表、时序/日志类数据 | HBase、Cassandra | 埋点、监控、日志、设备上报 |
| 图数据库 | 关系遍历和路径查询 | Neo4j、JanusGraph | 社交关系、风控关联、知识图谱 |
| 搜索引擎 | 全文搜索、多字段检索、排序聚合 | Elasticsearch | 商品搜索、文章搜索、日志检索 |
怎么考: 题干如果强调的是"关系复杂、需要路径查询",优先想到图数据库;如果强调"全文检索、分词、高亮、相关度排序",优先想到搜索引擎;如果强调"高频读写、热点缓存、排行榜",优先想到 Redis。
3. MongoDB 为什么常见
MongoDB 适合的不是"所有数据",而是结构经常变化、对象聚合明显、字段不固定的数据。
比如内容平台的文章文档、低代码平台的动态表单、用户画像标签、地图 POI 信息,都很适合文档模型。因为这些数据天然就是"一个对象一坨信息",硬拆成很多关系表反而让开发和查询都变复杂。
它常被看中的能力包括:
- 文档模型天然贴近 JSON 接口
- schema 相对灵活,适合快速迭代
- 支持分片,水平扩展能力较好
- 聚合能力和地理空间查询能力较强
在地理空间场景里,MongoDB 支持 GeoJSON 和 2dsphere 索引,这也是它常出现在地图、门店、轨迹类系统中的原因。
4. Redis 为什么不只是缓存
Redis 经常被简单描述成"缓存",但它真正强的地方在于:内存级性能 + 多种数据结构 + 简单直接的操作语义。
常见数据结构可以这样记:
| 结构 | 最常见用途 |
|---|---|
| String | 缓存值、计数器、分布式锁 |
| Hash | 用户属性、对象字段缓存 |
| List | 最新列表、简单队列 |
| Set | 去重、标签集合、共同关注 |
| ZSet | 排行榜、优先队列、延迟任务 |
为什么 ZSet 很常考: 因为它同时有"集合"和"排序"两种能力。像积分榜、热搜榜、直播间贡献榜,本质上都很适合用 member + score 表示。
常见命令通常围绕这几个点考:
ZADD:写入成员和分数ZRANGE/ZREVRANGE:按排序取区间ZSCORE:查单个成员分数ZRANK/ZREVRANK:查排名ZCOUNT:统计区间数量
四、分库分表与读写分离
1. 为什么数据库会走到拆分
数据库拆分不是"上来就该做"的架构动作,而是当单库单表真的扛不住时才有意义。
常见触发点有:
- 单表数据量太大,索引和查询明显变慢
- 单库磁盘、CPU、IO 接近瓶颈
- 某些热点业务流量远高于其他业务
- 不同业务模块的生命周期和扩容需求完全不同
2. 分库分表到底在拆什么
水平拆分 是按行拆,把不同记录分散到不同库或表;垂直拆分 是按业务边界或字段类型拆,把不同功能的数据分开放。
常见分片策略可以这样理解:
| 策略 | 适合怎么理解 | 主要问题 |
|---|---|---|
| 范围分片 | 按 ID、时间区间分布 | 容易形成热点,如最新时间段写入过热 |
| 哈希分片 | 尽量打散,追求均匀 | 扩容时数据迁移成本高 |
| 一致性哈希 | 为了降低节点增减时迁移范围 | 设计和实现更复杂 |
行业里的例子:
- 订单表按用户 ID 或订单 ID 分片,是典型水平分片
- 用户库、订单库、支付库分开,是典型垂直分库
- 日志表按天或按月分表,是时间维度分片的常见形态
怎么考: 题干如果强调"单表过大、单机容量不足、热点明显、业务边界清晰",通常就是在考分片思路。
3. 读写分离解决什么问题
如果系统的主要压力来自读多写少,最直接的办法不一定是分片,而是先做读写分离。
怎么理解: 主库负责写,从库负责读。这样写路径仍然集中控制,读路径则可以横向扩展。
它常见于:
- 商品详情、文章详情、配置查询这类高频读取场景
- 后台报表、运营查询、历史记录查询
- 读流量明显高于写流量的业务
真正要注意的点:
- 主从复制存在延迟,刚写完马上读可能读到旧值
- 不是所有读都能走从库,强一致读通常还要回主库
- 应用层、中间件层都需要处理读写路由
所以读写分离提升的是吞吐,但它不会自动解决一致性问题。
五、缓存设计
1. 为什么系统离不开缓存
缓存的本质,不是"让系统更高级",而是把那些重复、高频、热点的读请求挡在数据库前面。
最常见的行业场景包括:
- 商品详情页、文章详情页反复访问同一份数据
- 首页推荐、热榜、用户画像反复被读取
- 秒杀、大促、热点活动在极短时间内打爆某些 key
如果这些请求全都落到数据库,数据库很快就会成为瓶颈。所以缓存设计的核心问题是:哪些数据值得缓存、缓存多久、更新时怎么处理、故障时怎么兜底。
2. 常见缓存策略怎么理解
2.1 Cache-Aside:最常见,也最像真实工程
这几乎是大多数业务系统的默认方案。
读流程:
- 先查缓存
- 命中直接返回
- 未命中则查数据库
- 把结果写回缓存
写流程:
- 先更新数据库
- 再删除缓存
为什么不是"先更新数据库再更新缓存": 因为缓存不是数据的最终来源,它只是数据库前面的一层加速层。看起来"更新数据库后顺手更新缓存"很自然,但在并发写的情况下,较早请求的旧值有可能在后面又把缓存覆盖掉,结果缓存里反而变成旧数据。相比之下,更新数据库后删除缓存更稳,因为后续读请求会从数据库重新加载最新值。
为什么是"先更库,再删缓存": 如果先删缓存,数据库还没更新完时来了一个读请求,就会把旧数据重新写回缓存。这是缓存不一致最经典的来源之一。
2.2 Read-Through:把读回源交给缓存层
应用只跟缓存打交道,缓存自己决定未命中时如何加载数据库。它适合那些希望把缓存逻辑从业务代码里抽出去的场景,但实际业务里最常见的仍然是 Cache-Aside。
2.3 Write-Through:同步把缓存和数据库都写掉
它的好处是写完之后缓存和数据库天然更一致,缺点也很直接:写延迟会上升,因为数据库必须一起成功。
这类模式更适合对透明性要求高、写路径较可控的场景,不是大多数互联网业务的默认选项。
2.4 Write-Behind:先写缓存,稍后异步落库
这是典型的"用一致性换性能"方案。它适合那些极端追求写吞吐、能接受缓存层承担更多持久化责任的场景,但一旦缓存故障,数据丢失风险会更高。
3. 怎么选,怎么考
| 策略 | 一致性 | 性能 | 更像什么场景 |
|---|---|---|---|
| Cache-Aside | 最终一致 | 中 | 通用互联网业务 |
| Read-Through | 最终一致 | 中 | 希望统一缓存回源逻辑 |
| Write-Through | 更强一致 | 较低 | 写路径可控、强调同步落库 |
| Write-Behind | 弱一致 | 高 | 特定高吞吐写场景 |
考试里如果没有特别说明,优先写 Cache-Aside,因为它最符合真实系统的常见实现。
六、缓存的三类典型问题
缓存问题很容易混,真正好记的方法不是死背名词,而是看数据库被打穿的原因分别是什么。
1. 缓存穿透:查的是根本不存在的数据
怎么理解: 请求的 key 在缓存里没有,在数据库里也没有,于是每次都直接打到数据库。
这类问题常见于恶意攻击、错误参数、枚举式探测。比如不断请求不存在的商品 ID、用户 ID、订单号。
常见解决方法有:
| 方案 | 适合怎么理解 |
|---|---|
| 缓存空值 | 查不到也缓存一个短期空结果,避免反复打库 |
| 布隆过滤器 | 先做一次"可能存在性"判断,不可能存在的直接拦掉 |
| 参数校验 | 对非法 ID、非法格式、明显异常请求先拦截 |
布隆过滤器为什么常考: 因为它的特点很鲜明。它能高效判断"一个元素一定不存在",但不能保证"一定存在",会有误判。
2. 缓存击穿:查的是很热,但刚好过期的数据
怎么理解: 一个热点 key 平时都被缓存挡住了,但它一过期,瞬间大量并发一起打到数据库。
比如一个爆款商品详情、一个热搜词、一个首页配置 key,在某个时间点突然失效,数据库就会被这一个热点点位打爆。
常见方案有:
| 方案 | 适合怎么理解 |
|---|---|
| 互斥锁 | 只允许一个线程去回源加载,其他线程等待 |
| 逻辑过期 | 数据先返回旧值,后台异步刷新新值 |
| 热点 key 永不过期 | 对极热点数据人工管理生命周期 |
3. 缓存雪崩:不是一个点炸,是一大片一起炸
怎么理解: 大量 key 同时过期,或者整个缓存集群不可用,结果本该被缓存挡住的请求一起冲向数据库。
这比击穿更危险,因为击穿通常是一个热点 key,雪崩是整个缓存层失效。
常见方案有:
| 方案 | 适合怎么理解 |
|---|---|
| 过期时间加随机值 | 把集中失效打散 |
| 多级缓存 | 本地缓存 + Redis 缓存,降低同一层全部失效的冲击 |
| 缓存高可用 | 哨兵、Cluster、主从等,减少单点故障 |
| 限流降级 | 数据库前再做最后一层保护 |
| 缓存预热 | 系统上线前先把热点数据准备好 |
4. 三者怎么区分,怎么考
| 问题 | 核心特征 | 一句话区分 |
|---|---|---|
| 穿透 | 数据本来就不存在 | 缓存和数据库都没有 |
| 击穿 | 单个热点 key 失效 | 一个点被并发打爆 |
| 雪崩 | 大量 key 同时失效或缓存层故障 | 一大片流量同时压库 |
答题时最好先判断是哪一种,再写对应方案,避免把三类问题的答案混着写。
七、缓存与数据库一致性
1. 为什么这件事总会出问题
缓存和数据库是两个独立系统,对它们的更新不是一个原子操作。只要存在并发,只要有时间窗口,就可能出现短暂不一致。
最经典的冲突场景是:
- 线程 A 更新数据库
- 线程 A 删除缓存之前
- 线程 B 恰好读缓存未命中,于是回源数据库
- 如果 B 读到的是旧值,再把旧值写回缓存,就形成了脏缓存
所以缓存一致性问题的根源,不是某一行代码写错了,而是缓存和数据库天然是两个时钟、两套状态、两次操作。
2. 工程上通常怎么做
| 方案 | 核心思路 | 更适合什么场景 |
|---|---|---|
| 先更库再删缓存 | 最经典的 Cache-Aside 写法 | 通用业务系统 |
| 延迟双删 | 删除缓存两次,尽量覆盖并发窗口 | 能接受实现更复杂的场景 |
| MQ 异步删缓存 | 更新数据库后发消息删缓存,可重试 | 强调可靠删除 |
| Binlog 驱动刷新 | 监听数据库变更后同步缓存 | 统一化、平台化治理 |
| 读写锁/强约束 | 用更强同步手段换一致性 | 少量强一致场景 |
| Write-Through | 把写入统一放到缓存层 | 特定框架或特定存储模型 |
最常见的标准答案: 如果没有特别苛刻的强一致要求,通常优先采用 先更新数据库,再删除缓存。它不是绝对零不一致,而是在复杂度和效果之间非常均衡。
3. 软件行业里怎么取舍
- 商品详情、文章详情、用户主页这类读多写少场景,通常接受短暂不一致
- 库存、价格、账户余额这类对错误特别敏感的场景,会更谨慎,甚至降低缓存比例
- 平台化团队会更偏向 MQ、Binlog、统一缓存框架来收敛一致性治理
考试里如果问"为什么不能完全一致",答题重点要落在:性能、复杂度、并发窗口和业务容忍度之间的权衡。
八、答题模板
1. 数据库设计题
对于题目中的 [业务场景] ,如果重点是保证数据结构稳定、减少冗余和更新异常,应优先采用 规范化设计 ;如果重点是提升查询效率、减少关联查询成本,则可在核心数据正确的前提下进行 适度反规范化 ,例如 [冗余列/派生列/预聚合]。
2. NoSQL 选型题
该场景适合选用 [产品或类型] 。因为其主要业务特征是 [高并发/结构灵活/海量写入/复杂关系/全文检索] ,而 [产品能力] 恰好能够满足这一需求,具体表现为 [1~2 条能力]。
3. 缓存流程题
写 Cache-Aside 时,标准顺序通常是:
- 读先查缓存
- 未命中再查数据库
- 查询结果回填缓存
- 写时先更新数据库
- 再删除缓存
4. 缓存问题分析题
题目中的问题属于 [穿透/击穿/雪崩] 。其根本原因是 [不存在数据反复访问/热点 key 过期/大量 key 同时失效] 。可采用 [2~3 个方案] 进行治理,分别从 [拦截不存在请求/保护回源加载/避免大面积同时失效] 等方面降低数据库压力。
5. 一致性分析题
缓存与数据库不一致的根本原因在于两者属于独立存储,对数据库更新与缓存操作之间不存在原子性保证。在并发场景下,更新库和删缓存之间存在时间窗口,因此通常采用 先更新数据库,再删除缓存 作为折中方案,并结合 延迟双删、MQ 重试、Binlog 同步 等机制进一步降低不一致概率。