怎么用 八股讲不顺 → 基础环节会挂;讲得顺 → 只是不被这块淘汰,不等于能过。注意本题库不含算法,而算法是大厂应届的硬门槛。场景题难度对标社招两三年,已超出应届考察标准,它的价值是让你知道正确的思考框架——照原文背会被追问穿,要用自己真做过的经历去填充表达。

面试题库(电商 / 短视频 / 社区 / 本地生活 / 旅游 / 企业服务 多业务场景)

一、索引与存储引擎

  1. InnoDB 为什么用 B+ 树做索引,不用 B 树、哈希或者跳表?★★★

    核心是磁盘 IO。数据库的瓶颈在磁盘,索引结构要尽量减少读盘次数,也就是让树尽可能矮。B+ 树的非叶子节点只存键不存数据,一个 16KB 的页能放上千个键,所以扇出极大,三层就能存两千万行左右,查任何一行最多三次 IO,而且根节点常驻内存,实际只需一两次。B 树的非叶子节点也存数据,一页能放的键少得多,树就更高。

    第二个原因是范围查询。B+ 树所有数据都在叶子节点上,而且叶子节点之间用双向链表串起来,范围扫描和排序只要顺着链表走,这是顺序 IO。B 树的数据散在各层,范围查询要做中序遍历,跳来跳去全是随机 IO。

    哈希索引单点查询确实是 O(1),但不支持范围查询、不支持排序、不支持最左前缀匹配,而且哈希冲突和扩容 rehash 在大数据量下代价很高。所以只在 Memory 引擎和 InnoDB 的自适应哈希里作为辅助手段用。

    跳表的问题是它是内存友好的结构,节点分散,磁盘上没有页的概念,局部性差。Redis 和 LevelDB 用跳表是因为它们本来就在内存里操作,B+ 树针对块设备的优化在那里派不上用场。这题答的时候一定要落到「磁盘页」和「树高」上,只说「B+ 树查询快」是拿不到分的。

  2. 聚簇索引和二级索引的区别,什么是回表和索引覆盖?★★

    聚簇索引的叶子节点直接存整行数据,InnoDB 的主键索引就是聚簇索引,所以表数据本身就是按主键顺序组织的,一张表只能有一个。二级索引的叶子节点存的是索引列的值加上主键值,不存整行。

    回表就是:用二级索引查到主键,但要的字段不在这个索引里,就得拿主键再去聚簇索引里查一次完整行。等于两棵树各走一遍,IO 翻倍。索引覆盖是指要查的字段全都在索引里,直接从二级索引就能拿到全部数据,不用回表,执行计划的 Extra 里会显示 Using index

    这是最常用的优化手段之一。比如 select id, name from user where name = 'x',在 name 上建索引就自动覆盖了,因为二级索引里有 name 和主键 id。要是再查个 age,就得建 (name, age) 联合索引才能覆盖。

    顺带一个实践点(这条很重要):主键要用自增整型,不要用 UUID。因为聚簇索引按主键顺序存,自增插入是顺序追加,UUID 是随机值,会不断在中间插入导致页分裂和碎片,写性能差很多,而且 UUID 更长,所有二级索引都要存主键,会连带把每个二级索引都撑大。

  3. 最左前缀原则是什么,联合索引怎么设计?★★★

    联合索引是按列的顺序依次排序的:先按第一列排,第一列相同的再按第二列排,就像先按省排再按市排的通讯录。所以必须从最左边的列开始用,中间断了后面的列就用不上索引定位

    拿索引 (a, b, c) 举例:条件是 a、a 和 b、a b c 都能用上;只有 b 或者只有 c 完全用不上a = 1 and c = 3 只能用到 a 这一段。不过 MySQL 5.6 之后有索引下推,c 的条件能在存储引擎层就过滤掉,减少回表次数,执行计划的 Extra 里会显示 Using index condition——所以「用不上索引」要区分「用不上索引定位」和「完全没帮助」。

    还有一条同样重要但更容易忘的:范围查询之后的列用不上索引a > 1 and b = 2 里,a 用了范围,b 就没法再用索引定位了,因为 a 不确定时 b 的顺序是乱的。所以建索引时等值条件的列放前面,范围条件的列放后面

    另外条件的书写顺序不重要where b = 2 and a = 1 一样能命中 (a, b),因为优化器会重排条件顺序。很多人以为要按索引顺序写 SQL,这是误解。

    怎么设计:我的做法是先从慢查询日志里统计出最常用的几种条件组合,然后尽量用一个联合索引覆盖多个查询场景,而不是给每个字段单独建索引。原则上区分度高的列靠前(能更快缩小范围),但要结合实际查询——如果某个低区分度的列几乎每次查询都带(比如 tenant_id),它放最前面反而更实用。

    最后一个反向的注意点:索引不是越多越好。每个索引都要占空间,而且增删改时所有相关索引树都要维护,写入性能会下降。一张表的索引控制在五六个以内比较合理,超过就该审视是不是查询设计有问题了。

  4. explain 你重点看哪几个字段?★★★

    我主要看四个:type、key、rows、Extra

    type 是访问类型,从好到坏大致是 system / const(主键或唯一索引等值命中,最多一行)→ eq_ref(join 时被驱动表走唯一索引)→ ref(走非唯一索引等值)→ range(索引范围扫描)→ index(全索引扫描)→ ALL(全表扫描)。见到 ALLindex 就要优化range 及以上一般可接受。注意 index 虽然名字带索引,但它是把整棵索引树扫一遍,只比全表扫好一点。

    key 看实际用了哪个索引。如果 possible_keys 有值但 key 是 null,说明优化器主动放弃了索引,要查为什么——通常是它估算走索引回表的行数太多,认为全表顺序扫更快。key_len 能反推出联合索引实际用到了几个字段,这个在排查「最左前缀是不是真的生效」时特别有用。

    rows 是预估扫描行数,注意它是基于统计信息的估算值,可能和实际差很远,但数量级有参考意义。filtered 是过滤后剩余的百分比,rows × filtered 才是真正返回给上一层的行数,多表 join 时看这个才准。

    Extra 信息量最大:Using index 是好事,说明索引覆盖了不用回表;Using where 表示在 server 层还做了过滤;Using index condition 是索引下推生效;Using filesortUsing temporary 是要重点优化的信号,前者说明排序没能利用索引的有序性,后者说明用了临时表,通常出现在 group bydistinct、或者复杂的 order by 上。

    光看 explain 有时不够,因为 rows 是估算的、而且它不真正执行。所以我会配合两个手段:EXPLAIN ANALYZE(8.0.18+)会真正执行并给出每一步的实际耗时和实际行数,估算和实际差距大的地方就是问题所在;optimizer_trace 能看到优化器的完整决策过程,在「明明有索引却不走」的时候是唯一能给出答案的工具。

    还有个实践细节:explain 的结果会随数据量和统计信息变化。测试库几百行的表和线上几千万行的表,同一条 SQL 的执行计划可能完全不同。所以要在贴近生产的数据量上看执行计划,或者至少先 ANALYZE TABLE 更新统计信息。

  5. 什么情况下索引会失效?★★★

    最常见的几种。索引列上做运算或者用函数,比如 where YEAR(create_time) = 2024,因为索引存的是原值,算完就对不上了,得改成范围查询 where create_time >= '2024-01-01' and < '2025-01-01'

    隐式类型转换,这个最阴险。字段是 varchar 但传了数字,where phone = 13800138000,MySQL 会把字段转成数字再比较,等于在列上加了函数,索引失效。反过来字段是 int 传字符串没问题,因为转换发生在参数侧。所以线上突然某个接口变慢,我会先检查参数类型对不对。

    like '%xx' 以通配符开头没法用索引,因为 B+ 树是按前缀排序的。or 连接的条件如果有一边没索引,整个查询就走全表。!=not inis not null 通常也走不了,不过这些不绝对。

    还有一类是优化器主动放弃:如果它估算走索引要回表的行数太多,比如超过全表的百分之二三十,会认为全表扫描的顺序 IO 更快,干脆不用索引。这不是失效而是正确决策。另外字符集或排序规则不一致的表做 join 也会让索引失效,这个在分库分表迁移时踩过不少。

  6. Buffer Pool 的作用和淘汰策略是什么?★★★

    Buffer Pool 是 InnoDB 在内存里缓存数据页和索引页的地方,读写都先在这里进行。读的时候页在内存就直接返回;写的时候直接改内存页,把它标记成脏页,之后由后台线程刷盘,所以 InnoDB 的写是「改内存加写 redo log」,磁盘上的数据页是异步更新的,这是它性能好的关键。生产环境一般给到物理内存的 50% 到 70%。

    淘汰用的是改良版 LRU。标准 LRU 有两个问题:一是预读失效,InnoDB 会预读相邻的页,如果预读的页压根没被用到,却占了链表头部,把真正的热数据挤走了;二是缓冲池污染,一条不带索引的大范围扫描会把整张表的页都读进来,瞬间把所有热数据冲掉。

    解法是把链表分成 young 和 old 两段,默认 old 占 3/8。新读入的页先放在 old 区的头部,而不是整个链表的头部。要想进 young 区,得满足两个条件:这个页被再次访问,并且距离第一次访问超过了 1 秒(由 innodb_old_blocks_time 控制)。全表扫描的特点是每个页读完就不再用了,或者短时间内连续用完就丢,正好过不了这个门槛,所以只在 old 区转一圈就被淘汰,热数据安然无恙。这个设计很值得说,因为它体现了「针对实际访问模式做优化」的思路。

    顺便说 change buffer:对于非唯一二级索引的写操作,如果页不在内存里,InnoDB 不会立刻读盘,而是把变更记在 change buffer 里,等下次这个页被读进来时再合并。这样把随机读盘变成了延迟合并,写密集场景提升很大。唯一索引享受不到,因为要判断唯一性就必须把页读进来看一眼。

  7. 大表加字段、加索引怎么办?★★★

    MySQL 5.6 之后有 Online DDL,很多操作可以 ALGORITHM=INPLACE 不用重建表,加字段、加索引大多支持,期间可以读写。但要注意两点:一是 DDL 开始和结束时需要短暂的元数据锁,如果这时候有长事务正在跑,DDL 会被卡住等 MDL,而它一旦在等,后面所有对这张表的查询全部排队,几秒内就能把连接池打满,这是线上最容易出的事故。二是有些操作 INPLACE 也要重建表,比如改字段类型、改字符集,几千万行的表可能跑几十分钟,期间磁盘 IO 和 Buffer Pool 都受影响。

    所以生产环境的做法是:先在低峰期执行,执行前 show processlist 确认没有长事务,DDL 语句加 lock_wait_timeout 避免无限等待。表特别大就用 gh-ost 或 pt-online-schema-change,思路都是新建一张目标结构的空表,把老数据慢慢拷过去,同时把增量变更也同步过来,最后瞬间改名切换。gh-ost 用 binlog 同步增量,不需要触发器,对源表的影响更小,还能随时暂停和限速,我更推荐它。

    另外从设计上避免这个问题:预留一些扩展字段,或者干脆用一个 JSON 字段存非索引的可变属性,这样加业务属性就不用动表结构了。

二、事务与 MVCC

  1. 四种隔离级别分别解决什么问题,MySQL 为什么默认用可重复读?★★

    读未提交能读到别人还没提交的数据,也就是脏读,一旦对方回滚你读到的就是根本不存在的数据。读已提交解决了脏读,但同一个事务里两次读同一行结果可能不一样,这是不可重复读。可重复读保证一个事务内多次读结果一致。串行化把所有操作变成串行,连幻读都没有,但性能极差,基本不用。

    三个概念的区别在于读到了什么:脏读是读到未提交的数据,不可重复读是同一行两次读结果不同(针对 update),幻读是同样的条件两次查到的行数不同(针对 insert)。

    MySQL 默认可重复读,这跟大多数数据库不一样,Oracle 和 PostgreSQL 默认都是读已提交。历史原因是早期 MySQL 的 binlog 只有 statement 格式,在读已提交下 statement 格式会导致主从数据不一致——因为语句在主库和从库的执行顺序可能不同而产生不同结果,所以必须用更强的隔离级别兜住。现在 binlog 用 row 格式已经没这个问题了。

    值得说的是很多互联网公司会手动改成读已提交,理由有三个:可重复读要靠间隙锁防幻读,锁的范围大、死锁概率高;长事务在可重复读下会让 undo log 长期无法清理,版本链越来越长;而业务上大多数场景对「一个事务内两次读必须一致」并没有强需求。阿里的规范里就是建议 RC。这个取舍能讲出来,比单纯背四个级别有价值得多。

  2. MVCC 是怎么实现的,ReadView 里有什么?★★★

    MVCC 靠三样东西配合:行记录里的隐藏字段、undo log 版本链、ReadView。

    每行有两个关键隐藏字段,trx_id 记录最后修改它的事务 ID,roll_pointer 指向 undo log 里的上一个版本。每次修改都会把旧值写进 undo log,用 roll_pointer 串成一条版本链,链头是最新版本,往后是历史版本。

    ReadView 是读的时候生成的一致性视图,主要四个内容:m_ids 是当前活跃(未提交)事务的 ID 列表,min_trx_id 是这里面最小的,max_trx_id 是下一个将要分配的事务 ID,creator_trx_id 是自己的事务 ID。

    可见性判断就是拿版本上的 trx_id 跟 ReadView 比:小于 min_trx_id 说明这个事务在我之前就提交了,可见;大于等于 max_trx_id 说明是我生成视图之后才开启的事务,不可见;落在中间就看它在不在 m_ids 里,在就是还没提交,不可见,不在就是已提交,可见。不可见就顺着 roll_pointer 往版本链后面找,直到找到一个可见的版本。

    RR 和 RC 的区别就只在生成 ReadView 的时机:RR 是事务里第一次读的时候生成一个,之后一直复用,所以看到的永远是同一个快照,自然可重复读;RC 是每次 select 都重新生成一个,所以能看到别人最新提交的数据。同一套机制,改一个时机就实现了两种隔离级别,这是 MVCC 设计上最漂亮的地方。

  3. MVCC 能完全解决幻读吗?★★★

    不能,只能解决快照读的幻读。这题是区分「背过」和「真懂」的分水岭。

    普通的 select 是快照读,走 MVCC,读的是历史版本,所以别人插入的新行看不到,没有幻读。但 select ... for updateselect ... lock in share mode、以及 updatedelete 这些是当前读,必须读最新数据,不走快照,这时候就得靠间隙锁来防止幻读。

    所以 InnoDB 在 RR 下防幻读是两套机制配合:快照读靠 MVCC,当前读靠 next-key lock,也就是记录锁加间隙锁。

    还有一个更隐蔽的场景,快照读也能「看到」不该看到的行:如果事务里先快照读没看到某行,然后去 update 它,因为 update 是当前读能看到,改完之后这行的 trx_id 就变成自己的了,再 select 就看得见了。这种自己把自己的快照打破的情况叫「写后读」,很多人以为 RR 下绝对不会读到新行,其实这就是个例外。

  4. 什么是长事务,危害是什么?★★

    长事务的危害主要在 undo log 和锁上。

    undo log 要保留到「所有比它更早的 ReadView 都不再需要它」为止。一个长事务一直不提交,它的 ReadView 就一直活着,所有它可能用到的历史版本都不能删,回滚段就会不断膨胀,MySQL 5.5 之前 ibdata 文件涨了还不能自动收缩,只能重建实例。同时版本链变得很长,查一行要顺着链找很久,查询性能明显下降。

    锁方面,事务持有的行锁要到提交才释放,长事务会长时间阻塞别人。而且它持有的元数据锁会让 DDL 卡住,进而堵住这张表的所有后续查询。

    排查用 information_schema.innodb_trxtrx_started,找出跑了很久的事务。预防上有几点实践:@Transactional 的方法里不要放 RPC 调用、不要放大循环、不要放文件操作,这些是最常见的长事务来源;事务范围尽量小,先把数据准备好再开事务;设置 innodb_lock_wait_timeout 和监控告警。Spring 里还有个坑是 @Transactional 加在一个内部有大量查询的方法上,明明只读也开了事务,白白拖长事务时间。

三、锁

  1. InnoDB 有哪些锁,间隙锁和临键锁是怎么工作的?★★★

    表级有表锁、意向锁、元数据锁;行级主要是记录锁、间隙锁、临键锁、插入意向锁。

    记录锁锁的是索引上存在的一条记录。间隙锁锁的是两条记录之间的开区间,它的作用只有一个——阻止别人在这个区间插入新记录,从而防止幻读。间隙锁之间是不互斥的,两个事务可以同时持有同一个间隙的锁,因为它们的目的都是「禁止插入」,不冲突。临键锁是记录锁加间隙锁的组合,锁的是左开右闭的区间,这是 RR 隔离级别下的默认加锁单位

    插入意向锁是插入前要获取的一种特殊间隙锁,如果这个位置已经被别人的间隙锁盖住了,就得等。这就是间隙锁阻塞插入的实现方式。

    实际影响很大的一点是:等值查询没命中记录时,临键锁会退化成间隙锁等值查询命中唯一索引时,会退化成记录锁,因为唯一索引已经保证不会再插入相同值,不需要锁间隙。这两条退化规则是死锁分析的基础。

    还有个很多人不知道的坑:如果查询条件没走索引,InnoDB 会给扫描到的所有行都加锁,等于把整张表锁住了。所以 updatedelete 的 where 条件一定要走索引,不然并发下会大面积阻塞,这个在线上出过事故。

  2. 为什么 RR 下容易死锁,怎么分析死锁日志?★★★

    因为 RR 要靠间隙锁防幻读,锁的范围从「一行」变成了「一个区间」,两个事务锁的区间很容易交叉重叠,形成互相等待。RC 下没有间隙锁,死锁概率就低很多,这也是很多公司改用 RC 的原因之一。

    最经典的死锁场景是两个事务都执行「先查不存在的记录,再插入」:查的时候各自拿到了同一个区间的间隙锁(间隙锁互相兼容,都能拿到),然后各自要插入就都需要插入意向锁,而插入意向锁跟对方的间隙锁冲突,于是互相等,死锁。这个场景在「先查后插」的幂等逻辑里非常常见。

    分析用 SHOW ENGINE INNODB STATUS,看 LATEST DETECTED DEADLOCK 那一段。重点看每个事务持有什么锁(HOLDS THE LOCK)、在等什么锁(WAITING FOR THIS LOCK),锁类型是 X 还是 S、有没有 gap before rec 这样的标记,再对照 SQL 反推加锁顺序。

    解决思路:把「先查后插」改成直接 insert ... on duplicate key update 或者靠唯一索引兜底捕获异常,避免间隙锁;批量操作前按主键排序保证所有事务加锁顺序一致;把隔离级别降到 RC;缩小事务范围让锁尽快释放。InnoDB 本身会自动检测死锁并回滚代价小的那个事务,所以业务代码要能处理死锁异常并重试。

  3. 深分页为什么慢,怎么优化?★★

    limit 1000000, 20 的问题在于 MySQL 得先把前一百万零二十行全部取出来(走二级索引的话还要逐行回表),然后把前一百万行丢掉,只返回最后 20 行。扫描和回表的成本全白费了。

    最好的优化是游标分页,也叫 seek 方法:记住上一页最后一条的主键,下一页用 where id > last_id order by id limit 20。这样直接从 B+ 树定位到位置,只扫 20 行,不管翻到多少页都是恒定耗时。缺点是不能跳页,只能上一页下一页,但移动端无限下拉的场景正好合适。

    如果产品必须支持跳页,用延迟关联:先在覆盖索引上只查主键 select id from t order by xx limit 1000000, 20,这一步不回表所以快得多,再拿这 20 个 id 去 join 原表取完整数据。

    还有一个思路是限制可翻页深度,比如只允许翻到 100 页,超过就引导用户加筛选条件。这不是技术妥协,而是符合实际的产品决策——真实用户不会翻到第一万页,会翻的基本是爬虫。这个回答比单纯说技术方案更有说服力。

四、日志与崩溃恢复

  1. redo log、undo log、binlog 各是干什么的,有什么区别?★★★

    redo log 是 InnoDB 的物理日志,记的是「在某个数据页的某个偏移量上做了什么修改」,用来崩溃恢复,保证持久性。它是固定大小循环写的,写满了就得把前面的脏页刷盘腾地方。undo log 是逻辑日志,记的是反向操作,用来回滚和支撑 MVCC 的版本链。这两个都是 InnoDB 引擎层的。

    binlog 是 MySQL server 层的逻辑日志,记的是 SQL 逻辑或者行变更,所有引擎都有。它是追加写的,不会覆盖,用途是主从复制和数据恢复,比如误删数据后用全量备份加 binlog 重放到某个时间点。

    核心区别就三点:层次不同(引擎层 vs server 层)、内容不同(物理 vs 逻辑)、写法不同(循环覆盖 vs 追加)。用途上最关键的分界是:redo log 保证的是「已提交的事务不丢」,binlog 保证的是「数据能被复制和还原」,一个对内一个对外,缺哪个都不行。

    有个常被追问的点:为什么有了 binlog 还要 redo log?因为 binlog 没有崩溃恢复能力——它不记录数据页的物理状态,恢复时不知道哪些页已经刷盘了、哪些没有,没法做到精确重放。这是历史原因加设计分工,InnoDB 当年是作为插件引擎加入的,必须自己解决持久性问题。

  2. 两阶段提交是怎么保证 redo log 和 binlog 一致的?★★★

    顺序是:先写 redo log 置为 prepare 状态,然后写 binlog 并落盘,最后把 redo log 改成 commit 状态。

    崩溃恢复时按这个规则判断:如果 redo log 里事务是 commit 状态,直接提交;如果是 prepare 状态,就去看 binlog 里有没有这个事务的完整记录,有就提交,没有就回滚。这样两个日志的状态就永远一致了。

    为什么必须两阶段?假设不这么做,只按顺序写完两个日志。如果先写 redo 后写 binlog,redo 写完崩溃了,恢复后数据是改了的,但 binlog 没记录,从库不知道这个变更,主从数据就不一致了;反过来先写 binlog 后写 redo,binlog 写完崩溃,主库这个改动丢了但从库执行了,同样不一致。而且用这份 binlog 做数据恢复也会多出一条本不该存在的数据。

    两阶段的本质是用一个中间状态把「两个不可分割的写操作」变成可判定的,崩溃在任何一个点上都能推断出正确结果。这个思路跟分布式事务的 2PC 是一样的,可以顺着讲过去。

    相关的两个参数常一起问:innodb_flush_log_at_trx_commit 设 1 表示每次提交都刷 redo 到磁盘,sync_binlog 设 1 表示每次提交都刷 binlog。这就是所谓的双 1 配置,最安全但性能损耗大;金融场景必须双 1,一般业务可以适当放宽换性能,代价是宕机可能丢最后一点数据。

  3. 什么是主从延迟,怎么解决?★★★

    主从复制的流程是:主库写 binlog,从库的 IO 线程拉过来写成 relay log,SQL 线程再回放。延迟主要出在回放这一步,因为主库的写是多线程并发的,而早期从库回放是单线程,天然跟不上。

    常见原因:从库配置比主库差、从库承担了大量读查询抢占资源、主库有大事务(比如一次删几百万行,binlog 巨大,从库要完整执行一遍)、大表 DDL、没有主键的表导致行级回放变成全表扫描。

    解决办法分几层。并行复制是最主要的手段,MySQL 5.7 支持基于组提交的并行回放,8.0 有 WriteSet 机制,能把不冲突的事务并行执行,效果明显,把 slave_parallel_workers 调上去。避免大事务,删数据要分批,每批几千行。补齐主键,这个很关键,row 格式下没主键的表在从库回放要逐行全表扫,延迟能放大成百倍。

    业务层面的应对更实际:写完之后需要立刻读的场景强制走主库,这是最常用的;或者用 select master_pos_wait() 等从库追上指定位点;或者把刚写的数据放进缓存,读的时候先看缓存。还有个思路是延迟感知,读之前判断从库延迟超过阈值就自动切主库,这个可以在中间件层统一做,业务代码不用改。

五、SQL 优化与线上问题

  1. 一条 SQL 很慢,你的排查思路是什么?★★★

    先确认「慢」是稳定慢还是偶发慢,这决定了排查方向,很多人一上来就 explain 反而绕远。

    稳定慢就是 SQL 本身的问题,看 explain:有没有走索引、扫了多少行、有没有 filesort 和临时表、join 的驱动表选得对不对。常见结论是缺索引、索引失效、返回字段太多、深分页、或者一次查了不该查的数据量。

    偶发慢基本不是 SQL 的问题,而是环境的问题。可能是锁等待,被别的事务堵住了,查 innodb_trxinnodb_lock_waits;可能是 Buffer Pool 命中率下降,数据被其他大查询冲掉了要重新读盘;可能是主库在刷脏页(innodb_io_capacity 设小了);可能是有大事务或者 DDL 在跑;也可能压根不是数据库,而是连接池被占满、网络抖动、或者应用 GC 停顿。

    工具上我会看慢查询日志(把 long_query_time 设到 100ms 这种级别)、show processlist 看当前在跑什么、performance_schema 看等待事件。如果是偶发问题,还得配合应用侧的 APM 数据看时间到底花在哪一段,因为「接口慢」不等于「SQL 慢」,很多时候是连接获取等待或者结果集处理慢。

    这题的加分点是区分这两类,然后说清各自的排查工具。只讲 explain 会被认为只做过开发环境的优化。

  2. join 的原理是什么,大表 join 怎么优化?★★★

    MySQL 的 join 本质上只有嵌套循环这一类思路,具体分三种实现。

    Index Nested-Loop Join 是最理想的:拿驱动表的每一行,去被驱动表走索引查找匹配,复杂度接近驱动表行数乘以索引查找代价,可以接受。

    Block Nested-Loop Join 是被驱动表关联字段没有索引时的退化方案:把驱动表数据批量读进 join_buffer,扫一遍被驱动表,拿每一行和 buffer 里所有行比较。这样把「扫 N 次被驱动表」降到「扫 buffer 装不下的次数」,但比较次数依然是乘法级,两个大表这么 join 会非常慢。

    Hash Join 是 8.0.18 之后加的:对驱动表建哈希表,再扫被驱动表探测。等值连接下比 BNL 快一个量级,MySQL 8 里已取代大部分 BNL 场景。

    优化第一原则是让它走 Index NLJ:被驱动表的关联字段必须有索引。这一条能解决绝大多数 join 慢的问题。

    第二是小表做驱动表。注意这里的「小」不是绝对行数,而是经过 where 过滤后参与 join 的数据量。优化器一般能选对,估算不准时用 STRAIGHT_JOIN 强制指定。

    第三个坑最容易踩:关联字段的类型和字符集必须完全一致。不一致会触发隐式转换导致索引失效,join 直接退化成 BNL。这在跨库、跨团队、历史表结构不统一的情况下极其常见,而且从 SQL 上完全看不出问题,只有 explain 能发现。

    关于阿里规范「禁止三表以上 join」,本质原因不是 join 慢,而是多表 join 的执行计划不可预测——数据量变化时优化器可能突然换方案,性能断崖式下跌且难以提前发现。所以核心链路我倾向拆成多条简单查询在应用层组装:多了几次网络往返,但每条 SQL 行为可控、能单独加缓存、也更容易分库分表。报表和后台查询对稳定性要求低,用 join 无所谓。

  3. 分库分表怎么做,分片键怎么选?★★★

    先分清垂直和水平。垂直拆分是按业务把表拆到不同库,或者把大字段拆到扩展表,缓解的是单库压力和 IO。水平拆分是把同一张表的数据按规则散到多个库表,解决的是单表数据量问题。真正麻烦的是水平拆分。

    分片键的选择是整个方案里最关键的决定,选错了几乎无法挽回。原则是用最高频查询条件做分片键。比如订单表,用户端查「我的订单」最频繁,那就用 user_id 分片,这样一个用户的订单都在一个分片上,查询不用跨库。如果用 order_id 分片,用户查自己的订单就得扫所有分片再聚合,性能和复杂度都很差。

    分片算法一般是取模或者一致性哈希。取模简单均匀,但扩容时数据要大面积迁移;按范围分(比如按时间)扩容方便、还能把冷数据归档,但容易出热点,最新的分片承担所有写入。实践中常用的做法是取模加预留,一上来就分足够多的表(比如 1024 张),物理库少的时候多张表放一个库,扩容时按库迁移而不动分片规则。

    接着必须解决几个衍生问题:主键不能用自增了,要用雪花算法或者号段模式;跨分片的分页和排序只能各分片查完在内存归并,所以要限制翻页深度;跨库事务只能靠柔性事务;order_id 这种非分片键的查询,做法是建一张基因映射表或者把 user_id 的几位编码进 order_id里,这样从 order_id 能直接算出分片位置,这个技巧叫基因法,答出来是加分项。

    最后要说的是,能不拆就不拆。先看能不能靠归档冷数据、加缓存、读写分离、优化索引解决,分库分表之后开发和运维复杂度是数量级上升的。

六、Redis 数据结构与原理

  1. Redis 单线程为什么这么快,6.0 引入的多线程做了什么?★★

    快的原因按重要性排:数据全在内存里,这是决定性的;数据结构针对场景做了精细优化,每种类型都有小数据量和大数据量两套实现;单线程避免了锁竞争和上下文切换;用 IO 多路复用(epoll)加自己实现的事件循环,一个线程就能高效处理成千上万个连接。

    要先纠正一个说法:「Redis 是单线程」并不准确,准确说是命令执行是单线程。它一直都有后台线程在做别的事——持久化的 fork 子进程、异步删除大 key(lazyfree)、AOF 刷盘。这个区分说出来能显示理解得比较细。

    6.0 引入的多线程只用在网络 IO 上,也就是从 socket 读请求数据、解析协议、以及把响应写回 socket 这几步交给多个线程并行做,命令的实际执行仍然是单线程串行的。这样既利用了多核处理网络 IO,又保留了单线程执行的最大好处——不用加锁,不存在并发数据竞争,代码复杂度可控。

    为什么瓶颈会落在网络 IO 上?因为内存操作实在太快了,一条 get 的执行时间可能只有几十纳秒,而 socket 读写和协议解析花的时间反而更多。所以把这部分并行化收益最大。这也印证了一个通用道理:优化要打在真正的瓶颈上,而不是想当然地去优化看起来最核心的那部分。

    由单线程模型还能推出一条重要的实践结论:任何一条慢命令都会阻塞所有其他请求。所以生产环境要禁掉 keys *,不要对大集合执行 hgetallsmemberslrange 0 -1,删大 key 要用 unlink。这跟多线程服务器「一个慢请求只影响一个线程」的情况完全不同。

  2. Redis 常用数据结构的底层实现是什么?★★★

    String 底层是 SDS,简单动态字符串。相比 C 字符串,它记录了长度所以取长度是 O(1),能二进制安全地存任意数据,还有预分配和惰性释放减少内存重分配次数。整数值会直接用 int 编码存,短字符串用 embstr(和对象头连续分配,只需一次内存分配),超过 44 字节用 raw。

    List 早期是 ziplist 加 linkedlist,3.2 之后统一用 quicklist,本质是双向链表串起来的一堆 ziplist,兼顾了链表的插入效率和压缩列表的内存紧凑。Redis 7 之后 ziplist 换成了 listpack,解决了 ziplist 的连锁更新问题。

    Hash 元素少且小的时候用 listpack(老版本 ziplist),超过阈值转 hashtable。Set 全是整数且数量少用 intset(有序数组,可以二分查找),否则用 listpack 或 hashtable。ZSet 元素少用 listpack,多了用 skiplist 加 hashtable 的组合,跳表负责范围查询和排序,哈希表负责 O(1) 按成员查分值,两个结构共享同一份数据。

    这里的设计思想值得说:Redis 对每种类型都准备了「小数据量的紧凑结构」和「大数据量的高效结构」两套,根据实际大小自动切换,而且只能从紧凑转高效,不能反向转回去。因为绝大多数 key 存的数据量都很小,紧凑编码能省下大量内存,这在动辄几千万 key 的实例上非常关键。

  3. ZSet 为什么用跳表不用红黑树或 B+ 树?★★★

    几个原因。范围查询上跳表天然占优,找到起点之后顺着最底层链表往后走就行;红黑树要做中序遍历,实现复杂得多。实现和维护成本上,跳表就是链表加多级索引,插入删除只需要改几个指针加一次随机层数,代码量小很多;红黑树的旋转和变色逻辑复杂,容易出 bug——对于一个要长期维护的开源项目,这个因素的实际权重很高,作者本人也提过这点。

    还有一点是并发友好,跳表可以做到局部加锁甚至无锁,红黑树的旋转会影响树的大范围结构。虽然 Redis 是单线程用不上,但这是跳表在其他系统里流行的原因。

    不用 B+ 树是因为 B+ 树是为磁盘设计的,它的核心优化是「用一次 IO 读一整页」来降低树高。Redis 数据全在内存里,随机访问没有代价,B+ 树那套按页组织的复杂度带不来收益,反而浪费空间。

    跳表的空间开销是平均每个节点多 1 到 2 个指针(Redis 用的概率是 1/4,比经典的 1/2 更省内存),比红黑树的两个子指针加一个父指针其实还更省。所以这是个各方面都合适的选择,不是妥协。

  4. Redis 的过期删除和内存淘汰策略是怎样的?★★★

    过期删除用的是惰性删除加定期删除的组合。惰性删除是访问某个 key 时才检查是否过期,过期了就删并返回空,好处是完全没有额外开销,坏处是不访问就永远占着内存。定期删除是每 100ms 随机抽一批带过期时间的 key 检查,过期比例超过 25% 就继续抽,直到低于阈值或者超时。这里用随机抽样而不是全扫,就是为了不阻塞主线程。

    两者配合的意义是在 CPU 和内存之间取平衡:定期删除保证过期 key 不会无限堆积,惰性删除保证不会有已过期的数据被读到。代价是内存里始终可能残留一部分已过期但还没删的 key。

    内存不够时才触发淘汰策略,八种,实际常用的就 allkeys-lru(所有 key 里淘汰最久未用)和 volatile-lru(只淘汰设了过期时间的),还有 4.0 加的 allkeys-lfu 按访问频率淘汰。默认是 noeviction,也就是不淘汰、直接对写操作返回错误,这个默认值在生产环境通常是要改的,不然内存满了服务就写不进去了。

    Redis 的 LRU 不是标准 LRU,它没有维护完整的访问链表,而是在每个对象里记一个时间戳,淘汰时随机采样几个(maxmemory-samples 默认 5)挑最旧的,是个近似算法。这样省掉了维护链表的内存和时间开销,精度差一点但完全够用。LFU 则是用一个计数器加衰减机制,能识别「曾经很热但现在不用了」的 key,缓存场景下命中率通常比 LRU 更好。

  5. Redis 的 pipeline、事务、Lua 脚本分别用在什么场景?★★★

    三个都是「批量发命令」,但语义完全不同,混淆了会出错。

    pipeline 解决的是网络往返(RTT)问题,不提供任何原子性保证。它把多条命令一次性发出、一次性收回结果,把 N 次 RTT 压成 1 次。这是收益最大也最容易被忽略的优化——RTT 1ms 的话,100 条命令串行要 100ms,pipeline 只要几毫秒,跨机房或跨国部署时差距更夸张。注意 pipeline 里的命令可能和其他客户端的命令交错执行,也不能依赖前一条的结果(要一次性发完)。单批不要太大,否则缓冲响应会占大量内存,一般几百到几千条一批。

    事务(MULTI/EXEC)提供的是「不被打断」:入队的命令连续执行,中间不会插入其他客户端的命令。但它不是关系数据库的事务,两个关键差异要说清:不支持回滚——某条命令运行时出错(比如对 String 执行 lpush),其他命令照样执行,已执行的不撤销;不能在事务里读取结果再决策,因为命令是入队后统一执行的,拿不到中间结果。所以它的实用性其实很有限。配合 WATCH 能做乐观锁(被监视的 key 改过就放弃执行),但又要自己处理重试。

    Lua 脚本才是真正推荐的原子操作方案。整个脚本在服务端作为一个整体执行、期间不被打断,而且可以在脚本内部读取中间结果做逻辑判断——这是它和事务的本质区别。所以「先判断再操作」这类复合逻辑必须用 Lua,典型场景是分布式锁释放(比对持有者标识再删除)、库存扣减(判断够不够再扣)、限流(令牌桶计算)。

    Lua 的注意事项:脚本必须短小、无耗时操作、无死循环,因为它会阻塞整个 Redis;所有 key 必须通过 KEYS 数组传入而不是在脚本里拼接,否则集群模式下无法正确路由;集群下脚本涉及的所有 key 必须在同一个槽,要用 hash tag 保证;用 SCRIPT LOADEVALSHA 可以避免重复传输脚本内容。

    选择判断很简单:只想减少网络往返用 pipeline;需要原子性且有条件判断用 Lua;Redis 事务实践中基本可以不用,Lua 能覆盖它的所有场景而且更强。

  6. 什么是大 key 和热 key,怎么发现和解决?★★★

    大 key 是单个 value 特别大,比如一个几十兆的 String,或者一个存了几百万元素的 Hash。危害是:Redis 单线程,读一个大 key 会阻塞后面所有命令;网络传输占满带宽;删除大 key 更危险,一次释放大量内存会造成明显卡顿(4.0 之后可以用 unlink 异步删除);集群模式下还会导致分片内存严重不均。

    发现手段:redis-cli --bigkeys 扫一遍(用的是 scan,不阻塞),或者分析 RDB 文件(rdb_bigkeys 这类工具),或者用 memory usage 命令看单个 key。生产上我倾向定期跑 RDB 分析,因为它不占线上资源。

    解决就是。大 Hash 按字段哈希拆成多个小 Hash;大 List 按范围拆;单个大 String 考虑压缩或者干脆别放 Redis,放对象存储只在 Redis 存个地址。设计阶段就要预估集合类 key 的最大元素数量,这是最省事的。

    热 key 是某个 key 访问量特别高,把单个分片的 CPU 或网卡打满,典型场景是秒杀商品、明星微博。发现靠 redis-cli --hotkeys(需要 LFU 策略)、monitor 抽样(慎用,本身有性能影响)、或者在客户端做本地统计上报。

    解决有三招:本地缓存,在应用进程里缓存几秒,绝大部分请求压根不到 Redis,这是最有效的;拆分打散,把一个 key 复制成 N 个副本 key_1key_N,读的时候随机选一个,把压力分到不同分片;读写分离,加从节点分担读。实际做法通常是本地缓存加多副本一起上。

七、Redis 持久化与内存

  1. RDB 和 AOF 的区别,怎么选?★★

    RDB 是内存快照,二进制紧凑格式。优点是文件小、恢复快(直接加载到内存,不用逐条重放)、适合做备份和主从全量同步。缺点是会丢数据,因为是间隔性的,两次快照之间宕机就全丢了。

    AOF 记录的是每条写命令,追加写。优点是丢数据少,appendfsync everysec 的话最多丢一秒。缺点是文件大得多、恢复慢(要一条条重放)。

    RDB 的生成靠 fork 出子进程加写时复制:fork 出来的子进程和父进程共享内存页,父进程要修改某个页时先复制一份再改,子进程始终看到 fork 那一刻的数据。这个机制的代价是fork 本身会阻塞主进程(要复制页表,内存越大越慢,几十 G 的实例可能阻塞几百毫秒),而且如果 fork 期间写入很多,内存占用可能接近翻倍。这是 Redis 持久化最需要注意的性能点。

    选择上,生产环境基本都是两个都开:AOF 保证故障时丢的数据少,RDB 作为备份手段和快速恢复的兜底。Redis 4.0 之后有混合持久化,AOF 重写时把当前数据以 RDB 格式写进 AOF 文件开头,后面继续追加增量命令,这样兼具了 RDB 恢复快和 AOF 丢数据少两个优点,这是现在的推荐配置。

    不过有个前提要说清:如果 Redis 纯粹当缓存用,数据都能从数据库重建,那可以两个都关掉,省掉 fork 带来的抖动,重启后让缓存慢慢预热就行。这个取舍取决于 Redis 在你的架构里是缓存还是存储。

  2. Redis 变慢了怎么排查?★★★

    先用 redis-cli --latency 或者 --intrinsic-latency 确认基线延迟,区分是 Redis 自身慢还是网络慢。然后按几个方向查。

    慢命令slowlog get 看有没有 keys *hgetall 大 Hash、smembers 大 Set 这类 O(N) 命令。生产环境应该直接把 keysflushallrename-command 禁掉,改用 scan

    大 key 操作:删除或者读取大 key 会阻塞,前面说的。集中过期:大量 key 设了同一个过期时间,定期删除任务一次要清很多,造成周期性卡顿,解法是过期时间加个随机值打散。

    fork 阻塞:RDB 生成或 AOF 重写时 fork,看 info stats 里的 latest_fork_usec,实例内存大就会明显。AOF 刷盘appendfsync always 每条命令都刷盘,磁盘慢的话直接拖垮 Redis。

    内存交换:这个最致命,一旦 Redis 的内存被操作系统 swap 到磁盘,性能会下降几个数量级,看 /proc/<pid>/smaps 确认。所以生产环境要保证物理内存充足,maxmemory 不要设到接近物理内存。

    最后别忘了不是 Redis 的问题的可能:客户端连接池不够导致等待、网络带宽被大 value 打满、客户端和 Redis 跨机房、或者应用侧 GC 停顿导致的假慢。我排查时会先看客户端和服务端两侧的耗时监控对比,如果服务端 P99 正常而客户端很慢,问题就在网络或客户端。

八、缓存问题与一致性

  1. 缓存穿透、击穿、雪崩分别是什么,怎么解决?★★

    穿透是查一个数据库里压根不存在的数据,缓存里自然没有,每次都打到数据库。恶意攻击时用随机 ID 刷接口,数据库直接被打挂。解法一是缓存空值并设个较短的过期时间,二是布隆过滤器,把所有存在的 ID 提前放进过滤器,查询前先判断,不存在就直接返回。布隆过滤器有假阳性(说存在可能不存在)但没有假阴性(说不存在就一定不存在),正好满足这个场景。三是参数校验,ID 明显不合法的直接挡掉。

    击穿是某个热点 key 过期的瞬间,大量并发请求同时发现缓存没了,一起去查数据库。解法是互斥锁,只让一个线程去查数据库重建缓存,其他线程等待或者返回旧值;或者逻辑过期,value 里存一个过期时间字段而 key 本身永不过期,发现逻辑过期就开个异步线程去更新,当前请求先返回旧数据,这样完全不阻塞,代价是有短暂的数据不一致。热点数据我更倾向逻辑过期加异步更新。

    雪崩是大量 key 同时过期,或者 Redis 整个挂了,请求全压到数据库。前者解法是过期时间加随机值打散;后者要靠高可用(哨兵或集群)、加本地缓存做二级兜底、以及给数据库加限流和熔断,宁可拒绝一部分请求也不能让数据库彻底挂掉——数据库一挂,恢复时间会长得多。

    三个概念的区别在量级和成因:穿透是数据不存在,击穿是单个热点 key 失效,雪崩是大面积失效。面试时把这个区分讲清楚,比背解法更能显示理解。

  2. 缓存和数据库的双写一致性怎么保证?★★★

    先说结论:强一致做不到,只能追求最终一致,除非用分布式锁把读写全串行化,但那样缓存就失去意义了。所以这题的重点是分析各种方案的不一致窗口有多大。

    四种组合里,先更新数据库再删缓存(Cache Aside)是标准做法。为什么是删而不是更新缓存?因为更新缓存有并发写覆盖的问题——两个线程分别写库和写缓存,顺序交错就可能把旧值写进缓存;而且如果缓存值需要复杂计算,每次写都算一遍很浪费,删掉让下次读的时候按需重建更划算。

    为什么是先写库后删缓存,而不是先删缓存?如果先删缓存再写库,删完之后写库还没完成,这时候来一个读请求,会把旧值重新加载进缓存,之后就一直是旧的,不一致窗口很长。反过来先写库后删缓存,出问题的窗口只存在于「读请求刚好在写库完成前读到旧值,又在删缓存之后才写入缓存」这个极窄的时间点,概率低很多。

    延迟双删是针对上面那个窄窗口和主从延迟的补丁:写完库删一次缓存,过几百毫秒再删一次,把可能在中间被写入的脏数据清掉。缺点是延迟多久很难定,本质是靠猜。

    更可靠的是订阅 binlog,用 Canal 监听数据库变更再去删缓存。好处是彻底解耦,业务代码只管写库,而且 binlog 是最终事实,不会漏;配合消息队列还能重试保证删除一定成功。缺点是引入了新组件和一点延迟。对一致性要求高的核心业务我会用这个方案。

    最后还有个兜底手段最容易被忽略:给缓存设一个不太长的过期时间。就算所有删除机制都失效了,最多不一致到过期为止。这是成本最低、最有效的保险。

  3. Redis 实现分布式锁要注意什么,Redisson 是怎么做的?★★★

    最简版是 SET key value NX EX 30,一条命令搞定加锁和设过期时间。这里有几个细节必须做对。

    必须原子:不能先 setnxexpire,中间宕机就成了永久锁,必须用带 NX 和 EX 参数的一条 SET。value 必须唯一:存一个 UUID 或者线程标识,释放时要先检查是不是自己的锁再删,不然可能删掉别人的——想象 A 的锁超时自动释放了,B 拿到锁,这时 A 执行完来删锁,就把 B 的锁删了。检查加删除也必须原子,所以释放要用 Lua 脚本把「比对 value」和「删除」放在一起执行。

    最难的是业务执行时间超过锁过期时间。设短了会提前释放导致并发,设长了故障时锁迟迟不释放。Redisson 的解法是看门狗:默认锁 30 秒,同时启动一个后台任务每 10 秒(三分之一时间)检查一下,业务还没执行完就自动续期到 30 秒。这样只要进程活着锁就不会过期,进程一挂看门狗停止,锁最多 30 秒后自动释放,不会死锁。注意只有不指定过期时间时看门狗才生效,如果你自己传了 leaseTime,Redisson 就不续期了,这个坑很多人踩。

    Redisson 还用 Hash 结构存锁,field 是线程标识,value 是重入次数,从而支持可重入;加解锁全部用 Lua 保证原子性;释放锁时用发布订阅通知等待者,避免无脑轮询。

    另外一个容易忽略的点:主从架构下分布式锁本身是不可靠的。主节点写入锁之后还没同步到从节点就挂了,从节点升主,锁就丢了,两个客户端会同时持有锁。RedLock 试图用多个独立节点来解决,但需要向过半节点加锁成功,实现复杂、对时钟敏感,Martin Kleppmann 和 antirez 有过著名的争论。我的实际看法是:要绝对正确就别用 Redis,用 ZooKeeper 或者数据库唯一约束;用 Redis 锁就要接受极小概率的失效,业务上再加一层幂等兜底,这才是工程上更实用的做法。

九、高可用与集群

  1. Redis 主从复制的流程是怎样的?★★

    全量同步增量传播两个阶段。

    第一次连接走全量:从节点发 psync,主节点 fork 子进程生成 RDB 快照发过去,同时把这期间的新写命令记进复制缓冲区,RDB 传完再把缓冲区里的命令补发。从节点加载完 RDB 再执行这批增量命令,就追上了。之后进入命令传播阶段,主节点把每条写命令实时转发给从节点。

    断线重连不需要重做全量。主节点维护一个环形的 repl_backlog 缓冲区,从节点重连时带上自己的复制偏移量(offset)和主节点的 runid,如果这个偏移量还在 backlog 覆盖范围内,主节点就只补发缺失的那一段,这叫部分重同步。如果偏移量已经被环形缓冲覆盖掉了(断开太久或者期间写入量太大),就只能退回全量同步。

    所以 repl-backlog-size 这个参数很关键,设小了网络稍微抖动一下就触发全量同步,而全量同步要 fork、生成 RDB、传大文件,对主节点冲击很大,极端情况会引发连锁反应(主节点因为 fork 卡顿导致更多从节点超时重连,又触发更多全量同步)。写入量大的实例我会把它调到几十上百兆。

    必须理解的一点是:Redis 的主从复制是异步的。主节点写完立刻返回给客户端,不等从节点确认。所以主节点宕机时,还没同步出去的数据就丢了WAIT 命令可以让客户端阻塞等待指定数量的从节点确认,但那会牺牲性能,而且也不是真正的强一致(它只保证「已经同步到了 N 个从节点」,不保证故障切换时一定不丢)。

    这个异步的特性是理解脑裂丢数据的基础,也是为什么 Redis 不适合作为唯一的数据存储——它的定位是缓存和高性能数据结构服务,需要不丢数据的场景要么用数据库兜底,要么接受这个风险并有对账机制。

    另外 4.0 之后有psync2,改进了主从切换后的复制:新主节点会保留原主的复制 ID,这样其他从节点切过来时能继续走部分重同步而不是全量,这在故障转移的场景下大幅降低了冲击。

  2. 哨兵是怎么工作的,什么是脑裂?★★★

    哨兵负责监控、通知、自动故障转移和配置中心四件事。它每秒 ping 一次所有节点,某个哨兵发现主节点没响应,先标记为主观下线,然后询问其他哨兵,超过 quorum 个哨兵都认为它下线了,就升级为客观下线。接着哨兵之间用 Raft 选出一个 leader 来执行故障转移:从从节点里挑一个(按优先级、复制偏移量、runid 排序)提升为主,让其他从节点重新指向它,并通知客户端新的主节点地址。

    哨兵至少要部署三个且是奇数,因为选举需要过半同意,两个哨兵挂一个就没法选举了。

    脑裂是网络分区导致出现两个主节点。原主节点其实没死,只是和哨兵、从节点之间的网络断了,哨兵以为它挂了就提升了一个新主。这时候如果客户端还连着老主节点在写数据,就出现两个主同时接受写入。等网络恢复,老主节点会被降级为从节点并清空自己的数据去同步新主,那段时间写进老主的数据就全丢了。

    缓解办法是两个参数配合:min-replicas-to-write 要求主节点至少有 N 个从节点连着才允许写,min-replicas-max-lag 要求从节点的延迟不超过多少秒。这样一旦主节点发现自己失去了足够的从节点,就主动拒绝写入,宁可不可用也不接受可能会丢的数据。这是个典型的 CAP 权衡:牺牲一部分可用性来保住一致性。

    要说清的是这只能缩小丢数据的窗口,不能根除,因为异步复制的本质决定了总有数据在飞行中。要真正不丢就得用同步复制,代价是延迟。

  3. Redis Cluster 的槽位是怎么设计的,为什么是 16384 个?★★★

    Cluster 把整个键空间分成 16384 个槽,每个节点负责一部分。定位一个 key 就是 CRC16(key) % 16384 算出槽号,再找到负责这个槽的节点。客户端会缓存槽和节点的映射关系,直接连对应节点,所以正常情况下没有额外跳转。如果映射过期了访问错节点,会收到 MOVED 重定向,客户端顺便更新缓存。

    16384 这个数字的原因,作者本人解释过:节点之间的心跳包里要用位图携带槽的分配信息,16384 位就是 2KB,如果用 65536 个槽就要 8KB,心跳包每秒都在发,这个开销放大了很多倍。而 Redis Cluster 的设计上限是大约 1000 个节点,16384 个槽足够均匀分配了,再多没必要。这是个「刚好够用且开销最小」的工程取舍。

    用槽而不是直接哈希到节点,好处是解耦了数据分布和节点数量。扩容缩容时移动的是槽,节点数变化不影响哈希算法,只要把一部分槽从旧节点迁到新节点即可,迁移期间还能通过 ASK 重定向保证正在迁移的槽也能正常访问。这比一致性哈希更可控,因为槽的分配是显式的,可以人工干预让数据更均匀。

    使用上有几个限制要知道:不支持跨槽的多 key 操作mget 几个 key 落在不同槽就会报错,解决办法是用 {} 指定 hash tag,比如 {user1}:name{user1}:age 只用花括号里的部分算哈希,就能保证落到同一个槽。同理事务和 Lua 脚本里操作的 key 也必须在同一个槽。只有 db0,不支持多数据库。

没有匹配的题目,换个关键词试试。

模块 02 · 共 34 题 · 题目与答案分离,建议先自答再展开