分布式存储面试
分布式存储面试
缓存
扩展
【简单】什么是缓存?为什么需要缓存?⭐⭐
缓存就是数据交换的缓冲区,用于将频繁访问的数据暂存在访问速度快的存储介质。
缓存的本质是一种利用空间换时间的设计:牺牲一定的数据实时性,使得访问更快、更近:
- 将数据存储到读取速度更快的存储(设备);
- 将数据存储到离应用最近的位置;
- 将数据存储到离用户最近的位置。
缓存是用于存储数据的硬件或软件的组成部分,以使得后续更快访问相应的数据。缓存中的数据可能是提前计算好的结果、数据的副本等。典型的应用场景:有 cpu cache, 磁盘 cache 等。本文中提及到缓存主要是指互联网应用中所使用的缓存组件。
缓存命中率是缓存的重要度量指标,命中率越高越好。
缓存命中率 = 从缓存中读取次数 / 总读取次数【简单】何时需要缓存?⭐⭐
引入缓存,会增加系统的复杂度,并牺牲一定的数据实时性。所以,引入缓存前,需要先权衡是否值得,考量点如下:
- CPU 开销 - 如果应用某个计算需要消耗大量 CPU,可以考虑缓存其计算结果。典型场景:复杂的、频繁调用的正则计算;分布式计算中间状态等。
- IO 开销 - 如果数据库连接池比较繁忙,可以考虑缓存其查询结果。
在数据层引入缓存,有以下几个好处:
- 提升数据读取速度。
- 提升系统扩展能力,通过扩展缓存,提升系统承载能力。
- 降低存储成本,Cache+DB 的方式可以承担原有需要多台 DB 才能承担的请求量,节省机器成本。

【中等】缓存有哪些分类?⭐⭐
缓存从部署角度,可以分为客户端缓存和服务端缓存。
客户端缓存
- Http 缓存:HTTP/1.1 中的
Cache-Control、HTTP/1 中的Expires - 浏览器缓存:HTML5 提供的 SessionStorage 和 LocalStorage、Cookie
- APP 缓存:Android、IOS
服务端缓存
- CDN 缓存 - CDN 将数据缓存到离用户物理距离最近的服务器,使得用户可以就近获取请求内容。CDN 一般缓存静态资源文件(页面,脚本,图片,视频,文件等)。
- 反向代理缓存 - 反向代理(Reverse Proxy)方式是指以代理服务器来接受网络连接请求,然后将请求转发给内部网络上的服务器,并将从服务器上得到的结果返回给客户端,此时代理服务器对外就表现为一个反向代理服务器。反向代理缓存一般针对的是静态资源,而将动态资源请求转发到应用服务器处理。
- 数据库缓存 - 数据库(如 Mysql)自身一般也有缓存,但因为命中率和更新频率问题,不推荐使用。
- 进程内缓存 - 缓存应用字典等常用数据。
- 分布式缓存 - 缓存数据库中的热点数据。
其中,CDN 缓存、反向代理缓存、数据库缓存一般由专职人员维护(运维、DBA)。
后端开发一般聚焦于进程内缓存、分布式缓存。
【中等】什么是 CDN?CDN 的工作原理是什么?⭐⭐
CDN 是一种将内容缓存到离用户更近的节点的分布式网络系统。CDN 一般缓存静态资源文件(页面,脚本,图片,视频,文件等)。
国内网络异常复杂,跨运营商的网络访问会很慢。为了解决跨运营商或各地用户访问问题,可以在重要的城市,部署 CDN 应用。使用户就近获取所需内容,降低网络拥塞,提高用户访问响应速度和命中率。

CDN 原理
- 就近调度:用户被导向最近的 CDN 节点
- 缓存+回源:节点有内容直接返回(缓存命中);节点无内容则向源站请求并缓存(缓存未命中)
CDN 特点
优点:
- 缓存加速:提升访问速度,尤其是含有大量图片和静态页面站点
- 带宽优化:自动生成服务器的远程 Mirror(镜像)cache 服务器,远程用户访问时从 cache 服务器上读取数据,减少远程访问的带宽、分担网络流量、减轻原站点 WEB 服务器负载等功能。
- 集群抗攻击 - 广泛分布的 CDN 节点加上节点之间的智能冗余机制,可以有效地预防黑客入侵以及降低各种 D.D.o.S 攻击对网站的影响,同时保证较好的服务质量。
缺点:
- 不适宜缓存动态资源
- 解决方案:主要缓存静态资源,动态资源建立多级缓存或准实时同步;
- 存在数据的一致性问题
- 解决方案(主要是在性能和数据一致性二者间寻找一个平衡)
- 设置缓存失效时间(1 个小时,过期后同步数据)。
- 针对资源设置版本号。
【中等】反向代理缓存的工作原理是什么?⭐⭐
反向代理服务器部署在应用服务器前端,作为流量入口。既是反向代理(转发请求),也是缓存服务器(缓存响应)。
反向代理(Reverse Proxy)方式是指以代理服务器来接受网络连接请求,然后将请求转发给内部网络上的服务器,并将从服务器上得到的结果返回给客户端,此时代理服务器对外就表现为一个反向代理服务器。

反向代理位于应用服务器同一网络,处理所有对 WEB 服务器的请求。
反向代理缓存的原理:
- 如果用户请求的页面在代理服务器上有缓存的话,代理服务器直接将缓存内容发送给用户。
- 如果没有缓存则先向 WEB 服务器发出请求,取回数据,本地缓存后再发送给用户。
这种方式通过降低向 WEB 服务器的请求数,从而降低了 WEB 服务器的负载。
反向代理缓存一般针对的是静态资源,而将动态资源请求转发到应用服务器处理。常用的缓存应用服务器有 Varnish,Ngnix,Squid。
【中等】缓存有哪些淘汰算法?⭐⭐⭐
扩展
缓存算法设计思路
缓存一般存于访问速度较快的存储介质,快也就意味着资源昂贵并且有限。正所谓,好钢要用在刀刃上。因此,缓存要合理利用,需要设定一些机制,将一些访问频率偏低或过期的数据淘汰。
淘汰缓存首先要做的是,确定什么时候触发淘汰缓存,一般有以下几个思路:
- 基于空间 - 设置缓存空间大小。
- 基于容量 - 设置缓存存储记录数。
- 基于时间
- TTL(Time To Live,即存活期) - 缓存数据从创建到过期的时间。
- TTI(Time To Idle,即空闲期) - 缓存数据多久没被访问的时间。
主流缓存算法对比
接下来,就要确定如何淘汰缓存,常见的缓存淘汰算法有以下几个:
| 算法 | 淘汰策略 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| FIFO(先进先出) | 淘汰最先进入的数据(队列结构) | 实现简单 | 缓存命中率低(无视访问频率,可能淘汰热点数据) | 数据访问无规律,或实现简单的缓存系统 |
| LIFO(后进先出) | 淘汰最后进入的数据(栈结构) | 实现简单 | 缓存命中率低(无视访问频率,可能淘汰热点数据) | 特殊场景(如回退操作,新数据价值低) |
| MRU(最近最多使用) | 淘汰最近最多使用的数据 | 适合访问局部性强的场景(如用户浏览信息流,看过内容不再看) | 可能频繁淘汰缓存,降低命中率 | 数据访问模式具有“看后即弃”特性(如信息流推荐) |
| LRU(最近最少使用) | 淘汰最近最少被使用的数据(基于访问时间排序) | 避免 FIFO 问题,对热点数据友好,综合性能好 | 临界区问题(热点数据在统计窗口末期无访问会被误淘汰) | 通用场景(Web 缓存、数据库缓存等) |
| LFU(最近最少频率使用) | 淘汰使用频率最低的数据(额外记录访问频率) | 解决 LRU 临界区问题,对长期热点数据保护更好 | 空间开销大(需记录频率); 频率衰减问题(旧热点难以淘汰) | 热点数据稳定,访问模式变化慢(如视频热门榜) |
【困难】缓存更新有哪些策略?⭐⭐⭐

一般来说,系统如果不是严格要求缓存和数据库保持一致性的话,尽量不要将读请求和写请求串行化。串行化可以保证一定不会出现数据不一致的情况,但是它会导致系统的吞吐量大幅度下降。缓存更新的常见策略有以下几种:
- Cache Aside
- Wirte Through
- Read Though
- Wirte Behind
需要注意的是:以上几种缓存更新策略,都无法保证数据强一致。如果一定要保证强一致性,可以通过两阶段提交(2PC)或 Paxos 协议来实现。但是 2PC 太慢,而 Paxos 太复杂,所以如果不是非常重要的数据,不建议使用强一致性方案。
Cache Aside(旁路缓存)
Cache Aside 的思路是:先更新数据库,再删除缓存。具体来说:
失效:尝试读缓存,如果不命中,则读数据库,然后更新缓存。
命中:尝试读缓存,命中则直接返回数据。
更新:先更新数据库,再删除缓存。

为什么不能先更新数据库,再更新缓存?
多个并发的写操作可能导致脏数据:当有多个并发的写请求时,无法保证更新数据库的顺序和更新缓存的顺序一致,从而导致数据库和缓存数据不一致的问题。

说明:如上图的场景中,两个写线程由于执行顺序,导致数据库中 val = 2,而缓存中 val = 1,数据不一致。
为什么不能先删缓存,再更新数据库?
存在并发读请求和写请求时,可能导致脏数据。

说明:如上图的场景中,读线程和写线程并行执行,导致数据库中 val = 2,而缓存中 val = 1,数据不一致。
先更新数据库,再删除缓存就没问题了吗?
存在并发读请求和写请求时,可能导致脏数据。

上图中问题发生的概率非常低:因为通常数据库更新操作比内存操作耗时多出几个数量级,最后一步回写缓存速度非常快,通常会在更新数据库之前完成。所以 Cache Aside 模式选择先更新数据库,再删除缓存,而不是先删缓存,再更新数据库。
不过,如果真的出现了这种场景,为了避免缓存中一直保留着脏数据,可以为缓存设置过期时间,过期后缓存自动失效。通常,业务系统中允许少量数据短时间出现不一致的情况。
Read/Write Through(读写穿透)

Read Through 的思路是:查询时更新缓存。当缓存失效时,缓存服务自己进行加载。
Write Through 的思路是:当数据更新时,缓存服务负责更新缓存。
Read/Write Through vs. Cache Aside
- Cache Aside 模式中,应用需要维护两个数据源头:一个是缓存,一个是数据库。
- Read-Through 模式中,应用无需管理缓存和数据库,只需要将数据库的同步委托给缓存服务即可。
Write behind(异步回写)
Write Behind 又叫 Write Back。Write Behind 的思路是:应用更新数据时,只更新缓存, 缓存服务每隔一段时间将缓存数据批量更新到数据库中,即延迟写入。这个设计的好处就是让提高 I/O 效率,因为异步,Write Behind 还可以合并对同一个数据的多次操作,所以性能的提高是相当可观的。
Refresh Ahead(预刷新)
- 原理:缓存过期前主动刷新,避免用户等待
- 优点:减少延迟,体验更平滑
- 缺点:可能浪费资源(预刷新未访问的数据)
【困难】多级缓存架构如何设计?⭐
一般来说,多级缓存架构使用二级缓存已可以满足大部分业务需求,过多的分级会增加系统的复杂度以及维护的成本。因此,多级缓存不是分级越多越好,需要根据实际情况进行权衡。
一个典型的二级缓存架构,可以使用进程内缓存(如: Caffeine/Google Guava/Ehcache/HashMap)作为一级缓存;使用分布式缓存(如:Redis/Memcached)作为二级缓存。
多级缓存查询

多级缓存查询流程如下:
- 首先,查询 L1 缓存,如果缓存命中,直接返回结果;如果没有命中,执行下一步。
- 接下来,查询 L2 缓存,如果缓存命中,直接返回结果并回填 L1 缓存;如果没有命中,执行下一步。
- 最后,查询数据库,返回结果并依次回填 L2 缓存、L1 缓存。
多级缓存更新
对于 L1 缓存,如果有数据更新,只能删除并更新所在机器上的缓存,其他机器只能通过超时机制来刷新缓存。超时设定可以有两种策略:
- 设置成写入后多少时间后过期
- 设置成写入后多少时间刷新
对于 L2 缓存,如果有数据更新,其他机器立马可见。但是,也必须要设置超时时间,其时间应该比 L1 缓存的有效时间长。
为了解决进程内缓存不一致的问题,设计可以进一步优化:

通过消息队列的发布、订阅机制,可以通知其他应用节点对进程内缓存进行更新。使用这种方案,即使消息队列服务挂了或不可靠,由于先执行了数据库更新,但进程内缓存过期,刷新缓存时,也能保证数据的最终一致性。
【中等】什么是缓存雪崩?如何应对?⭐⭐⭐
“缓存雪崩”是指,缓存不可用或者大量缓存由于超时时间相同在同一时间段失效,大量请求直接访问数据库,数据库压力过大导致系统雪崩。
举例来说,对于系统 A,假设每天高峰期每秒 5000 个请求,本来缓存在高峰期可以扛住每秒 4000 个请求,但是缓存机器意外发生了全盘宕机。缓存挂了,此时 1 秒 5000 个请求全部落数据库,数据库必然扛不住,它会报一下警,然后就挂了。此时,如果没有采用什么特别的方案来处理这个故障,DBA 很着急,重启数据库,但是数据库立马又被新的流量给打死了。
解决缓存雪崩的主要手段如下:
- 增加缓存系统可用性(事前)。例如:部署 Redis Cluster(主从+哨兵),以实现 Redis 的高可用,避免全盘崩溃。
- 采用多级缓存方案(事中)。例如:本地缓存(Ehcache/Caffine/Guava Cache) + 分布式缓存(Redis/ Memcached)。
- 限流、降级、熔断方案(事中),避免被流量打死。如:使用 Hystrix 进行熔断、降级。
- 缓存如果支持持久化,可以在恢复工作后恢复数据(事后)。如:Redis 支持持久化,一旦重启,自动从磁盘上加载数据,快速恢复缓存数据。
上面的解决方案简单来说,就是多级缓存方案。系统收到一个查询请求,先查本地缓存,再查分布式缓存,最后查数据库,只要命中,立即返回。
解决缓存雪崩的辅助手段如下:
- 监控缓存,弹性扩容。
- 缓存的过期时间可以取个随机值。这么做是为避免缓存同时失效,使得数据库 IO 骤升。比如:以前是设置 10 分钟的超时时间,那每个 Key 都可以随机 8-13 分钟过期,尽量让不同 Key 的过期时间不同。
【中等】什么是缓存穿透?如何应对?⭐⭐⭐
“缓存穿透”是指,查询的数据在数据库中不存在,那么缓存中自然也不存在。所以,应用在缓存中查不到,则会去查询数据库,当这样的请求多了后,数据库的压力就会增大。
解决缓存穿透,一般有两种方法:
(一)缓存空值
对于返回为 NULL 的依然缓存,对于抛出异常的返回不进行缓存。

采用这种手段的会增加我们缓存的维护成本,需要在插入缓存的时候删除这个空缓存,当然我们可以通过设置较短的超时时间来解决这个问题。
(二)过滤不可能存在的数据

制定一些规则过滤一些不可能存在的数据。可以使用布隆过滤器(针对二进制操作的数据结构,所以性能高),比如你的订单 ID 明显是在一个范围 1-1000,如果不是 1-1000 之内的数据那其实可以直接给过滤掉。
针对于一些恶意攻击,攻击带过来的大量 key 是不存在的,那么我们采用第一种方案就会缓存大量不存在 key 的数据。
此时我们采用第一种方案就不合适了,我们完全可以先对使用第二种方案进行过滤掉这些 key。
针对这种 key 异常多、请求重复率比较低的数据,我们就没有必要进行缓存,使用第二种方案直接过滤掉。
而对于空数据的 key 有限的,重复率比较高的,我们则可以采用第一种方式进行缓存。
【中等】什么是缓存击穿?如何应对?⭐⭐⭐
“缓存击穿”是指,热点缓存数据失效瞬间,大量请求直接访问数据库。例如,某些 key 是热点数据,访问非常频繁。如果某个 key 失效的瞬间,大量的请求过来,缓存未命中,然后去数据库访问,此时数据库访问量会急剧增加。
为了避免这个问题,我们可以采取下面的两个手段:
- 分布式锁 - 锁住热点数据的 key,避免大量线程同时访问同一个 key。
- 定时异步刷新 - 可以对部分数据采取失效前自动刷新的策略,而不是到期自动淘汰。淘汰其实也是为了数据的时效性,所以采用自动刷新也可以。
【中等】如何保证 Redis 中始终是热点数据?⭐⭐
淘汰策略 + 读时缓存 + 过期时间 + 预热 + 监控,让 Redis 自动保留热点数据,冷数据自然淘汰。
选择合适的内存淘汰策略
Redis 提供多种内存淘汰策略,其中最适合热点数据的是基于访问频率或最近访问时间的策略:
- allkeys-lru:从所有键中淘汰最近最少使用的数据。
- volatile-lru:从设置了过期时间的键中淘汰最近最少使用的数据。
- allkeys-lfu(Redis 4.0+):从所有键中淘汰最不经常使用的数据,更精确地统计访问频率。
- volatile-lfu:从设置了过期时间的键中淘汰最不经常使用的数据。
推荐使用 allkeys-lfu 或 allkeys-lru,让 Redis 自主淘汰冷数据,保留热点。
采用 Cache-Aside 模式(读时缓存)
- 查询流程:先读 Redis,命中则直接返回;未命中则查 MySQL,将结果写入 Redis(并设置合理的过期时间),再返回。
- 这种模式自然地将被访问的数据加入缓存,未被访问的数据不会进入 Redis。结合淘汰策略,长时间未被访问的数据会被逐步淘汰,留下的始终是最近/最常访问的数据。
差异化过期时间
- 对于已知的热点类别(如爆款商品、热门用户),可以设置较长的过期时间(如 1 天)。
- 对于普通数据,设置较短的过期时间(如 30 分钟),让冷数据快速失效,减少内存占用。
- 过期时间应结合业务访问规律动态调整,避免集中失效导致缓存雪崩。
预加载(预热)
- 通过离线分析 MySQL 访问日志(如慢查询日志、业务统计),统计出高频访问的数据列表。
- 在系统低峰期或启动时,将这些数据提前加载到 Redis 中,并设置合适的过期时间。
- 预热可以确保核心热点从一开始就存在于缓存中,避免首次访问时的穿透。
监控与动态调整
- 监控 Redis 的内存使用、命中率、淘汰键数量等指标。
- 如果命中率持续偏低,说明缓存内容与热点不匹配,可能需要调整淘汰策略、过期时间或增加内存。
- 可以记录被淘汰的 key,分析是否为误淘汰,从而优化策略。
【困难】Redis 集群模式的工作原理是什么?⭐⭐
Redis Cluster 通过虚拟槽(slot)分区、Gossip 协议通信、主从复制容灾,实现去中心化的数据分片与高可用。
数据分片:16384 个虚拟槽
Redis Cluster 将整个键空间划分为 16384 个 slot,每个 key 通过 CRC16 计算后取模得到所属 slot:
slot = CRC16(key) mod 16384集群中的每个主节点负责一部分 slot(例如 3 主节点的集群,节点 A 负责 0-5460,节点 B 负责 5461-10922,节点 C 负责 10923-16383)。这种设计的好处是:
- slot 是数据迁移的最小单位,扩容/缩容时按 slot 迁移,业务无感。
- 节点与 slot 解耦,节点变化只需迁移 slot,不影响数据本身。
客户端路由:MOVED 与 ASK 重定向
- 客户端可以连接集群中任意节点。
- 节点收到请求后,计算 key 的 slot,若属于自己的范围则直接处理;否则返回
MOVED错误,告知客户端正确的节点地址。 - 客户端会缓存 slot 与节点的映射关系,后续请求直接路由到目标节点。
- slot 正在迁移中时,源节点返回
ASK重定向(临时性,客户端不缓存),目标节点正常处理。
集群通信:Gossip 协议
集群节点间通过 Gossip 协议进行通信和故障检测:
- 每个节点定期向其他节点发送
PING消息,携带自己已知的集群信息。 - 收到
PING的节点回复PONG,并更新自己的集群视图。 - 通过 Gossip,最终所有节点都能感知到集群的完整拓扑和各节点状态。
故障转移
- 节点之间通过 Gossip 检测存活状态,若超过半数主节点认为某主节点下线(
PFAIL→FAIL),则标记其下线。 - 该主节点的从节点发起选举(基于 Raft 变种),胜出的从节点升级为主节点,接管原主节点的 slot。
- 新主节点向集群广播自己的升级信息,集群恢复服务。
集群的限制
- 不支持跨 slot 的多键操作(除非使用 Hash Tag
{tag}强制相同 tag 的 key 落在同一 slot)。 - 不支持
SELECT切换数据库,集群模式下只能使用 db0。 - 事务和 Lua 脚本受限于单 slot,跨 slot 操作会报错。
- 客户端必须支持集群协议(如 Jedis Cluster、Lettuce、Redisson)。
【困难】如何发现并解决热点 Key 问题?⭐⭐
热点 Key 指单个 Key 被高频访问,导致所在 Redis 节点 CPU、带宽打满,甚至引发缓存击穿。解决思路是发现 + 拆分 + 本地缓存 + 限流。
热点 Key 的危害
- 单节点压力过大:Redis Cluster 下,热点 Key 所在的 slot 对应节点 CPU 飙升、网卡打满,其他节点却很空闲。
- 缓存击穿:热点 Key 失效瞬间,大量请求穿透到数据库。
- 集群雪崩:热点节点故障,流量转移到其他节点,引发连锁反应。
如何发现热点 Key
| 方法 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| Redis 命令统计 | redis-cli --hotkeys 或 OBJECT FREQ(LFU 模式下) | 官方原生支持 | 需开启 LFU 淘汰策略 |
| MONITOR 命令 | 实时捕获所有命令,统计 key 频次 | 准确,能发现实时热点 | 性能开销大,不适合生产长期开启 |
| 代理层统计 | 在 Twemproxy/ShardingSphere 等代理层拦截并统计 key 访问频次 | 对业务透明,无侵入 | 需引入代理层 |
| 日志分析 | 应用层记录 key 访问日志,离线统计分析 | 灵活可控 | 有日志开销,实时性差 |
| 带宽监控 | 监控各节点网卡流量,异常飙升的节点可能存在热点 Key | 简单快速 | 只能定位到节点,无法精确到 key |
如何解决热点 Key
- 拆分热点 Key(Key 分片):将一个热点 Key 拆分为多个子 Key,分散到不同节点。例如
hot_key→hot_key_1~hot_key_10,客户端随机访问其中一个。
/**
* 热点 Key 拆分:将一个 key 拆为多个副本,分散访问压力
*/
public String getHotKey(String key) {
int shard = ThreadLocalRandom.current().nextInt(10);
String shardedKey = key + ":" + shard;
String value = redisTemplate.opsForValue().get(shardedKey);
if (value == null) {
value = queryFromDatabase(key);
// 回填所有副本
for (int i = 0; i < 10; i++) {
redisTemplate.opsForValue().set(key + ":" + i, value, 300, TimeUnit.SECONDS);
}
}
return value;
}本地缓存(多级缓存):在应用层引入 Caffeine/Guava 本地缓存,热点数据缓存在 JVM 内存中,减少对 Redis 的访问。
限流保护:对热点 Key 的访问进行限流(如令牌桶),超过阈值的请求直接降级返回默认值或排队等待。
永不过期 + 异步刷新:热点 Key 不设置过期时间,由后台任务定时刷新,避免失效瞬间引发击穿。
【困难】如何发现并解决大 Key 问题?⭐⭐
大 Key 指 value 体积过大的 Key(如 string 超过 10KB、list/hash/set 元素超过 1 万),会引发阻塞、网络打满、内存不均等问题。
大 Key 的危害
- 阻塞主线程:Redis 单线程模型下,操作大 Key(如
DEL一个 100MB 的 key)会阻塞主线程数秒,导致其他请求超时。 - 内存不均:Cluster 模式下,大 Key 所在节点内存远高于其他节点,引发不均衡。
- 网络带宽打满:读取大 Key 时单次响应数据量大,可能打满网卡。
- 删除引发抖动:大 Key 过期或主动删除时,释放内存耗时,造成服务抖动。
- 引发缓存雪崩:大 Key 失效瞬间,大量请求穿透到数据库。
如何发现大 Key
| 方法 | 命令/工具 | 说明 |
|---|---|---|
| redis-cli --bigkeys | redis-cli --bigkeys | 离线扫描,统计各类型最大的 key |
| MEMORY USAGE | MEMORY USAGE key | 查询单个 key 占用的内存(字节) |
| RDB 离线分析 | rdb-tools、redis-rdb-tools | 离线分析 RDB 快照,不阻塞线上 |
| 代理层监控 | ShardingSphere 等代理层 | 拦截大 value 的请求并告警 |
如何解决大 Key
拆分大 Key:
- 大 Hash:按字段拆分为多个小 Hash,或按业务维度拆分。
- 大 List:按时间或范围拆分为多个 List(如
log:20240101、log:20240102)。 - 大 String:如果是 JSON,拆分为多个字段存储;如果是文件,改用对象存储(OSS)。
压缩 value:对 String 类型的 value 进行 GZIP/Snappy 压缩,减少内存占用和网络传输。
异步删除(UNLINK):删除大 Key 时使用
UNLINK而非DEL,UNLINK会异步释放内存,不阻塞主线程。
# 使用 UNLINK 异步删除大 Key
UNLINK big_key_123
# Redis 4.0+ 还可以配置异步删除策略
# 配置文件中设置
lazyfree-lazy-eviction yes
lazyfree-lazy-expire yes
lazyfree-lazy-server-del yes设置合理的过期时间:为大 Key 设置过期时间,避免长期占用内存;但过期时间要错开,避免同时删除引发抖动。
避免大 Key 写入:在写入层做校验,限制单个 key 的 value 大小(如超过 1MB 拒绝写入并告警)。
【中等】什么是缓存预热?什么是缓存降级?⭐⭐
缓存预热
缓存预热是指系统上线或重启后,提前将热点数据加载到缓存,避免用户首次访问时全部穿透到数据库。
预热的实现方式:
- 系统启动时预热:在应用启动的
@PostConstruct或CommandLineRunner中加载热点数据。 - 定时任务预热:通过定时任务(如 XXL-JOB)在低峰期刷新缓存。
- 手动触发预热:提供管理后台,运维人员手动触发预热接口。
@Component
public class CacheWarmUpRunner implements CommandLineRunner {
@Autowired
private ProductMapper productMapper;
@Autowired
private StringRedisTemplate redisTemplate;
@Override
public void run(String... args) {
// 加载热门商品到缓存
List<Long> hotProductIds = productMapper.selectHotProductIds(1000);
for (Long id : hotProductIds) {
Product product = productMapper.selectById(id);
if (product != null) {
redisTemplate.opsForValue().set(
"product:" + id,
JSON.toJSONString(product),
1, TimeUnit.HOURS
);
}
}
}
}缓存降级
缓存降级是指在缓存不可用或压力过大时,主动牺牲部分功能或一致性,保证核心业务可用。常见降级策略:
| 降级策略 | 触发条件 | 实现方式 |
|---|---|---|
| 读降级 | 缓存故障 | 直接返回默认值/兜底数据,不查数据库 |
| 写降级 | 数据库压力大 | 先写缓存,异步写入数据库(Write Behind) |
| 一致性降级 | 高并发场景 | 暂时不保证缓存与 DB 一致性,只保证最终一致 |
| 限流降级 | 流量超载 | 超过阈值的请求返回降级数据 |
@HystrixCommand(
fallbackMethod = "getProductFallback",
commandProperties = {
@HystrixProperty(name = "circuitBreaker.requestVolumeThreshold", value = "20"),
@HystrixProperty(name = "circuitBreaker.errorThresholdPercentage", value = "50")
}
)
public Product getProduct(Long id) {
// 正常流程:先查缓存,再查数据库
}
public Product getProductFallback(Long id) {
// 降级:返回兜底数据
Product fallback = new Product();
fallback.setId(id);
fallback.setName("商品信息加载中");
return fallback;
}读写分离
【简单】什么是读写分离?为什么需要读写分离?⭐⭐
读写分离将数据库的读操作和写操作分离到不同的数据库实例。
读写分离作用
- 有效减少锁竞争 - 主服务器只负责写,从服务器只负责读,能够有效的避免由数据更新导致的行锁竞争,使得整个系统的查询性能得到极大的改善。
- 提高查询吞吐量 - 通过一主多从的配置方式,可以将查询请求均匀的分散到多个数据副本,能够进一步的提升系统的处理能力。
- 提升数据库可用性 - 使用多主多从的方式,不但能够提升系统的吞吐量,还能够提升数据库的可用性,可以达到在任何一个数据库宕机,甚至磁盘物理损坏的情况下仍然不影响系统的正常运行。
读写分离工作原理
- 写操作:所有写请求(增删改)只发送到主库
- 数据同步:主库通过复制机制将数据变更同步到从库
- 读操作:读请求分发到多个从库(负载均衡)
- 延迟处理:需处理主从同步延迟带来的数据不一致问题
【中等】如何实现读写分离?⭐⭐
读写分离的基本原理是:主服务器用来处理写操作以及实时性要求比较高的读操作,而从服务器用来处理读操作。
读写分离的实现是根据 SQL 语义分析,将读操作和写操作分别路由至主库与从库。
读写分离有两种实现方式:代码封装、中间件。以下是两种方案的对比:
| 方案 | 实现方式 | 优点 | 缺点 |
|---|---|---|---|
| 代码封装 | 业务层通过代理类路由读写请求(读走从库,写走主库)。 | 简单灵活,可定制化 - 适合业务特定需求 | 主从切换需修改配置并重启 - 多语言需重复开发 |
| 中间件 | 独立代理服务(如 MySQL-Proxy、ShardingSphere),客户端无感知。 | 屏蔽多语言差异,统一管理数据源 | 有额外维护成本,可能成为性能瓶颈 |
结论:代码封装适合简单架构,但扩展性差;中间件适合复杂架构,但需维护。
常见的读写分离中间件
- MySQL-Proxy(官方)
- Atlas(360)
- ShardingSphere(Apache)
- Mycat
【中等】MySQL 主从复制的原理是什么?⭐⭐⭐
MySQL 主从复制基于 binlog 日志,通过 IO 线程拉取 + SQL 线程重放实现,分为异步复制、半同步复制、组复制三种模式。
核心流程(三个线程协作)
主库 Master 从库 Slave
┌──────────────┐ ┌──────────────────┐
│ 写操作 │ │ IO Thread │
│ ↓ │ 2.拉取 binlog │ ↑ │
│ binlog 日志 │◄──────────────│ 写入 relay log │
│ │ 3.推送事件 │ ↓ │
└──────────────┘ │ SQL Thread │
│ 读取 relay log │
│ ↓ │
│ 重放 SQL │
│ ↓ │
│ 更新数据 │
└──────────────────┘- 主库写入 binlog:主库执行写操作后,将变更记录到二进制日志(
binlog)。binlog是 MySQL Server 层的日志,记录所有 DDL 和 DML 语句(除 SELECT)。 - 从库 IO 线程拉取:从库的 IO 线程连接主库,请求从指定 binlog 位点(position)或 GTID 开始读取日志。
- 写入 relay log:主库将 binlog 事件发送给从库,从库 IO 线程将其写入中继日志(
relay log)。relay log的格式与binlog相同。 - SQL 线程重放:从库的 SQL 线程读取
relay log,解析并重放 SQL 语句,使从库数据与主库保持一致。
三种复制模式对比
| 模式 | 原理 | 数据安全性 | 性能 | 适用场景 |
|---|---|---|---|---|
| 异步复制(默认) | 主库提交事务后立即返回,不等从库 ACK | 低,主库故障可能丢数据 | 高 | 对数据安全性要求不高的场景 |
| 半同步复制 | 主库提交后等待至少一个从库 ACK 才返回 | 中,降低丢数据风险 | 中 | 对数据安全性有一定要求的场景 |
| 组复制(MGR) | 基于 Paxos 协议的多数派写入 | 高,强一致 | 较低 | 对一致性要求极高的金融场景 |
binlog 的三种格式
| 格式 | 说明 | 优点 | 缺点 |
|---|---|---|---|
| STATEMENT | 记录 SQL 语句 | 日志量小 | 函数(如 NOW()、UUID())可能导致主从不一致 |
| ROW(推荐) | 记录每行数据的变更 | 精确,不会出现主从不一致 | 日志量大(尤其批量操作) |
| MIXED | 混合模式,默认用 STATEMENT,不安全时用 ROW | 兼顾日志量与准确性 | 复杂度略高 |
【中等】如何应对主从复制延迟?⭐⭐⭐
主从延迟是异步复制的固有问题,应对策略包括:强制读主库、并行复制、半同步复制、缓存兜底。
延迟产生的原因
- 异步复制:主库提交不等从库,从库重放需要时间。
- 大事务:单个事务涉及大量数据变更,从库重放耗时长。
- 从库性能差:从库硬件配置低于主库,或负载过高(如承担报表查询)。
- 网络延迟:主从跨机房部署,网络往返延迟高。
- 单线程重放:MySQL 5.6 之前从库 SQL 线程是单线程,无法并行重放。
解决方案
| 方案 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| 强制读主库 | 写后立即读的场景,路由到主库 | 简单直接,数据强一致 | 主库压力增大 |
| 并行复制 | 从库多线程重放 binlog | 显著降低延迟 | 需要 MySQL 5.7+ |
| 半同步复制 | 主库等从库 ACK 才返回 | 降低丢数据风险 | 写性能下降 |
| 缓存兜底 | 写后先将数据写入缓存,读优先走缓存 | 减少读库压力 | 增加缓存维护成本 |
| 写后短暂休眠 | 写操作后短暂 sleep 再读 | 简单 | 不优雅,不可靠 |
强制读主库的典型实现
// ShardingSphere 使用 HintManager 强制路由到主库
public Order createAndQuery(Long userId, String productCode) {
Order order = new Order();
order.setUserId(userId);
order.setProductCode(productCode);
orderMapper.insert(order);
// 写操作完成后立即读,强制走主库避免主从延迟
try (HintManager hintManager = HintManager.getInstance()) {
hintManager.setMasterRouteOnly();
return orderMapper.selectById(order.getId());
}
}从库并行复制配置(MySQL 5.7+)
# 从库开启基于组提交的并行复制
slave_parallel_type = LOGICAL_CLOCK
slave_parallel_workers = 8
binlog_transaction_dependency_tracking = WRITESET【中等】MySQL 半同步复制和异步复制的区别是什么?⭐⭐
| 对比项 | 异步复制 | 半同步复制 |
|---|---|---|
| 主库提交行为 | 提交后立即返回,不等从库 | 等待至少一个从库 ACK 才返回 |
| 数据丢失风险 | 主库故障可能丢失未同步的数据 | 大幅降低(至少一个从库有数据) |
| 写性能 | 最高 | 略有下降(增加等待 ACK 时间) |
| 超时降级 | 无 | 超时后自动降级为异步复制 |
| 适用场景 | 对数据安全性要求不高 | 对数据安全性有一定要求 |
无损半同步(MySQL 5.7+)
MySQL 5.7 引入了 AFTER_SYNC(无损半同步)模式:
- 旧版(AFTER_COMMIT):主库先提交事务,再等从库 ACK。若主库在 ACK 后故障,已提交的事务可能丢失(从库还没收到)。
- 新版(AFTER_SYNC):主库先等从库 ACK,再提交事务。保证已提交的事务一定已在至少一个从库上存在。
# 主库配置无损半同步
rpl_semi_sync_master_wait_point = AFTER_SYNC分库分表
【简单】什么是分库分表?为什么需要分库分表?⭐⭐
什么是分库分表?
分库分表是一种数据库水平拆分方案,用于解决单机数据库的存储瓶颈和性能瓶颈问题。
- 分库:将数据分散到不同的数据库实例(如
DB1、DB2)。 - 分表:将数据分散到同一数据库的不同表(如
order_1、order_2)。
为何要分库分表?
分库分表主要基于以下理由:
- 并发连接 - 一个健康的单库最好保持在每秒 1000 个并发左右,不要太大。
- 磁盘容量 - 磁盘容量占满,会导致服务器不可用。
- SQL 性能 - 单表数据量过大,会导致 SQL 执行效率低下。一般,单表超过 1000 万条数据,就可以考虑分表了。
| # | 分库分表前 | 分库分表后 |
|---|---|---|
| 并发支撑情况 | MySQL 单机部署,扛不住高并发 | MySQL 从单机到多机,能承受的并发增加了多倍 |
| 磁盘使用情况 | MySQL 单机磁盘容量几乎撑满 | 拆分为多个库,数据库服务器磁盘使用率大大降低 |
| SQL 执行性能 | 单表数据量太大,SQL 越跑越慢 | 单表数据量减少,SQL 执行效率明显提升 |
【困难】如何实现分库分表?⭐⭐
分库分表 = 选好拆分键 + 用对路由算法 + 平滑数据迁移 + 改造查询语句,核心是让数据均匀分布且查询能精准定位到分片。
数值范围路由
数值范围路由,就是根据 ID、时间范围 这类具有排序性的字段来进行划分。例如:用户 Id 为 1-9999 的记录分到第一个库,10000-20000 的分到第二个库,以此类推。
按这种策略划分出来的数据,具有数据连续性。
优点:数据迁移很简单。
缺点:容易产生热点问题,大量的流量都打在最新的数据上了。
Hash 路由
典型的 Hash 路由,如根据数值取模,当需要扩容时,一般以 2 的幂次方进行扩容(这样,扩容时迁移的数据量会小一些)。例如:用户 Id mod n,余数为 0 的记录放到第一个库,余数为 1 的放到第二个库,以此类推。
一般采用 预分区 的方式,提前根据 数据量 规划好 分区数,比如划分为 512 或 1024 张表,保证可支撑未来一段时间的 数据容量,再根据 负载情况 将 表 迁移到其他 数据库 中。扩容时通常采用 翻倍扩容,避免 数据映射 全部被 打乱,导致 全量迁移 的情况。
优点:数据离散分布,不存在热点问题。
缺点:数据迁移、扩容麻烦(之前的数据需要重新计算 hash 值重新分配到不同的库或表)。当 节点数量 变化时,如 扩容 或 收缩 节点,数据节点 映射关系 需要重新计算,会导致数据的 重新迁移。
路由表
这种策略,就是用一张独立的表记录路由信息。
优点:简单、灵活,尤其是在扩容、迁移时,只需要迁移指定的数据,然后修改路由表即可。
缺点:每次查询,必须先查路由表,增加了 IO 开销。并且,如果路由表本身太大,也会面临性能瓶颈,如果想对路由表再做分库分表,将出现死循环式的路由算法选择问题。

【困难】分库分表存在哪些问题?⭐⭐
分库分表主要存在以下问题:
- 分布式 ID 问题
- 分布式事务问题
- 跨节点 Join 和聚合
- 跨节点的排序分页

分布式 ID 问题
一旦数据库被切分到多个物理结点上,我们将不能再依赖数据库自身的主键生成机制。一方面,某个分区数据库自生成的 ID 无法保证在全局上是唯一的;另一方面,应用程序在插入数据之前需要先获得 ID,以便进行 SQL 路由。
分布式 ID 的解决方案详见:分布式 ID
分布式事务问题
跨库事务也是分布式的数据库集群要面对的棘手事情。 合理采用分表,可以在降低单表数据量的情况下,尽量使用本地事务,善于使用同库不同表可有效避免分布式事务带来的麻烦。在不能避免跨库事务的场景,有些业务仍然需要保持事务的一致性。 而基于 XA 的分布式事务由于在并发度高的场景中性能无法满足需要,并未被互联网巨头大规模使用,他们大多采用最终一致性的柔性事务代替强一致事务。
分布式事务的解决方案详见:分布式事务
跨节点 Join 和聚合
分库分表后,无法直接跨节点 join 、count、order by、group by 以及聚合。
针对这类问题,普遍做法是二次查询。
在第一次查询时,获取各个节点上的结果。
在程序中将这些结果进行合并、筛选。
跨节点的排序分页
一般来讲,分页时需要按照指定字段进行排序。当排序字段就是分片字段的时候,我们通过分片规则可以比较容易定位到指定的分片,而当排序字段非分片字段的时候,情况就会变得比较复杂了。为了最终结果的准确性,我们需要在不同的分片节点中将数据进行排序并返回,并将不同分片返回的结果集进行汇总和再次排序,最后再返回给用户。如下图所示:

上面图中所描述的只是最简单的一种情况(取第一页数据),看起来对性能的影响并不大。但是,如果想取出第 10 页数据,情况又将变得复杂很多,如下图所示:

有些读者可能并不太理解,为什么不能像获取第一页数据那样简单处理(排序取出前 10 条再合并、排序)。其实并不难理解,因为各分片节点中的数据可能是随机的,为了排序的准确性,必须把所有分片节点的前 N 页数据都排序好后做合并,最后再进行整体的排序。很显然,这样的操作是比较消耗资源的,用户越往后翻页,系统性能将会越差。
那如何解决分库情况下的分页问题呢?有以下几种办法:
如果是在前台应用提供分页,则限定用户只能看前面 n 页,这个限制在业务上也是合理的,一般看后面的分页意义不大(如果一定要看,可以要求用户缩小范围重新查询)。
如果是后台批处理任务要求分批获取数据,则可以加大 page size,比如每次获取 5000 条记录,有效减少分页数(当然离线访问一般走备库,避免冲击主库)。
分库设计时,一般还有配套大数据平台汇总所有分库的记录,有些分页查询可以考虑走大数据平台。
【困难】如何实现迁库和扩容?⭐⭐
停机迁移/扩容(不推荐)
停机迁移/扩容是最暴力、最简单的迁移、扩容方案。

停机迁移/扩容流程:
- 预估停服时间,发布停服公告;停服,不允许数据访问。
- 编写临时的数据导入程序,从老数据库中读取数据。
- 将数据写入中间件。
- 中间件根据分片规则,将数据分发到分库(分表)中。
- 应用程序修改配置,重启。
停机迁移/扩容方案分析:
- 优点:简单、无数据一致性问题。
- 缺点:
- 停服时间长(数据量大时可能需数小时)。
- 风险高,失败后难以回滚。
结论:代价过高,不推荐使用。
双写迁移
双写迁移方案核心思想:
- 新旧库同时写入,通过开关控制读写状态(只写旧库、只写新库、双写)。
- 逐步切换读请求到新库,确保数据一致性。
双写迁移方案关键步骤:
- 双写阶段:先写旧库,再写新库,以旧库结果为准。记录旧库成功但新库失败的日志,用于补偿。
- 数据校验:运行对比程序,检查新旧库数据差异并修复。
- 灰度切换读请求:逐步将读流量切至新库,观察稳定性。
- 最终切换:读写全部切至新库,清理旧库冗余数据。

双写迁移流程:
- 修改应用程序配置,将数据同时写入老数据库和中间件。这就是所谓的双写,同时写俩库,老库和新库。
- 编写临时程序,读取老数据库。
- 将数据写入中间件。如果数据不存在,直接写入;如果数据存在,比较时间戳,只允许新数据覆盖老数据。
- 导入数据后,有可能数据还是存在不一致,那么就对数据进行校验,比对新老库的每条数据。如果存在差异,针对差异数据,执行(3)。循环(3)、(4)步骤,直至数据完全一致。
- 修改应用程序配置,将数据只写入中间件。
- 中间件根据分片规则,将数据分发到分库(分表)中。
双写迁移方案分析:
优点:
- 无需停服,业务影响小。
- 可灰度验证,风险可控。
缺点:
- 实现复杂,需处理双写一致性和补偿逻辑。
主从替换
生产环境的数据库,为了保证高可用,一般会采用主从架构。主库支持读写操作,从库支持读操作。

由于主从节点数据一致,所以将从库升级为主节点,并修改分片配置,将从节点作为分库之一,就实现了扩容。

主从替换方案流程:
- 解除主从关系,从库升级为主库。
- 应用程序,修改配置,读写通过中间件。
- 分库分表中间,修改分片配置。将数据按照新的规则分发。
- 编写临时程序,清理冗余数据。比如:原来是一个单库,数据量为 400 万。从节点升级为分库之一后,每个分库都有 400 万数据,其中 200 万是冗余数据。清理完后,进行数据校验。
- 为每个分库添加新的从库,保证高可用。
主从替换方案分析:
- 无需停机,无需全量数据迁移。
- 利用现有从库资源,节省成本。
三种方案对比
| 方案 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| 停机迁移 | 小规模数据,容忍停服 | 简单,无一致性问题 | 停服时间长,风险高 |
| 双写迁移 | 大规模数据,要求高可用 | 无停服,灰度可控 | 复杂,需补偿机制 |
| 主从替换 | 已有主从架构 | 无需迁移数据,快速扩容 | 依赖现有从库,清理冗余复杂 |
推荐选择:
- 优先双写迁移:适合大多数业务,平衡风险与复杂度。
- 主从升级:适合已有主从且数据量适中的场景。
- 避免停机迁移:除非数据量极小且可接受停服。
【中等】如何选择分片键(Sharding Key)?⭐⭐
分片键决定了数据如何分布和查询如何路由,是分库分表设计的核心。好的分片键应保证数据均匀分布、查询能精确路由、避免数据倾斜。
分片键选择原则
| 原则 | 说明 |
|---|---|
| 高基数 | 分片键的取值范围要足够大,避免数据倾斜(如用 user_id 而非 gender) |
| 分布均匀 | 分片键的取值经过 hash 后能均匀分布到各分片,避免热点 |
| 查询带分片键 | 大部分查询条件都包含分片键,避免全分片扫描 |
| 不可变 | 分片键值一旦确定不再变化,避免数据迁移 |
| 单调递增(可选) | 若用范围分片,分片键最好单调递增(如时间、自增 ID) |
常见场景的分片键选择
| 业务场景 | 推荐分片键 | 理由 |
|---|---|---|
| 电商订单 | user_id | 用户查询自己的订单,精确路由;商家查询需走全分片或异构索引 |
| 用户中心 | user_id | 绝大多数查询以用户为维度 |
| 社交消息 | sender_id 或 conversation_id | 按发送者或会话维度查询 |
| 日志流水 | create_time | 按时间范围查询为主,范围分片友好 |
| 支付交易 | user_id 或 merchant_id | 根据主要查询维度选择,可能需要异构索引 |
分片键选择的坑
- 避免使用会变化的字段:如手机号、状态字段,变更后需跨分片迁移数据。
- 避免使用低基数字段:如性别、状态,只有几个取值,必然数据倾斜。
- 多维度查询问题:若订单表以
user_id分片,商家查询自己的订单需要扫描全部分片。解决方案:- 异构索引:用
merchant_id建立另一套索引表(可以是 ES 或另一张分片表)。 - 双写双分片:同时按
user_id和merchant_id分片存储两份数据。 - 数仓/搜索引擎:复杂查询走 Elasticsearch 或数据仓库。
- 异构索引:用
【中等】常见的分布式 ID 方案有哪些?⭐⭐
分布式 ID 需要满足全局唯一、趋势递增、高可用、高性能。常见方案有 UUID、雪花算法、号段模式、Redis INCR、数据库自增、Leaf 等。
| 方案 | 原理 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| UUID | 生成 128 位随机字符串 | 本地生成,无网络开销;全局唯一 | 无序,B+ 树插入性能差;占用空间大(36 字符) | 对顺序无要求的场景 |
| 数据库自增 | 利用 AUTO_INCREMENT | 简单,有序 | 单点瓶颈;扩展困难 | 单库小规模应用 |
| 数据库号段模式 | 批量从 DB 取一段 ID 缓存到本地 | 减少 DB 压力;有序 | 重启会浪费一段 ID;需保证 DB 高可用 | 中大规模应用(如美团 Leaf) |
| 雪花算法(Snowflake) | 时间戳 + 机器 ID + 序列号 | 有序;高性能;无依赖 | 时钟回拨问题;机器 ID 分配需管理 | 大规模分布式系统 |
| Redis INCR | 利用 Redis 原子自增 | 简单;有序 | 依赖 Redis;有网络开销 | 对性能要求不极致的场景 |
| ZooKeeper 序列节点 | 创建顺序节点获取序号 | 强一致 | 性能差;依赖 ZK | 对一致性要求高、并发低的场景 |
| Leaf(美团) | 号段模式 + Snowflake 双缓冲 | 高性能;高可用 | 实现复杂 | 超大规模应用 |
雪花算法详解
雪花算法生成的 64 位 ID 结构:
| 1 bit 符号位 | 41 bit 时间戳(ms) | 10 bit 机器 ID | 12 bit 序列号 |- 符号位:恒为 0,保证 ID 为正数。
- 时间戳(41 bit):相对于起始时间的毫秒数,可用约 69 年。
- 机器 ID(10 bit):可表示 1024 个节点,通常拆分为 5 bit 数据中心 + 5 bit 机器。
- 序列号(12 bit):同一毫秒内的自增序列,支持每毫秒每节点 4096 个 ID。
时钟回拨问题及解决:
- 问题:服务器时钟回拨(NTP 校时或手动调整)会导致生成的 ID 重复或时间戳倒退。
- 解决:
- 回拨时间短(<5ms):等待回拨时间过去再生成。
- 回拨时间长:拒绝服务或报警,采用备用机器 ID。
- 使用逻辑时钟替代物理时钟(如百度 UidGenerator)。
【简单】垂直拆分和水平拆分有什么区别?⭐⭐
| 对比项 | 垂直拆分 | 水平拆分 |
|---|---|---|
| 拆分维度 | 按业务或字段拆分到不同库/表 | 按行数据拆分到多个结构相同的表/库 |
| 拆分方式 | 专库专用(用户库、订单库);冷热字段分离 | 按分片键 hash/范围拆分 |
| 数据分布 | 每个库存储不同表/字段的数据 | 每个分片存储部分行的数据 |
| 能否解决单表数据量过大 | 不能(只是换了个库存) | 能(单表数据量减少) |
| 能否解决单库并发瓶颈 | 部分(分散到不同库) | 能(分散到多个库) |
| 跨库 JOIN | 频繁出现(业务关联表分散) | 不出现(同结构表) |
| 分布式事务 | 少(同业务在同一库) | 多(跨库操作) |
| 实施难度 | 低(业务梳理清晰即可) | 高(需引入中间件、分布式 ID 等) |
| 典型场景 | 业务边界清晰的系统 | 单表数据量超千万级的系统 |
实践建议:通常先垂直拆分(按业务分库),再对单表数据量过大的表进行水平拆分。
缓存与数据库一致性
【困难】如何保证缓存与数据库的一致性?⭐⭐⭐
Cache Aside(先更新 DB,再删除缓存)是业界主流方案,辅以延迟双删、消息补偿、binlog 订阅等手段,可实现最终一致。
核心结论
| 场景 | 推荐方案 | 一致性强度 |
|---|---|---|
| 一般业务 | Cache Aside(先更新 DB,再删除缓存) | 最终一致 |
| 强一致要求 | 延迟双删 + 消息补偿 | 较强最终一致 |
| 高可靠要求 | 订阅 binlog 异步更新缓存 | 最终一致(更可靠) |
| 强一致(不推荐) | 2PC / 分布式锁串行化 | 强一致 |
方案一:Cache Aside(旁路缓存)
详见前文 缓存更新策略 章节。核心流程:
- 读:先查缓存,未命中查 DB 并回填缓存。
- 写:先更新 DB,再删除缓存。
存在的问题:并发场景下仍有小概率出现不一致(见前文分析)。
方案二:延迟双删
在 Cache Aside 基础上,增加一次"延迟删除",消除并发读写导致的不一致:
1. 删除缓存
2. 更新数据库
3. 休眠 N 毫秒(等待读线程完成脏数据回填)
4. 再次删除缓存public void updateWithDoubleDelete(Long id, Object newValue) {
String key = "entity:" + id;
// 1. 先删除缓存
redisTemplate.delete(key);
// 2. 更新数据库
mapper.updateById(id, newValue);
// 3. 延迟一段时间(通常为读业务耗时 + 几百毫秒)
try {
TimeUnit.MILLISECONDS.sleep(500);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
// 4. 再次删除缓存
redisTemplate.delete(key);
}延迟双删的注意点:
- 延迟时间要大于一次读请求的耗时(从读缓存未命中到回填缓存的时间)。
- 第二次删除失败仍可能导致不一致,需配合消息队列重试。
- 休眠会阻塞线程,高并发场景建议用异步任务执行第二次删除。
方案三:订阅 binlog 异步更新缓存
通过 Canal 等工具订阅 MySQL binlog,异步更新/删除缓存,解耦业务代码:
应用 → 更新 DB → DB 写入 binlog → Canal 订阅 binlog → 发送 MQ → 消费者删除/更新缓存// Canal 消费者监听 binlog 事件
@Component
@RocketMQMessageListener(topic = "canal-binlog", consumerGroup = "cache-sync")
public class CanalCacheListener implements RocketMQListener<CanalMessage> {
@Autowired
private StringRedisTemplate redisTemplate;
@Override
public void onMessage(CanalMessage message) {
if ("UPDATE".equals(message.getEventType()) || "DELETE".equals(message.getEventType())) {
for (Map<String, String> row : message.getData()) {
String id = row.get("id");
String cacheKey = "entity:" + id;
redisTemplate.delete(cacheKey);
}
}
}
}binlog 订阅方案的优点:
- 业务解耦:业务代码只管写 DB,缓存维护完全由 binlog 消费者负责。
- 可靠性高:binlog 是 MySQL 的持久化日志,不会丢失。
- 一致性更好:即使业务代码忘记删缓存,binlog 也能兜底。
binlog 订阅方案的缺点:
- 引入额外组件:需部署 Canal、MQ 等,增加系统复杂度。
- 有延迟:binlog 订阅 → MQ → 消费存在毫秒级延迟。
- 需保证消费幂等:重复消费会导致缓存状态错误。
【中等】缓存一致性各方案的对比是什么?⭐⭐
| 方案 | 一致性 | 性能 | 复杂度 | 适用场景 |
|---|---|---|---|---|
| Cache Aside | 最终一致 | 高 | 低 | 大多数业务 |
| 延迟双删 | 较强最终一致 | 中(有休眠) | 中 | 对一致性要求较高 |
| binlog 订阅 | 最终一致(更可靠) | 高 | 高 | 对可靠性要求高,业务解耦 |
| Read/Write Through | 强一致 | 中 | 中 | 缓存服务支持(如 Guava LoadingCache) |
| Write Behind | 弱一致 | 最高 | 中 | 写多读少,容忍丢失 |
| 2PC / 分布式锁 | 强一致 | 低 | 高 | 金融等强一致场景(不推荐常规业务) |
选型建议:
- 常规业务:Cache Aside + 过期时间兜底,足够应对。
- 高一致要求:Cache Aside + 延迟双删 + 消息重试。
- 解耦 + 高可靠:binlog 订阅(Canal + MQ)异步更新缓存。
- 金融强一致:不要用缓存,直接读 DB;或用分布式锁串行化(牺牲性能)。
【简单】缓存一致性的几种模式是什么?⭐⭐
| 模式 | 读流程 | 写流程 | 一致性 | 适用场景 |
|---|---|---|---|---|
| Cache Aside | 先读缓存,未命中读 DB 并回填 | 先写 DB,再删缓存 | 最终一致 | 通用场景(最常用) |
| Read Through | 缓存服务负责加载(应用无感) | 同 Cache Aside | 最终一致 | 缓存服务支持自动加载 |
| Write Through | 同 Cache Aside | 同步写缓存和 DB | 强一致 | 对一致性要求高 |
| Write Behind | 同 Cache Aside | 只写缓存,异步刷 DB | 弱一致 | 写多读少,容忍丢失 |
| Refresh Ahead | 缓存过期前主动刷新 | 同 Cache Aside | 最终一致 | 热点数据,避免过期抖动 |
关键区别:
- Cache Aside:应用同时操作缓存和 DB,缓存只做"读时填充、写时删除"。
- Read/Write Through:应用只操作缓存,缓存服务代为操作 DB,对应用透明。
- Write Behind:应用只写缓存,缓存异步批量刷 DB,性能最高但可能丢数据。
详见前文 缓存更新策略 章节的详细分析。