数据库基础知识总结

什么是数据库,数据库管理系统,数据库系统,数据库管理员

四个概念描述了从数据本身到管理整个体系的不同层次,这里用一个图书馆的例子来把它们串联起来理解。

  • 数据库(DataBase/DB): 它就像是图书馆里存放的所有书籍和资料。从技术上讲,数据库就是按照一定数据模型组织、描述和储存起来的、可以被各种用户共享的结构化数据的集合。数据库就是我们最终要存取的信息本身。
  • 数据库管理系统(Database Management System/DBMS): 它就像是整个图书馆的管理系统,包括图书的分类编目规则、借阅归还流程、安全检查系统等。从技术上讲是一种大型软件,比如常用的MysqlPostgreSQL等。它的核心职责是科学地组织和存储数据、高效地获取和维护数据;为我们屏蔽了底层文件操作的复杂性,提供了一套标准接口(如SQL)来操纵数据,并负责并发控制、事务管理、权限控制等复杂问题。
  • 数据库系统(Database System/DBS): 它就是整个正常运转的图书馆。这是一个更大的概念,不仅包括书(DB)和管理系统(DBMS),还包括了硬件、应用和使用的人。
  • 数据库管理员(Database Administrator/DBA): 他就是图书馆的馆长,负责整个数据库系统正常运行。他的职责非常宽泛,包括数据库的设计、安装、监控、性能调优、备份与恢复、安全管理等,确保整个系统的稳定、高效和安全。

DB和DBMS我们通常会搞混,这里再简单提一下:通常我们说“用MySQL数据库”,其实是用MySQL(DBMS)来管理一个或多个数据库(DB)。

DBMS有哪些主要的功能

主要提供四大核心功能:

  • 数据定义: 这是 DBMS 的基础。它提供了一套数据定义语言(Data Definition Language/DDL),让我们能创建、修改和删除数据库中的各种对象。这不仅仅是定义表的结构(比如字段名、数据类型),还包括定义视图、索引、触发器、存储过程等。
  • 数据操作: 这是我们作为开发者日常使用最多的功能。它提供了一套数据操作语言(Data Manipulation Language/DML),核心就是我们熟悉的增、删、改、查(CRUD)操作。它让我们能够方便地对数据库中的数据进行操作和检索。
  • 数据控制: 这是保证数据正确、安全、可靠的关键。通常包含并发控制、事务管理、完整性约束、权限控制、安全性限制等功能。
  • 数据库维护: 这部分功能是为了保障数据库系统的长期稳定运行。它包括了数据的导入导出、数据库的备份与恢复、性能监控与分析以及系统日志管理等。

你知道哪些类型的DBMS

关系型数据库

除了我们最常用的关系型数据库(RDBMS),比如MySQL(开源首选)、PostgreSQL(功能最全)、Oracle(企业级),它们基于严格的表结构和 SQL,非常适合结构化数据和需要事务保证的场景,例如银行交易、订单系统。

近年来,为了应对互联网应用带来的海量数据、高并发和多样化数据结构的需求,涌现出了一大批NoSQLNewSQL数据库。

NoSQL 数据库

它们的共同特点是为了极致的性能和水平扩展能力,在某些方面(通常是事务)做了妥协。

  1. 键值数据库,代表是 Redis
    • 特点: 数据模型及其简单,就是一个巨大的Map,通过Key来存取Value。内存操作,性能极高。
    • 使用场景: 非常适合做缓存、会话存储、计数器等对读写要求极高的场景。
  2. **文档数据库,代表是 MongoDB **
    • 特点: 它存储的是半结构化的文档(比如 JSON/BSON),结构灵活,不需要预先定义表结构。
    • 使用场景: 特别适合那些数据结构多变、快速迭代的业务,比如用户画像、内容管理系统、日志存储等。
  3. 列式数据库,代表是 HBase, Cassandra
    • 特点: 数据是按列族而不是按行来存储的。这使得它在对大量行进行少量列的读取时,性能极高。
    • 适用场景: 专为海量数据存储和分析设计,非常适合做大数据分析、监控数据存储、推荐系统等需要高吞吐量写入和范围扫描的场景。
  4. 图形数据库,代表是 Neo4j
    • 特点: 数据模型节点(Nodes)和边(Edges),专门用来存储和查询实体之间的复杂关系。
    • 适用场景: 在社交网络(好友关系)、推荐引擎(用户-商品关系)、知识图谱、欺诈检测(资金流动关系)等场景下,表现远超关系数据库。

NewSQL 数据库

由于NoSQL不支持事务,很多对于数据安全要求非常高的系统(比如财务系统、订单系统、交易系统)就不太适合使用了。但是这些系统往往有存储大量数据的需求,解决这个问题往往只能通过购买更好的计算机或者是通过数据库中间件来提高存储能力。不过,前者的金钱成本太高,后者的开发成本太高,NewSQL 因此而被设计出来。

简单来说,NewSQL就是:分布式存储+SQL+事务。NewSQL 不仅具有 NoSQL 对海量数据的存储管理能力,还保持了传统数据库支持 ACID 和 SQL 等特性。因此,NewSQL 也可以称为分布式关系型数据库

NewSQL 数据库设计的一些目标:

  1. 横向扩展(Scale Out): 通过增加机器的方式来提高系统的负载能力。与之类似的是 Scale Up(纵向扩展),升级硬件设备的方式来提高系统的负载能力。
  2. 强一致性(Strict Consistency): 在任意时刻,所有节点中的数据是一样的。
  3. 高可用(High Availability): 系统几乎可以一直提供服务。
  4. 支持标准 SQL(Structured Query Language): PostgreSQLMySQLOracle 等关系型数据库都支持 SQL。
  5. 事务(ACID): 原子性(Atomicity)、一致性(Consistency)、 隔离性(Isolation)、 持久性(Durability)。
  6. 兼容主流关系型数据库: 兼容MySQLOraclePostgreSQL等常用关系型数据库。
  7. 云原生(Cloud Native): 可在公有云、私有云、混合云中实现部署工具化、自动化。
  8. HTAP(Hybrid Transactional/Analytical Processing): 支持 OLTP 和 OLAP 混合处理。

NewSQL数据库代表: Google 的 F1/Spanner、阿里的 OceanBase、PingCAP 的 TiDB

什么是元组、码、候选码、主码、外码、主属性、非主属性

在关系型数据库理论中,理解这些核心观念对于数据库设计和规范化至关重要。这些概念构成了关系数据库的理论基础。

基础概念

  • 元组(Tuple): 元组是关系数据库中的基本单位,在二维表中对应一行记录。每个元组包含了一个实体的完整信息。例如,在学生表中,每个学生的完整信息构成了一个元组。
  • 码(Key): 码是能够唯一标识关系中元组的一个或多个属性的集合。码的主要作用是保证数据的唯一性和完整性。

码的分类

  • 候选码(Candidate Key): 候选码是能够唯一标识元组的最小属性集合,其任何真子集都不能唯一标识元组。一个关系可能有多个候选码。例如,在学生表中,如果学号能唯一标识学生同时身份证号也能唯一标识的话,那么{学号}和{身份证号}都是候选码。
  • 主码/主键(Primary Key): 主码是从候选码中选择的一个,用于唯一标识关系中的元组。每个关系只能有一个主码,但可以有多个候选码。选择主码时通常考虑:简单性、稳定性、无业务含义等因素。
  • 外码/外键(Foreign Key): 外码是一个关系中的属性和属性组,它对应另一个关系的主码。外码用于建立和维护两个关系之间的联系,是实现参考完整性的重要机制。例如,在选课表中的学号如果引用学生表的主码学号,则选课表中的学号就是外码。

属性分类

  • 主属性(Prime Attribute): 主属性是包含在任何一个候选码中的属性。如果一个关系有多个候选码,那么这些候选码中出现的所有属性都是主属性。例如,工人关系(工号,身份证号,姓名,性别,部门)中,如果{工号}和{身份证号}都是候选码,那么“工号”和“身份证号”都是主属性。
  • 非主属性(Non-prime Attribute): 非主属性是不包含在任何候选码中的属性。这些属性完全依赖于候选码来确定其值。在上述工人关系中,“姓名”、“性别”、“部门”都是非主属性。

什么是ER图

经常被问到的一个点!!!

ER 图全称是Entity Relationship Diagram(实体联系图),提供了表示实体类型、属性和联系的方法。

ER 图由下面3个要素组成:

  • 实体: 通常是现实世界的业务对象,当然使用一些逻辑对象也可以。比如对于一个校园管理系统,会涉及学生、教师、课程、班级等等实体。在 ER 图中,实体使用矩阵框表示。
  • 属性: 即某个实体拥有的属性,属性用来描述组成实体的要素,对于产品设计来说可以理解为字段。在 ER 图中,属性使用椭圆形表示。
  • 联系: 即实体与实体之间的关系,在 ER 图中用菱形表示,这个关系不仅有业务关联关系,还能通过数字表示实体之间的数量对照关系。例如,一个班级会有多个学生就是一种实体之间的联系。

下图是 ER 图的一个实例,展示的实体之间的关系有1对1(1:1),1对多(1:N),此外还有多对多(M:N)。

ER图示例

数据库范式

数据库范式有3种:

  • 1NF(第一范式):属性不可再分。
  • 2NF(第二范式):1NF的基础之上,消除了非主属性对于码的部分函数依赖。
  • 3NF(第三范式):3NF在2NF的基础之上,消除了非主属性对于码的传递函数依赖。

1NF(第一范式)

属性(对应表中的字段)不能再被分割,也就是这个字段只能是一个值,不能再分为多个其他的字段了。1NF是所有关系型数据库的最基本要求,也就是说关系型数据库中创建的表一定满足第一范式。

2NF(第二范式)

2NF 在 1NF 的基础之上,消除了非主属性对于码的部分函数依赖。如下图所示,展示了第一范式到第二范式的过渡。第二范式在第一范式的基础上增加了一个列,这个列称为主键,非主属性都依赖于主键。

第二范式示例

一些重要的概念:

  • 函数依赖(functional dependency): 若在一张表中,在属性(或属性组) X 的值确定的情况下,必定能确定属性 Y 的值,那么就可以说 Y 函数依赖于 X,写作X -> Y
  • 部分函数依赖(partial functional dependency): 如果X -> Y,并且存在 X 的一个真子集 Xo,使得Xo -> Y,则称 Y 对 X 部分函数依赖。比如学生基本信息表 R 中(学号,身份证号,姓名)当然学号属性取值是唯一的,在 R 关系中,(学号,身份证号) -> (姓名),(学号) -> (姓名),(身份证号) -> (姓名);所以姓名部分函数依赖于(学号,身份证号)。
  • 完全函数依赖(full functional dependency): 在一个关系中,若某个非主属性数据项依赖于全部关键字称之为完全函数依赖。比如学生基本信息表 R(学号,班级,姓名)假设不同的班级学号有相同的,班级内学号不能相同,在 R 关系中,(学号,班级) -> (姓名),但是(学号) -> (姓名) 不成立,(班级) -> (姓名) 不成立,所以姓名完全函数依赖于(学号,班级)。
  • 传递函数依赖: 在关系模式 R(U)中,设 X,Y,Z是 U 的不同的属性子集,如果 X 确定 Y、Y 确定 Z,且有 X 不包含 Y,Y 不确定 X,(X∪Y)∩Z={},则称 Z 传递函数依赖(transitive functional dependency)于 X。传递函数依赖会导致数据冗余和异常。传递函数依赖的 Y 和 Z 子集往往同属于某一个事物,因此可将其合并放到一个表中。比如在关系 R(学号,姓名,系名,系主任)中,学号 -> 系名,系名 -> 系主任,所以存在非主属性系主任对于学号的传递函数依赖。

3NF(第三范式)

3NF 在 2NF 的基础之上,消除了非主属性对于码的传递函数依赖。符合 3NF 要求的数据库设计,基本上解决了数据冗余过大,插入异常,修改异常,删除异常的问题。比如在关系 R(学号 , 姓名, 系名,系主任)中,学号 -> 系名,系名 -> 系主任,存在非主属性系主任对于学号的传递函数依赖,所以该表的设计不符合 3NF 的要求。

主键和外键的区别

从定义和属性上看,它们的区别是:

  • 主键(Primary Key): 它的核心作用是唯一标识表中的每一行数据。因此,主键列的值必须是唯一的(Unique)且不能为空(Not Null)。一张表只能有一个主键。主键保证了实体完整性。
  • 外键(Foreign Key): 它的核心作用是建立并强制两张表之间的关联关系。一张表中的外键列,其值必须对应另一张表中某行的候选键值(通常是主键,也可以是唯一键),或者是一个NULL值。因此,外键的值可以重复,也可以为空。一张表可以有多个外键,分别关联到不同的表。外键保证了引用完整性。

用一个简单的电商例子来说明:假设我们有两张表:users (用户表) 和 orders (订单表)。

  • users 表中,user_id 列是主键。每个用户的 user_id 都是独一无二的,我们用它来区分张三和李四。
  • orders 表中,order_id 是它自己的主键。同时,它会有一个 user_id 列,这个列就是一个外键,它引用了 users 表的 user_id 主键。

这个外键约束就保证了:

  1. 你不能创建一个不属于任何已知用户的订单( user_idusers 表中不存在)。
  2. 你不能删除一个已经下了订单的用户(除非设置了级联删除等特殊规则)。

为什么不推荐使用外键与级联?

【强制】不得使用外键和级联,一切外键概念必须在应用层解决。

解释:以学生和成绩的关系为例,学生表中的 student_id 是主键,那么成绩表中的 student_id 则为外键。如果更新学生表中的 student_id,同时触发成绩表中的 student_id 更新,即为级联更新。外键与级联更新适用于单机低并发,不适合分布式、高并发集群;级联更新是强阻塞,存在数据库更新风暴的风险;外键影响数据库的插入速度。

问过 ai 的代码示例,大概就是字段仍然保留只不过是去掉外键约束,然后在代码层面进行手动检查,这样做的坏处就是维护成本加大,但好处很多:白盒环境、处理更加灵活、高并发等等。

为什么不用外键呢?

  1. 增加了复杂性: 每次做DELETE或者UPDATE都必须考虑外键约束,会导致开发的时候很痛苦, 测试数据极为不方便; 外键的主从关系是定的,假如哪天需求有变化,数据库中的这个字段根本不需要和其他表有关联的话就会增加很多麻烦。
  2. 增加了额外工作: 数据库需要增加维护外键的工作,比如当我们做一些涉及外键字段的增,删,更新操作之后,需要触发相关操作去检查,保证数据的一致性和正确性,这样会不得不消耗数据库资源。如果在应用层面去维护的话,可以减小数据库压力。
  3. 对分库分表不友好: 因为分库分表下外键是无法生效的。

外键虽然存在一些问题但是也有很多好处(存在即有一定道理,系统不涉及到分库分表,并发量不是很高的情况下还是可以考虑使用的):

  1. 保证数据库数据的一致性和完整性
  2. 级联操作方便,减轻了程序代码量

什么是存储过程

存储过程是数据库预编译的 SQL 语句集合,它将多条 SQL 语句和程序逻辑控制语句(如IF-ELSEWHILE循环等)封装在一起,形成一个可重复调用的数据库对象。

  • 存储过程的优势: 在传统企业级应用中,存储过程具有一定的实用价值。当业务逻辑复杂时,需要执行大量 SQL 语句才能完成一个业务操作,此时可以将这些语句封装成存储过程,简化调用过程。由于存储过程在创建时就已经编译并存储在数据库中,执行时无需重新编译,因此相比动态 SQL 语句具有更好的执行性能。同时,一旦存储过程调试完成,其运行相对稳定可靠

  • 存储过程的局限性: 然而,在现代互联网架构中,存储过程的使用越来越少。主要原因包括:调试困难,缺乏成熟的调试工具;扩展性差,修改业务逻辑需要直接修改数据库对象;移植性差,不同数据库系统的存储过程语法差异较大;占用数据库资源,增加数据库服务器负担;版本管理困难,不便于进行代码版本控制。

  • 行业规范: 基于以上原因,许多互联网公司的开发规范中明确限制或禁止使用存储过程。例如,《阿里巴巴 Java 开发手册》中明确规定禁止使用存储过程,推荐将业务逻辑放在应用层实现,保持数据库的简单和高效。

    【强制】禁止使用存储过程,存储过程难以调试和扩展,更没有移植性。

DROP、DELETE、TRUNCATE 的区别

在数据库操作中,DROPDELETETRUNCATE是三个常用的数据删除命令,它们在功能、性能和使用场景上存在显著差异。

  • DROP 命令:
    • 语法:DROP TABLE 表名
    • 作用:完全删除整个表,包括表结构、数据、索引、触发器、约束等所有相关对象
    • 使用场景:当表不再需要时使用
  • TRUNCATE 命令:
    • 语法:TRUNCATE TABLE 表名
    • 作用:清空表中所有数据,但保留表结构
    • 特点:自增长字段(AUTO_INCREMENT)会重置为初始值(通常为1)
    • 使用场景:需要快速清空表数据但保留表结构时使用
  • DELETE 命令:
    • 语法:DELETE FROM 表名 WHERE 条件
    • 作用:删除满足条件的数据行,不带 WHERE 子句时删除所有数据
    • 特点:自增长字段不会重置,继续从之前的值递增
    • 使用场景:需要有选择地删除部分数据时使用

TRUNCATE和不带WHERE子句的DELETE、以及DROP都会删除表内的数据,但是**TRUNCATEDELETE只删除数据不删除表的结构(定义),执行DROP语句,此表的结构也会删除,也就是执行DROP之后对应的表不复存在**。

对表结构的影响

  • DROP:删除表结构和所有数据,表将不复存在
  • TRUNCATE:仅删除数据,保留表结构和定义
  • DELETE:仅删除数据,保留表结构和定义

触发器

  • DELETE 操作会触发相关的 DELETE 触发器
  • TRUNCATEDROP 不会触发 DELETE 触发器

事务和回滚

  • DROPTRUNCATE 属于DDL操作,执行后立即生效,不能回滚
  • DELETE 属于DML操作,可以回滚(在事务中)

执行速度

一般来说:DROP > TRUNCATE > DELETE(不一定准确,待实际验证)

  • DELETE命令执行的时候会产生数据库的binlog日志,因为日志记录是需要消耗时间的,但是也有一个好处方便数据回滚恢复。
  • TRUNCATE命令执行的时候不会产生数据库日志,因此比DELETE要快。除此之外,还会把表的自增值重置和索引恢复到初始大小等。
  • DROP命令会把表占用的空间全部释放掉。

应该更关注使用场景而不是执行效率。

DML 语句和 DDL 的语句区别

  • DML 是数据库操作语言(Data Manipulate Language)的缩写,是指对数据库中表记录的操作,主要包括记录的插入、更新、删除和查询,是开发人员日常使用最频繁的操作。
  • DDL(Data Definition Language)是数据定义语言的缩写,简单来说,就是对数据库内部的对象进行创建、删除、修改的操作语言。它和 DML 语言的最大区别是 DML 只是对表内部数据的操作,而不涉及到表的定义、结构的修改,更不会涉及到其他对象。DDL 语句更多的被数据库管理员(DBA)所使用,一般的开发人员很少使用。

数据库设计的步骤

数据库设计步骤

需求分析阶段

目标: 深入了解和分析用户需求,明确系统边界

主要工作:

  • 收集和分析数据需求:确定需要存储哪些数据,数据量太小,数据更新频率
  • 明确功能需求:系统需要支持哪些业务操作,各操作的优先级
  • 定义性能需求:响应时间要求,并发用户数,数据吞吐量
  • 确定安全需求:数据访问权限,加密要求,审计要求

产出物: 需求规格说明书、数据字典初稿

概念结构设计阶段

目标: 将需求转化为信息世界的概念模型

主要工作:

  • 识别实体:确定系统中的主要对象
  • 定义属性:明确每个实体的特征
  • 建立联系:确定实体之间的关系(一对一、一对多、多对多)
  • 绘制 E-R 图(实体-关系图)

产出物: E-R 图、概念数据模型文档

逻辑结构设计阶段

目标: 将概念模型转换为特定 DBMS 支持的逻辑模型

主要目标:

  • E- R 图向关系模型转换:将实体转换为表,属性转换为字段
  • 规范化处理:通过范式化消除数据冗余和更新异常(通常达到3NF)
  • 定义完整性约束:主键、外键、唯一性约束、检查约束
  • 优化模型:根据性能需求进行适当的反规范化

产出物: 逻辑数据模型、表结构设计文档

物理结构设计阶段

目标: 确定数据的物理存储方案和访问方法

主要方法:

  • 选择存储引擎:如 MySQL 的 InnoDB、MyISAM 等
  • 设计索引策略:确定需要建立的索引类型和字段
  • 分区设计:对大表进行分区以提高性能
  • 确定存储参数:表空间大小、数据文件位置、缓冲区配置
  • 制定备份策略:全量备份、增量备份的频率和方式

数据库实施阶段

目标: 将设计转化为实际运行的数据库系统

主要工作:

  • 创建数据库和表结构:编写和执行 DDL 语句
  • 开发存储过程和触发器(如需要)
  • 编写应用程序接口
  • 导入初始数据
  • 系统集成测试:功能测试、性能测试、压力测试
  • 用户培训和文档编写

产出物: 数据库脚本、测试报告、用户手册

运行和维护阶段

目标: 确保数据库系统稳定高效运行

主要工作:

  • 日常监控:性能监控、空间监控、错误日志分析
  • 性能优化:查询优化、索引调整、参数调优
  • 数据备份和恢复:定期备份、恢复演练
  • 安全管理:权限管理、安全补丁更新、审计
  • 容量规划:预测数据增长,提前扩容
  • 变更管理:需求变更的评估和实施

产出物: 运维报告、优化方案、变更记录

设计原则

在整个设计过程中应遵循:数据独立性原则、完整性原则、安全性原则、可扩展性原则和标准化原则。

NoSQL 基础知识总结

什么是 NoSQL

NoSQL(Not Only SQL 的缩写)泛指非关系型的数据库,主呀针对的是键值、文档以及图形类型数据存储。并且,NoSQL 数据库天生支持分布式,数据冗余和数据分片等特性,旨在提供可扩展的高可用高性能的数据存储解决方案。

一个常见的误解是 NoSQL 数据库或关系型数据库不能很好地存储关系型数据。NoSQL 数据库可以存储关系型数据——它们与关系型数据库的存储方式不同。

NoSQL 数据库代表:HBase、Cassandra、MongoDB、Redis

NoSQL 例子

SQL 和 NoSQL 有什么区别

SQL 数据库 NoSQL 数据
数据存储模型 结构化存储,具有固定行和列的表格 非结构化存储。文档:JSON 文档,键值:键值对,宽列:包含行和动态列的表,图:节点和边
发展历程 开发于1970年代,重点是减少数据重复 开发于2000年代后期,重点是提升可扩展性,减少大规模数据的存储成本
例子 Oracle、MySQL、Microsoft SQL Server、PostgreSQL 文档:MongoDB、CouchDB,键值:Redis、DynamoDB,宽列:Cassandra、HBase,图表:Neo4j、Amazon Neptune、Giraph
ACID 属性 提供原子性、一致性、隔离性和持久性(ACID)属性 通常不支持 ACID 事务,为了可扩展性、高性能进行了权衡,少部分支持比如MongoDB,不过在对 ACID 事务的支持上和 MySQL 还是有所区别的
性能 性能通常取决于磁盘子系统。要获得最佳性能,通常需要优化查询、索引和表结构 性能通常由底层硬件集群大小、网络延迟以及调用应用程序来决定
扩展 垂直(使用性能更强大的服务器进行扩展)、读写分离、分库分表 横向(增加服务器的方式横向扩展,通常是基于分片机制)
用途 普通企业级的项目的数据存储 用途广泛比如图数据库支持分析和遍历连接数据之间的关系、键值数据库可以处理大量数据扩展和极高的状态变化
查询语法 结构化查询语言(SQL) 数据访问语法可能因数据库而异

NoSQL 数据库的优势

NoSQL 数据库非常适合许多现代应用程序,例如移动、Web和游戏等应用程序,它们需要灵活、可扩展、高性能和功能强大的数据库以提供卓越的用户体验。

  • 灵活性: NoSQL 数据库通常提供灵活的架构,以实现更快速、更多的迭代开发。灵活的数据模型是 NoSQL 数据库成为半结构化和非结构化数据的理想之选。
  • 可扩展性: NoSQL 数据库通常被设计为通过使用分布式硬件集群来横向扩展,而不是通过添加昂贵和强大的服务器来纵向扩展。
  • 高性能: NoSQL 数据库针对特定的数据模型和访问模型进行了优化,这与尝试使用关系数据库完成类似功能相比可实现更高的性能。
  • 强大的功能: NoSQL 数据库提供强大的 API 和数据类型,专门针对其各自的数据模型而构建。

NoSQL 数据库的类型

主要分为下面的四种类型:

  • 键值: 键值数据库是一种较简单的数据库,其中每个项目都包含键和值。这是极为灵活的 NoSQL 数据库类型,因为应用可以完全控制 value 字段中存储的内容,没有任何限制。Redis 和 DynanoDB 是两款非常流行的键值数据库。

  • 文档: 文档数据库中的数据被存储在类似于 JSON(JavaScript 对象表示法)对象的文档中,非常清晰直观。每个文档包含成对的字段和值。这些值通常可以是各种类型,包括字符串、数字、布尔值、数组或对象等,并且它们的结构通常与开发者在代码中使用的对象保持一致。MongoDB 就是一款非常流行的文档数据库。

  • 图形: 图形数据库旨在轻松构建和运行与高度连接的数据集一起使用的应用程序。图形数据库的典型使用案例包括社交网络、推荐引擎、欺诈检测和知识图形。Neo4j 和 Giraph 是两款非常流行的图形数据库。

  • 宽列: 宽列存储数据库非常适合需要存储大量的数据。Cassandra 和 HBase 是两款非常流行的宽列存储数据库。

PS: 面试题仍然考的是 SQL 语法的掌握程度,需要进行全面复习和记忆。

MySQL 常见面试题总结

MySQL 基础

关系型数据库是什么

顾名思义,关系型数据库(RDB/Rational Database)就是建立在一种关系模型的基础上的数据库。关系模型表明了数据库中所存储的数据之间的联系(一对一、一对多、多对多)。

关系型数据库中,我们的数据都被存放在了各种表中(比如用户表),表中的每一行就存放着一条数据(比如一个用户的信息)。

大部分关系型数据库都使用 SQL 来操作数据库中的数据。并且,大部分关系型数据库都支持事务的四大特性(ACID)。

常见的关系型数据库: MySQL、PostgreSQL、Oracle、SQL Server、SQLite(微信本地的聊天记录的存储就是用的 SQLite)

SQL 是什么

SQL 是一种结构化查询语言(Structured Query Language),专门用来和数据库打交道,目的是提供一种从数据库读写数据的简单有效的方法。

几乎所有的主流关系库数据库都支持 SQL,适用性非常强。并且,一些非关系型数据库也兼容 SQL 或者使用的是类似于 SQL 的查询语言。

MySQL 是什么

MySQL 是一种关系型数据库,主要用于持久化存储我们系统中的一些数据比如用户信息。

由于 MySQL 是开源免费且比较成熟的数据库,因此,MySQL 被大量使用在各种系统中。任何人都可以在 GPL(General Public License)的许可下下载并根据个性化的需要对其进行修改。MySQL 的默认端口号是3306

重点:MySQL 的优点

本质上是在问 MySQL 如此流行的原因。MySQL 成功可以归功于在生态、功能和运维这三个层面上的综合优势。

第一,从生态和成本角度看,它的护城河非常深。

  • 开源免费: 这是它得以广泛普及的基石。任何公司和个人都可以免费使用,极大地降低了技术门槛和初期成本。
  • 社区庞大,生态完善: 经过几十年的发展,MySQL 拥有极其活跃的社区和丰富的生态系统。这意味着你可以在网上找到各种问题的解决方案;同时市面上所有的主流编程语言、框架、ORM 工具、监控系统都对 MySQL 有完美的支持。它的文档也非常丰富,学习资源很多。

第二,从核心技术功能上看,它非常强大且均衡。

  • 强大的事务支持: 这是它作为关系型数据库的立身之本。值得一提的是,InnoDB 默认的可重复读(REPEATABLE-READ)隔离级别,通过 MVCC 和 Next-Key Lock 机制,很大程度上避免了幻读问题,这在很多其他数据库中都需要更高的级别才能做到,兼顾了性能和一致性。

  • 优秀的性能和可扩展性: MySQL 本身经过了海量互联网业务的严酷考验,单机性能非常出色。更重要的是,它围绕着水平扩展,形成了一套非常成熟的架构方案,比如主从复制、读写分离、以及通过中间件实现的分库分表。这让它能够支撑从初创公司到大型互联网平台的各种规模的业务。

第三,从运维和使用角度看,它非常“亲民”。

  • 开箱即用,上手简单: 相比于 Oracle 等大型商业数据库,MySQL 的安装、配置和安装使用都非常简单直观,学习曲线平缓,对于开发者和初级 DBA 非常友好。
  • 维护成本低: 由于其简单性和庞大的社区,找到相关的运维人才和解决方案都相对容易,整体的维护成本也更低。

最近几年, PostgreSQL 的使用量和普及度都大幅增加,需要深入了解。

MySQL 字段类型

下面一张图进行了总结:

summary-of-mysql-field-types

重点:整数类型的 UNSIGNED 属性有什么用

MySQL 中的整数类型可以使用可选的 UNSIGNED 属性来表示不允许负值的无符号整数。使用 UNSIGNED 属性可以将正整数的上限提高一倍,因为它不需要存储负数值。

例如,TINYINT UNSIGNED 类型的取值范围是 0~255,而普通的 TINYINT 类型的值范围是 -128~127。INT UNSIGNED 类型的取值范围是 0~4294967295,而普通的 INT 类型的值范围是 -2147483648~2147483647。

对于从 0 开始递增的 ID 列,使用 UNSIGNED 属性可以非常适合,因为不允许负值并且可以拥有更大的上限范围,提供了更多的 ID 值可查。

CHAR 和 VARCHAR 的区别是什么

CHAR 和 VARCHAR 是最常用到的字符串类型,两者的主要区别在于:CHAR 是定长字符串,VARCHAR 是变长字符串。

CHAR 在存储时会在右边填充空格以达到指定的长度,检索时会去掉空格;VACHAR 在存储时需要使用 1 或 2 个额外字节记录字符串的长度,检索时不需要处理。

CHAR 更适合存储长度较短或者长度都差不多的字符串,例如 Bcrypt 算法、MD5 算法加密后的密码、身份证号码。VARCHAR 类型适合存储长度不确定或者差异较大的字符串,例如用户昵称、文章标题等。

CHAR(M) 和 VARCHAR(M) 的 M 都代表能够保存的字符数的最大值,无论是字母、数字还是中午,每个都只占用一个字符。

VARCHAR(100) 和 VARCHAR(10) 的区别是什么

VARCHAR(100) 和 VARCHAR(10) 都是变长类型,表示能存储最多 100 个字符和 10 个字符。因此,VARCHAR(100) 可以满足更大范围的字符存储需求,有更好的业务拓展性。而 VARCHAR(10) 存储超过 10 个字符时,就需要修改表结构才可以。

虽说 VARCHAR(100) 和 VARCHAR(10) 能存储的字符范围不同,但二者存储相同的字符串,所占用的磁盘的存储空间是一样的。

不过,VARCHAR(100) 会消耗更多的内存,这是因为 VARCHAR 类型在内存中操作时,通常会分配固定大小的内存块来保存值,即使用字符类型中定义的长度。例如在进行排序的时候,VARCHAR(100) 是按照 100 这个长度来进行的,也就会消耗更多内存。

DECIMAL 和 FLOAT/DOUBLE 的区别是什么

DECIMAL 和 FLOAT 的区别是:DECIMAL 是定点数,FLOAT/DOUBLE 是浮点数。DECIMAL 可以存储精确的小数值,FLOAT/DOUBLE 只能存储近似的小数值。

DECIMAL 用于存储具有精度要求的小数,例如与货币相关的数据,可以避免浮点数带来的精度损失。

在 Java 中,MySQL 的 DECIMAL 类型对应的是 Java 类java.math.BigDecimal

为什么不推荐使用 TEXT 和 BLOB

TEXT 类型类似于 CHAR(0-255 字节) 和 VARCHAR(0-65535 字节),但可以存储更长的字符串,即长文本数据,例如博客内容。

类型 可存储大小 用途
TINYTEXT 0-255 字节 一般文本字符串
TEXT 0-65535 字节 长文本字符串
MEDIUMTEXT 0-166772150 字节 较大文本数据
LONGTEXT 0-4294967295 字节 极大文本数据

BLOB 类型主要用于存储二进制大对象,例如图片、音视频等文件。

类型 可存储大小 用途
TINYBLOB 0-255 字节 短文本二进制字符串
BLOB 0-65 KB 二进制字符串
MEDIUMBLOB 0-16 MB 二进制形式的长文本数据
LONGBLOB 0-4 GB 二进制形式的极大文本数据

在日常开发中,很少使用 TEXT 类型,但偶尔会用到,而 BLOB 类型则基本不常用。如果预期长度范围可以通过 VARCHAR 来满足,建议避免使用 TEXT。

数据库规范通常不推荐使用 BLOB 和 TEXT 类型,这两种类型具有一些缺点和限制,例如:

  • 不能有默认值。
  • 在使用临时表时无法使用内存临时表,只能在磁盘上创建临时表(《高性能 MySQL》书中有提到)。
  • 检索效率较低。
  • 不能直接创建索引,需要指定前缀长度。
  • 可能会消耗大量的网络和 IO 带宽。
  • 可能导致表上的 DML 操作变慢。

DATETIME 和 TIMESTAMP 的区别是什么,如何选择

DATETIME 类型没有时区信息,TIMESTAMP 和时区有关。

TIMESTAMP 只需要使用 4 个字节的存储空间,但是 DATETIME 需要耗费 8 个字节的存储空间。但是,这样同样造成了一个问题,TIMESTAMP 表示的时间范围更小。

  • DATETIME:’1000-01-01 00:00:00.000000’ 到 ‘9999-12-31 23:59:59.999999’

  • Timestamp:’1970-01-01 00:00:01.000000’ UTC 到 ‘2038-01-19 03:14:07.999999’ UTC

TIMESTAMP 的核心优势在于其内建的时区处理能力。数据库负责 UTC 存储和基于会话时区的自动转换,简化了需要处理多时区应用的开发。如果应用需要处理多时区,或者希望数据库能自动管理时区转换,TIMESTAMP 是自然的选择(注意其时间范围限制,也就是 2038 问题)。

如果应用不涉及时区转换,或者希望应用程序完全控制时区逻辑,并且需要表示 2038 年之后的时间,DATETIME 是更稳妥的选择。

NULL 和 ‘’ 的区别是什么

NULL ''(空字符串) 是两个完全不同的值,它们分别表示不同的含义,并在数据库中有着不同的行为。NULL代表缺少或未知的数据,而''表示一个已知存在的空字符串。它们的区别主要如下:

  1. 含义:
    • NULL代表一个不确定的值,它不等于任何值,包括它自身。因此,SELECT NULL = NULL的结果是NULL,而不是truefalseNULL意味这确实或未知的信息。虽然NULL不等于任何值,但在某些操作中,数据库系统会将NULL值视为相同的类别进行处理,例如:DISTINCTGROUP BYORDER BY。需要注意的是,这些操作将NULL值视为相同的类别进行处理,并不意味着NULL值之间是相等的。它们只是在特定操作中被特殊处理,以保证结果的正确性和一致性。这种处理方式是为了方便数据操作,而不是改变了NULL的语义。
    • ''表示一个空字符串,它是一个已知的值。
  2. 存储空间:
    • NULL的存储空间占用取决于数据库的实现,通常需要一些空间来标记该值为空。
    • ''的存储空间占用通常较小,因为它只存储一个空字符串的标志,不需要存储实际的字符。
  3. 比较运算:
    • 任何值与NULL进行比较(例如=、!=、>、<等)的结果都是NULL,表示结果不确定。要判断一个值是否为NULL,必须使用IS NULLIS NOT NULL
    • ''可以像其他字符一样进行比较运算。例如,'' = ''的结果是true
  4. 聚合函数:
    • 大多数聚合函数(例如SUMAVGMINMAX)会忽略NULL值。
    • COUNT(*)会统计所有行数,包括包含NULL值的行。COUNT(列名)会统计指定列中非NULL值的行数。
    • 空字符串''会被聚合函数计算在内。例如,SUM会将其视为 0。MINMAX会将其视为一个空字符串。

重要:Boolean 类型如何表示

MySQL 中没有专门的布尔类型,而是用TINYINT(1)类型来表示布尔值。TINYINT(1)类型可以存储 0 或 1,分别对应 false 或 true。

重要:手机号存储 INT 还是 VARCHAR

存储手机号,强烈建议使用 VARCHAR 类型,而不是 INT 或 BIGINT。主要原因如下:

  1. 格式兼容性与完整性:
    • 手机号可能包含前导零(如某些地区的固化区号)、国家代码前缀(‘+’),甚至可能带有分隔符(‘-‘ 或空格)。INT 和 BIGINT 这种数字类型会自动丢失这些重要的格式信息(比如前导零会被去掉,’+’ 和 ‘-‘ 无法存储)。
    • VARCHAR 可以原样存储各种格式的号码,无论是国内的 11 位手机号,还是带有国家代码的国际号码,都能完美兼容。
  2. 非算数性:
    • 手机号虽然看起来是数字,但我们从不对它进行数学运算(比如求和、平均值)。它本质上是一个标识符,更像是一个字符串。用 VARCHAR 更符合其数据性质。
  3. 查询灵活性:
    • 业务中常常需要根据号段(前缀)进行查询,例如查找所有 “138” 开头的用户。使用 VARCHAR 类型配合 LIKE '138%' 这样的 SQL 查询既直观又高效。
    • 如果使用数字类型,进行类似的前缀匹配通常需要复杂的函数转换(如 CAST 或 SUBSTRING),或者使用范围查询(如 WHERE phone >= 13800000000 AND phone < 13900000000),这不仅写法繁琐,而且可能无法有效利用索引,导致性能下降。
  4. 加密存储的需求(非常关键):
    • 出于数据安全和隐私合规的要求,手机号这类敏感个人信息通常必须加密存储在数据库中。
    • 加密后的数据(密文)是一长串字符串(通常由字母、数字、符号组成,或经过 Base64/Hex 编码),INT 或 BIGINT 类型根本无法存储这种密文。只有 VARCHAR、TEXT 或者 BLOB 这些类型才可以。

关于 VARCHAR 长度的选择:

  • 如果不加密存储(强烈不推荐!): 考虑到国际号码和可能的格式符,VARCHAR(20) 到 VARCHAR(32) 通常是一个比较安全的范围,足以覆盖全球绝大多数手机号格式。VARCHAR(15) 可能对某些带国家码和格式符的号码来说不够用。
  • 如果进行加密存储(推荐的标准做法): 长度必须根据所选加密算法产生的密文最大长度,以及可能的编码方式(如 Base64 会使长度增加约 1/3)来精确计算和设定。通常会需要更长的 VARCHAR 长度,例如 VARCHAR(128),VARCHAR(256)甚至更长。

MySQL 基础架构

下图是 MySQL 的一个简要架构图,从下图你可以很清晰的看到客户端的一条 SQL 语句在 MySQL 内部是如何执行的。

MySQL 架构图

主要由下面几部分构成:

  • 连接器: 身份认证和权限相关(登录的时候)。
  • 查询缓存: 执行查询语句的时候,会先查询缓存(MySQL 8.0 版本后移除,因为这个功能不太实用)。
  • 分析器: 没有命中缓存的话,SQL 语句就会经过分析器,分析器说白了就是要先看你的 SQL 语句要干嘛,再检查你的 SQL 语句语法是否正确。
  • 优化器: 按照 MySQL 认为最优的方案去执行。
  • 执行器: 执行语句,然后从存储引擎返回数据。执行语句之前会先判断是否有权限,如果没有权限的话,就会报错。
  • 插件式存储引擎: 主要负责数据的存储和读取,采用的是插件式架构,支持 InnoDB、MyISAM、Memory 等多种存储引擎。InnoDB 是MySQL 的默认存储引擎,绝大部分场景使用 InnoDB 就是最好的选择。

MySQL 存储引擎

MySQL 核心在于存储引擎,想要深入学习 MySQL,必定要深入研究 MySQL 存储引擎。

MySQL 支持哪些存储引擎,默认使用哪一个

MySQL 支持多种存储引擎,你可以通过SHOW ENGINES命令来查看 MySQL 支持的所有存储引擎。

MySQL 引擎查询

从上图我们可以查看出,MySQL (version: 9.5.0)当前的默认引擎是 InnoDB。并且所有的存储引擎中只有 InnoDB 是事务性存储引擎,也就是说只有 InnoDB 支持事务。

MySQL 5.5.5 之前,MyISAM 是 MySQL 的默认存储引擎。5.5.5 版本之后,InnoDB 是 MySQL 的默认存储引擎。

如果想要深入了解每个存储引擎以及它们之间的区别,推荐阅读以下 MySQL 官方文档对应的介绍(面试不会问这么细,了解即可,问一下 ai 也行):

MySQL 存储引擎架构了解吗

MySQL 存储引擎采用的是插件式架构,支持多种存储引擎,我们甚至可以为不同的数据库表设置不同的存储引擎以适应不同场景的需要。存储引擎是基于表的,而不是数据库。

下图展示了具有可插拔存储引擎的 MySQL 架构:

mysql-architecture

还可以根据 MySQL 定义的存储引擎实现标准接口来编写一个属于自己的存储引擎。这些非官方提供的存储引擎可以称为第三方存储引擎,区别于官方存储引擎。像目前最常用的 InnoDB 其实刚开始就是一个第三方存储引擎,后面由于过于优秀,其被 Oracle 直接收购了。

重点:MyISAM 和 InnoDB 有什么区别

简单对比两者:

  1. 是否支持行级锁

    MyISAM 只有表级锁(table-level locking),而 InnoDB 支持行级锁(row-level locking)和表级锁,默认为行级锁。

    也就是说,MyISAM 是直接锁住了一整个表,在并发性能上远低于 InnoDB。

  2. 是否支持事务

    MyISAM 不提供事务支持。

    InnoDB 提供事务支持,实现了 SQL 标准定义了四个隔离级别,具有提交(commit)和回滚(rollback)事务的能力。并且,InnoDB 默认使用的 REPEATABLE-READ(可重读)隔离级别是可以解决幻读问题发生的(基于 MVCC 和 Next-Key Lock)。

  3. 是否支持外键

    MyISAM 不支持,而 InnoDB 支持。

    外键对于维护数据一致性非常有帮助,但是对性能有一定的损耗。因此,通常情况下,我们是不建议在实际生产项目中使用外键的,在业务代码中进行约束即可!(参考之前外键部分)

  4. 是否支持数据库异常崩溃后的安全恢复

    MyISAM 不支持,而 InnoDB 支持。

    使用 InnoDB 的数据库在异常崩溃后,数据库重新启动的时候会保证数据库恢复到崩溃前的状态。这个恢复的过程依赖于redo log

  5. 是否支持 MVCC

    MyISAM 不支持,而 InnoDB 支持。

    MVCC 是多并发版本控制,具体实现方式给每一个事务建立一个临时表,可以看作行级锁的一个升级,能够有效减少加锁操作,提高性能。

  6. 索引实现不一样

    虽然 MyISAM 引擎和 InnoDB 引擎都是使用 B+Tree 作为索引结构,但是两者的实现方式不太一样。

    InnoDB 引擎中,其数据文件本身就是索引文件。相比 MyISAM 索引文件和数据文件是分离的,InnoDB 表数据文件本身就是按 B+Tree 组织的一个索引结构,树的叶节点 data 域保存了完整的数据记录。

  7. 性能有差别

    InnoDB 的性能比 MyISAM 更强大,不管是在读写混合模式下还是只读模式下,随着 CPU 核数的增加,InnoDB 的读写能力呈线性增长。MyISAM 因为读写不能并发,它的处理能力跟核数没关系

    innodb-myisam-performance-comparison

  8. 数据缓存策略和机制实现不同

    InnoDB 使用缓冲池(Buffer Pool)缓存数据页和索引页,MyISAM 使用键缓存(Key Cache)仅缓存索引页而不缓存数据页。

总结: 最后用一张表来总结常见的几种 MySQL 存储引擎。

comparison-of-common-mysql-storage-engines

MyISAM 和 InnoDB 如何选择

大多数时候我们使用的都是 InnoDB 存储引擎,在某些读密集的情况下,使用 MyISAM 也是合适的。

《MySQL 高性能》上面有一句话这样写到:

不要轻易相信“MyISAM 比 InnoDB 快”之类的经验之谈,这个结论往往不是绝对的。在很多我们已知场景中,InnoDB 的速度都可以让 MyISAM 望尘莫及,尤其是用到了聚簇索引,或者需要访问的数据都可以放入内存的应用。

也就是基本上不需要考虑 MyISAM 了。

重点:MySQL 索引

索引是什么

索引是一种用于快速查询和检索数据的数据结构,其本质可以看成是一种排序好的数据结构。

索引的作用就相当于书的目录。打个比方:我们在查字典的时候,如果没有目录,那我们就只能一页一页地去找我们需要查的那个字,速度很慢;如果有目录了,我们只需要先去目录里查找子的位置,然后直接翻到那一页就好了。

索引底层数据结构存在很多种类型,常见的索引结构有:B 树、B+ 树和 Hash、红黑树。在 MySQL 中,无论是 InnoDB 还是 MyISAM,都使用了 B+ 树作为索引结构。

索引的优点:

  1. 查询速度快(主要目的): 通过索引,数据库可以大幅减少需要扫描的数据量,直接定位到符合条件的记录,从而显著加快数据检索速度,减少磁盘 I/O 次数。
  2. 保证数据唯一性: 通过创建唯一索引(Unique Index),可以确保表中的某一列(或几列组合)的值是独一无二的,比如用户 ID、邮箱等。主键本身就是一种唯一索引。
  3. 加速排序和分组: 如果查询中的 ORDER BY 或 GROUP BY 子句涉及的列键有索引,数据库往往可以直接利用索引已经排好序的特性,避免额外的排序操作,从而提高性能。

索引的缺点:

  1. 创建和维护耗时: 创建索引本身需要时间,特别是对大表操作时。更重要的是,当对表中的数据进行增删改(DML 操作)时,不仅要操作数据本身,相关的索引也必须动态更新和维护,这会降低这些 DML 操作的执行效率
  2. 占用存储空间: 索引本质上也是一种数据结构,需要以物理文件(或内存结构)的形式存储,因此会额外占用一定的磁盘空间。索引越多、越大,占用的空间也就越多。
  3. 可能被误用或失效: 如果索引设计不当,或者查询语句写得不好,数据库优化器可能不会选择使用索引(或者选错索引),反而导致性能下降。

那么,用了索引就一定能提高查询性能吗?

不一定。 大多数情况下,合理使用索引确实比全表扫描快得多。但也有例外:

  • 数据量太小:如果表里的数据非常少(比如就几百条),全表扫描可能比通过索引查找更快,因为走索引本身也有开销。
  • 查询结果集占比过大:如果要查询的数据占了整张表的大部分(比如超过 20%-30%),优化器可能会认为全表扫描更划算,因为通过索引多次回表(随机 I/O)的成本可能高于一次顺序的全表扫描。
  • 索引维护不当或统计信息过时:导致优化器做出错误判断。

索引为什么快

索引之所以快,核心原因是它大大减少了磁盘 I/O 的次数。

它的本质是一种排好序的数据结构,就像书的目录,让我们不用一页一页地翻(全表扫描)。

在 MySQL 中,这个数据结构是B+树。B+ 树结构主要从两方面做了优化:

  1. B+ 树的特点是“矮胖”,一个千万数据的表,索引树的高度可能只有 3-4 层。这意味着,最多只需要 3-4 次磁盘 I/O,就能精确定位到我想要的数据,而全表扫描可能需要成千上万次,所以速度极快。
  2. B+ 树的叶子节点是用链表连起来的。找到开头后,就能顺着链表顺序读下去,这对磁盘非常友好,还能触发预读。

MySQL 索引底层数据结构是什么

在 MySQL 中,MyISAM 引擎和 InnoDB 引擎都使用 B+Tree 作为索引结构。

为什么 InnoDB 没有使用哈希作为索引的数据结构

无论提问还是回答这个问题都要区分好存储引擎,不能想当然的认为 MySQL 底层并没有使用哈希或者 B 树作为索引的数据结构。比如 MEMORY 引擎就同时支持哈希和 B 树。

哈希索引的底层是哈希表。它的优点是在进行精确的等值查询时,理论上时间复杂度是 O(1),速度极快。比如WHERE id = 114514

但是,它有几个对于通用数据库来说致命的缺点:

  1. 不支持范围查询: 这是最主要的原因。哈希函数的一个特点是它会把相邻的输入值映射到哈希表中完全不相邻的位置。这种顺序的破坏,使得我们无法处理像WHERE age > 30BETWEEN 100 AND 200这样的范围查询。要完成这种查询,哈希索引只能退化为全表扫描。
  2. 不支持排序: 同理,因为哈希值是无序的,所以我们无法利用哈希索引来优化ORDER BY子句。
  3. 不支持部分索引键查询: 对于联合索引,比如(col1, col2),哈希索引必须使用所有索引列进行查询,它无法单独利用col1来加速查询。
  4. 哈希冲突问题: 当不同的键产生冲突时,需要额外的链表或开放寻址来解决,这会降低性能。

鉴于数据库查询中范围查询和排序是极其常见的操作,一个不支持这些功能的索引结构,显然不能作为默认的、通用的索引类型。

为什么 InnoDB 没有使用 B 树作为索引的数据结构

B 树和 B+ 树都是优秀的多路平衡搜索树,非常适合磁盘存储,因为它们都很“矮胖”,能最大化地利用每一次磁盘 I/O。

但 B+ 树是 B 树的一个增强版,它针对数据库场景做了几个关键优化:

  1. I/O 效率更高: 在 B+ 树中,只有叶子节点才存储数据(或数据指针),而非叶子节点只存储索引键。因为非叶子节点不存数据,所以它们可以容纳更多的索引键。这意味着 B+ 树的“扇出”更大,在同样的数据量下,B+ 树通常会比 B 树更矮,也就意味着查找数据所需的磁盘 I/O 次数更少。
  2. 查询性能更稳定: 在 B+ 树中,任何一次查询都必须从根节点走到叶子节点才能找到数据,所以查询路径的长度是固定的。而在 B 树中,如果运气好,可能在非叶子节点就找到了数据,但运气不好也得走到叶子,这导致查询性能不稳定。
  3. 对范围查询极其友好: 这是 B+ 树最核心的优势。它的所有叶子节点之间通过一个双向链表连接。当我们执行一个范围查询(比如 WHERE id > 100)时,只需要通过树形结构找到 id=100 的叶子节点,然后就可以沿着链表向后顺序扫描,而无需再回溯到上层节点。这使得范围查询的效率大大提高。

覆盖索引是什么

如果一个索引包含(或者说覆盖)所有需要查询的字段的值,我们就称之为覆盖索引(Covering Index)

在 InnoDB 存储引擎中,非主键索引的叶子节点包含的是主键的值。这意味着,当使用非主键索引进行查询时,数据库会先找到对应的主键值,然后再通过主键索引来定位和检索完整的行数据。这个过程被称为“回表”。

覆盖索引即需要查询的字段正好是索引的字段,那么直接根据该索引,就可以查到数据了,而无需回表查询。

请解释一下 MySQL 的联合索引及其最左前缀原则

使用表的多个字段创建索引,就是联合索引,也叫组合索引复合索引

例子:以scorename两个字段建立联合索引:

1
ALTER TABLE `cus_order` ADD INDEX id_score_name(score, name);

最左前缀原则指的是在使用联合索引时,MySQL 会根据索引中的字段顺序,从左到右依次匹配查询条件中的字段。如果查询条件与索引中的最左侧字段相匹配,那么 MySQL 就会使用索引来过滤数据,这样可以提高查询效率。

最左匹配原则会一直向右匹配,直到遇到范围查询(如 >、<)为止。对于 >=、<=、BETWEEN 以及前缀匹配 LIKE 的范围查询,不会停止匹配(相关阅读:联合索引的最左匹配原则全网都在说的一个错误结论)。

假设有一个联合索引 (column1, column2, column3),其从左到右的所有前缀为 (column1)(column1, column2)(column1, column2, column3)(创建 1 个联合索引相当于创建了 3 个索引),包含这些列的所有查询都会走索引而不会全表扫描。

在使用联合索引时,可以将区分度高的字段放在最左边,这也可以过滤更多数据。

简单演示一下最左前缀匹配的效果:

  1. 创建一个名为 student 的表,这张表只有 idnameclass 这 3 个字段。

    1
    2
    3
    4
    5
    6
    7
    CREATE TABLE `student` (
    `id` int NOT NULL,
    `name` varchar(100) DEFAULT NULL,
    `class` varchar(100) DEFAULT NULL,
    PRIMARY KEY (`id`),
    KEY `name_class_idx` (`name`,`class`)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
  2. 下面是三条不同的 SQL 语句的测试:

    leftmost-prefix-matching-rule

    可以看到SELECT * FROM student WHERE name = 'Anne Henry';SELECT * FROM student WHERE name = 'Anne Henry' AND class = 'lIrm08RYVk';都可以命中索引,但是SELECT * FROM student WHERE class = 'lIrm08RYVk';没有命中索引。

  3. 一道常见的面试题:如果有索引 联合索引(a,b,c),查询 a=1 AND c=1 会走索引么?c=1 呢?b=1 AND c=1 呢? b = 1 AND a = 1 AND c = 1 呢?

    答案如下:

    1. 查询 a=1 AND c=1:根据最左前缀匹配原则,查询可以使用索引的前缀部分。因此,该查询仅在 a=1 上使用索引,然后对结果进行 c=1 的过滤。
    2. 查询 c=1:由于查询中不包含最左列 a,根据最左前缀匹配原则,整个索引都无法被使用。
    3. 查询 b=1 AND c=1:和第二种一样的情况,整个索引都不会使用。
    4. 查询 b=1 AND a=1 AND c=1:这个查询是可以用到索引的。查询优化器分析 SQL 语句时,对于联合索引,会对查询条件进行重排序,以便用到索引。会将 b=1a=1 的条件进行重排序,变成 a=1 AND b=1 AND c=1

MySQL 8.0.13 版本引入了索引跳跃扫描(Index Skip Scan,简称 ISS,了解即可),它可以在某些索引查询场景下提高查询效率。在没有 ISS 之前,不满足最左前缀匹配原则的联合索引查询中会执行全表扫描。而 ISS 允许 MySQL 在某些情况下避免全表扫描,即使查询条件不符合最左前缀。

SELECT * 会导致索引失效吗

SELECT *不会直接导致索引失效(如果不走索引大概率是因为 where 查询范围过大导致的),但它可能会带来一些其他的性能问题:造成网络传输和数据处理的浪费、无法使用索引覆盖等。

哪些字段适合创建索引

  • 不为 NULL 的字段: 索引字段的数据应该尽量不为 NULL,因为对于数据为 NULL 的字段,数据库较难优化。如果字段频繁被查询,但又避免不了为 NULL,建议使用 0,1,true,false 这样语义较为清晰的短值或短字符作为替代。
  • 被频繁查询的字段: 我们创建索引的字段应该是查询操作非常频繁的字段。
  • 被作为条件查询的字段: 被作为 WHERE 条件查询的字段,应该被考虑建立索引。
  • 频繁需要排序的字段: 索引已经排序,这样查询可以利用索引的排序,加快排序查询时间。
  • 被频繁用于连接的字段: 经常用于连接的字段可能是一些外键列,对于外键列并不一定要建立外键,只是说该列涉及到表与表的关系。对于频繁被连接查询的字段,可以考虑建立索引,提高多表连接查询的效率。

索引失效的原因

  1. 创建了组合索引,但查询条件未遵守最左匹配原则
  2. 在索引列上进行计算、函数、类型转换等操作
  3. 以 % 开头的 LIKE 查询
  4. 查询条件中使用 OR,且 OR 的前后条件中有一个列没有索引,涉及的索引都不会被使用到
  5. IN 的取值范围较大时会导致索引失效,走全表扫描(NOT IN 和 IN 的失效场景相同)
  6. 发生隐式转换

MySQL 查询缓存

MySQL 查询缓存时查询结果缓存。执行查询语句的时候,会先查询缓存,如果缓存中有对应的查询结果,就会直接返回。

MySQL 可以执行以下命令来开启查询缓存:

1
2
set global  query_cache_type=1;
set global query_cache_size=600000;

查询缓存会在同样的查询条件和数据情况下,直接返回缓存中的结果。但需要注意的是,查询缓存的匹配条件非常严格,任何细微的差异都会导致缓存无法命中。这里的查询条件包括查询语句本身、当前使用的数据库、以及其他可能影响结果的因素,如客户端协议版本号等。

查询缓存不命中的情况:

  1. 任何两个查询在任何字符上的不同都会导致缓存不命中。
  2. 如果查询中包含任何用户自定义函数、存储函数、用户变量、临时表、MySQL 库中的系统表,其查询结果也不会被缓存。
  3. 缓存建立之后,MySQL 的查询缓存系统会跟踪查询中涉及的每张表,如果这些表(数据和结构)发生变化,那么和这张表的所有缓存数据都将失效。

缓存虽然能够提升数据库的查询性能,但是缓存同时也带来了额外的开销,每次查询后都要做一次缓存操作,失效后还要销毁。 因此,开启查询缓存要谨慎,尤其对于写密集的应用来说更是如此。如果开启,要注意合理控制缓存空间大小,一般来说其大小设置为几十 MB 比较合适。此外,还可以通过 sql_cachesql_no_cache 来控制某个查询语句是否需要缓存:

1
SELECT sql_no_cache COUNT(*) FROM usr;

MySQL 8.0 开始,已经不再支持查询缓存了。

重要:MySQL 日志(三大日志需要掌握)

重要:MySQL 事务

什么是事务

何为事务? 一言蔽之,事务是逻辑上的一组操作,要么都执行,要么都不执行。

事务最经典也经常被拿出来说例子就是转账了。假如小明要给小红转账 1000 元,这个转账会涉及到两个关键操作,这两个操作必须都成功或者都失败。

  1. 将小明的余额减少 1000 元
  2. 将小红的余额增加 1000 元。

事务会把这两个操作就可以看成逻辑上的一个整体,这个整体包含的操作要么都成功,要么都要失败。这样就不会出现小明余额减少而小红的余额却并没有增加的情况。

什么是数据库事务

大多数情况下,我们在谈论事务的时候,如果没有特指分布式事务,往往指的就是数据库事务

数据库事务在我们日常开发中接触的最多了。如果项目属于单体架构的话,接触到的往往就是数据库事务了。

数据库事务的作用:

简单来说,数据库事务可以保证多个对数据库的操作(也就是 SQL 语句)构成一个逻辑上的整体。构成这个逻辑上的整体的这些数据库操作遵循:要么全部执行成功,要么全部不执行

1
2
3
4
5
6
# 开启一个事务
START TRANSACTION;
# 多条 SQL 语句
SQL1,SQL2...
## 提交事务
COMMIT;

另外,关系型数据库(例如:MySQLSQL ServerOracle)事务都有 ACID 特性:

  1. 原子性(Atomicity):事务是最小的执行单位,不允许分割。事务的原子性确保动作要么全部完成,要么完全不起作用;
  2. 一致性(Consistency):执行事务前后,数据保持一致,例如转账业务中,无论事务是否成功,转账者和收款人的总额应该是不变的;
  3. 隔离性(Isolation):并发访问数据库时,一个用户的事务不被其他事务所干扰,各并发事务之间数据库是独立的;
  4. 持久性(Durability):一个事务被提交之后。它对数据库中数据的改变是持久的,即使数据库发生故障也不应该对其有任何影响。

Atomicity, isolation, and durability are properties of the database, whereas consis‐
tency (in the ACID sense) is a property of the application. The application may rely
on the database’s atomicity and isolation properties in order to achieve consistency,
but it’s not up to the database alone.

翻译过来的意思是:原子性,隔离性和持久性是数据库的属性,而一致性(在 ACID 意义上)是应用程序的属性。应用可能依赖数据库的原子性和隔离属性来实现一致性,但这并不仅取决于数据库。因此,字母 C 不属于 ACID。

并发事务带来了哪些问题

脏读(Dirty read)

一个事务读取数据并且对数据进行了修改,这个修改对其他事务来说是可见的,即使当前事务没有提交。这时另外一个事务读取了这个还未提交的数据,但第一个事务突然回滚,导致数据并没有被提交到数据库,那第二个事务读取到的就是脏数据,这也就是脏读的由来。

例如:事务 1 读取某表中的数据 A=20,事务 1 修改 A=A-1,事务 2 读取到 A=19,事务 1 回滚导致对 A 的修改并未提交到数据库,A 的值还是 20。

丢失修改(Lost to modify)

在一个事务读取一个数据时,另外一个事务也访问了该数据,那么在第一个事务中修改了这个数据后,第二个事务也修改了这个数据。这样第一个事务内的修改结果就被丢失,因此称为丢失修改。

例如:事务 1 读取某表中的数据 A=20,事务 2 也读取 A=20,事务 1 先修改 A=A-1,事务 2 后来也修改 A=A-1,最终结果 A=19,事务 1 的修改被丢失。

不可重复读(Unrepeatable read)

指在一个事务内多次读同一数据。在这个事务还没有结束时,另一个事务也访问该数据。那么,在第一个事务中的两次读数据之间,由于第二个事务的修改导致第一个事务两次读取的数据可能不太一样。这就发生了在一个事务内两次读到的数据是不一样的情况,因此称为不可重复读。

例如:事务 1 读取某表中的数据 A=20,事务 2 也读取 A=20,事务 1 修改 A=A-1,事务 2 再次读取 A =19,此时读取的结果和第一次读取的结果不同。

幻读(Phantom read)

幻读与不可重复读类似。它发生在一个事务读取了几行数据,接着另一个并发事务插入了一些数据时。在随后的查询中,第一个事务就会发现多了一些原本不存在的记录,就好像发生了幻觉一样,所以称为幻读。

例如:事务 2 读取某个范围的数据,事务 1 在这个范围插入了新的数据,事务 2 再次读取这个范围的数据发现相比于第一次读取的结果多了新的数据。

不可重复读和幻读有什么区别

  • 不可重复读的重点是内容修改或者记录减少比如多次读取一条记录发现其中某些记录的值被修改。
  • 幻读的重点在于记录新增比如多次执行同一条查询语句(DQL)时,发现查到的记录增加了。

幻读其实可以看作是不可重复读的一种特殊情况,单独把幻读区分出来的原因主要是解决幻读和不可重复读的方案不一样。

举个例子:执行deleteupdate操作的时候,可以直接对记录加锁,保证事务安全。而执行insert操作的时候,由于记录锁(Record Lock)只能锁住已经存在的记录,为了避免插入新记录,需要依赖间隙锁(Gap Lock)。也就是说执行insert操作的时候需要依赖 Next-Key Lock(Record Lock+Gap Lock)进行加锁来保证不出现幻读。

并发事务的控制方式有哪些

MySQL 中并发事务的控制方式无非就两种:MVCC。锁可以看作是悲观控制的模式,多版本并发控制(MVCC,Multiversion concurrency control)可以看作是乐观控制的模式。

控制方式下会通过锁来显示控制共享资源而不是通过调度手段,MySQL 中主要是通过读写锁来实现并发控制。

  • 共享锁(S 锁): 又称读锁,事务在读取记录的时候获取共享锁,允许多个事务同时获取(锁兼容)。
  • 排他锁(X 锁): 又称写锁/独占锁,事务在修改记录的时候获取排他锁,不允许多个事务同时获取。如果一个记录已经被加了排他锁,那其他事务不能再对这条记录加任何类型的锁(锁不兼容)。

读写锁可以做到读读并行,但是无法做到写读、写写并行。另外,根据根据锁粒度的不同,又被分为表级锁(table-level locking)行级锁(row-level locking)。InnoDB 不光支持表级锁,还支持行级锁,默认为行级锁。行级锁的粒度更小,仅对相关的记录上锁即可(对一行或者多行记录加锁),所以对于并发写入操作来说,InnoDB 的性能更高。不论是表级锁还是行级锁,都存在共享锁(Share Lock,S 锁)和排他锁(Exclusive Lock,X 锁)这两类。

MVCC 是多版本并发控制方法,即对一份数据会存储多个版本,通过事务的可见性来保证事务能看到自己应该看到的版本,通常会有一个全局的版本分配器来为每一行数据设置版本号,版本号是唯一的。

MVCC 在 MySQL 中实现所依赖的手段主要是:隐藏字段、read view、undo log

  • undo log: undo log 用于记录某行数据的多个版本的数据。
  • read view 和 隐藏字段: 用来判断当前版本数据的可见性。

SQL 标准定义了哪些事务隔离级别

SQL 标准定义了四种事务隔离级别,用来平衡事务的隔离性(Isolation)和并发性能。级别越高,数据一致性越好,但并发性能可能越低。这四个级别是:

  • READ-UNCOMMITTED(读取未提交): 最低的隔离级别,允许读取尚未提交的数据变更,可能会导致脏读、幻读或不可重复读。这种级别在实际应用中很少使用,因为对数据的一致性保证太弱。

  • READ-COMMITTED(读取已提交): 允许读取并发事务已经提交的数据,可以阻止脏读,但是幻读或不可重复读仍有可能发生。这是大多数数据库(如 Oracle,SQL Server)的默认隔离级别。

  • REPEATABLE-READ(可重复读): 对同一字段的多次读取结果都是一致的,除非数据是被本身事务自己所修改,可以阻止脏读和不可重复读,但幻读仍有可能发生。

    MySQL InnoDB 存储引擎的默认隔离级别正是 REPEATABLE-READ。并且,InnoDB 在此级别下通过 MVCC(多版本并发控制) 和 Next-Key Locks(间隙锁+行锁) 机制,在很大程度上解决了幻读问题。

  • SERIALIZABLE(可串行化): 最高的隔离级别,完全服从 ACID 的隔离级别。所有的事务依次逐个执行,这样事务之间就完全不可能产生干扰,也就是说,该级别可以防止脏读、不可重复读以及幻读。

隔离级别 脏读 (Dirty Read) 不可重复读 (Non-Repeatable Read) 幻读 (Phantom Read)
READ-UNCOMMITTED
READ-COMMITTED ×
REPEATABLE-READ × × √(标准) / ≈× (InnoDB)
SERIALIZABLE × × ×

MySQL 的默认隔离级别是什么

MySQL InnoDB 存储引擎的默认隔离级别是 REPEATABLE-READ。可以通过以下命令查看:

MySQL 默认隔离级别

MySQL 的隔离级别是基于锁实现的吗

MySQL 的隔离级别基于锁和 MVCC 机制共同实现的。

SERIALIZABLE 隔离级别是通过锁来实现的,READ-COMMITTED 和 REPEATABLE-READ 隔离级别是基于 MVCC 实现的。不过, SERIALIZABLE 之外的其他隔离级别可能也需要用到锁机制,就比如 REPEATABLE-READ 在当前读情况下需要使用加锁读来保证不会出现幻读。

MySQL 锁

锁是一种常见的并发事务的控制方式。

表级锁和行级锁

MyISAM 仅仅支持表级锁(table-level locking),一锁就锁整张表,这在并发写的情况下性非常差。InnoDB 不光支持表级锁(table-level locking),还支持行级锁(row-level locking),默认为行级锁。

行级锁的粒度更小,仅对相关的记录上锁即可(对一行或者多行记录加锁),所以对于并发写入操作来说, InnoDB 的性能更高。

表级锁和行级锁对比:

  • 表级锁: MySQL 中锁定粒度最大的一种锁(全局锁除外),是针对非索引字段加的锁,对当前操作的整张表加锁,实现简单,资源消耗也比较少,加锁快,不会出现死锁。不过,触发锁冲突的概率最高,高并发下效率极低。表级锁和存储引擎无关,MyISAM 和 InnoDB 引擎都支持表级锁。
  • 行级锁: MySQL 中锁定粒度最小的一种锁,是针对索引字段加的锁,只针对当前操作的行记录进行加锁。行级锁能大大减少数据库操作的冲突。其加锁粒度最小,并发度高,但加锁的开销也最大,加锁慢,会出现死锁。行级锁和存储引擎有关,是在存储引擎层面实现的。

行级锁的使用有什么注意事项

InnoDB 的行锁是针对索引字段加的锁,表级锁是针对非索引字段加的锁。当我们执行UPDATEDELETE语句时,如果WHERE条件中字段没有命中唯一索引或者索引失效的话,就会导致扫描全表对表中的所有行记录进行加锁。

不过,很多时候即便使用了索引也有可能会走全表扫描,这是因为 MySQL 优化器的原因。

InnoDB 有哪几类行锁

InnoDB 行锁是通过对索引数据页上的记录加锁实现的,MySQL InnoDB 支持三种行锁定方式:

  • 记录锁(Record Lock): 属于单个行记录上的锁。
  • 间隙锁(Gap Lock): 锁定一个范围,不包括记录本身。
  • 临键锁(Next-Key Lock): Record Lock+Gap Lock,锁定一个范围,包含记录本身,主要目的是为了解决幻读问题(MySQL 事务部分提到过)。记录锁只能锁住已经存在的记录,为了避免插入新记录,需要依赖间隙锁。

在 InnoDB 默认的隔离级别 REPEATABLE-READ 下,行锁默认使用的是 Next-Key Lock。但是,如果操作的索引是唯一索引或主键,InnoDB 会对 Next-Key Lock 进行优化,将其降级为 Record Lock,即仅锁住索引本身,而不是范围。

共享锁和排他锁

不论是表级锁还是行级锁,都存在共享锁(Share Lock,S 锁)和排他锁(Exclusive Lock,X 锁)这两类:

  • 共享锁(S锁): 又称读锁,事务在读取记录的时候获取共享锁,允许多个事务同时获取(锁兼容)。
  • 排他锁(X锁): 又称写锁/独占锁,事务在修改记录的时候获取排他锁,不允许多个事务同时获取。如果一个记录已经被加了排他锁,那其他事务不能再对这条事务加任何类型的锁(锁不兼容)。

由于 MVCC 的存在,对于一般的SELECT语句,InnoDB 不会加任何锁。不过,你可以通过以下语句显示加共享锁或排他锁。

1
2
3
4
5
6
# 共享锁 可以在 MySQL 5.7 和 MySQL 8.0 中使用
SELECT ... LOCK IN SHARE MODE;
# 共享锁 可以在 MySQL 8.0 中使用
SELECT ... FOR SHARE;
# 排他锁
SELECT ... FOR UPDATE;

意向锁有什么作用

如果需要用到表锁的话,如何判断表中的记录有没有行锁呢,不可能对每一行进行遍历,所以引入了意向锁的设计。

意向锁是表级锁,共有两种:

  • 意向共享锁(Intention Shared, IS 锁): 事务有意向对表中的某些记录加共享锁(S 锁),加共享锁前必须先取得该表的 IS 锁。
  • 意向排他锁(Intention Exclusive Lock, IX 锁): 事务有意向对表中的某些记录加排他锁(X 锁),加排他锁之前必须先取得该表的 IX 锁。

意向锁是由数据引擎自己维护的,用户无法手动操作意向锁,在为数据行加共享/排他锁之前,InnoDB 会先获取该数据行所在在数据表的对应意向锁。

意向锁之间是互相兼容的。

意向锁和共享锁和排它锁互斥(这里指的是表级别的共享锁和排他锁,意向锁不会与行级的共享锁和排他锁互斥):

IS 锁 IX 锁
S 锁 兼容 互斥
X 锁 互斥 互斥

当前读和快照读有什么区别

快照读(一致性非锁定读)就是单纯的SELECT语句,但不包括下面这两类SELECT语句:

1
2
3
4
5
SELECT ... FOR UPDATE
# 共享锁 可以在 MySQL 5.7 和 MySQL 8.0 中使用
SELECT ... LOCK IN SHARE MODE;
# 共享锁 可以在 MySQL 8.0 中使用
SELECT ... FOR SHARE;

快照即记录的历史版本,每行记录可能存在多个历史版本(多版本技术)。

快照读的情况下,如果读取的记录正在进行 UPDATE/DELETE 操作,读取操作不会因此去等待记录上 X 锁的释放,而是会去读取行的一个快照。

只有在事务隔离级别 RC(读取已提交) 和 RR(可重读) 下,InnoDB 才会使用一致性非锁定读:

  • 在 RC 级别下,对于快照数据,一致性非锁定读总是读取被锁定行的最新一份快照数据。
  • 在 RR 级别下,对于快照数据,一致性非锁定读总是读取本事务开始时的行数据版本。

快照读比较适合对于数据一致性要求不是特别高且追求极致性能的业务场景。

当前读(一致性锁定读)就是给行记录加 X 锁或 S 锁。

当前读的一些常见 SQL 语句类型如下:

1
2
3
4
5
6
7
8
9
10
# 对读的记录加一个X锁
SELECT...FOR UPDATE
# 对读的记录加一个S锁
SELECT...LOCK IN SHARE MODE
# 对读的记录加一个S锁
SELECT...FOR SHARE
# 对修改的记录加一个X锁
INSERT...
UPDATE...
DELETE...

什么是自增锁(不太重要)

关系型数据库设计表的时候,通常会有一列作为自增主键。InnoDB 中的自增主键会涉及一种比较特殊的表级锁——自增锁(AUTO-INC Locks)

1
2
3
4
5
6
CREATE TABLE `sequence_id` (
`id` BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
`stub` CHAR(10) NOT NULL DEFAULT '',
PRIMARY KEY (`id`),
UNIQUE KEY `stub` (`stub`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

更准确一点来说,不仅仅是自增主键,AUTO_INCREMENT的列都会涉及到自增锁,毕竟非主键也可以设置自增长。

如果一个事务正在插入数据到有自增列的表时,会先获取自增锁,拿不到就可能会被阻塞住。这里的阻塞行为只是自增锁行为的其中一种,可以理解为自增锁就是一个接口,其具体的实现有多种。具体的配置项为 innodb_autoinc_lock_mode (MySQL 5.1.22 引入),可以选择的值如下:

innodb_autoinc_lock_mode 介绍
0 传统模式
1 连续模式(MySQL 8.0 之前默认)
2 交错模式(MySQL 8.0 之后默认)

交错模式下,所有的“INSERT-LIKE”语句(所有的插入语句,包括:INSERTREPLACEINSERT…SELECTREPLACE…SELECTLOAD DATA等)都不使用表级锁,使用的是轻量级互斥锁实现,多条插入语句可以并发执行,速度更快,扩展性也更好。

不过,如果你的 MySQL 数据库有主从同步需求并且 Binlog 存储格式为 Statement 的话,不要将 InnoDB 自增锁模式设置为交叉模式,不然会有数据不一致性问题。这是因为并发情况下插入语句的执行顺序就无法得到保障。

如果 MySQL 采用的格式为 Statement ,那么 MySQL 的主从同步实际上同步的就是一条一条的 SQL 语句。

MySQL 性能优化

能用 MySQL 直接存储文件(比如图片)吗

可以自然是可以的,直接存储二进制文件对应的二进制数据即可。不过,还是建议不要在数据库中存储文件,会严重影响数据库性能,消耗过多存储空间。

可以选择使用云服务厂商提供的开箱即用的文件存储系统服务(比如OSS,NAS),成熟稳定,价格也比较低。

也可以选择自建文件存储服务,实现起来也不难,基于 FastDFS、MinIO(推荐)等开源项目就可以实现分布式文件服务。

数据库只存储文件地址信息,文件由文件存储服务负责存储。

MySQL 如何存储 IP 地址

可以将 IP 地址转换成整型数据存储,性能更好,占用空间也更小。

MySQL 提供了两个方法来处理 IP 地址:

  • INET_ATON(): 把 ip 转为无符号整型(4-8字节)
  • INET_NTOA(): 把整型的 ip 转为地址

插入数据前,先用 INET_ATON() 把 ip 地址转为整型,显示数据时,使用 INET_NTOA() 把整型的 ip 地址转为地址显示即可。

MySQL 性能怎么优化

MySQL 性能优化是一个系统性工程,涉及多个方面,在面试中不可能面面俱到。因此,建议按照“点-线-面”的思路展开,从核心问题入手,再逐步扩展,展示粗你对问题的思考深度和解决能力。

  1. 抓住核心:慢 SQL 定位与分析

    性能优化的第一步永远是找到瓶颈。面试时,建议先从慢 SQL 定位和分析入手,这不仅能展示你解决问题的思路,还能体现你对数据库性能监控的熟练掌握:

    • 监控工具: 介绍常用的慢 SQL 监视工具,如 MySQL 慢查询日志、Performance Schema 等,说明你对这些工具的熟悉程度以及如何通过它们定位问题。
    • EXPLAIN 命令: 详细说明EXPLAIN命令的使用,分析查询计划、索引使用情况,可以结合实际案例展示如何解读分析结果,比如执行顺序、索引使用情况、全表扫描等。
  2. 由点及面:索引、表结构和 SQL 优化

    定位到慢 SQL 后,接下来就要针对具体问题进行优化。这里可以重点介绍索引、表结构和 SQL 编写规范等方面的优化技巧:

    • 索引优化: 这是 MySQL 性能优化的重点,可以介绍索引的创建原则、覆盖索引、最左前缀匹配原则等。如果能结合你项目的实际应用来说明如何选择合适的索引,会更加分一些。

    • 表结构优化: 优化表结构设计,包括选择合适的字段类型、避免冗余字段、合理使用范式和反范式设计等等。

    • SQL 优化:

      • 查询性能优化:

        • 减少请求的数据量
          • 只返回必要的列:最好不要使用SELECT *语句。
          • 只返回必要的行:使用LIMIT语句来限制返回的数据。
          • 缓存重复查询的数据:使用缓存可以避免在数据库中进行查询,特别在要查询的数据经常被重复查询时,缓存带来的查询性能提升将会是非常明显的。
        • 减少服务器端扫描的次数: 最有效的方式是使用索引来覆盖查询。
      • 重构查询方式:

        • 切分大查询

          一个大查询如果一次性执行的话,可能一次锁住很多数据、占满整个事务日志、耗尽系统资源、阻塞很多小的但重要的查询。

          1
          DELETE FROM messages WHERE create < DATE_SUB(NOW(), INTERVAL 3 MONTH);
          1
          2
          3
          4
          5
          rows_affected = 0
          do {
          rows_affected = do_query(
          "DELETE FROM messages WHERE create < DATE_SUB(NOW(), INTERVAL 3 MONTH) LIMIT 10000")
          } while rows_affected > 0
        • 分解大连接查询

          将一个大连接查询分解成对每一个表进行一次单表查询,然后在应用程序中进行关联,这样做的好处有:

          • 让缓存更高效。对于连接查询,如果其中一个表发生变化,那么整个查询缓存就无法使用。而分解后的查询,即是其中一个表发生变化,对其他表的查询缓存依然可以使用。
          • 分解成多个单表查询,这些单表查询的缓存结果更可能被其他查询使用到,从而减少冗余记录的查询。
          • 减少锁竞争。
          • 在应用层进行连接,可以更容易对数据库进行拆分,从而更容易做到高性能和可伸缩。
          • 查询本身效率也可能会有所提升。例如下面的例子中,使用IN()代替连接查询,可以让 MySQL 按照 ID 顺序进行查询,这可能比随机的连接要更高效。
          1
          2
          3
          4
          SELECT * FROM tag
          JOIN tag_post ON tag_post.tag_id=tag.id
          JOIN post ON tag_post.post_id=post.id
          WHERE tag.tag='mysql';
          1
          2
          3
          SELECT * FROM tag WHERE tag='mysql';
          SELECT * FROM tag_post WHERE tag_id=1234;
          SELECT * FROM post WHERE post.id IN (123,456,567,9098,8904);
  3. 进阶方案:架构优化

    当面试官对基础优化知识比较满意时,可能会深入探讨一些架构层面的优化方案。以下是一些常见的架构优化策略:

    • 读写分离: 将读操作和写操作分离到不同的数据库实例,提升数据库的并发处理能力。
    • 分库分表: 将数据分散到多个数据库实例或数据表中,降低单表数据量,提升查询效率。但要权衡其带来的复杂性和维护成本,谨慎使用。
    • 数据冷热分离: 根据数据的访问频率和业务重要性,将数据分为冷数据和热数据,冷数据一般存储在低成本、低性能的介质中,热数据存储到高性能存储介质中。
    • 缓存机制: 使用 Redis 等缓存中间件,将热点数据缓存到内存中,减轻数据库压力。这个非常有用,提升效果非常明显,性价比极高!
  4. 其他优化手段

    除了慢 SQL 定位、索引优化和架构优化,还可以提及一些其他优化手段,展示你对 MySQL 性能调优的全面理解:

    • 连接池配置: 配置合理的数据库连接池(如连接池大小、超时时间等),能够有效提升数据库连接的效率,避免频繁的连接开销。
    • 硬件配置: 提升硬件性能也是优化的重要手段之一。使用高性能服务器、增加内存、使用 SSD 硬盘等硬件升级,都可以有效提升数据库的整体性能。

如何分析 SQL 的性能

我们可以使用EXPLAIN命令来分析 SQL 的执行计划。执行计划是指一条 SQL 语句在经过 MySQL 查询优化器的优化后具体的执行方式。

EXPLAIN并不会真的去执行相关的语句,而是通过查询优化器对语句进行分析,找出最优的查询方案,并显示对应的信息。

EXPLAIN 适用于 SELECT, DELETE, INSERT, REPLACE, 和 UPDATE语句,我们一般分析 SELECT 查询较多。

EXPLAIN 的输出各个字段如下:

列名 含义
id SELECT 查询的序列标识符
select_type SELECT 关键字对应的查询类型
table 用到的表名
partitions 匹配的分区,对于未分区的表,值为 NULL
type 表的访问方法
possible_keys 可能用到的索引
key 实际用到的索引
key_len 所选索引的长度
ref 当使用索引等值查询时,与索引做比较的列或常量
rows 预计要读取的行数
filtered 按表条件过滤后,留存的记录数的百分比
extra 附加信息

读写分离和分库分表了解吗

什么是读写分离

根据读写分离的名字,我们就可以知道:读写分离主要是为了将对数据库的读写操作分散到不同的数据库节点上。 这样的话,就能够小幅提升写性能,大幅提升读性能。

一般情况下,我们都会选择一主多从,也就是一台主数据库负责写,其他的从数据库负责读。主库和从库之间会进行数据同步,以保证从库中数据的准确性。这样的架构实现起来比较简单,并且也符合系统的写少读多的特点。

如何实现读写分离

不论是使用哪一种读写分离具体的实现方案,想要实现读写分离一般包含如下几步:

  1. 部署多台数据库,选择其中的一台作为主数据库,其他的一台或者多台作为从数据库。

  2. 保证主数据库和从数据库之间的数据是实时同步的,这个过程也就是我们常说的主从复制

  3. 系统将写请求交给主数据库处理,读请求交给从数据库处理。

落实到项目本身的话,常用的方式有两种:

  1. 代理方式

    我们可以在应用和数据中间加了一个代理层。应用程序所有的数据请求都交给代理层处理,代理层负责分离读写请求,将它们路由到对应的数据库中。

    提供类似功能的中间件有 MySQL Router(官方, MySQL Proxy 的替代方案)、Atlas(基于 MySQL Proxy)、MaxScale、MyCat

    关于 MySQL Router 多提一点:在 MySQL 8.2 的版本中,MySQL Router 能自动分辨对数据库读写/操作并把这些操作路由到正确的实例上。这是一项有价值的功能,可以优化数据库性能和可扩展性,而无需在应用程序中进行任何更改。

  2. 组件方式

    在这种方式中,我们可以通过引入第三方组件来实现读写请求的路由。

    这也是我比较推荐的一种方式。这种方式目前在各种互联网公司中用的最多的,相关的实际的案例也非常多。如果你要采用这种方式的话,推荐使用 ShardingSphere-JDBC ,直接引入 jar 包即可使用,非常方便。同时,也节省了很多运维的成本。

还有很多东西,但是稍微了解一下就行,这里就不展开了。

深度分页如何优化

什么是深度分页,怎么导致的

查询偏移量过大的场景我们称之为深度分页,这会导致查询性能较低,例如:

1
2
# MySQL 在无法利用索引的情况下跳过1000000条记录后,再获取10条记录
SELECT * FROM t_order ORDER BY id LIMIT 1000000, 10

当查询偏移量过大时,MySQL 的查询优化器可能会选择全表扫描而不是利用索引来优化查询。

深度分页变慢的根本原因在于 MySQL 的执行机制:对于 LIMIT offset, N,MySQL 并非直接跳到 offset 处,而是必须从头扫描 offset + N 条记录。如果查询依赖二级索引且不满足覆盖索引,这意味着 MySQL 需要对前 offset 条记录执行毫无意义的回表查询(产生海量的随机 I/O),最后再将这些辛苦查出的数据丢弃。即便优化器最终因代价过高退化为全表扫描,顺序扫描百万行的成本依然巨大。

MySQL 的查询优化器采用基于成本的策略来选择最优的查询执行计划。它会根据 CPU 和 I/O 的成本来决定是否使用索引扫描或全表扫描。如果优化器认为全表扫描的成本更低,它就会放弃使用索引。不过,即使偏移量很大,如果查询中使用了覆盖索引(covering index),MySQL 仍然可能会使用索引,避免回表操作。

深度分页优化建议

基于 MySQL 8.0 + InnoDB 存储引擎,不同版本优化器行为可能存在差异

范围查询(游标分页)

通过记录上一页最后一条记录的 ID,使用WHERE id > lsat_id LIMIT n获取下一页数据:

1
2
# 通过记录上次查询结果的最后一条记录的 ID 进行下一页的查询
SELECT * FROM t_order WHERE id > 100000 ORDER BY id LIMIT 10

游标分页的核心优势不依赖 ID 的连续性。MySQL 只需要在 B+ 树上定位到 last_id 的位置,然后顺序向后读取 n 条记录即可,中间是否有断层(如 ID 被删除)完全不影响结果的准确性和性能。

这种方式的限制:

  1. 不支持跳页:无法直接跳转到第 N 页,只能逐页向后(或向前)翻页。
  2. 排序字段受限:如果查询需要按照其他字段(如创建时间)排序而非 ID 排序,需使用联合游标 (sort_field, id) 保证唯一性和顺序。
  3. 并发场景:当分页查询期间有新数据插入或删除时,可能出现:
    • 数据遗漏:查询第二页时,有新数据插入到第一页范围内,导致该数据被”挤”到第二页,但第二页查询已基于旧的最后 ID 跳过它。
    • 数据重复:查询第二页时,第一页末尾有数据被删除,原第二页的第一条数据”升”到第一页末尾,导致第二页查询再次返回它。
子查询

我们先查询出 limit 第一个参数对应的主键值,再根据这个主键值再去过滤并 limit,这样效率会更快一些。

阿里巴巴《Java 开发手册》中也有对应的描述:

利用延迟关联或者子查询优化超多分页场景。

子查询

1
2
3
4
5
-- 先通过子查询在主键索引上进行偏移,快速找到起始ID
SELECT * FROM t_order
WHERE id >= (
SELECT id FROM t_order ORDER BY id LIMIT 1000000, 1
) ORDER BY id LIMIT 10;

工作原理:

  1. 子查询 SELECT id FROM t_order ORDER BY id LIMIT 1000000, 1 利用主键索引扫描并跳过前 1000000 条记录,返回第 1000001 条记录的主键值。

  2. 主查询 SELECT * FROM t_order WHERE id >= ... ORDER BY id LIMIT 10 以该主键为起点,获取后续 10 条完整记录。

不过,某些情况下子查询可能会产生临时表,影响性能,因此在复杂查询中建议优先考虑延迟关联。

复杂过滤场景: 在包含复杂过滤条件的分页场景中(如WHERE status = 1 ORDER BY id LIMIT 1000000, 10),符合条件的 ID 往往是离散的。此时子查询的优势更加明显:通过在子查询中利用联合索引(如(status, id))实现覆盖索引扫描,可以高效地跳过前100万条符合条件的记录,定位到目标 ID 后,主查询只需回表 10 次。

当然,我们也可以利用子查询先去获取目标分页的 ID 集合,然后再根据 ID 集合获取内容,但这种写法非常繁琐,不如使用 INNER JOIN 延迟关联。

延迟关联

延迟关联与子查询的优化思路类似,都是通过将 LIMIT 操作转移到主键索引树上,减少回表次数。相比直接使用子查询,延迟关联通过 INNER JOIN 将子查询结果集成到主查询中,避免了子查询可能产生的临时表。在执行 INNER JOIN 时,MySQL 优化器能够利用索引进行高效的连接操作(如索引扫描或其他优化策略),因此在深度分页场景下,性能通常优于直接使用子查询。

1
2
3
4
5
6
7
8
-- 使用 INNER JOIN 进行延迟关联
SELECT t1.*
FROM t_order t1
INNER JOIN (
-- 这里的子查询可以利用覆盖索引,性能极高
SELECT id FROM t_order ORDER BY id LIMIT 1000000, 10
) t2 ON t1.id = t2.id
ORDER BY t1.id;

工作原理:

  1. 子查询 SELECT id FROM t_order ORDER BY id LIMIT 1000000, 10 利用主键索引扫描并跳过前 1000000 条记录,返回目标分页的 10 条记录的 ID。
  2. 通过 INNER JOIN 将子查询结果与主表 t_order 关联,获取完整的记录数据。

除了使用 INNER JOIN 之外,还可以使用逗号连接子查询:

1
2
3
4
5
-- 使用逗号进行延迟关联
SELECT t1.* FROM t_order t1,
(SELECT id FROM t_order ORDER BY id LIMIT 1000000, 10) t2
WHERE t1.id = t2.id
ORDER BY t1.id;

注意: 虽然逗号连接子查询也能实现类似的效果,但为了代码可读性和可维护性,建议使用更规范的 INNER JOIN 语法。

数据冷热处理

什么是数据冷热处理

数据冷热分离是指根据数据的访问频率业务重要性,将数据划分为冷数据和热数据,并分别存储在不同的性能和成本的存储介质的架构策略。

这种架构的核心目标有三个:

  1. 提升查询性能: 热数据存储在高性能介质(如 SSD、内存)中,保障核心业务的响应速度。
  2. 降低存储成本: 冷数据迁移至低成本介质(如 HDD、对象存储),大幅削减存储开支。
  3. 满足合规要求: 部分行业(如金融、医疗)要求数据长期归档,冷热分离可兼顾合规与成本。
冷数据和热数据

热数据是指被频繁访问和修改、且需要快速响应的数据;冷数据是指访问频率极低、对当前业务价值较小、但需要长期保留的数据。

冷热数据的区分方法主要有两种:

  1. 时间维度区分:按照数据的创建时间、更新时间或过期时间划分。例如,订单系统将一段时间前(如 90 天或 1 年)的订单数据标记为冷数据。该方法适用于数据访问频率与时间强相关的场景,实现简单、成本低。
  2. 访问频率区分:将高频访问的数据视为热数据,低频访问的数据视为冷数据。例如,内容系统将浏览量低于阈值的文章标记为冷数据。该方法需要额外记录访问频率,适用于访问频率与数据本身特性强相关的场景。

如何选择区分策略?

  • 若业务数据天然具有时效性(如订单、日志、账单),优先选择时间维度,实现成本最低。
  • 若数据价值与时间无关(如文章、商品、用户画像),需结合访问频率进行判定。
  • 实际项目中,可将两者结合使用:以时间维度为主、访问频率为辅,覆盖更多业务场景。

还有分层策略之类的需要了解,这里就不展开来讲了。

缓存基础常见面试题总结

缓存的基本思想

缓存的基本思想很简单,就是我们非常熟悉的空间换时间这一经典性能优化策略的运用。所谓空间换时间,也就是用更多的存储空间来存储一些可能重复使用或计算的数据,从而减少数据的重新获取或计算的时间。

常见的空间换时间的例子:

  • 索引: 索引是一种将数据库表中的某些列或字段按照一定的排序规则组织成一个单独的数据结构,虽然额外占用空间,但可以大大提高检索效率,降低数据排序成本。
  • 数据库表字段冗余: 将经常联合查询的数据冗余存储在同一张表中,以减少对多张表的关联查询,进而提升查询性能,减轻数据库压力。
  • CDN(内容分发网络): 将静态资源分发到多个边缘节点以实现就近访问,进而加快静态资源的访问速度,减轻源站服务器以及带宽的负担。

缓存的思想在 CPU(CPU Cache)、操作系统(TLB/快表)或者其他很多地方都被大量用到。

缓存的分类

本地缓存

什么是本地缓存

这个实际在很多项目中用的蛮多,特别是单体架构的时候。数据量不大,并且没有分布式要求的话,使用本地缓存还是可以的。

本地缓存位于应用内部,其最大的优点是应用存在于同一个进程内部,请求本地缓存的速度非常快,不存在额外的网络开销。

常见的单体架构图如下,我们使用 Nginx 来做负载均衡,部署两个相同的应用到服务器,两个服务使用同一个数据库,并且使用的是本地缓存。

local-cache

注意: 在集群模式下使用本地缓存,必须考虑负载均衡策略。如果 Nginx 使用默认的轮询(Round-Robin),同一个用户的请求会随机落在不同的机器上,导致本地缓存命中率极低。

解决方案如下:

  1. 网关层: 使用一致性哈希或 Sticky Session,保证同一用户的请求固定打到同一台机器。
  2. 应用层: 仅将本地缓存用于**“全局几乎不变”**的数据(如配置字典),而非用户维度数据。
本地缓存的方案有哪些
  1. JDK 自带的HashMapConcurrentHashMap

    ConcurrentHashMap 可以看作是线程安全版本的 HashMap ,两者都是存放 key/value 形式的键值对。但是,大部分场景来说不会使用这两者当做缓存,因为只提供了缓存的功能,并没有提供其他诸如过期时间之类的功能。一个稍微完善一点的缓存框架至少要提供:过期时间淘汰机制命中率统计这三点。

  2. **EhcacheGuava CacheSpring Cache**这三者是使用的比较多的本地缓存框架

    • Ehcache 的话相比于其他两者更加重量。不过,相比于 Guava CacheSpring Cache 来说, Ehcache 支持可以嵌入到 hibernate 和 mybatis 作为多级缓存,并且可以将缓存的数据持久化到本地磁盘中、同时也提供了集群方案(比较鸡肋,可忽略)。

    • Guava CacheSpring Cache 两者的话比较像。Guava 相比于 Spring Cache 的话使用的更多一点,它提供了 API 非常方便我们使用,同时也提供了设置缓存有效时间等功能。它的内部实现也比较干净,很多地方都和 ConcurrentHashMap 的思想有异曲同工之妙。

    • 使用 Spring Cache 的注解实现缓存的话,代码会看着很干净和优雅,但是很容易出现问题比如缓存穿透、内存溢出。

  3. 后起之秀 Caffeine

    相比于 Guava 来说 Caffeine 在各个方面比如性能都要更加优秀,一般建议使用其来替代 Guava 。并且, GuavaCaffeine 的使用方式很像!

    使用 Caffeine 创建本地缓存的代码示例,用到了建造者模式:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    // 使用 Caffeine 创建本地缓存示例
    Cache<String, String> cache = Caffeine.newBuilder()
    // 设置写入后 60 天过期
    .expireAfterWrite(60, TimeUnit.DAYS)
    // 初始容量
    .initialCapacity(100)
    // 最大条数限制
    .maximumSize(500)
    // 开启统计功能
    .recordStats()
    .build();
本地缓存有什么痛点

本地缓存的优势非常明显:低依赖、轻量、简单、成本低

但是,本地缓存存在下面的这些缺陷:

  • **本地缓存应用耦合,对分布式架构支持不友好,**比如同一个相同的服务部署在多台机器上的时候,各个服务之间的缓存是无法共享的,因为本地缓存只在当前机器上有。
  • 本地缓存容量受服务部署所在的机器限制明显。如果当前系统服务所耗费的内存多,那么本地缓存可用的容量就很少。因为本地缓存通常直接使用的是应用程序所在的 JVM 内存中

分布式缓存

什么是分布式缓存

我们可以把分布式缓存(Distributed Cache) 看作是一种内存数据库的服务,它的最终作用就是提供缓存数据的服务。

分布式缓存脱离于应用独立存在,多个应用可直接的共同使用同一个分布式缓存服务。

如下图所示,就是一个简单的使用分布式缓存的架构图。我们使用 Nginx 来做负载均衡,部署两个相同的应用到服务器,两个服务使用同一个数据库和缓存:

distributed-cache

使用分布式缓存之后,缓存服务可以部署在一台单独的服务器上,即使同一个相同的服务部署在多台机器上,也是使用的同一份缓存。 并且,单独的分布式缓存服务的性能、容量和提供的功能都要更加强大。

软件系统设计中没有银弹,往往任何技术的引入都像是把双刃剑。你使用的方式得当,就能为系统带来很大收益。否则,只是费了精力不讨好。

为系统引入分布式缓存之后往往会带来下面的问题:

  • 系统复杂性增加: 引入缓存之后,你要维护缓存和数据库的数据一致性、维护热点缓存、保证缓存服务的高可用等等。
  • 系统开发成本往往会增加: 引入缓存意味着系统需要一个单独的缓存服务,这是需要花费相应的成本的,并且这个成本还是很贵的,毕竟耗费的是宝贵的内存。
分布式缓存的方案有哪些

分布式缓存比较老牌且使用的比较多的还是 MemcachedRedis。不过,现在基本没有看过还有项目使用 Memcached 来做缓存,都是直接用 Redis

Memcached 是分布式缓存最开始兴起的那会儿比较常用的。后来随着 Redis 的发展,大家慢慢转向使用更加强大的 Redis 了。

目前,比较业界认可的 Redis 替代品还是下面这两个开源分布式缓存(都是通过碰瓷 Redis 火的):

  • Dragonfly:一种针对现代应用程序负荷需求而构建的内存数据库,完全兼容 Redis 和 Memcached 的 API,迁移时无需修改任何代码,号称全世界最快的内存数据库。
  • KeyDB: Redis 的一个高性能分支,专注于多线程、内存效率和高吞吐量。

不过,还是建议分布式缓存首选 Redis ,毕竟经过这么多年的生产考验,生态也这么优秀,资料也很全面。

多级缓存

什么是多级缓存,为什么要用

这里只简单介绍一下本地缓存 + 分布式缓存的多级缓存方案,这也是最常用的多级缓存实现方式。

既然用了分布式缓存,为什么还要用本地缓存呢?

本地缓存和分布式缓存虽然都属于缓存,但本地缓存的访问速度要远大于分布式缓存,这是因为访问本地缓存不存在额外的网络开销,我们在上面也提到了。

不过,一般情况下,我们也是不建议使用多级缓存的,这会增加维护负担(比如你需要保证一级缓存和二级缓存的数据一致性)。而且,其实际带来的提升效果对于绝大部分业务场景来说其实并不是很大。

这里简单总结一下适合多级缓存的两种业务场景:

  • 缓存的数据不会频繁修改,比较稳定;
  • 数据访问量特别大比如秒杀场景。

多级缓存方案中,第一级缓存(L1)使用本地内存(比如 Caffeine),第二级缓存(L2)使用分布式缓存(比如 Redis)。

multilevel-cache

读取缓存数据的时候,我们先从 L1 中读取,读取不到的时候再去 L2 读取。这样可以降低 L2 的压力,减少 L2 的读次数。如果 L2 也没有此数据的话,再去数据库查询,数据查询成功后再将数据写入到 L1 和 L2 中。

多级缓存开源实现推荐:

  • J2Cache:基于本地内存和 Redis 的两级 Java 缓存框架。
  • JetCache:阿里开源的缓存框架,支持多级缓存、分布式缓存自动刷新、 TTL 等功能。
多级缓存的一致性如何保证

在多级缓存系统中,保证一致性成本太高,业界的几个提供多级缓存功能的缓存框架基本都是保证最终一致性保证。例如,可以使用 Redis 的发布/订阅机制、Redis Stream 或者消息队列来确保当一个实例的本地缓存发生变化时,其他实例能够及时更新其本地缓存,以保持缓存一致性。

政采云技术的方案是 Canal(数据库增量日志解析组件) + 广播消息,这里简单介绍一下:

  1. DB 修改数据:首先在数据库中进行数据修改
  2. 通过监听 Canal 消息,触发缓存的更新:使用 Canal 监听数据库的变更操作,当检测到数据变化时,触发缓存更新。
  3. 同步 Redis 缓存:对于 Redis 缓存,因为集群只共享一份数据,所以直接同步缓存即可。
  4. 同步本地缓存:由于本地缓存分布在不同的 JVM 实例中,需要借助广播消息队列(MQ)机制,将更新通知广播到各个业务示例,从而同步本地缓存。

Redis 常见面试题总结

什么是 Redis

Redis (REmote DIctionary Server) 是一个基于 C 语言开发的开源 NoSQL 数据库(BSD 许可)。与传统数据库不同的是,Redis 的数据是保存在内存中的(内存数据库,支持持久化),因此读写速度非常快,被广泛应用于分布式缓存方向。并且,Redis 存储的是 KV 键值对数据。

为满足不同的业务场景,Redis 内置了多种数据类型实现(比如 String、Hash、Sorted Set、Bitmap、HyperLogLog、GEO)。并且,Redis 还支持事务、持久化、Lua 脚本、发布订阅模型、多种开箱即用的集群方案(Redis Sentinel、Redis Cluster)。

redis-overview-of-data-types-2023-09-28

重点:Redis 为什么这么快

Redis 内部做了非常多的性能优化,比较重要的有下面 4 点:

  1. 纯内存操作(Memory-Based Storage): 这是最主要的原因。Redis 数据读写操作都发生在内存中,访问速度是纳秒级别,而传统数据库频繁读写磁盘的速度是毫秒级别。
  2. 高效的 I/O 模型(I/O Multiplexing & Single-Threaded Event Loop): Redis 使用单线程事件循环配合 I/O 多路复用技术,让单个线程可以同时处理多个网络连接上的 I/O 事件(如读写),避免了多线程模型中的上下文切换和锁竞争问题。虽然是单线程,但结合内存操作的高效性和 I/O 多路复用,使得 Redis 能轻松处理大量并发请求(Redis 线程模型会在后文中详细介绍到)。
  3. 优化的内部数据结构(Optimized Data Structures): Redis 提供多种数据类型(如 String、List、Hash、Set、Sorted Set等),其内部实现采用高度优化的编码方式(如 ziplist、quicklist、skiplist、hashtable 等)。Redis 会根据数据大小和类型动态选择最合适的内部编码,以在性能和空间效率之间取得最佳平衡。
  4. 简洁高效的通信协议(Simple Protool - RESP): Redis 使用的是自己设计的 RESP(REdis Serialization Protool) 协议。这个协议实现简单、解析性能好,并且是二进制安全的。客户端和服务端之间通信的序列化/反序列化开销很小,有助于提升整体的交互速度。

why-redis-so-fast

那既然都这么快了,为什么不直接用 Redis 当主数据库呢?主要是因为内存成本太高,并且 Redis 提供的数据持久化仍然有数据丢失的风险。

除了 Redis,你还知道其他分布式缓存方案吗

见上一个大章节分布式缓存的内容,补充一个:腾讯的 Tendis,但是已经没有被维护更新了。

说一下 Redis 和 Memcached 的区别和共同点

共同点:

  1. 都是基于内存的数据库,一般用来当做缓存使用
  2. 都有过期策略
  3. 两者的性能都非常高

区别:

  1. 数据类型: Redis 支持更丰富的数据类型(支持更复杂的应用场景)。Redis 不仅仅支持简单的 k/v 类型的数据,同时还提供 list、set、zset、hash 等数据结构的存储;而 Memcached 只支持最简单的 k/v 数据类型。
  2. 数据持久化: Redis 支持数据的持久化,可以将内存中的数据保持在磁盘中,重启的时候可以再次加载进行使用;而 Memcached 把数据全部存在内存之中。也就是说,Redis 有灾难恢复机制,而 Memcached 没有。
  3. 集群模式支持: Memcached 没有原生的集群模式,需要依靠客户端来实现往集群中分片写入数据;而 Redis 字 3.0 版本起是原生支持集群模式的。
  4. 线程模型: Memcached 是多线程、非阻塞 IO 复用的网络模型;而 Redis 使用单线程的多路 IO 复用模型(Redis 6.0 针对网络数据的读写引入了多线程)。
  5. 特性支持: Redis 支持发布订阅模型、Lua 脚本、事务等功能,而 Memcached 不支持。并且,Redis 支持更多的编程语言。
  6. 过期数据删除: Memcached 过期数据的删除策略只用了惰性删除,而 Redis 同时使用了惰性删除与定期删除。

重要:为什么要用 Redis

1、访问速度更快

传统数据库数据保存在磁盘,而 Redis 基于内存,内存的访问速度比磁盘快很多。引入 Redis 之后,我们可以把一些高频访问的数据放到 Redis 中,这样下次就可以直接从内存中读取,速度可以提升几十倍甚至上百倍。

2、高并发

一般像 MySQL 这类的数据库的 QPS 大概都在 4k 左右(4 核 8g),但是使用 Redis 缓存之后很容易达到 5w+,甚至能达到 10w+(就单机 Redis 的情况,Redis 集群的话会更高)。

QPS(Query Per Second):服务器每秒可以执行的查询次数;

由此可见,直接操作缓存能够承受的数据库请求数量是远远大于直接访问数据库的,所以我们可以考虑把数据库中的部分数据转移到缓存中去,这样用户的一部分请求会直接到缓存这里而不用经过数据库。进而,我们也就提高了系统整体的并发。

3、功能全面

Redis 除了可以用作缓存之外,还可以用于分布式锁、限流、消息队列、延时队列等场景,功能强大!

重要:为什么用 Redis 而不用本地缓存

特性 本地缓存 Redis
数据一致性 多服务器部署时存在数据不一致问题 数据一致
内存限制 受限于单台服务器内存 独立部署,内存空间更大
数据丢失风险 服务器宕机数据丢失 可持久化,数据不易丢失
管理维护 分散,管理不便 集中管理,提供丰富的管理工具
功能丰富性 功能有限,通常只提供简单的键值对存储 功能丰富,支持多种数据结构和功能

常见的缓存读写策略

需要看文章复习。

什么是 Redis Module,有什么用

Redis 从 4.0 版本开始,支持通过 Module 来扩展其功能以满足特殊的需求。这些 Module 以动态链接库(so 文件)的形式被加载到 Redis 中,这是一种非常灵活的动态扩展功能的实现方式。

我们每个人都可以基于 Redis 去定制化开发自己的 Module,比如实现搜索引擎功能、自定义分布式锁和分布式限流。

目前,被 Redis 官方推荐的 Module 有:

  • RediSearch:用于实现搜索引擎的模块。
  • RedisJSON:用于处理 JSON 数据的模块。
  • RedisGraph:用于实现图形数据库的模块。
  • RedisTimeSeries:用于处理时间序列数据的模块。
  • RedisBloom:用于实现布隆过滤器的模块。
  • RedisAI:用于执行深度学习/机器学习模型并管理其数据的模块。
  • RedisCell:用于实现分布式限流的模块。
  • ……

关于 Redis 模块的详细介绍,可以查看官方文档:https://redis.io/modules。

重要:Redis 应用

Redis 除了做缓存,还能做什么

  • 分布式锁: 通过 Redis 来做分布式锁是一种比较常见的方式。通常情况下,我们都是基于 Redisson 来实现分布式锁。
  • 限流: 一般是通过 Redis + Lua 脚本的方式来实现限流。如果不想自己写 Lua 脚本的话,也可以直接利用 Redission 中的 RRateLimiter 来实现分布式限流,其底层实现就是基于 Lua 代码 + 令牌桶算法。
  • 消息队列: Redis 自带的 List 数据结构可以作为一个简单的队列使用。Redis 5.0 中增加的 Stream 类型的数据结构更加适合用来做消息队列。它比较类似于 Kafka,有主题和消费组的概念,支持消息持久化以及 ACK 机制。
  • 延时队列: Redission 内置了延时队列(基于 Sorted Set 实现的)。
  • 分布式 Session: 利用 String 或者 Hash 数据类型保存 Session 数据,所有的服务器都可以访问。
  • 复杂业务场景: 通过 Redis 以及 Redis 扩展(比如 Redisson)提供的数据结构,我们可以很方便地完成很多复杂的业务场景,比如通过 Bitmap 统计活跃用户、通过 Sorted Set 维护排行榜、通过 HyperLogLog 统计网站 UV 和 PV。

如何基于 Redis 实现分布式锁

需要专门学习。

Redis 可以做消息队列吗,怎么实现

先说结论:

  • 如果业务简单、量小、追求极致性能,且能容忍极小概率的数据丢失,使用 Redis Stream 是最优解,因为它省去了部署维护 MQ 的成本,可以复用现有的 Redis 组件(大部分需要用到 MQ 的项目,通常都会需要 Redis)。
  • 如果是金融级业务、海量数据、需要严格保证不丢消息,必须选择 Kafka、RabbitMQ 等更成熟的 MQ。

需要专门学习。

如何基于 Redis 实现延时任务

类似的问题:

  • 订单在 10 分钟后未支付就失效,如何用 Redis 实现?
  • 红包 24 小时未被查收自动退还,如何用 Redis 实现?

基于 Redis 实现延时任务的功能无非就下面两种方案:

  1. Redis 过期事件监听。
  2. Redisson 内置的延时队列。

Redis 过期事件监听存在时效性较差、丢消息、多服务实例下消息重复消费等问题,不被推荐使用。

Redisson 内置的延时队列具备下面这些优势:

  1. 减少了丢消息的可能:DelayedQueue 中的消息会被持久化,即使 Redis 宕机了,根据持久化机制,也只可能丢失一点消息,影响不大。当然了,你也可以使用扫描数据库的方法作为补偿机制。
  2. 消息不存在重复消费问题:每个客户端都是从同一个目标队列中获取任务的,不存在重复消费的问题。

需要专门学习。

重要:Redis 数据类型

Redis 常用的数据类型有哪些

Redis 中比较常见的数据类型有下面这些:

  • 5 种基础数据类型: String(字符串)、List(列表)、Set(集合)、Hash(散列)、Zset(有序集合)
  • 3 种特殊数据类型: HyperLogLog(基数统计)、Bitmap(位图)、Geospatial(地理位置)

除了上面提到的之外,还有一些其他的比如 Bloom filter(布隆过滤器)、Bitfield(位域)。

都需要看文章详细了解一下。

String 的应用场景有哪些

String 是 Redis 中最简单同时也是最常用的一个数据类型。它是一种二进制安全的数据类型,可以用来存储任何类型的数据比如字符串、整数、浮点数、图片(图片的 base64 编码或者解码或者图片的路径)、序列化后的对象。

String 的常见应用场景如下:

  • 常规数据(比如 Session、Token、序列化后的对象、图片的路径)的缓存
  • 计数比如用户单位时间的请求数(简单限流可以用到)、页面单位时间的访问数
  • 分布式锁(利用 SETNX key value 命令可以实现一个最简易的分布式锁)

String 还是 Hash 存储对象数据更好呢

简单对比一下二者:

  • 对象存储方式: String 存储的是序列化后的对象数据,存放的是整个对象,操作简单直接。Hash 是对对象的每个字段单独存储,可以获取部分字段的信息,也可以修改或者添加部分字段,节省网络流量。如果对象中某些字段需要经常变动或者经常需要单独查询对象中的个别字段信息,Hash 就非常适合。
  • 内存消耗: Hash 通常比 String 更节省内存,特别是在字段较多且字段长度较短时。Redis 对小型 Hash 进行优化(如使用 ziplist 存储),进一步降低内存占用。
  • 复杂对象存储:String 在处理多层嵌套或复杂结构的对象时更方便,因为无需处理每个字段的独立存储和操作。
  • 性能:String 的操作通常具有 O(1) 的时间复杂度,因为它存储的是整个对象,操作简单直接,整体读写的性能较好。Hash 由于需要处理多个字段的增删改查操作,在字段较多且经常变动的情况下,可能会带来额外的性能开销。

总结:

  • 在绝大多数情况下,String 更适合存储对象数据,尤其是当对象结构简单且整体读写是主要操作时。
  • 如果你需要频繁操作对象的部分字段或节省内存,Hash 可能是更好的选择。

String 的底层实现

Redis 是基于 C 语言编写的,但 Redis 的 String 类型的底层实现并不是 C 语言中的字符串(即以空字符 \0 结尾的字符数组),而是自己编写了 SDS (Simple Dynamic String,简单动态字符串)来作为底层实现。

SDS 最早是 Redis 作者为日常 C 语言开发而设计的 C 字符串,后来被应用到了 Redis 上,并经过了大量的修改完善以适合高性能操作。

Redis 7.0 的 SDS 的部分源代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
/* Note: sdshdr5 is never used, we just access the flags byte directly.
* However is here to document the layout of type 5 SDS strings. */
struct __attribute__ ((__packed__)) sdshdr5 {
unsigned char flags; /* 3 lsb of type, and 5 msb of string length */
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr8 {
uint8_t len; /* used */
uint8_t alloc; /* excluding the header and null terminator */
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr16 {
uint16_t len; /* used */
uint16_t alloc; /* excluding the header and null terminator */
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr32 {
uint32_t len; /* used */
uint32_t alloc; /* excluding the header and null terminator */
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr64 {
uint64_t len; /* used */
uint64_t alloc; /* excluding the header and null terminator */
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[];
};

通过源码可以看出,SDS 共有五种实现方式:SDS_TYPE_5(并未用到)、SDS_TYPE_8、SDS_TYPE_16、SDS_TYPE_32、SDS_TYPE_64,其中只有后四种实际用到。Redis 会根据初始化的长度决定使用哪种类型,从而减少内存的使用。

对于后四种实现都包含了下面这 4 个属性:

  • len:字符串的长度也就是已经使用的字节数。
  • alloc:总共可用的字符空间大小,alloc-len 就是 SDS 剩余的空间大小。
  • buf[]:实际存储字符串的数组。
  • flags:低三位保存类型标志。

SDS 相比于 C 语言中的字符串有如下提升:

  1. 可以避免缓冲区溢出: C 语言中的字符串被修改(比如拼接)时,一旦没有分配足够长度的内存空间,就会造成缓冲区溢出。SDS 被修改时,会先根据 len 属性检查空间大小是否满足要求,如果不满足,则先扩展至所需大小再进行修改操作。
  2. 获取字符串长度的复杂度较低: C 语言中的字符串的长度通常是经过遍历计数来实现的,时间复杂度为 O(n)。SDS 的长度获取直接读取 len 属性即可,时间复杂度为 O(1)。
  3. 减少内存分配次数: 为了避免修改(增加/减少)字符串时,每次都需要重新分配内存(C 语言的字符串是这样的),SDS 实现了空间预分配和惰性空间释放两种优化策略。当 SDS 需要增加字符串时,Redis 会为 SDS 分配好内存,并且根据特定的算法分配多余的内存,这样可以减少连续执行字符串增长操作所需的内存重分配次数。当 SDS 需要减少字符串时,这部分内存不会立即被回收,会被记录下来,等待后续使用(支持手动释放,有对应的 API)。
  4. 二进制安全: C 语言中的字符串以空字符 \0 作为字符串结束的标识,这存在一些问题,像一些二进制文件(比如图片、视频、音频)就可能包括空字符,C 字符串无法正确保存。SDS 使用 len 属性判断字符串是否结束,不存在这个问题。

购物车信息是用 String 还是 Hash 存储更好呢

由于购物车中的商品频繁修改和变动,购物车信息建议使用 Hash 存储:

  • 用户 id 为 key
  • 商品 id 为 field,商品数量为 value

那用户购物车信息的维护具体应该怎么操作:

  • 用户添加商品就是往 Hash 里面增加新的 field 与 value;
  • 查询购物车信息就是遍历对应的 Hash;
  • 更改商品数量直接修改对应的 value 值(直接 set 或者做运算皆可);
  • 删除商品就是删除 Hash 中对应的 field;
  • 清空购物车直接删除对应的 key 即可。

使用 Redis 实现一个排行榜怎么做

Redis 中有一个叫做 Sorted Set (有序集合) 的数据类型经常被用在各种排行榜的场景,比如直播间送礼物的排行榜、朋友圈的微信步数排行榜、王者荣耀中的段位排行榜、话题热度排行榜等等。

相关的一些 Redis 命令:ZRANGE (从小到大排序)、ZREVERANGE (从大到小排序)、ZREVRANK (指定元素排名)。

Redis 的有序集合为什么要用跳表,而不用平衡树、红黑树或者 B+ 树

这道题很多大厂喜欢问,难度也不小:

  • 平衡树 vs 跳表: 平衡树的插入、删除和查询的时间复杂度和跳表一样都是 O(log n)。对于范围查询来说,平衡树也可以通过中序遍历的方式达到和跳表一样的效果。但是它的每一次插入或者删除操作都需要保证整颗树左右节点的绝对平衡,只要不平衡就要通过旋转操作来保持平衡,这个过程是比较耗时的。跳表诞生的初衷就是为了克服平衡树的一些缺点。跳表使用概率平衡而不是严格强制的平衡,因此,跳表中的插入和删除算法比平衡树的等效算法简单得多,速度也快得多。
  • 红黑树 vs 跳表: 相比较于红黑树来说,跳表的实现也更简单一些,不需要通过旋转和染色(红黑变换)来保证黑平衡。并且,按照区间来查找数据这个操作,红黑树的效率没有跳表高。
  • B+ 树 vs 跳表: B+ 树更适合作为数据库和文件系统中常用的索引结构之一,它的核心思想是通过可能少的 IO 定位到尽可能多的索引来获得查询数据。对于 Redis 这种内存数据库来说,它对这些并不感冒,因为 Redis 作为内存数据库它不可能存储大量的数据,所以对于索引不需要通过 B+ 树这种方式进行维护,只需按照概率进行随机维护即可,节约内存。而且使用跳表实现 zset 时相较前者来说更简单一些,在进行插入时只需通过索引将数据插入到链表中合适的位置再随机维护一定高度的索引即可,也不需要像 B+ 树那样插入时发现失衡时还需要对节点分裂与合并。

Set 的应用场景是什么

Redis 中 Set 是一种无序集合,集合中的元素没有先后顺序但都唯一,有点类似于 Java 中的 HashSet

Set 的常见应用场景如下:

  • 存放的数据不能重复的场景:网站 UV 统计(数据量巨大的场景还是 HyperLogLog 更适合一些)、文章点赞、动态点赞等等。
  • 需要获取多个数据源交集、并集和差集的场景:共同好友(交集)、共同粉丝(交集)、共同关注(交集)、好友推荐(差集)、音乐推荐(差集)、订阅号推荐(差集+交集)等等。
  • 需要随机获取数据源中的元素的场景:抽奖系统、随机点名等等。

使用 Set 实现抽奖系统怎么做

如果想要使用 Set 实现一个简单的抽奖系统的话,直接使用下面这几个命令就可以了:

  • SADD key member1 member2 ...:向指定集合添加一个或多个元素。
  • SPOP key count:随机移除并获取指定集合中一个或多个元素,适合不允许重复中奖的场景。
  • SRANDMEMBER key count:随机获取指定集合中指定数量的元素,适合允许重复中奖的场景。

使用 Bitmap 统计活跃用户怎么做

Bitmap 存储的是连续的二进制数字(0 或 1),通过 Bitmap,只需要一个 bit 位来表示某个元素对应的值或者状态,key 就是对应元素本身。我们知道 8 个 bit 可以组成一个 byte,所以 Bitmap 本身会极大的节省存储空间。

你可以将 Bitmap 看作是一个存储二进制数字(0 或 1)的数组,数组中每个元素的下标叫做 offset(偏移量)。

Bitmap 示意图

如果想要使用 Bitmap 统计活跃用户的话,可以使用日期(精确到天)作为 key,然后用户 ID 为 offset,如果当日活跃过就设置为 1。

1
2
3
4
5
6
SETBIT 20210308 1 1
(integer) 0
SETBIT 20210308 2 1
(integer) 0
SETBIT 20210309 1 1
(integer) 0

统计 20210308~20210309 总活跃用户数:

1
2
3
4
BITOP and desk1 20210308 20210309
(integer) 1
BITCOUNT desk1
(integer) 1

统计 20210308~20210309 在线活跃用户数:

1
2
3
4
BITOP or desk2 20210308 20210309
(integer) 1
BITCOUNT desk2
(integer) 2

HyperLogLog 适合什么场景

HyperLogLog(HLL) 是一种非常巧妙的概率性数据结构,它专门解决一类非常棘手的大数据问题:在海量数据中,用极小的内存,估算一个集合中不重复元素的数量,也就是我们常说的基数(Cardinality)。

HLL 做的最核心的权衡,就是用一点点精确度的损失,来换取巨大的内存空间节省。它给出的不是一个 100%精确的数字,而是一个带有很小标准误差(Redis 中默认是 0.81%)的近似值。

基于这个核心权衡,HyperLogLog 最适合以下特征的场景:

  1. 数据量巨大,内存敏感: 这是 HLL 的主战场。比如,要统计一个亿级日活 App 的每日独立访客数(UV),如果用传统的 Set 来存储用户 ID,一个 ID 占几十字节,上亿个 ID 可能需要几个 GB 甚至几十 GB 的内存,这在很多场景下是不可接受的。而 HLL 在 Redis 中只需要固定的 12KB 内存,就能处理天文数字级别的基数,这是一个颠覆性的优势。
  2. 对结果的精确度要求不是100%: 这是使用 HLL 的前提。比如,产品经理想知道一个热门帖子的 UV(独立访客数) 是大约 1000 万还是 1010 万,这个细微的差别通常不影响商业决策。但如果场景是统计一个交易系统的准确交易笔数,那 HLL 就完全不适用,因为金融场景要求 100% 的精确。

因此,HyperLogLog 具体的应用场景就非常清晰了:

  • 网站/App 的 UV(Unique Visitor) 统计: 比如统计首页每天有多少个不同的 IP 或用户 ID 访问过。
  • 搜索引擎关键词统计: 统计每天有多少个不同的用户搜索了某个关键词。
  • 社交网络互动统计: 比如统计一条微博被多少个不同的用户转发过。

在这些场景下,我们关心的是数量级和趋势,而不是个位数的差异。

使用 HyperLogLog 统计页面 UV 怎么做

使用 HyperLogLog 统计页面 UV 主要需要用到下面这两个命令:

  • PFADD key element1 element2 ...: 添加一个或多个元素到 HyperLogLog 中。
  • PFCOUNT key1 key2: 获取一个或者多个 HyperLogLog 的唯一计数。
  1. 将访问指定页面的每个用户 ID 添加到 HyperLogLog 中:

    1
    PFADD PAGE_1:UV USER1 USER2 ...... USERn
  2. 统计指定页面的 UV:

    1
    PFCOUNT PAGE_1:UV

如果我想判断一个元素是否不在海量元素集合中,用什么数据类型

这是布隆过滤器的经典应用场景。布隆过滤器可以告诉你一个元素一定不存在或者可能存在,它也有极高的空间效率和一定的误判率,但绝不会漏报。也就是说,布隆过滤器说某个元素存在,小概率会误判。布隆过滤器说某个元素不在,那么这个元素一定不在。

Bloom Filter 的简单原理图如下:

布隆过滤器简单原理图

当字符串存储要加入到布隆过滤器中时,该字符串首先由多个哈希函数生成不同的哈希值,然后将对应的位数组的下标设置为 1(当位数组初始化时,所有位置均为 0)。当第二次存储相同字符串时,因为先前的对应位置已设置为 1,所以很容易知道此值已经存在(去重非常方便)。

如果我们需要判断某个字符串是否在布隆过滤器中时,只需要对给定字符串再次进行相同的哈希计算,得到值之后判断位数组中的每个元素是否都为 1,如果值都为 1,那么说明这个值在布隆过滤器中,如果存在一个值不为 1,说明该元素不在布隆过滤器中。

重要:Redis 持久化机制

需要单独看文章

重要:Redis 线程模型

对于读写命令来说,Redis 一直是单线程模型。不过,在 Redis 4.0 版本之后引入了多线程来执行一些大键值对的异步删除操作,Redis 6.0 版本之后引入了多线程来处理网络请求(提高网络 IO 读写性能)。

Redis 单线程模型了解吗

Redis 基于 Reactor 模式设计开发了一套高效的事件处理模型(Netty 的线程模型也基于 Reactor 模式,Reactor 模式不愧是高性能 IO 的基石),这套事件处理模型对应的是 Redis 中的文件事件处理器(file event handler)。由于文件事件处理器(file event handler)是单线程方式运行的,所以我们一般都说 Redis 是单线程模型。

Redis 基于 Reactor 模式开发了自己的网络事件处理器:这个处理器被称为文件事件处理器(file event handler)。

  • 文件事件处理器使用 I/O 多路复用(multiplexing)程序来同时监听多个套接字,并根据套接字目前执行的任务来为套接字关联不同的事件处理器。
  • 当被监听的套接字准备好执行连接应答(accept)、读取(read)、写入(write)、关闭(close)等操作时,与操作相对应的文件事件就会产生,这时文件事件处理器就会调用套接字之前关联好的事件处理器来处理这些事件。

虽然文件事件处理器以单线程方式运行,但通过使用 I/O 多路复用程序来监听多个套接字,文件事件处理器既实现了高性能的网络通信模型,又可以很好地与 Redis 服务器中其他同样以单线程方式运行的模块进行对接,这保持了 Redis 内部单线程设计的简单性。

既然是单线程,那怎么监听大量的客户端连接呢?

Redis 通过 IO 多路复用程序 来监听来自客户端的大量连接(或者说是监听多个 socket),它会将感兴趣的事件及类型(读、写)注册到内核中并监听每个事件是否发生。

这样的好处非常明显:I/O 多路复用技术的使用让 Redis 不需要额外创建多余的线程来监听客户端的大量连接,降低了资源的消耗(和 NIO 中的 Selector 组件很像)。

文件事件处理器(file event handler)主要是包含 4 个部分:

  • 多个 socket (客户端连接)
  • IO 多路复用程序(支持多个客户端连接的关键)
  • 文件事件分派器(将 socket 关联到相应的事件处理器)
  • 事件处理器(连接应答处理器、命令请求处理器、命令回复处理器)

Redis 6.0 之前为什么不使用多线程

虽然说 Redis 是单线程模型,但实际上,Redis 在 4.0 之后的版本中就已经加入了对多线程的支持。

不过,Redis 4.0 增加的多线程主要是针对一些大键值对的删除操作的命令,使用这些命令就会使用主线程之外的其他线程来“异步处理”,从而减少对主线程的影响。

为此,Redis 4.0 之后新增了几个异步命令:

  • UNLINK:可以看作是 DEL 命令的异步版本。
  • FLUSHALL ASYNC:用于清空所有数据库的所有键,不限于当前 SELECT 的数据库。
  • FLUSHDB ASYNC:用于清空当前 SELECT 数据库中的所有键。

redis4.0-more-thread

总的来说,直到 Redis 6.0 之前,Redis 的主要操作仍然是单线程处理的。

那 Redis6.0 之前为什么不使用多线程,主要原因有 3 点:

  • 单线程编程容易并且更容易维护;
  • Redis 的性能瓶颈不在 CPU,主要在内存和网络;
  • 多线程就会存在死锁、线程上下文切换等问题,甚至会影响性能。

Redis 6.0 之后为何引入了多线程

Redis6.0 引入多线程主要是为了提高网络 IO 读写性能,因为这个算是 Redis 中的一个性能瓶颈(Redis 的瓶颈主要受限于内存和网络)。

虽然,Redis6.0 引入了多线程,但是 Redis 的多线程只是在网络数据的读写这类耗时操作上使用了,执行命令仍然是单线程顺序执行。因此,你也不需要担心线程安全问题。

Redis6.0 的多线程默认是禁用的,只使用主线程。如需开启需要设置 IO 线程数 > 1,需要修改 redis 配置文件 redis.conf

1
io-threads 4 #设置1的话只会开启主线程,官网建议4核的机器建议设置为2或3个线程,8核的建议设置为6个线程

另外:

  • io-threads 的个数一旦设置,不能通过 config 动态设置。
  • 当设置 ssl 后,io-threads 将不工作。

开启多线程后,默认只会使用多线程进行 IO 写入 writes,即发送数据给客户端(比如 redis-cli),如果需要开启多线程 IO 读取 reads,同样需要修改 redis 配置文件 redis.conf

1
io-threads-do-reads yes

但是官网描述开启多线程读并不能有太大提升,因此一般情况下并不建议开启。

Redis 后台进程了解吗

我们虽然经常说 Redis 是单线程模型(主要逻辑是单线程完成的),但实际还有一些后台线程用于执行一些比较耗时的操作:

  • 通过 bio_close_file 后台线程来释放 AOF / RDB 等过程中产生的临时文件资源。
  • 通过 bio_aof_fsync 后台线程调用 fsync 函数将系统内核缓冲区还未同步到到磁盘的数据强制刷到磁盘(AOF 文件)。
  • 通过 bio_lazy_free 后台线程释放大对象(已删除)占用的内存空间。