不能安于现状太久,要安排时间让自己升升级,这样机会来了才能把握住,或者即使没把握住,能有面试的机会也是不错的。同样地,先在脑子里思考一下问题,答不上的再去看后面的解答。
问答题
(1)HashMap 是线程不安全的,那么两个不同的线程设置同一个 key 时会有冲突吗?
(2)HashMap 是怎么扩容的?扩容后原数据是怎么转移的?
(3)泛型是怎么实现的?
(4)介绍一下 Java 的内存模型。
(5)反射在虚拟机中是怎么实现的?
(6)虚拟内存有什么好处?是怎么实现的?
(7)介绍一下 select 和 epoll 的工作机制。
(8)Mysql 用的什么数据库引擎?聚集索引和非聚集索引的区别是什么?
(9)设计一个类似 TCP/IP 的硬件设备与服务端的交互协议。
(10)解决跨域有哪些方法?
(11)RESTful API 的设计规范有哪些?
(12)幂等性是什么?如何保证幂等性?
(13)事务有哪些特性?数据库事务的隔离级别,原理是什么?分布式事务的解决方案,原理是什么?
编程题
(14)3 * 7 的表格,从左上走到右下,每次只能往右或者往下走一格,有几种不同的走法?写程序实现,要求最终能对任意大小的表格进行统计。
参考答案
(1)会有冲突的风险。因为 HashMap 的 put() 方法是非原子性的操作,它涉及多个步骤,包括计算 key 的哈希值、找到在数组中的位置、处理哈希冲突等,且没有同步机制,当多个线程对同一个 key 设置时,常见的问题是后设置的值会覆盖先设置的值,导致数据丢失。如果是在 JDK 1.7 中,多个线程同时 put 并都触发扩容时,扩容同样是一个非原子性的操作,那么新的链表可能出现环形结构,这会导致在 get 时陷入死循环,进而导致 CPU 利用率接近 100%。
在 JDK 1.8 及以后版本中,红黑树的使用大大降低了死循环出现的概率,但并非为 0,所以在多线程并发时,建议使用 ConcurrentHashMap 或 Hashtable 来保证线程安全,且 ConcurrentHashMap 的性能更高。因为 Hashtable 是对整个哈希表加锁,意味着在任一时刻只有一个线程可以访问 Hashtable,这在高并发环境下会发生锁的争用,导致性能显著下降,只适用于并发级别较低或需要兼容旧代码的场景。而 ConcurrentHashMap 采用了粒度更细的锁机制,它将整个哈希表分为多个段,每个段独立加锁,允许多个线程同时操作不同的段,从而避免了锁竞争,后又进一步引入了无锁的 CAS,这使得在低冲突的情况下可以完全无锁地执行操作,进一步提升了性能。
HashMap 的工作原理:
HashMap 是基于哈希表实现的数据结构,它结合了数组和链表(在 JDK 1.8 及以后版本中,还结合了红黑树,用这种结构的原因当然是为了利用它们各自的优点,使得查询、插入和删除的效率都很高)。HashMap 的主干是一个 Node 数组,Node 是 Entry 接口的实现类,每个 Node 对象包含一个 key-value 键值对和指向下一个节点的 next 引用。当要存储一个键值对时,首先会计算 key 的哈希值,然后通过这个哈希值确定该键值对在数组中的位置,如果两个不同的 key 计算出的哈希值相同(即发生了哈希冲突,产生冲突的本质是哈希函数的输入域是无限的而输出域有限),HashMap 会采用链表存储(当链表长度超过 8 时,转为红黑树,小于 6 时又退化为链表)的方式来解决。
接下来需要判断要存储的键值对是否已经存在。先判断 key 是否存在(通过先比较 key 的哈希值,再用 equals() 比较 key 的原始值的方式),如果 key 不存在,则调用 addEntry() 方法添加键值对,如果 key 已存在,则覆盖之前的 value。然后 Map.Entry 的哈希值就是存放在其中的 key 的哈希值,遍历 HashMap 效率最高的方式是对 entrySet().iterator() 进行遍历,使用 iterator.remove() 可以安全地删除元素。
(2)HashMap 中数组的长度是固定的,当存放的元素越来越多的时候,哈希冲突的几率也越来越高,所以当 HashMap 中的元素数量超过阈值时(当前容量与负载因子的乘积),就会进行扩容。默认情况下,HashMap 的负载因子为 0.75,这意味着当 HashMap 中的元素数量达到其容量的 75% 时,就会触发扩容操作,新容量通常是旧容量的两倍(比如 16 * 2 = 32),但不会超过 HashMap 允许的最大容量 2^30(数组扩容这个操作在 ArrayList 中也有)。
扩容的过程也是非原子性的,在多线程并发时会有问题。它包括创建新数组、重新计算所有 key 的哈希值、将原数组中的元素迁移到新数组中、设置新阈值等步骤,如果原数组中存在链表或红黑树结构的哈希冲突,这些结构在扩容过程中也会被拆分并重新分配到新数组上。扩容是一个昂贵的操作,很消耗性能,因为它涉及到大量的内存分配和元素重新映射,因此,为了避免频繁的扩容,可以通过预估 HashMap 要存放的元素个数,然后设置合适的初始容量,或者设置合理的负载因子来提高性能。
(3)泛型是通过类型擦除来实现的,这是一种在编译期将泛型类型替换为原始类型(通常是 Object)的机制,使得泛型代码在不支持泛型的 JVM 上也可以运行。
所谓泛型,就是指宽泛的数据类型、任意的数据类型,它是 JDK 1.5 的新特性,其本质是将数据类型参数化(用尖括号标识),可以用在类、接口和方法的创建中。在 Java 中,泛型信息只存在于编译阶段,当编译器遇到泛型代码时,它会先进行类型检查,确保类型参数的使用是合法的,然后,编译器会将泛型代码中的类型参数替换为它们的原始类型(通常是 Object),并插入必要的类型转换和检查代码(强转的类型就是那个被擦除掉的类型),这些工作都是在生成 .class 文件之前完成的。因此,在运行期间,JVM 中并没有泛型类型的信息,也称之为伪泛型。
泛型的好处是使用简单,可以提高代码的安全性、复用性和可读性。其局限性是不能在运行时检查到泛型的信息,比如无法用 instanceof 判断一个对象是否是一个 List<Integer> 的实例;不能创建泛型数组;也不能使用基本类型作为泛型参数(如 int、char)。此外,由于类型擦除,反射机制可以绕过编译期的类型检查,从而执行不安全的操作。
(4)JMM 是在多线程环境下变量访问和内存交互的规范,旨在解决多线程并发中的可见性、原子性和有序性问题。首先它在逻辑上分为主内存和工作内存,这与 JVM 的内存结构(堆、栈等)无直接对应关系,主内存大致对应堆中的对象实例部分,而工作内存可能对应栈、CPU 缓存、寄存器:
主内存:所有线程共享,存储所有共享变量,包括实例字段、静态字段、数组元素等;
工作内存:线程私有,保存了该线程使用到的变量的副本,线程对变量的所有操作都必须在工作内存中进行,而不能直接操作主内存中的变量。
(A)可见性:一个线程修改共享变量后,其他线程能够立即看到这个修改。可见性出现问题的原因是,变量的副本未及时同步到主内存,解决方案为通过 volatile(写操作立即刷新到主内存,读操作从主内存读取)、synchronized 关键字或 Lock 类来保证可见性(释放锁前,将工作内存变量同步到主内存;获取锁时,从主内存重新加载变量)。
(B)原子性:一个操作不可中断。原子性出现问题的原因是,一个操作包含了多个子步骤,比如自增操作就包含读取、修改、写入三步,解决方案为通过 synchronized 关键字或 Lock、AtomicInteger 等类来保证原子性。
(C)有序性:程序执行顺序要符合代码顺序。有序性出现问题的原因是,现代计算机为了优化性能,编译器和处理器可能对指令进行重排序,解决方案为通过 volatile(通过插入内存屏障)、synchronized 关键字(限制了单线程执行)或 Happens-Before 原则来保证有序性。内存屏障能保证有序性和可见性,它是 Java 编译器在生成指令序列时,在适当位置插入的一种特殊指令,能够阻止重排序。
Happens-Before 原则定义了两个操作间的有序性和可见性,主要包括:
程序顺序规则:同一线程内的操作按代码顺序执行;
锁规则:解锁操作先于后续的加锁操作;
volatile 规则:volatile 变量的写操作先于后续的读操作;
传递性规则:若 A 先于 B,且 B 先于 C,则 A 先于 C;
线程启动规则:Thread.start() 先于线程内的任何操作;
线程终止规则:线程的所有操作先于其他线程检测到它已经终止。
(5)反射是通过在 JVM 中加载 .class 文件,然后通过这些 .class 文件反编译出对应的 Java 代码实现的,这个过程是在程序运行时动态进行的,因此可以实现动态加载和操作类。每个类都有一个与之对应的 Class 对象,这个 Class 对象包含了该类的所有信息,包括它的属性和方法,因此 JVM 可以通过这个 Class 对象来获取该类的所有信息,并可以动态地创建对象、调用方法、获取和设置属性等,而不需要提前在编译期就知道运行的对象是谁。
反射机制的好处是能提高应用的扩展性,有时也能简化代码。坏处是可能会破坏类的封装性,因为反射允许访问私有成员,这会使类变得不那么安全;另外它有一定的性能开销,在性能敏感的场景中应尽量避免使用。
(6)虚拟内存的好处:
(A)提高系统性能:当物理内存不足时,操作系统可以自动将部分数据暂时存储到硬盘上的虚拟内存区域,以释放物理内存空间供其他应用程序使用,从而提高系统响应速度和稳定性;
(B)扩大内存空间:在不增加物理内存的情况下扩大内存空间,使计算机能够处理更多的数据;
(C)提高数据安全性:可以将敏感数据暂时存储到硬盘上,而不是直接暴露在物理内存中,可防止数据泄露。
它可通过内存分页(Page,通常为 4KB)、分段(Segment,根据逻辑划分,常分为代码段、数据段、堆栈段等)、或者分页 + 分段的方式实现,过程包括页或段的划分、页表或段表的建立、请求调页或调段、页或段的置换算法、内存和硬盘之间的数据交换等步骤(在程序开始运行之前,仅装入当前要执行的部分页或者部分段即可运行,若在运行过程中发现要访问的页或者段不在内存,则将所需页或者段从硬盘的虚拟内存区域调入物理内存,若物理内存不足,则操作系统根据置换算法(如 FIFO、LRU 等)选择一个页或者一个段将其置换出去)。存放在硬盘虚拟内存区域的程序指令使用的也是虚拟的内存地址,它需要 CPU 中的内存管理单元(MMU,借助寄存器)将虚拟内存地址转换成实际物理地址才能运行,虚拟地址与物理地址之间通过页表或段表来映射(页表或段表存储在内存中),如果采用的是段页式,则先通过段表转换出一个线性地址,然后再通过页表得到物理地址。
虚拟内存是一种重要的计算机系统内存管理技术,它允许操作系统使用硬盘空间来模拟额外的 RAM(随机访问存储器),通俗的说,就是当物理内存不够用时,为避免系统故障,系统就分出一部分硬盘空间来当内存使用,存放暂时还没有被访问到的代码和数据,一般设置为物理内存的 1 至 1.5 倍最佳(根据代码的局部性原理,被高频访问的代码往往只有一小部分,所以可以把暂时不运行的代码放到硬盘的虚拟内存区域,用的时候再分块加载到物理内存,反之,如果物理内存已满,则把物理内存中已经运行完的代码写回硬盘的虚拟内存区域,以便腾出空间)。它的缺点就是会占用一定的物理硬盘空间,加大对硬盘的读写,如果设置不当也会影响整机稳定。
(7)select 和 epoll 都是 Linux 下实现 I/O 多路复用的机制,也就是说能用单个线程同时监视多个文件描述符的 I/O 事件。
select 的工作过程:应用程序调用 select 函数时,会将文件描述符(fd)和超时时间传给它,然后 select 会将接收到的这些文件描述符都阻塞在当前线程,并将它们作为一个 fd_set 集合传给内核,内核再以线性扫描的方式检查这些文件描述符是否就绪(即是否有数据可读、可写或发生异常)或者超时,如果有任何文件描述符就绪或超时,select 就返回相应的文件描述符,应用程序就可以根据返回的结果无阻塞地进行读取、写入或其他操作了。
epoll 的工作过程:与 select 不同的是,epoll 会在内核空间中维护一个事件表(用 epoll_create 创建,用 epoll_ctl 操作),然后通过这个事件表来跟踪每个文件描述符的状态,而不是每次都线性检查所有的文件描述符。当调用 epoll_wait 函数时,它同样会将所有的 fd 阻塞在当前线程,直到事件表跟踪到某个文件描述符上有事件发生或超时,epoll 就返回给应用程序去处理,这种事件驱动的方式可以确保应用程序在等待时只占用较少的资源。
(8)Mysql 默认用的是 InnoDB。数据库引擎是用于存储、处理和保护数据的核心服务,当我们访问数据库时,不管是手动访问,还是程序访问,都不是直接读写数据库文件,而是通过数据库引擎访问数据库文件。主流的有:
(A)InnoDB:基于聚集索引,支持事务,默认使用行级锁,支持外键约束,适合需要高可靠性和高并发处理的场景,如售票系统,缺点是占用的数据库空间较大。
(B)MyISAM:基于非聚集索引,默认使用表级锁,适合读多写少的场景,如数据仓库,缺点是不支持事务。
(C)Memory(也称为 Heap):将数据存储在内存中,读写速度非常快,适合需要高性能但不需要持久化的场景。
聚集索引和非聚集索引的区别(两者的索引结构都是使用 B+ 树存储):
(A)聚集索引:将完整数据与索引存到一起,找到索引也就找到了数据,索引结构的叶子节点存储的是索引键值和完整的行数据(整张表的行数据都按顺序存储在了叶子节点中),本质是根据每张表的主键构造出了一棵 B+ 树。在 InnoDB 中,聚集索引等价于主键索引,一张表只能有一个聚集索引。
(B)非聚集索引:将完整数据与索引分开存储,索引结构的叶子节点存储的是索引键值和指向数据行的地址,因此,查询过程需要经过两次扫描,导致速度相对较慢,适用于频繁查询但不经常修改的列,一张表可以有多个非聚集索引。比如在 InnoDB 中,非聚集索引的叶子节点存储的是主键索引值,查询时会先找到主键索引值,然后再到主键索引上查找对应的数据,这个过程也叫做回表,使用覆盖索引可以减少回表次数。
在 MyISAM 中,所有索引(包括主键索引)都是非聚集索引结构:
这里再介绍一下索引,索引是一种用于提高查询效率的数据结构,数据库的索引就像书的目录,如果书没有目录,那么找某一章的时候就只能从第一页开始,一页一页翻,而有了目录后,我们就可以先翻到目录,然后根据页码快速找到想看的章节,这里的「目录」就是数据库中的索引,它通过某种规律(比如按字母排序)帮我们快速定位数据的位置,避免逐行查找(还比如图书馆的书籍指引)。索引的代价是需要占用额外空间,且新增或者修改数据时要维护索引(类似图书馆新进书后要更新目录)。一句话总结:用空间换时间——牺牲一些存储空间和维护成本,换来查询速度的飞跃提升。
索引的分类:
(A)若按数据结构分,索引分为 B+ 树索引、哈希索引、全文索引、空间索引;
(B)若按功能分,索引分为主键索引、唯一索引、普通索引、组合索引、覆盖索引;
(C)若按物理存储分,索引分为聚集索引、非聚集索引(也被称为辅助索引或二级索引)。
主键索引:唯一且非空,标识行数据。MySQL 规定每张表都必须有主键索引,且只能有一个,因此,若显式指定了表的主键列(Primary Key),则数据库引擎会为其自动创建主键索引;若未指定主键列,但表中存在非空的唯一索引列(UNIQUE NOT NULL),则数据库引擎会自动选择该列为主键并为其生成主键索引;若前两种情况都不是,则数据库引擎会自动创建一个隐式主键及其索引,但对用户不可见,显式指定主键仍是数据库设计的最佳实践。
唯一索引:唯一但允许 NULL,确保字段唯一性。
普通索引:没有任何的限制。
组合索引(也被称为复合索引或联合索引):优化多条件查询,遵循最左前缀原则(指查询条件必须从索引的最左列开始,且不能跳过中间的列,才能有效命中索引,其本质是由 B+ 树索引的存储结构决定的)。
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-- 先定义一个组合索引 (col1, col2, col3)
CREATE INDEX idx_col1_col2_col3 ON table_name(col1, col2, col3);
-- 能命中索引的查询
WHERE col1 = 1 -- 命中,使用了索引的最左列 col1
WHERE col1 = 1 AND col2 = 2 -- 命中,连续使用 col1 和 col2
WHERE col1 = 1 AND col2 = 2 AND col3 = 3 -- 命中,完整使用所有索引列
WHERE col1 = 1 AND col3 = 3 -- 部分命中,仅 col1 走索引,col3 无法用索引(因为中间跳过了 col2)
-- 不能命中索引的查询
WHERE col2 = 2 -- 无法利用索引,因为未使用最左列 col1
WHERE col2 = 2 AND col3 = 3 -- 无法利用索引,因为未使用最左列 col1
WHERE col3 = 3 -- 无法利用索引,因为未使用最左列 col1
-- 特殊情况:范围查询后的列失效
WHERE col1 = 1 AND col2 > 10 AND col3 = 3;
-- 命中部分:col1 和 col2 走索引(存在范围查询 col2 > 10)
-- 失效部分:col3 无法利用索引(在范围查询后的列不参与索引匹配)
-- 优化建议:
-- 1.将等值查询列放在范围查询列的左侧(同时也要调整索引的顺序为 (col1, col3, col2))。
WHERE col1 = 1 AND col3 = 3 AND col2 > 10;
-- 2.查询条件的顺序不影响索引的使用,优化器会自动调整顺序,只要包含最左列即可。
WHERE col2 = 2 AND col1 = 1; -- 实际还是按 col1 优先匹配索引
-- 3.避免冗余索引:若已有索引 (col1, col2),则无需单独创建 col1 的索引。
-- 4.优先将区分度高(唯一性高)且频繁作为查询条件的列放在最左侧。其原理是组合索引会按 B+ 树的分层存储顺序,先按 col1 排序,col1 相同再按 col2 排序,依次类推,查询时像查字典一样,从根节点一层层缩小范围,最终直达目标数据的位置,这样可以大幅减少磁盘 I/O 次数。所以如果未指定 col1,则无法确定 col2 和 col3 的有序区间,只能全表扫描。
覆盖索引:是指查询的字段全部在某个索引中,使得数据库引擎无需访问数据行(即无需回表,比如在辅助索引中就可直接检索到数据并返回),仅通过索引即可获取所需数据,这种索引直接「覆盖」了查询需求,减少了磁盘 I/O 次数,可显著提升查询性能(但要注意值的长度较长的字段不适合使用覆盖索引)。
1
2
3
4
5
6
7
8
9
10-- 索引 (col1, col2, col3)
-- 索引已经包含了所有需要的字段,无需回表
SELECT col1, col2 FROM table WHERE col1 = 1;
-- 这里不满足最左前缀原则,但实际上也会走索引,因为优化器认为覆盖索引的性能会高于全表扫描
SELECT col1, col2, col3 FROM table WHERE col2 = 2 AND col3 = 3;
-- 查询所有字段,几乎必须回表
SELECT * FROM table WHERE col1 = 1;哈希索引:在单条数据的等值查询方面性能优秀,但不支持范围查询和模糊查询。
(9)以下是一个基本的设计思路:
(A)消息头:
协议版本:标识通信双方所使用的协议版本;
消息类型:标识消息的类型,如请求、响应、心跳等;
消息长度:整个消息的长度,包括消息头和消息体;
校验和:防止消息被篡改或损坏,并非加密技术,一般是对整个消息进行一些算法处理后得到,比如 IP/TCP/UDP 生成校验和的规则为:基于反码求和,接收方用相同规则生成后与发送方提供的对比,若不一致,则消息可能已损坏;
时间戳:记录时间。
(B)消息体:
根据消息类型,消息体包含不同的字段,例如:
请求消息:设备 ID、请求的操作码、参数等;
响应消息:操作结果、状态码、数据等。
(C)消息尾(可选):
包含一些额外信息,如签名。
(10)严格来说,只要协议、域名、端口中有任何一个不同,就被认为是跨域,这是浏览器主动实施的安全壁垒,而非技术缺陷。解决 CORS 问题(即跨源资源共享)通常涉及配置服务器以允许来自不同源的请求,以下是几种常见方法:
(A)通过 @Configuration 注解配置一个类,再让这个类实现 WebMvcConfigurer 接口来全局设置 CORS 策略;
(B)创建一个 Servlet Filter 来处理 CORS,再将这个 Filter 配置到 web.xml 中;
(C)对于特定的控制器或方法,可以使用 @CrossOrigin 注解来设置 CORS 策略;
(D)通过 Nginx 等代理服务器配置。
注意,允许来自任意源的请求可能会带来安全风险,务必限制允许的源和方法。
(11)RESTful 是目前最流行的 API 设计规范,包括:
(A)使用 HTTP 动词来表达操作,比如 GET、POST、PUT、DELETE 等;
- GET:侧重从服务器获取资源,请求参数放在 URL 后,用户可见,不安全,参数长度有限制,传输的数据量小;
- POST:侧重向服务器发送数据,请求参数放在实体中,用户不可见,安全,参数长度无限制,传输的数据量大。
(B)使用名词来表示资源,是 HTTP 动词作用的对象,通常使用复数形式;
(C)使用 URI 来定位资源,确保每个资源都有一个唯一的标识符;
(D)使用查询参数来过滤和分页;
(E)使用标准的 HTTP 状态码来表示请求结果,比如 200、400、404、500;
- 302:临时重定向(重定向:要完成请求必须进行更进一步的操作);
- 400:客户端的请求有语法错误;
- 404:服务器无法找到对应资源;
- 500:服务器内部错误。
(F)使用 JSON 来表示数据,便于客户端进行数据解析和处理;
(G)具有无状态性,每次请求都包含所有必要的信息,比如 Token;
(H)使用版本号来管理 API 的不同版本,比如 GET /v1/users?limit=10。
这里额外介绍一下网络通信的过程:通过 HTTP 发出请求,HTTP 的报文被打包在 TCP 的报文段中,然后又被放到 IP 层的数据报中,最后形成链路层的帧,通过网卡发了出去。这就是比 OSI 七层模型更符合实际情况的 TCP/IP 五层模型:应用层 <- 传输层 <- 网络层 <- 链路层 <- 物理层。
(12)幂等性是指一个操作无论执行多少次,它的执行结果都是一致的,不会因为多次执行而产生副作用。它可以避免重复请求带来的影响,比如支付接口重复扣款、订单重复创建等业务风险;也可以提升容错能力,比如应对网络超时重试、消息队列重复消费等场景。当然,也有一些操作不需要幂等,比如短信发送。
保证幂等性的常见方案有:
(A)唯一标识符:客户端为请求生成全局唯一 ID(如 UUID,并缓存在 Redis 中),服务端通过该 ID 判断请求是否已处理,若已处理,则直接返回历史结果。实现简单,扩展性强,适用于高并发接口调用、消息队列消费、异步任务等。
1 | 使用 Redis 的 SETNX 命令:若返回 1,表示首次处理;返回 0,表示重复请求 |
1 | // 使用 ConcurrentHashMap 模拟缓存 |
(B)数据库约束:通过唯一索引或主键防止重复数据插入(如唯一的订单号),可靠性高,适用于数据写入场景(如订单创建);除此之外,还可以基于乐观锁,根据版本号(VERSION)、时间戳(LAST_UPDATE)或特定条件更新,确保仅当数据未变更时才能更新成功,若发现数据已被修改,可以及时回滚,适用于高并发下的数据更新。
1 | UPDATE table SET balance = new_value, version = version + 1 WHERE version = old_version; |
(C)Token 机制:客户端请求获取 Token,然后提交请求时携带此 Token,服务端校验 Token 是否有效,若有效,则执行业务并令 Token 失效,适用于防止前端重复提交(如多次点击表单的提交按钮)。
(D)分布式锁:通过 Redis、ZooKeeper 等工具加锁,同一时刻仅有一个节点可持有锁,确保同一操作在分布式系统中仅执行一次,但要注意设置锁的超时时间,避免死锁。
(E) 状态机控制:适用于业务操作中附带状态流转的场景(如订单状态从「未支付」到「已支付」),仅允许特定状态下能执行操作。
1 | UPDATE orders SET status = 'paid' WHERE order_id = '123' AND status = 'unpaid'; |
(13)事务是一种一致性保障机制,本质是对多步骤操作的原子化控制,具有 ACID 特性,分别为:
(A)原子性:事务内的所有操作要么全部成功,要么全部失败回滚,比如扣款和入账要么都成功,要么都失败;
(B)一致性:事务执行前后数据都处于一致性状态,比如两个账户之间不管如何转账,总金额是不变的;
(C)隔离性:多个并发事务之间互不干扰,数据库对此提供了多种隔离级别;
(D)持久性:事务一旦提交,数据的变更将永久保存。
(13.1)对于数据库事务,当多个用户并发访问数据库的同一张表或同一条记录时,多个并发事务可能引发以下问题:
- 丢失更新:两个事务同时修改同一数据,后提交的事务覆盖了先提交事务的修改,导致先前的更新丢失。例如:事务 A 和事务 B 同时读到账户余额为 1000,事务 B 先于事务 A 将余额改为 1100 后提交,然后事务 A 将余额改为 900,此时无论事务 A 是提交还是撤销,事务 B 的更新都将丢失。
- 脏读:事务 A 读取了事务 B 尚未提交的修改数据并在此基础上操作,若事务 B 回滚,则事务 A 读到的就是脏数据。
- 不可重复读:同一事务多次读取同一数据时结果不一致,因其他事务在此期间修改并提交了该数据。例如:事务 A 首次读值为 100,事务 B 将该值改为 200 并提交,事务 A 再次读值时为 200。这里不仔细琢磨的话好像觉得没什么问题,因此再次提醒,就是两次读值都是在同一个事务 A 中,并且是在事务 A 提交之前,在事务 A 内部需要这个值保持不变,否则就可能出现问题:
(I)事务 A 读取余额并计算利息,若在计算过程中余额被事务 B 修改,则事务 A 的后续计算将基于错误数据;
(II)事务 A 已校验待注册用户名不存在,若事务 B 在数据插入前抢先注册同名用户,则会导致唯一约束冲突;
(III)事务 A 读取到商品库存为 10 件并生成出库单,若事务 B 在生成期间修改库存为 5 件并提交,则事务 A 后续基于原库存的操作将导致超卖(实际仅剩 5 件却允许出库 10 件)。 - 幻读:同一事务多次执行相同的范围查询时,因其他事务插入或删除了符合条件的数据,导致结果集行数不一致。例如:事务 A 查询年龄 > 30 的用户得到 3 条记录,事务 B 插入一条年龄为 35 的数据后,事务 A 再次查询得到 4 条记录。与不可重复读的理解类似,事务 A 内部的多次查询需要基于同一数据快照,否则就会出现问题(不可重复读是针对已存在的记录,幻读是针对新增的记录):
(I)在财务计算事务中,若中途插入新数据导致统计结果变化,则可能导致后续扣款金额错误;
(II)在统计当前库存时,若其他事务新增库存记录未被感知,则会导致实际库存与统计结果不一致;
(III)在银行系统中,若在账户余额计算期间有新增交易未被纳入统计,则可能导致超额放贷或风险控制失效;
(IV)当执行 WHERE age > 20 的条件更新时,若其他事务插入 age = 25 的新记录,则已提交的更新将无法作用于新增的记录。
为解决这些问题,数据库提供了几种隔离级别(由低到高,已解决的问题在更高的隔离级别中自然也被解决):
(A)读未提交:允许读取其他事务未提交的数据,解决丢失更新;
(B)读已提交:只能读取其他事务已提交的数据,解决脏读,是 Oracle 的默认隔离级别;
(C)可重复读:同一事务重复读取同一记录结果一致,解决不可重复读,是 MySQL 的默认隔离级别;
(D)串行化:强制事务串行执行,解决幻读,但其性能开销很大,影响并发效率,因此实际用得较少。
为了兼顾数据一致性与并发性能,在可能出现不可重复读或幻读的场合,可由应用程序用乐观锁或悲观锁控制。
数据库事务的 ACID 实现原理:
原子性:通过 undo log(回滚日志)实现。在执行真正的操作之前,记录事务修改前的旧数据,若事务执行失败或显式调用 ROLLBACK,则可根据 undo log 逆向操作,将数据恢复到事务开始前的状态。包括遇到断电或系统崩溃的情况,也可在系统重启后读 undo log 恢复。
持久性:通过 redo log(重做日志)实现。事务提交时,修改操作会先写入 redo log,再异步刷入磁盘数据页,即 WAL 机制(Write-Ahead Logging,预写日志);如果数据库崩溃,恢复时也会通过 redo log 重放未落盘的修改操作,确保已提交事务的修改不丢失。
隔离性:通过锁机制和 MVCC(多版本并发控制)实现,隔离级别越高,锁粒度越大或 MVCC 快照范围越严格,性能损耗越高。MVCC 能为每个事务生成一个数据快照,并用生成时的时间戳作为版本的唯一标识,通过时间戳管理历史版本,数据库系统为每个事务也会分配一个唯一的时间戳,事务只能访问时间戳早于自身时间戳的历史版本(每个行记录会存储指向 undo log 的指针,由此形成版本链,可以回溯历史数据),这样一来,不同事务读到的是不同版本,避免冲突发生。MVCC 的优势是实现无锁化操作,提升了并发性能。
(I)读未提交:写操作使用排他锁,读操作不加锁。
(II)读已提交:写操作使用排他锁(X 锁,阻止其他事务读写,如 SELECT … FOR UPDATE,持续到事务结束),读操作使用共享锁(S 锁,允许多个事务同时读取,如 SELECT … LOCK IN SHARE MODE,读取后立即释放)或 MVCC(每次 SELECT … 查询时,MVCC 会生成一个新的行数据快照,从中读取已提交的最新数据)。
(III)可重复读:写操作使用排他锁(持续到事务结束);读操作使用 MVCC,事务首次 SELECT 时,MVCC 生成一个当前时间点上的固定快照,后续整个事务期间读取同一快照(比如事务 A 在时间点 10:00 读取数据,之后事务 B 修改了数据,事务 A 再次读取时看到的依然是时间点 10:00 的版本);在此基础上使用间隙锁,可解决幻读(Gap Lock 锁定索引记录之间的空间,而不是锁定具体的记录,从而防止其他事务在这些间隙中插入新的数据行)。
(IV)串行化:全锁机制,所有写操作使用排他锁(持续到事务结束),读操作使用共享锁(持续到事务结束)。扩展:在 MySQL 中,执行 DELETE 后不会立即释放磁盘空间,而是仅将数据行逻辑标记为已删除,这样做的好处一是便于未提交的事务快速回滚,二是便于 MVCC 保留被删数据的旧版本,三是后续插入的新数据可直接复用这些空间,避免大量的 I/O 操作。
一致性:由原子性、持久性、隔离性和数据库约束共同保障。
(13.2)对于分布式事务,则是需要协调多个独立的服务或组件之间的一致性操作,涉及多个分布式的数据库。例如,在电商系统中,用户下单的操作可能涉及库存服务、支付服务和订单服务,分布式事务要确保库存扣减、支付扣款和订单生成这三个操作要么全部成功,要么全部失败,以防止出现用户支付成功但库存未扣减或订单未生成的情况。分布式事务的 BASE 模型概括了三个核心思想:基本上可用(Basically Available,部分节点发生故障时核心功能仍然可用)、软状态(Soft State,允许存在暂时的不一致状态)和最终一致性(Eventually Consistent,数据最终会达到一致状态)。常见的分布式事务解决方案有:
(I)保证强一致性(实时一致性):
(A)两阶段提交(2PC):分为准备阶段(Prepare)和提交阶段(Commit),通过协调者协调所有参与者(事务协调者简称 TC(Transaction Coordinator),负责维护全局事务状态,驱动分支事务提交或回滚)。准备阶段为协调者询问所有参与者是否准备好提交事务,然后参与者执行本地事务但不提交,向协调者报告;提交阶段为若所有参与者都反馈说准备好了,则协调者就通知所有参与者快速提交,否则通知回滚。2PC 没有统一的接口标准,后来在它的理论基础上制定了标准化的 XA 协议规范,明确定义了事务协调器为 TM(在某些框架中命名的是 TC),参与者为 RM(可以是数据库、消息队列等),RM 通过实现 XA 接口与 TM 通信,扩展了 2PC 的适用性。
2PC 的优点是逻辑简单,缺点是存在单点故障的风险,如果协调者出现问题,则各个数据库都会同步阻塞住,而且还有可能因为网络问题导致部分参与者收不到协调者发的提交命令,进而导致数据不一致,因此 2PC 不适合高并发场景。它适用于传统的银行转账,因为银行转账要求原子性操作要绝对可靠(也就是要求强一致性),且银行内部的网络环境可靠,转账的参与者数量也有限,这些特点弥补了 2PC 的缺陷。
(B)三阶段提交(3PC):细化了 2PC 中的 Prepare 阶段,分为预检查阶段(CanCommit,协调者询问所有参与者是否具备事务执行的条件,目的是在不锁定资源的前提下预判事务可行性,避免后续因资源不足回滚)、预提交阶段(PreCommit,若所有参与者都返回 Yes,则协调者通知所有参与者开始执行本地事务,但不提交,随后向协调者报告)和提交阶段(DoCommit,若所有参与者都返回 Yes,则协调者通知所有参与者正式提交,释放本地资源)。3PC 可缩短资源占用时间,同时引入了超时机制,若协调者宕机或参与者未收到指令,参与者可基于超时策略自行提交或回滚事务;缺点是实现复杂度增加,仍存在因参与者的超时策略差异导致的部分提交问题。
(II)保证最终一致性:
(C)TCC(Try-Confirm-Cancel):通过业务代码实现事务操作的三个阶段,在 Try 阶段进行资源预留(如锁定库存),尝试执行事务操作但不提交,若 Try 阶段成功,则进入 Confirm 阶段提交(如扣减库存);若 Try 阶段失败,则进入 Cancel 阶段撤销操作(如释放库存)。TCC 的这种设计使它能够支持细粒度的事务控制,灵活度高,且不依赖底层数据库的 XA 协议,适合复杂的高并发场景,如电商促销;缺点是每个操作都必须手动编写 TCC 三个阶段的代码,代码侵入性强,开发成本高。
(D)消息队列事务消息:使用支持事务的消息队列(如 RocketMQ、Kafka),生产者将消息标记为「半消息」状态发送至 MQ,此时消息对消费者不可见,MQ 将半消息持久化存储,并返回确认给生产者,生产者收到半消息存储成功的 ACK 后触发本地事务,根据本地事务执行结果,生产者向 MQ 发送 Commit(将半消息标记为可消费状态)或 Rollback 指令(直接删除半消息)。若 MQ 未收到指令(如生产者宕机),则 MQ 会主动向生产者发起请求,回查本地事务的最终状态,MQ 再根据返回结果决定是提交还是回滚。此方案适用于对实时性要求不高的场景,如发放活动积分、发送通知。
RabbitMQ 不原生支持分布式事务,但可通过本地事件表加定时任务的方式实现最终一致性:在生产者的本地数据库中创建一张事件表,然后生产者就能直接用本地事务完成两件事情,一是在本地数据库中要完成的正常业务,二是向事件表中插入一条记录,用于记录相关的其他分布式数据库要完成的操作,记录状态为「待发送」。接下来新增一个异步的定时任务,负责从事件表中取出待发送记录,然后将其写入消息队列,通过确认回调更新记录的状态为「已发送」,如果消息投递失败,则进行补发,节点间通过对账最终状态修复数据不一致的问题。
(E)Saga 模式:核心思想是将长事务拆分为多个子事务,每个子事务提交后触发下一个子事务,若某子事务失败,则触发补偿操作,适用于复杂的跨服务长流程业务,如物流状态更新。根据架构设计的不同,Saga 又可分为两种模式:编排式(去中心化设计,耦合度低,故障排查难度高,服务间基于事件驱动通信,采用无锁化设计)和协调式(中心化设计,耦合度高,故障排查难度低,由协调器统一管理流程)。
- 事务补偿:是分布式事务中用于回滚已提交操作的一种机制(传统数据库事务无法回滚已提交的操作)。当分布式事务中的某一步失败时,通过执行与原始操作逻辑相反的补偿操作,将系统状态恢复到事务执行前的状态,确保最终一致性(如订单创建成功后支付失败,则需取消订单,若存在未支付之类的中间状态,也要做相应处理)。一般通过超时机制和事务状态轮询来触发补偿,不同的模式有不同的补偿方式:
(I)显式补偿(TCC 模式):在业务代码中手动实现补偿逻辑,需显式调用 Cancel 等接口。
(II)隐式补偿(Saga 模式):先预定义好正向事务对应的逆向补偿操作,并记录事务日志,失败时自动触发。
(III)基于消息的补偿(消息队列事务消息):服务监听到失败后触发,将补偿操作封装为消息,让相关节点消费。
Spring Cloud 主要通过 Seata 组件实现分布式事务,主要支持 AT(默认)、TCC、Saga、XA 四种模式。AT 模式是 Seata 提出的一种解决方案,采用了 2PC 的思想并对其进行了改进(AT 模式会在一阶段直接提交并释放数据库锁,降低了锁竞争),它基于数据库事务,保证的是最终一致性。优点为无需侵入业务代码,只需添加 @GlobalTransactional 注解即可生效,对相对简单的场景能高效地处理;缺点为对于需要精细控制的复杂业务场景,AT 模式的自动化处理可能无法满足需求。
- AT 一阶段:TM 通知 TC 开启全局事务,RM 向 TC 注册分支事务,RM 拦截业务 SQL,在操作数据前生成 undo log(数据快照)存储到本地的 undo_log 表中,然后执行业务 SQL 并直接提交本地事务,释放数据库锁,RM 将分支事务的执行结果上报给 TC。
- AT 二阶段:TM 通知 TC 结束全局事务,TC 检查所有分支事务的状态,若所有分支事务都成功执行,则通知所有 RM 异步删除掉一阶段记录的 undo log,完成全局事务提交;若有分支事务执行失败,则通知已成功执行的 RM 根据 undo_log 自动生成反向更新 SQL 并执行,完成后再清除 undo log,完成全局事务回滚。
(14)当场的解题思路:直接写不出来,那就从最简单的 2 * 2 表格开始,一共有多少种走法可以直接数出来,然后逐渐扩大表格,慢慢找出规律,如下:
表格 | 走法总数 | 拆分(加 2 指的是先一直往右再一直往下和先一直往下再一直往右两种走法) |
---|---|---|
2 * 2 | 2 | 0 + 2 |
2 * 3 | 3 | 1 + 2 |
…… | …… | …… |
2 * 7 | 7 | 5 + 2 |
3 * 2 | 3 | 1 + 2 |
3 * 3 | 6 | 4 + 2 |
3 * 4 | 10 | 8 + 2 |
…… | …… | …… |
3 * 7 | 28 | 26 + 2 |
可惜当时并没有找到适用于任意 m * n 表格的规律(m 是行数,n 是列数),另外我还有一个对题目理解有误的地方是,我统计的是从左上角格子到右下角格子的走法,所以才从最小的 2 * 2 表格开始,但出题人的意思是从表格左上角的点走到右下角的点,这样的话一个小格子就是最小单位了。不过,虽然当时没做出来,但我的解题思路是对的,就是把大问题化为一个个小问题来看。这里以出题人的意思讲解,那么对每一个小格子来说,从左上的点走到右下的点一共只有 2 种走法:向右一种,向下一种;只不过越往右或者越往下的点,走法数是与之相邻的两个点的走法数量之和,如下图:(画图更直观,如果当时画图标注而不是研究数字,可能也做出来了)

代码实现:
1 | public class Test { |
扩展题:在一个 3 * 7 的方格,从左下角到右上角,最短路线有多少种走法?
答案:最短路线需考虑不能走回头路,那么需要向上走 3 次,向右走 7 次。如果不能连续向上行走,可看作是将 3 次向上的移动插入到向右的 7 次之间,7 次向右一共有 8 个空可以插入,那么就一共有 C(8,3)= 56 种走法;如果可以连续向上行走,那就看作是在总共 10 步中选择 3 步向上,那就一共有 C(10,3)= 120 种走法。