Jay's Blog

知而不行为不知


  • 首页

  • 关于

  • 标签

  • 分类

  • 归档

  • 留言

  • 搜索

如何基于Redis实现延时任务

发表于 2023-11-28 | 分类于 分布式 , redis | 阅读次数:
字数统计: 1.5k 字 | 阅读时长 ≈ 5 分钟

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

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

面试的时候,你可以先说自己考虑了这两种方案,但最后发现 Redis 过期事件监听这种方案存在很多问题,因此你最终选择了 Redisson 内置的 DelayedQueue 这种方案。

这个时候面试官可能会追问你一些相关的问题,我们后面会提到,提前准备就好了。

另外,除了下面介绍到的这些问题之外,Redis 相关的常见问题建议你都复习一遍,不排除面试官会顺带问你一些 Redis 的其他问题。

Redis 过期事件监听实现延时任务功能的原理?

Redis 2.0 引入了发布订阅 (pub/sub) 功能。在 pub/sub 中,引入了一个叫做 channel(频道) 的概念,有点类似于消息队列中的 topic(主题)。

pub/sub 涉及发布者(publisher)和订阅者(subscriber,也叫消费者)两个角色:

  • 发布者通过 PUBLISH 投递消息给指定 channel。
  • 订阅者通过SUBSCRIBE订阅它关心的 channel。并且,订阅者可以订阅一个或者多个 channel。

Redis 发布订阅 (pub/sub) 功能

在 pub/sub 模式下,生产者需要指定消息发送到哪个 channel 中,而消费者则订阅对应的 channel 以获取消息。

Redis 中有很多默认的 channel,这些 channel 是由 Redis 本身向它们发送消息的,而不是我们自己编写的代码。其中,__keyevent@0__:expired 就是一个默认的 channel,负责监听 key 的过期事件。也就是说,当一个 key 过期之后,Redis 会发布一个 key 过期的事件到__keyevent@<db>__:expired这个 channel 中。

我们只需要监听这个 channel,就可以拿到过期的 key 的消息,进而实现了延时任务功能。

这个功能被 Redis 官方称为 keyspace notifications ,作用是实时监控实时监控 Redis 键和值的变化。

Redis 过期事件监听实现延时任务功能有什么缺陷?

1、时效性差

官方文档的一段介绍解释了时效性差的原因,地址:https://redis.io/docs/manual/keyspace-notifications/#timing-of-expired-events 。

Redis 过期事件

这段话的核心是:过期事件消息是在 Redis 服务器删除 key 时发布的,而不是一个 key 过期之后就会就会直接发布。

我们知道常用的过期数据的删除策略就两个:

  1. 惰性删除:只会在取出 key 的时候才对数据进行过期检查。这样对 CPU 最友好,但是可能会造成太多过期 key 没有被删除。
  2. 定期删除:每隔一段时间抽取一批 key 执行删除过期 key 操作。并且,Redis 底层会通过限制删除操作执行的时长和频率来减少删除操作对 CPU 时间的影响。

定期删除对内存更加友好,惰性删除对 CPU 更加友好。两者各有千秋,所以 Redis 采用的是 定期删除+惰性/懒汉式删除 。

因此,就会存在我设置了 key 的过期时间,但到了指定时间 key 还未被删除,进而没有发布过期事件的情况。

2、丢消息

Redis 的 pub/sub 模式中的消息并不支持持久化,这与消息队列不同。在 Redis 的 pub/sub 模式中,发布者将消息发送给指定的频道,订阅者监听相应的频道以接收消息。当没有订阅者时,消息会被直接丢弃,在 Redis 中不会存储该消息。

3、多服务实例下消息重复消费

Redis 的 pub/sub 模式目前只有广播模式,这意味着当生产者向特定频道发布一条消息时,所有订阅相关频道的消费者都能够收到该消息。

这个时候,我们需要注意多个服务实例重复处理消息的问题,这会增加代码开发量和维护难度。

Redisson 延迟队列原理是什么?有什么优势?

Redisson 是一个开源的 Java 语言 Redis 客户端,提供了很多开箱即用的功能,比如多种分布式锁的实现、延时队列。

我们可以借助 Redisson 内置的延时队列 RDelayedQueue 来实现延时任务功能。

Redisson 的延迟队列 RDelayedQueue 是基于 Redis 的 SortedSet 来实现的。SortedSet 是一个有序集合,其中的每个元素都可以设置一个分数,代表该元素的权重。Redisson 利用这一特性,将需要延迟执行的任务插入到 SortedSet 中,并给它们设置相应的过期时间作为分数。

Redisson 使用 zrangebyscore 命令扫描 SortedSet 中过期的元素,然后将这些过期元素从 SortedSet 中移除,并将它们加入到就绪消息列表中。就绪消息列表是一个阻塞队列,有消息进入就会被监听到。这样做可以避免对整个 SortedSet 进行轮询,提高了执行效率。

相比于 Redis 过期事件监听实现延时任务功能,这种方式具备下面这些优势:

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

跟 Redisson 内置的延时队列相比,消息队列可以通过保障消息消费的可靠性、控制消息生产者和消费者的数量等手段来实现更高的吞吐量和更强的可靠性,实际项目中首选使用消息队列的延时消息这种方案。

Java基础 threadlocal

发表于 2023-11-21 | 分类于 Java , Java基础 | 阅读次数:
字数统计: 773 字 | 阅读时长 ≈ 2 分钟

整体结构

从上图中看出,在++每个Thread类中,都有一个ThreadLocalMap的成员变量++,该变量包含了一个Entry数组,该数组真正保存了ThreadLocal类set的数据。

Entry是由threadLocal和value组成,其中threadLocal对象是弱引用,在GC的时候,会被自动回收。而value就是ThreadLocal类set的数据。

下面用一张图总结一下引用关系:

上图中除了Entry的key对ThreadLocal对象是弱引用,其他的引用都是强引用。

Hash冲突

和HashMap的最大的不同在于,ThreadLocalMap结构非常简单,没有next引用,也就是说ThreadLocalMap中解决Hash冲突的方式并非链表的方式,而是==采用线性探测==的方式,所谓线性探测,就是根据初始key的hashcode值确定元素在table数组中的位置,如果发现这个位置上已经有其他key值的元素被占用,则利用固定的算法寻找一定步长的下个位置,依次判断,直至找到能够存放的位置。

ThreadLocal-hash冲突与内存泄漏_threadlocal有hash冲突的可能吗-CSDN博客

弱引用

Entry的key,传入的是ThreadLocal对象,使用了WeakReference对象,即被设计成了弱引用。是为解决内存泄露问题的。(可以将方法运行结束后无用的threadlocal对象回收,不然的话无法回收,造成内存泄漏)。

使用弱引用,就可以使ThreadLocal对象在方法执行完毕后顺利被回收且Entry的key引用指向为null。

ThreadLocal一个线程只能存放一个变量吗?想存多个怎么搞? - 知乎 (zhihu.com)

内存泄漏

但是即使设计为弱引用,还是会有内存泄漏的问题(假如ThreadLocalMap中存在很多key为null的Entry,但后面的程序,一直都没有调用过有效的ThreadLocal的get、set或remove方法。

那么,Entry的value值一直都没被清空。长此以往,造成内存泄露),因此使用完threadlocal变量后,一定要通过调用ThreadLocal对象的remove方法。

        但是弱引用也不是万事大吉了。当我们为threadLocal变量赋值,实际上就是当前的Entry(threadLocal实例为key,值为value)往这个threadLocalMap中存放。Entry中的key是弱引用,当threadLocal外部强引用被置为null(tl=null),那么系统 GC 的时候,根据可达性分析,这个threadLocal实例就没有任何一条链路能够引用到它,这个ThreadLocal势必会被回收,这样一来,ThreadLocalMap中就会出现key为null的Entry,就没有办法访问这些key为null的Entry的value,如果当前线程再迟迟不结束的话(使用线程池的情况下),这些key为null的Entry的value就会一直存在一条强引用链:Thread Ref -> Thread -> ThreaLocalMap -> Entry -> value永远无法回收,造成内存泄漏。

        但在实际使用中我们有时候会用线程池去维护我们的线程,比如在Executors. newFixedThreadPool()时创建线程的时候,为了复用线程是不会结束的,所以threadLocal内存泄漏就值得我们小心。

MySQL日期类型选择建议

发表于 2023-10-16 | 分类于 数据库 , MYSQL | 阅读次数:
字数统计: 1.7k 字 | 阅读时长 ≈ 6 分钟

我们平时开发中不可避免的就是要存储时间,比如我们要记录操作表中这条记录的时间、记录转账的交易时间、记录出发时间、用户下单时间等等。你会发现时间这个东西与我们开发的联系还是非常紧密的,用的好与不好会给我们的业务甚至功能带来很大的影响。所以,我们有必要重新出发,好好认识一下这个东西。

不要用字符串存储日期

和绝大部分对数据库不太了解的新手一样,我在大学的时候就这样干过,甚至认为这样是一个不错的表示日期的方法。毕竟简单直白,容易上手。

但是,这是不正确的做法,主要会有下面两个问题:

  1. 字符串占用的空间更大!
  2. 字符串存储的日期效率比较低(逐个字符进行比对),无法用日期相关的 API 进行计算和比较。

Datetime 和 Timestamp 之间的抉择

Datetime 和 Timestamp 是 MySQL 提供的两种比较相似的保存时间的数据类型,可以精确到秒。他们两者究竟该如何选择呢?

下面我们来简单对比一下二者。

时区信息

DateTime 类型是没有时区信息的(时区无关) ,DateTime 类型保存的时间都是当前会话所设置的时区对应的时间。这样就会有什么问题呢?当你的时区更换之后,比如你的服务器更换地址或者更换客户端连接时区设置的话,就会导致你从数据库中读出的时间错误。

Timestamp 和时区有关。Timestamp 类型字段的值会随着服务器时区的变化而变化,自动换算成相应的时间,说简单点就是在不同时区,查询到同一个条记录此字段的值会不一样。

下面实际演示一下!

建表 SQL 语句:

1
2
3
4
5
6
CREATE TABLE `time_zone_test` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`date_time` datetime DEFAULT NULL,
`time_stamp` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

插入数据:

1
INSERT INTO time_zone_test(date_time,time_stamp) VALUES(NOW(),NOW());

查看数据:

1
select date_time,time_stamp from time_zone_test;

结果:

1
2
3
4
5
+---------------------+---------------------+
| date_time | time_stamp |
+---------------------+---------------------+
| 2020-01-11 09:53:32 | 2020-01-11 09:53:32 |
+---------------------+---------------------+

现在我们运行

修改当前会话的时区:

1
set time_zone='+8:00';

再次查看数据:

1
2
3
4
5
+---------------------+---------------------+
| date_time | time_stamp |
+---------------------+---------------------+
| 2020-01-11 09:53:32 | 2020-01-11 17:53:32 |
+---------------------+---------------------+

扩展:一些关于 MySQL 时区设置的一个常用 sql 命令

1
2
3
4
5
6
7
8
9
10
# 查看当前会话时区
SELECT @@session.time_zone;
# 设置当前会话时区
SET time_zone = 'Europe/Helsinki';
SET time_zone = "+00:00";
# 数据库全局时区设置
SELECT @@global.time_zone;
# 设置全局时区
SET GLOBAL time_zone = '+8:00';
SET GLOBAL time_zone = 'Europe/Helsinki';

占用空间

下图是 MySQL 日期类型所占的存储空间(官方文档传送门:https://dev.mysql.com/doc/refman/8.0/en/storage-requirements.html):

在 MySQL 5.6.4 之前,DateTime 和 Timestamp 的存储空间是固定的,分别为 8 字节和 4 字节。但是从 MySQL 5.6.4 开始,它们的存储空间会根据毫秒精度的不同而变化,DateTime 的范围是 58 字节,Timestamp 的范围是 47 字节。

表示范围

Timestamp 表示的时间范围更小,只能到 2038 年:

  • DateTime:1000-01-01 00:00:00.000000 ~ 9999-12-31 23:59:59.499999
  • Timestamp:1970-01-01 00:00:01.000000 ~ 2038-01-19 03:14:07.499999

性能

由于 TIMESTAMP 需要根据时区进行转换,所以从毫秒数转换到 TIMESTAMP 时,不仅要调用一个简单的函数,还要调用操作系统底层的系统函数。这个系统函数为了保证操作系统时区的一致性,需要进行加锁操作,这就降低了效率。

DATETIME 不涉及时区转换,所以不会有这个问题。

为了避免 TIMESTAMP 的时区转换问题,建议使用指定的时区,而不是依赖于操作系统时区。

数值时间戳是更好的选择吗?

很多时候,我们也会使用 int 或者 bigint 类型的数值也就是数值时间戳来表示时间。

这种存储方式的具有 Timestamp 类型的所具有一些优点,并且使用它的进行日期排序以及对比等操作的效率会更高,跨系统也很方便,毕竟只是存放的数值。缺点也很明显,就是数据的可读性太差了,你无法直观的看到具体时间。

时间戳的定义如下:

时间戳的定义是从一个基准时间开始算起,这个基准时间是「1970-1-1 00:00:00 +0:00」,从这个时间开始,用整数表示,以秒计时,随着时间的流逝这个时间整数不断增加。这样一来,我只需要一个数值,就可以完美地表示时间了,而且这个数值是一个绝对数值,即无论的身处地球的任何角落,这个表示时间的时间戳,都是一样的,生成的数值都是一样的,并且没有时区的概念,所以在系统的中时间的传输中,都不需要进行额外的转换了,只有在显示给用户的时候,才转换为字符串格式的本地时间。

数据库中实际操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
mysql> select UNIX_TIMESTAMP('2020-01-11 09:53:32');
+---------------------------------------+
| UNIX_TIMESTAMP('2020-01-11 09:53:32') |
+---------------------------------------+
| 1578707612 |
+---------------------------------------+
1 row in set (0.00 sec)

mysql> select FROM_UNIXTIME(1578707612);
+---------------------------+
| FROM_UNIXTIME(1578707612) |
+---------------------------+
| 2020-01-11 09:53:32 |
+---------------------------+
1 row in set (0.01 sec)

总结

MySQL 中时间到底怎么存储才好?Datetime?Timestamp?还是数值时间戳?

并没有一个银弹,很多程序员会觉得数值型时间戳是真的好,效率又高还各种兼容,但是很多人又觉得它表现的不够直观。

《高性能 MySQL 》这本神书的作者就是推荐 Timestamp,原因是数值表示时间不够直观。下面是原文:

每种方式都有各自的优势,根据实际场景选择最合适的才是王道。下面再对这三种方式做一个简单的对比,以供大家实际开发中选择正确的存放时间的数据类型:

类型 存储空间 日期格式 日期范围 是否带时区信息
DATETIME 5~8 字节 YYYY-MM-DD hh:mm:ss[.fraction] 1000-01-01 00:00:00[.000000] ~ 9999-12-31 23:59:59[.999999] 否
TIMESTAMP 4~7 字节 YYYY-MM-DD hh:mm:ss[.fraction] 1970-01-01 00:00:01[.000000] ~ 2038-01-19 03:14:07[.999999] 是
数值型时间戳 4 字节 全数字如 1578707612 1970-01-01 00:00:01 之后的时间 否

MySQL查询缓存详解

发表于 2023-10-15 | 分类于 数据库 , MYSQL | 阅读次数:
字数统计: 3.1k 字 | 阅读时长 ≈ 11 分钟

缓存是一个有效且实用的系统性能优化的手段,不论是操作系统还是各种软件和网站或多或少都用到了缓存。

然而,有经验的 DBA 都建议生产环境中把 MySQL 自带的 Query Cache(查询缓存)给关掉。而且,从 MySQL 5.7.20 开始,就已经默认弃用查询缓存了。在 MySQL 8.0 及之后,更是直接删除了查询缓存的功能。

这又是为什么呢?查询缓存真就这么鸡肋么?

带着如下几个问题,我们正式进入本文。

  • MySQL 查询缓存是什么?适用范围?
  • MySQL 缓存规则是什么?
  • MySQL 缓存的优缺点是什么?
  • MySQL 缓存对性能有什么影响?

MySQL 查询缓存介绍

MySQL 体系架构如下图所示:

为了提高完全相同的查询语句的响应速度,MySQL Server 会对查询语句进行 Hash 计算得到一个 Hash 值。MySQL Server 不会对 SQL 做任何处理,SQL 必须完全一致 Hash 值才会一样。得到 Hash 值之后,通过该 Hash 值到查询缓存中匹配该查询的结果。

  • 如果匹配(命中),则将查询的结果集直接返回给客户端,不必再解析、执行查询。
  • 如果没有匹配(未命中),则将 Hash 值和结果集保存在查询缓存中,以便以后使用。

也就是说,一个查询语句(select)到了 MySQL Server 之后,会先到查询缓存看看,如果曾经执行过的话,就直接返回结果集给客户端。

MySQL 查询缓存管理和配置

通过 show variables like '%query_cache%'命令可以查看查询缓存相关的信息。

8.0 版本之前的话,打印的信息可能是下面这样的:

1
2
3
4
5
6
7
8
9
10
11
12
mysql> show variables like '%query_cache%';
+------------------------------+---------+
| Variable_name | Value |
+------------------------------+---------+
| have_query_cache | YES |
| query_cache_limit | 1048576 |
| query_cache_min_res_unit | 4096 |
| query_cache_size | 599040 |
| query_cache_type | ON |
| query_cache_wlock_invalidate | OFF |
+------------------------------+---------+
6 rows in set (0.02 sec)

8.0 以及之后版本之后,打印的信息是下面这样的:

1
2
3
4
5
6
7
mysql> show variables like '%query_cache%';
+------------------+-------+
| Variable_name | Value |
+------------------+-------+
| have_query_cache | NO |
+------------------+-------+
1 row in set (0.01 sec)

我们这里对 8.0 版本之前show variables like '%query_cache%';命令打印出来的信息进行解释。

  • have_query_cache: 该 MySQL Server 是否支持查询缓存,如果是 YES 表示支持,否则则是不支持。
  • query_cache_limit: MySQL 查询缓存的最大查询结果,查询结果大于该值时不会被缓存。
  • query_cache_min_res_unit: 查询缓存分配的最小块的大小(字节)。当查询进行的时候,MySQL 把查询结果保存在查询缓存中,但如果要保存的结果比较大,超过 query_cache_min_res_unit 的值 ,这时候 MySQL 将一边检索结果,一边进行保存结果,也就是说,有可能在一次查询中,MySQL 要进行多次内存分配的操作。适当的调节 query_cache_min_res_unit 可以优化内存。
  • query_cache_size: 为缓存查询结果分配的内存的数量,单位是字节,且数值必须是 1024 的整数倍。默认值是 0,即禁用查询缓存。
  • query_cache_type: 设置查询缓存类型,默认为 ON。设置 GLOBAL 值可以设置后面的所有客户端连接的类型。客户端可以设置 SESSION 值以影响他们自己对查询缓存的使用。
  • **query_cache_wlock_invalidate**:如果某个表被锁住,是否返回缓存中的数据,默认关闭,也是建议的。

query_cache_type 可能的值(修改 query_cache_type 需要重启 MySQL Server):

  • 0 或 OFF:关闭查询功能。
  • 1 或 ON:开启查询缓存功能,但不缓存 Select SQL_NO_CACHE 开头的查询。
  • 2 或 DEMAND:开启查询缓存功能,但仅缓存 Select SQL_CACHE 开头的查询。

建议:

  • query_cache_size不建议设置的过大。过大的空间不但挤占实例其他内存结构的空间,而且会增加在缓存中搜索的开销。建议根据实例规格,初始值设置为 10MB 到 100MB 之间的值,而后根据运行使用情况调整。

  • 建议通过调整 query_cache_size 的值来开启、关闭查询缓存,因为修改query_cache_type 参数需要重启 MySQL Server 生效。

    8.0 版本之前,my.cnf 加入以下配置,重启 MySQL 开启查询缓存

1
2
query_cache_type=1
query_cache_size=600000

或者,MySQL 执行以下命令也可以开启查询缓存

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

手动清理缓存可以使用下面三个 SQL:

  • flush query cache;:清理查询缓存内存碎片。
  • reset query cache;:从查询缓存中移除所有查询。
  • flush tables; 关闭所有打开的表,同时该操作会清空查询缓存中的内容。

MySQL 缓存机制

缓存规则

  • 查询缓存会将查询语句和结果集保存到内存(一般是 key-value 的形式,key 是查询语句,value 是查询的结果集),下次再查直接从内存中取。
  • 缓存的结果是通过 sessions 共享的,所以一个 client 查询的缓存结果,另一个 client 也可以使用。
  • SQL 必须完全一致才会导致查询缓存命中(大小写、空格、使用的数据库、协议版本、字符集等必须一致)。检查查询缓存时,MySQL Server 不会对 SQL 做任何处理,它精确的使用客户端传来的查询。
  • 不缓存查询中的子查询结果集,仅缓存查询最终结果集。
  • 不确定的函数将永远不会被缓存, 比如 now()、curdate()、last_insert_id()、rand() 等。
  • 不缓存产生告警(Warnings)的查询。
  • 太大的结果集不会被缓存 (< query_cache_limit)。
  • 如果查询中包含任何用户自定义函数、存储函数、用户变量、临时表、MySQL 库中的系统表,其查询结果也不会被缓存。
  • 缓存建立之后,MySQL 的查询缓存系统会跟踪查询中涉及的每张表,如果这些表(数据或结构)发生变化,那么和这张表相关的所有缓存数据都将失效。
  • MySQL 缓存在分库分表环境下是不起作用的。
  • 不缓存使用 SQL_NO_CACHE 的查询。
  • ……

查询缓存 SELECT 选项示例:

1
2
SELECT SQL_CACHE id, name FROM customer;# 会缓存
SELECT SQL_NO_CACHE id, name FROM customer;# 不会缓存

缓存机制中的内存管理

查询缓存是完全存储在内存中的,所以在配置和使用它之前,我们需要先了解它是如何使用内存的。

MySQL 查询缓存使用内存池技术,自己管理内存释放和分配,而不是通过操作系统。内存池使用的基本单位是变长的 block, 用来存储类型、大小、数据等信息。一个结果集的缓存通过链表把这些 block 串起来。block 最短长度为 query_cache_min_res_unit。

当服务器启动的时候,会初始化缓存需要的内存,是一个完整的空闲块。当查询结果需要缓存的时候,先从空闲块中申请一个数据块为参数 query_cache_min_res_unit 配置的空间,即使缓存数据很小,申请数据块也是这个,因为查询开始返回结果的时候就分配空间,此时无法预知结果多大。

分配内存块需要先锁住空间块,所以操作很慢,MySQL 会尽量避免这个操作,选择尽可能小的内存块,如果不够,继续申请,如果存储完时有空余则释放多余的。

但是如果并发的操作,余下的需要回收的空间很小,小于 query_cache_min_res_unit,不能再次被使用,就会产生碎片。

MySQL 查询缓存的优缺点

优点:

  • 查询缓存的查询,发生在 MySQL 接收到客户端的查询请求、查询权限验证之后和查询 SQL 解析之前。也就是说,当 MySQL 接收到客户端的查询 SQL 之后,仅仅只需要对其进行相应的权限验证之后,就会通过查询缓存来查找结果,甚至都不需要经过 Optimizer 模块进行执行计划的分析优化,更不需要发生任何存储引擎的交互。
  • 由于查询缓存是基于内存的,直接从内存中返回相应的查询结果,因此减少了大量的磁盘 I/O 和 CPU 计算,导致效率非常高。

缺点:

  • MySQL 会对每条接收到的 SELECT 类型的查询进行 Hash 计算,然后查找这个查询的缓存结果是否存在。虽然 Hash 计算和查找的效率已经足够高了,一条查询语句所带来的开销可以忽略,但一旦涉及到高并发,有成千上万条查询语句时,hash 计算和查找所带来的开销就必须重视了。
  • 查询缓存的失效问题。如果表的变更比较频繁,则会造成查询缓存的失效率非常高。表的变更不仅仅指表中的数据发生变化,还包括表结构或者索引的任何变化。
  • 查询语句不同,但查询结果相同的查询都会被缓存,这样便会造成内存资源的过度消耗。查询语句的字符大小写、空格或者注释的不同,查询缓存都会认为是不同的查询(因为他们的 Hash 值会不同)。
  • 相关系统变量设置不合理会造成大量的内存碎片,这样便会导致查询缓存频繁清理内存。

MySQL 查询缓存对性能的影响

在 MySQL Server 中打开查询缓存对数据库的读和写都会带来额外的消耗:

  • 读查询开始之前必须检查是否命中缓存。
  • 如果读查询可以缓存,那么执行完查询操作后,会查询结果和查询语句写入缓存。
  • 当向某个表写入数据的时候,必须将这个表所有的缓存设置为失效,如果缓存空间很大,则消耗也会很大,可能使系统僵死一段时间,因为这个操作是靠全局锁操作来保护的。
  • 对 InnoDB 表,当修改一个表时,设置了缓存失效,但是多版本特性会暂时将这修改对其他事务屏蔽,在这个事务提交之前,所有查询都无法使用缓存,直到这个事务被提交,所以长时间的事务,会大大降低查询缓存的命中。

总结

MySQL 中的查询缓存虽然能够提升数据库的查询性能,但是查询同时也带来了额外的开销,每次查询后都要做一次缓存操作,失效后还要销毁。

查询缓存是一个适用较少情况的缓存机制。如果你的应用对数据库的更新很少,那么查询缓存将会作用显著。比较典型的如博客系统,一般博客更新相对较慢,数据表相对稳定不变,这时候查询缓存的作用会比较明显。

简单总结一下查询缓存的适用场景:

  • 表数据修改不频繁、数据较静态。
  • 查询(Select)重复度高。
  • 查询结果集小于 1 MB。

对于一个更新频繁的系统来说,查询缓存缓存的作用是很微小的,在某些情况下开启查询缓存会带来性能的下降。

简单总结一下查询缓存不适用的场景:

  • 表中的数据、表结构或者索引变动频繁
  • 重复的查询很少
  • 查询的结果集很大

《高性能 MySQL》这样写到:

根据我们的经验,在高并发压力环境中查询缓存会导致系统性能的下降,甚至僵死。如果你一 定要使用查询缓存,那么不要设置太大内存,而且只有在明确收益的时候才使用(数据库内容修改次数较少)。

确实是这样的!实际项目中,更建议使用本地缓存(比如 Caffeine)或者分布式缓存(比如 Redis) ,性能更好,更通用一些。

参考

  • 《高性能 MySQL》
  • MySQL 缓存机制:https://zhuanlan.zhihu.com/p/55947158
  • RDS MySQL 查询缓存(Query Cache)的设置和使用 - 阿里元云数据库 RDS 文档:https://help.aliyun.com/document_detail/41717.html
  • 8.10.3 The MySQL Query Cache - MySQL 官方文档:https://dev.mysql.com/doc/refman/5.7/en/query-cache.html

Web 实时消息推送详解

发表于 2023-10-12 | 分类于 Java , WEB | 阅读次数:
字数统计: 5k 字 | 阅读时长 ≈ 19 分钟

原文地址:https://juejin.cn/post/7122014462181113887,JavaGuide 对本文进行了完善总结。

我有一个朋友做了一个小破站,现在要实现一个站内信 Web 消息推送的功能,对,就是下图这个小红点,一个很常用的功能。

站内信 Web 消息推送

不过他还没想好用什么方式做,这里我帮他整理了一下几种方案,并简单做了实现。

什么是消息推送?

推送的场景比较多,比如有人关注我的公众号,这时我就会收到一条推送消息,以此来吸引我点击打开应用。

消息推送通常是指网站的运营工作等人员,通过某种工具对用户当前网页或移动设备 APP 进行的主动消息推送。

消息推送一般又分为 Web 端消息推送和移动端消息推送。

移动端消息推送示例:

移动端消息推送示例

Web 端消息推送示例:

Web 端消息推送示例

在具体实现之前,咱们再来分析一下前边的需求,其实功能很简单,只要触发某个事件(主动分享了资源或者后台主动推送消息),Web 页面的通知小红点就会实时的 +1 就可以了。

通常在服务端会有若干张消息推送表,用来记录用户触发不同事件所推送不同类型的消息,前端主动查询(拉)或者被动接收(推)用户所有未读的消息数。

消息推送表

消息推送无非是推(push)和拉(pull)两种形式,下边我们逐个了解下。

消息推送常见方案

短轮询

轮询(polling) 应该是实现消息推送方案中最简单的一种,这里我们暂且将轮询分为短轮询和长轮询。

短轮询很好理解,指定的时间间隔,由浏览器向服务器发出 HTTP 请求,服务器实时返回未读消息数据给客户端,浏览器再做渲染显示。

一个简单的 JS 定时器就可以搞定,每秒钟请求一次未读消息数接口,返回的数据展示即可。

1
2
3
4
5
6
7
8
setInterval(() => {
// 方法请求
messageCount().then((res) => {
if (res.code === 200) {
this.messageCount = res.data;
}
});
}, 1000);

效果还是可以的,短轮询实现固然简单,缺点也是显而易见,由于推送数据并不会频繁变更,无论后端此时是否有新的消息产生,客户端都会进行请求,势必会对服务端造成很大压力,浪费带宽和服务器资源。

长轮询

长轮询是对上边短轮询的一种改进版本,在尽可能减少对服务器资源浪费的同时,保证消息的相对实时性。长轮询在中间件中应用的很广泛,比如 Nacos 和 Apollo 配置中心,消息队列 Kafka、RocketMQ 中都有用到长轮询。

Nacos 配置中心交互模型是 push 还是 pull?一文中我详细介绍过 Nacos 长轮询的实现原理,感兴趣的小伙伴可以瞅瞅。

长轮询其实原理跟轮询差不多,都是采用轮询的方式。不过,如果服务端的数据没有发生变更,会 一直 hold 住请求,直到服务端的数据发生变化,或者等待一定时间超时才会返回。返回后,客户端又会立即再次发起下一次长轮询。

这次我使用 Apollo 配置中心实现长轮询的方式,应用了一个类DeferredResult,它是在 Servlet3.0 后经过 Spring 封装提供的一种异步请求机制,直意就是延迟结果。

长轮询示意图

DeferredResult可以允许容器线程快速释放占用的资源,不阻塞请求线程,以此接受更多的请求提升系统的吞吐量,然后启动异步工作线程处理真正的业务逻辑,处理完成调用DeferredResult.setResult(200)提交响应结果。

下边我们用长轮询来实现消息推送。

因为一个 ID 可能会被多个长轮询请求监听,所以我采用了 Guava 包提供的Multimap结构存放长轮询,一个 key 可以对应多个 value。一旦监听到 key 发生变化,对应的所有长轮询都会响应。前端得到非请求超时的状态码,知晓数据变更,主动查询未读消息数接口,更新页面数据。

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
31
32
33
34
35
36
37
38
39
40
@Controller
@RequestMapping("/polling")
public class PollingController {

// 存放监听某个Id的长轮询集合
// 线程同步结构
public static Multimap<String, DeferredResult<String>> watchRequests = Multimaps.synchronizedMultimap(HashMultimap.create());

/**
* 设置监听
*/
@GetMapping(path = "watch/{id}")
@ResponseBody
public DeferredResult<String> watch(@PathVariable String id) {
// 延迟对象设置超时时间
DeferredResult<String> deferredResult = new DeferredResult<>(TIME_OUT);
// 异步请求完成时移除 key,防止内存溢出
deferredResult.onCompletion(() -> {
watchRequests.remove(id, deferredResult);
});
// 注册长轮询请求
watchRequests.put(id, deferredResult);
return deferredResult;
}

/**
* 变更数据
*/
@GetMapping(path = "publish/{id}")
@ResponseBody
public String publish(@PathVariable String id) {
// 数据变更 取出监听ID的所有长轮询请求,并一一响应处理
if (watchRequests.containsKey(id)) {
Collection<DeferredResult<String>> deferredResults = watchRequests.get(id);
for (DeferredResult<String> deferredResult : deferredResults) {
deferredResult.setResult("我更新了" + new Date());
}
}
return "success";
}

当请求超过设置的超时时间,会抛出AsyncRequestTimeoutException异常,这里直接用@ControllerAdvice全局捕获统一返回即可,前端获取约定好的状态码后再次发起长轮询请求,如此往复调用。

1
2
3
4
5
6
7
8
9
10
11
@ControllerAdvice
public class AsyncRequestTimeoutHandler {

@ResponseStatus(HttpStatus.NOT_MODIFIED)
@ResponseBody
@ExceptionHandler(AsyncRequestTimeoutException.class)
public String asyncRequestTimeoutHandler(AsyncRequestTimeoutException e) {
System.out.println("异步请求超时");
return "304";
}
}

我们来测试一下,首先页面发起长轮询请求/polling/watch/10086监听消息更变,请求被挂起,不变更数据直至超时,再次发起了长轮询请求;紧接着手动变更数据/polling/publish/10086,长轮询得到响应,前端处理业务逻辑完成后再次发起请求,如此循环往复。

长轮询相比于短轮询在性能上提升了很多,但依然会产生较多的请求,这是它的一点不完美的地方。

iframe 流

iframe 流就是在页面中插入一个隐藏的<iframe>标签,通过在src中请求消息数量 API 接口,由此在服务端和客户端之间创建一条长连接,服务端持续向iframe传输数据。

传输的数据通常是 HTML、或是内嵌的 JavaScript 脚本,来达到实时更新页面的效果。

iframe 流示意图

这种方式实现简单,前端只要一个<iframe>标签搞定了

1
<iframe src="/iframe/message" style="display:none"></iframe>

服务端直接组装 HTML、JS 脚本数据向 response 写入就行了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Controller
@RequestMapping("/iframe")
public class IframeController {
@GetMapping(path = "message")
public void message(HttpServletResponse response) throws IOException, InterruptedException {
while (true) {
response.setHeader("Pragma", "no-cache");
response.setDateHeader("Expires", 0);
response.setHeader("Cache-Control", "no-cache,no-store");
response.setStatus(HttpServletResponse.SC_OK);
response.getWriter().print(" <script type=\"text/javascript\">\n" +
"parent.document.getElementById('clock').innerHTML = \"" + count.get() + "\";" +
"parent.document.getElementById('count').innerHTML = \"" + count.get() + "\";" +
"</script>");
}
}
}

iframe 流的服务器开销很大,而且 IE、Chrome 等浏览器一直会处于 loading 状态,图标会不停旋转,简直是强迫症杀手。

iframe 流效果

iframe 流非常不友好,强烈不推荐。

SSE (推荐)

很多人可能不知道,服务端向客户端推送消息,其实除了可以用WebSocket这种耳熟能详的机制外,还有一种服务器发送事件(Server-Sent Events),简称 SSE。这是一种服务器端到客户端(浏览器)的单向消息推送。

大名鼎鼎的 ChatGPT 就是采用的 SSE。对于需要长时间等待响应的对话场景,ChatGPT 采用了一种巧妙的策略:它会将已经计算出的数据“推送”给用户,并利用 SSE 技术在计算过程中持续返回数据。这样做的好处是可以避免用户因等待时间过长而选择关闭页面。

ChatGPT 使用 SSE 实现对话

SSE 基于 HTTP 协议的,我们知道一般意义上的 HTTP 协议是无法做到服务端主动向客户端推送消息的,但 SSE 是个例外,它变换了一种思路。

SSE 在服务器和客户端之间打开一个单向通道,服务端响应的不再是一次性的数据包而是text/event-stream类型的数据流信息,在有数据变更时从服务器流式传输到客户端。

整体的实现思路有点类似于在线视频播放,视频流会连续不断的推送到浏览器,你也可以理解成,客户端在完成一次用时很长(网络不畅)的下载。

SSE 示意图

SSE 与 WebSocket 作用相似,都可以建立服务端与浏览器之间的通信,实现服务端向客户端推送消息,但还是有些许不同:

  • SSE 是基于 HTTP 协议的,它们不需要特殊的协议或服务器实现即可工作;WebSocket 需单独服务器来处理协议。
  • SSE 单向通信,只能由服务端向客户端单向通信;WebSocket 全双工通信,即通信的双方可以同时发送和接受信息。
  • SSE 实现简单开发成本低,无需引入其他组件;WebSocket 传输数据需做二次解析,开发门槛高一些。
  • SSE 默认支持断线重连;WebSocket 则需要自己实现。
  • SSE 只能传送文本消息,二进制数据需要经过编码后传送;WebSocket 默认支持传送二进制数据。

SSE 与 WebSocket 该如何选择?

技术并没有好坏之分,只有哪个更合适

SSE 好像一直不被大家所熟知,一部分原因是出现了 WebSocket,这个提供了更丰富的协议来执行双向、全双工通信。对于游戏、即时通信以及需要双向近乎实时更新的场景,拥有双向通道更具吸引力。

但是,在某些情况下,不需要从客户端发送数据。而你只需要一些服务器操作的更新。比如:站内信、未读消息数、状态更新、股票行情、监控数量等场景,SEE 不管是从实现的难易和成本上都更加有优势。此外,SSE 具有 WebSocket 在设计上缺乏的多种功能,例如:自动重新连接、事件 ID 和发送任意事件的能力。

前端只需进行一次 HTTP 请求,带上唯一 ID,打开事件流,监听服务端推送的事件就可以了

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
<script>
let source = null;
let userId = 7777
if (window.EventSource) {
// 建立连接
source = new EventSource('http://localhost:7777/sse/sub/'+userId);
setMessageInnerHTML("连接用户=" + userId);
/**
* 连接一旦建立,就会触发open事件
* 另一种写法:source.onopen = function (event) {}
*/
source.addEventListener('open', function (e) {
setMessageInnerHTML("建立连接。。。");
}, false);
/**
* 客户端收到服务器发来的数据
* 另一种写法:source.onmessage = function (event) {}
*/
source.addEventListener('message', function (e) {
setMessageInnerHTML(e.data);
});
} else {
setMessageInnerHTML("你的浏览器不支持SSE");
}
</script>

服务端的实现更简单,创建一个SseEmitter对象放入sseEmitterMap进行管理

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
31
32
33
34
35
36
private static Map<String, SseEmitter> sseEmitterMap = new ConcurrentHashMap<>();

/**
* 创建连接
*/
public static SseEmitter connect(String userId) {
try {
// 设置超时时间,0表示不过期。默认30秒
SseEmitter sseEmitter = new SseEmitter(0L);
// 注册回调
sseEmitter.onCompletion(completionCallBack(userId));
sseEmitter.onError(errorCallBack(userId));
sseEmitter.onTimeout(timeoutCallBack(userId));
sseEmitterMap.put(userId, sseEmitter);
count.getAndIncrement();
return sseEmitter;
} catch (Exception e) {
log.info("创建新的sse连接异常,当前用户:{}", userId);
}
return null;
}

/**
* 给指定用户发送消息
*/
public static void sendMessage(String userId, String message) {

if (sseEmitterMap.containsKey(userId)) {
try {
sseEmitterMap.get(userId).send(message);
} catch (IOException e) {
log.error("用户[{}]推送异常:{}", userId, e.getMessage());
removeUser(userId);
}
}
}

注意: SSE 不支持 IE 浏览器,对其他主流浏览器兼容性做的还不错。

SSE 兼容性

Websocket

Websocket 应该是大家都比较熟悉的一种实现消息推送的方式,上边我们在讲 SSE 的时候也和 Websocket 进行过比较。

这是一种在 TCP 连接上进行全双工通信的协议,建立客户端和服务器之间的通信渠道。浏览器和服务器仅需一次握手,两者之间就直接可以创建持久性的连接,并进行双向数据传输。

Websocket 示意图

WebSocket 的工作过程可以分为以下几个步骤:

  1. 客户端向服务器发送一个 HTTP 请求,请求头中包含 Upgrade: websocket 和 Sec-WebSocket-Key 等字段,表示要求升级协议为 WebSocket;
  2. 服务器收到这个请求后,会进行升级协议的操作,如果支持 WebSocket,它将回复一个 HTTP 101 状态码,响应头中包含 ,Connection: Upgrade和 Sec-WebSocket-Accept: xxx 等字段、表示成功升级到 WebSocket 协议。
  3. 客户端和服务器之间建立了一个 WebSocket 连接,可以进行双向的数据传输。数据以帧(frames)的形式进行传送,而不是传统的 HTTP 请求和响应。WebSocket 的每条消息可能会被切分成多个数据帧(最小单位)。发送端会将消息切割成多个帧发送给接收端,接收端接收消息帧,并将关联的帧重新组装成完整的消息。
  4. 客户端或服务器可以主动发送一个关闭帧,表示要断开连接。另一方收到后,也会回复一个关闭帧,然后双方关闭 TCP 连接。

另外,建立 WebSocket 连接之后,通过心跳机制来保持 WebSocket 连接的稳定性和活跃性。

SpringBoot 整合 WebSocket,先引入 WebSocket 相关的工具包,和 SSE 相比有额外的开发成本。

1
2
3
4
5
<!-- 引入websocket -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>

服务端使用@ServerEndpoint注解标注当前类为一个 WebSocket 服务器,客户端可以通过ws://localhost:7777/webSocket/10086来连接到 WebSocket 服务器端。

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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
@Component
@Slf4j
@ServerEndpoint("/websocket/{userId}")
public class WebSocketServer {
//与某个客户端的连接会话,需要通过它来给客户端发送数据
private Session session;
private static final CopyOnWriteArraySet<WebSocketServer> webSockets = new CopyOnWriteArraySet<>();
// 用来存在线连接数
private static final Map<String, Session> sessionPool = new HashMap<String, Session>();
/**
* 链接成功调用的方法
*/
@OnOpen
public void onOpen(Session session, @PathParam(value = "userId") String userId) {
try {
this.session = session;
webSockets.add(this);
sessionPool.put(userId, session);
log.info("websocket消息: 有新的连接,总数为:" + webSockets.size());
} catch (Exception e) {
}
}
/**
* 收到客户端消息后调用的方法
*/
@OnMessage
public void onMessage(String message) {
log.info("websocket消息: 收到客户端消息:" + message);
}
/**
* 此为单点消息
*/
public void sendOneMessage(String userId, String message) {
Session session = sessionPool.get(userId);
if (session != null && session.isOpen()) {
try {
log.info("websocket消: 单点消息:" + message);
session.getAsyncRemote().sendText(message);
} catch (Exception e) {
e.printStackTrace();
}
}
}
}

服务端还需要注入ServerEndpointerExporter,这个 Bean 就会自动注册使用了@ServerEndpoint注解的 WebSocket 服务器。

1
2
3
4
5
6
7
8
9
10
11
@Configuration
public class WebSocketConfiguration {

/**
* 用于注册使用了 @ServerEndpoint 注解的 WebSocket 服务器
*/
@Bean
public ServerEndpointExporter serverEndpointExporter() {
return new ServerEndpointExporter();
}
}

前端初始化打开 WebSocket 连接,并监听连接状态,接收服务端数据或向服务端发送数据。

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
31
32
33
34
35
36
37
38
<script>
var ws = new WebSocket('ws://localhost:7777/webSocket/10086');
// 获取连接状态
console.log('ws连接状态:' + ws.readyState);
//监听是否连接成功
ws.onopen = function () {
console.log('ws连接状态:' + ws.readyState);
//连接成功则发送一个数据
ws.send('test1');
}
// 接听服务器发回的信息并处理展示
ws.onmessage = function (data) {
console.log('接收到来自服务器的消息:');
console.log(data);
//完成通信后关闭WebSocket连接
ws.close();
}
// 监听连接关闭事件
ws.onclose = function () {
// 监听整个过程中websocket的状态
console.log('ws连接状态:' + ws.readyState);
}
// 监听并处理error事件
ws.onerror = function (error) {
console.log(error);
}
function sendMessage() {
var content = $("#message").val();
$.ajax({
url: '/socket/publish?userId=10086&message=' + content,
type: 'GET',
data: { "id": "7777", "content": content },
success: function (data) {
console.log(data)
}
})
}
</script>

页面初始化建立 WebSocket 连接,之后就可以进行双向通信了,效果还不错。

MQTT

什么是 MQTT 协议?

MQTT (Message Queue Telemetry Transport)是一种基于发布/订阅(publish/subscribe)模式的轻量级通讯协议,通过订阅相应的主题来获取消息,是物联网(Internet of Thing)中的一个标准传输协议。

该协议将消息的发布者(publisher)与订阅者(subscriber)进行分离,因此可以在不可靠的网络环境中,为远程连接的设备提供可靠的消息服务,使用方式与传统的 MQ 有点类似。

MQTT 协议示例

TCP 协议位于传输层,MQTT 协议位于应用层,MQTT 协议构建于 TCP/IP 协议上,也就是说只要支持 TCP/IP 协议栈的地方,都可以使用 MQTT 协议。

为什么要用 MQTT 协议?

MQTT 协议为什么在物联网(IOT)中如此受偏爱?而不是其它协议,比如我们更为熟悉的 HTTP 协议呢?

  • 首先 HTTP 协议它是一种同步协议,客户端请求后需要等待服务器的响应。而在物联网(IOT)环境中,设备会很受制于环境的影响,比如带宽低、网络延迟高、网络通信不稳定等,显然异步消息协议更为适合 IOT 应用程序。
  • HTTP 是单向的,如果要获取消息客户端必须发起连接,而在物联网(IOT)应用程序中,设备或传感器往往都是客户端,这意味着它们无法被动地接收来自网络的命令。
  • 通常需要将一条命令或者消息,发送到网络上的所有设备上。HTTP 要实现这样的功能不但很困难,而且成本极高。

具体的 MQTT 协议介绍和实践,这里我就不再赘述了,大家可以参考我之前的两篇文章,里边写的也都很详细了。

  • MQTT 协议的介绍:我也没想到 SpringBoot + RabbitMQ 做智能家居,会这么简单
  • MQTT 实现消息推送:未读消息(小红点),前端 与 RabbitMQ 实时消息推送实践,贼简单~

总结

以下内容为 JavaGuide 补充

介绍 优点 缺点
短轮询 客户端定时向服务端发送请求,服务端直接返回响应数据(即使没有数据更新) 简单、易理解、易实现 实时性太差,无效请求太多,频繁建立连接太耗费资源
长轮询 与短轮询不同是,长轮询接收到客户端请求之后等到有数据更新才返回请求 减少了无效请求 挂起请求会导致资源浪费
iframe 流 服务端和客户端之间创建一条长连接,服务端持续向iframe传输数据。 简单、易理解、易实现 维护一个长连接会增加开销,效果太差(图标会不停旋转)
SSE 一种服务器端到客户端(浏览器)的单向消息推送。 简单、易实现,功能丰富 不支持双向通信
WebSocket 除了最初建立连接时用 HTTP 协议,其他时候都是直接基于 TCP 协议进行通信的,可以实现客户端和服务端的全双工通信。 性能高、开销小 对开发人员要求更高,实现相对复杂一些
MQTT 基于发布/订阅(publish/subscribe)模式的轻量级通讯协议,通过订阅相应的主题来获取消息。 成熟稳定,轻量级 对开发人员要求更高,实现相对复杂一些

Spring Boot核心源码解读(付费)

发表于 2023-10-05 | 分类于 Java , 框架 , spring | 阅读次数:
字数统计: 51 字 | 阅读时长 ≈ 1 分钟

Spring Boot 核心源码解读 为我的知识星球(点击链接即可查看详细介绍以及加入方法)专属内容,已经整理到了《Java 必读源码系列》中。

Spring Boot核心源码解读

MySQL高性能优化规范建议总结

发表于 2023-09-29 | 分类于 数据库 , MYSQL | 阅读次数:
字数统计: 5.8k 字 | 阅读时长 ≈ 20 分钟

作者: 听风 原文地址: https://www.cnblogs.com/huchong/p/10219318.html。

JavaGuide 已获得作者授权,并对原文内容进行了完善补充。

数据库命名规范

  • 所有数据库对象名称必须使用小写字母并用下划线分割
  • 所有数据库对象名称禁止使用 MySQL 保留关键字(如果表名中包含关键字查询时,需要将其用单引号括起来)
  • 数据库对象的命名要能做到见名识意,并且最后不要超过 32 个字符
  • 临时库表必须以 tmp_ 为前缀并以日期为后缀,备份表必须以 bak_ 为前缀并以日期 (时间戳) 为后缀
  • 所有存储相同数据的列名和列类型必须一致(一般作为关联列,如果查询时关联列类型不一致会自动进行数据类型隐式转换,会造成列上的索引失效,导致查询效率降低)

数据库基本设计规范

所有表必须使用 InnoDB 存储引擎

没有特殊要求(即 InnoDB 无法满足的功能如:列存储,存储空间数据等)的情况下,所有表必须使用 InnoDB 存储引擎(MySQL5.5 之前默认使用 Myisam,5.6 以后默认的为 InnoDB)。

InnoDB 支持事务,支持行级锁,更好的恢复性,高并发下性能更好。

数据库和表的字符集统一使用 UTF8

兼容性更好,统一字符集可以避免由于字符集转换产生的乱码,不同的字符集进行比较前需要进行转换会造成索引失效,如果数据库中有存储 emoji 表情的需要,字符集需要采用 utf8mb4 字符集。

推荐阅读一下我写的这篇文章:MySQL 字符集详解 。

所有表和字段都需要添加注释

使用 comment 从句添加表和列的备注,从一开始就进行数据字典的维护

尽量控制单表数据量的大小,建议控制在 500 万以内

500 万并不是 MySQL 数据库的限制,过大会造成修改表结构,备份,恢复都会有很大的问题。

可以用历史数据归档(应用于日志数据),分库分表(应用于业务数据)等手段来控制数据量大小

谨慎使用 MySQL 分区表

分区表在物理上表现为多个文件,在逻辑上表现为一个表;

谨慎选择分区键,跨分区查询效率可能更低;

建议采用物理分表的方式管理大数据。

经常一起使用的列放到一个表中

避免更多的关联操作。

禁止在表中建立预留字段

  • 预留字段的命名很难做到见名识义。
  • 预留字段无法确认存储的数据类型,所以无法选择合适的类型。
  • 对预留字段类型的修改,会对表进行锁定。

禁止在数据库中存储文件(比如图片)这类大的二进制数据

在数据库中存储文件会严重影响数据库性能,消耗过多存储空间。

文件(比如图片)这类大的二进制数据通常存储于文件服务器,数据库只存储文件地址信息。

不要被数据库范式所束缚

一般来说,设计关系数据库时需要满足第三范式,但为了满足第三范式,我们可能会拆分出多张表。而在进行查询时需要对多张表进行关联查询,有时为了提高查询效率,会降低范式的要求,在表中保存一定的冗余信息,也叫做反范式。但要注意反范式一定要适度。

禁止在线上做数据库压力测试

禁止从开发环境,测试环境直接连接生产环境数据库

安全隐患极大,要对生产环境抱有敬畏之心!

数据库字段设计规范

优先选择符合存储需要的最小的数据类型

存储字节越小,占用也就空间越小,性能也越好。

a.某些字符串可以转换成数字类型存储比如可以将 IP 地址转换成整型数据。

数字是连续的,性能更好,占用空间也更小。

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

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

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

b.对于非负型的数据 (如自增 ID,整型 IP,年龄) 来说,要优先使用无符号整型来存储。

无符号相对于有符号可以多出一倍的存储空间

1
2
SIGNED INT -2147483648~2147483647
UNSIGNED INT 0~4294967295

c.小数值类型(比如年龄、状态表示如 0/1)优先使用 TINYINT 类型。

避免使用 TEXT,BLOB 数据类型,最常见的 TEXT 类型可以存储 64k 的数据

a. 建议把 BLOB 或是 TEXT 列分离到单独的扩展表中。

MySQL 内存临时表不支持 TEXT、BLOB 这样的大数据类型,如果查询中包含这样的数据,在排序等操作时,就不能使用内存临时表,必须使用磁盘临时表进行。而且对于这种数据,MySQL 还是要进行二次查询,会使 sql 性能变得很差,但是不是说一定不能使用这样的数据类型。

如果一定要使用,建议把 BLOB 或是 TEXT 列分离到单独的扩展表中,查询时一定不要使用 select *而只需要取出必要的列,不需要 TEXT 列的数据时不要对该列进行查询。

2、TEXT 或 BLOB 类型只能使用前缀索引

因为 MySQL 对索引字段长度是有限制的,所以 TEXT 类型只能使用前缀索引,并且 TEXT 列上是不能有默认值的

避免使用 ENUM 类型

  • 修改 ENUM 值需要使用 ALTER 语句;
  • ENUM 类型的 ORDER BY 操作效率低,需要额外操作;
  • ENUM 数据类型存在一些限制比如建议不要使用数值作为 ENUM 的枚举值。

相关阅读:是否推荐使用 MySQL 的 enum 类型? - 架构文摘 - 知乎 。

尽可能把所有列定义为 NOT NULL

除非有特别的原因使用 NULL 值,应该总是让字段保持 NOT NULL。

  • 索引 NULL 列需要额外的空间来保存,所以要占用更多的空间;
  • 进行比较和计算时要对 NULL 值做特别的处理。

相关阅读:技术分享 | MySQL 默认值选型(是空,还是 NULL) 。

一定不要用字符串存储日期

对于日期类型来说, 一定不要用字符串存储日期。可以考虑 DATETIME、TIMESTAMP 和 数值型时间戳。

这三种种方式都有各自的优势,根据实际场景选择最合适的才是王道。下面再对这三种方式做一个简单的对比,以供大家实际开发中选择正确的存放时间的数据类型:

类型 存储空间 日期格式 日期范围 是否带时区信息
DATETIME 5~8 字节 YYYY-MM-DD hh:mm:ss[.fraction] 1000-01-01 00:00:00[.000000] ~ 9999-12-31 23:59:59[.999999] 否
TIMESTAMP 4~7 字节 YYYY-MM-DD hh:mm:ss[.fraction] 1970-01-01 00:00:01[.000000] ~ 2038-01-19 03:14:07[.999999] 是
数值型时间戳 4 字节 全数字如 1578707612 1970-01-01 00:00:01 之后的时间 否

MySQL 时间类型选择的详细介绍请看这篇:MySQL 时间类型数据存储建议。

同财务相关的金额类数据必须使用 decimal 类型

  • 非精准浮点:float,double
  • 精准浮点:decimal

decimal 类型为精准浮点数,在计算时不会丢失精度。占用空间由定义的宽度决定,每 4 个字节可以存储 9 位数字,并且小数点要占用一个字节。并且,decimal 可用于存储比 bigint 更大的整型数据

不过, 由于 decimal 需要额外的空间和计算开销,应该尽量只在需要对数据进行精确计算时才使用 decimal 。

单表不要包含过多字段

如果一个表包含过多字段的话,可以考虑将其分解成多个表,必要时增加中间表进行关联。

索引设计规范

限制每张表上的索引数量,建议单张表索引不超过 5 个

索引并不是越多越好!索引可以提高效率同样可以降低效率。

索引可以增加查询效率,但同样也会降低插入和更新的效率,甚至有些情况下会降低查询效率。

因为 MySQL 优化器在选择如何优化查询时,会根据统一信息,对每一个可以用到的索引来进行评估,以生成出一个最好的执行计划,如果同时有很多个索引都可以用于查询,就会增加 MySQL 优化器生成执行计划的时间,同样会降低查询性能。

禁止使用全文索引

全文索引不适用于 OLTP 场景。

禁止给表中的每一列都建立单独的索引

5.6 版本之前,一个 sql 只能使用到一个表中的一个索引,5.6 以后,虽然有了合并索引的优化方式,但是还是远远没有使用一个联合索引的查询方式好。

每个 InnoDB 表必须有个主键

InnoDB 是一种索引组织表:数据的存储的逻辑顺序和索引的顺序是相同的。每个表都可以有多个索引,但是表的存储顺序只能有一种。

InnoDB 是按照主键索引的顺序来组织表的

  • 不要使用更新频繁的列作为主键,不使用多列主键(相当于联合索引)
  • 不要使用 UUID,MD5,HASH,字符串列作为主键(无法保证数据的顺序增长)
  • 主键建议使用自增 ID 值

常见索引列建议

  • 出现在 SELECT、UPDATE、DELETE 语句的 WHERE 从句中的列
  • 包含在 ORDER BY、GROUP BY、DISTINCT 中的字段
  • 并不要将符合 1 和 2 中的字段的列都建立一个索引, 通常将 1、2 中的字段建立联合索引效果更好
  • 多表 join 的关联列

如何选择索引列的顺序

建立索引的目的是:希望通过索引进行数据查找,减少随机 IO,增加查询性能 ,索引能过滤出越少的数据,则从磁盘中读入的数据也就越少。

  • 区分度最高的列放在联合索引的最左侧: 这是最重要的原则。区分度越高,通过索引筛选出的数据就越少,I/O 操作也就越少。计算区分度的方法是 count(distinct column) / count(*)。
  • 最频繁使用的列放在联合索引的左侧: 这符合最左前缀匹配原则。将最常用的查询条件列放在最左侧,可以最大程度地利用索引。
  • 字段长度: 字段长度对联合索引非叶子节点的影响很小,因为它存储了所有联合索引字段的值。字段长度主要影响主键和包含在其他索引中的字段的存储空间,以及这些索引的叶子节点的大小。因此,在选择联合索引列的顺序时,字段长度的优先级最低。 对于主键和包含在其他索引中的字段,选择较短的字段长度可以节省存储空间和提高 I/O 性能。

避免建立冗余索引和重复索引(增加了查询优化器生成执行计划的时间)

  • 重复索引示例:primary key(id)、index(id)、unique index(id)
  • 冗余索引示例:index(a,b,c)、index(a,b)、index(a)

对于频繁的查询优先考虑使用覆盖索引

覆盖索引:就是包含了所有查询字段 (where,select,order by,group by 包含的字段) 的索引

覆盖索引的好处:

  • 避免 InnoDB 表进行索引的二次查询,也就是回表操作: InnoDB 是以聚集索引的顺序来存储的,对于 InnoDB 来说,二级索引在叶子节点中所保存的是行的主键信息,如果是用二级索引查询数据的话,在查找到相应的键值后,还要通过主键进行二次查询才能获取我们真实所需要的数据。而在覆盖索引中,二级索引的键值中可以获取所有的数据,避免了对主键的二次查询(回表),减少了 IO 操作,提升了查询效率。
  • 可以把随机 IO 变成顺序 IO 加快查询效率: 由于覆盖索引是按键值的顺序存储的,对于 IO 密集型的范围查找来说,对比随机从磁盘读取每一行的数据 IO 要少的多,因此利用覆盖索引在访问时也可以把磁盘的随机读取的 IO 转变成索引查找的顺序 IO。

索引 SET 规范

尽量避免使用外键约束

  • 不建议使用外键约束(foreign key),但一定要在表与表之间的关联键上建立索引
  • 外键可用于保证数据的参照完整性,但建议在业务端实现
  • 外键会影响父表和子表的写操作从而降低性能

数据库 SQL 开发规范

尽量不在数据库做运算,复杂运算需移到业务应用里完成

尽量不在数据库做运算,复杂运算需移到业务应用里完成。这样可以避免数据库的负担过重,影响数据库的性能和稳定性。数据库的主要作用是存储和管理数据,而不是处理数据。

优化对性能影响较大的 SQL 语句

要找到最需要优化的 SQL 语句。要么是使用最频繁的语句,要么是优化后提高最明显的语句,可以通过查询 MySQL 的慢查询日志来发现需要进行优化的 SQL 语句。

充分利用表上已经存在的索引

避免使用双%号的查询条件。如:a like '%123%',(如果无前置%,只有后置%,是可以用到列上的索引的)

一个 SQL 只能利用到复合索引中的一列进行范围查询。如:有 a,b,c 列的联合索引,在查询条件中有 a 列的范围查询,则在 b,c 列上的索引将不会被用到。

在定义联合索引时,如果 a 列要用到范围查找的话,就要把 a 列放到联合索引的右侧,使用 left join 或 not exists 来优化 not in 操作,因为 not in 也通常会使用索引失效。

禁止使用 SELECT * 必须使用 SELECT <字段列表> 查询

  • SELECT * 会消耗更多的 CPU。
  • SELECT * 无用字段增加网络带宽资源消耗,增加数据传输时间,尤其是大字段(如 varchar、blob、text)。
  • SELECT * 无法使用 MySQL 优化器覆盖索引的优化(基于 MySQL 优化器的“覆盖索引”策略又是速度极快,效率极高,业界极为推荐的查询优化方式)
  • SELECT <字段列表> 可减少表结构变更带来的影响、

禁止使用不含字段列表的 INSERT 语句

如:

1
insert into t values ('a','b','c');

应使用:

1
insert into t(c1,c2,c3) values ('a','b','c');

建议使用预编译语句进行数据库操作

  • 预编译语句可以重复使用这些计划,减少 SQL 编译所需要的时间,还可以解决动态 SQL 所带来的 SQL 注入的问题。
  • 只传参数,比传递 SQL 语句更高效。
  • 相同语句可以一次解析,多次使用,提高处理效率。

避免数据类型的隐式转换

隐式转换会导致索引失效如:

1
select name,phone from customer where id = '111';

详细解读可以看:MySQL 中的隐式转换造成的索引失效 这篇文章。

避免使用子查询,可以把子查询优化为 join 操作

通常子查询在 in 子句中,且子查询中为简单 SQL(不包含 union、group by、order by、limit 从句) 时,才可以把子查询转化为关联查询进行优化。

子查询性能差的原因: 子查询的结果集无法使用索引,通常子查询的结果集会被存储到临时表中,不论是内存临时表还是磁盘临时表都不会存在索引,所以查询性能会受到一定的影响。特别是对于返回结果集比较大的子查询,其对查询性能的影响也就越大。由于子查询会产生大量的临时表也没有索引,所以会消耗过多的 CPU 和 IO 资源,产生大量的慢查询。

避免使用 JOIN 关联太多的表

对于 MySQL 来说,是存在关联缓存的,缓存的大小可以由 join_buffer_size 参数进行设置。

在 MySQL 中,对于同一个 SQL 多关联(join)一个表,就会多分配一个关联缓存,如果在一个 SQL 中关联的表越多,所占用的内存也就越大。

如果程序中大量的使用了多表关联的操作,同时 join_buffer_size 设置的也不合理的情况下,就容易造成服务器内存溢出的情况,就会影响到服务器数据库性能的稳定性。

同时对于关联操作来说,会产生临时表操作,影响查询效率,MySQL 最多允许关联 61 个表,建议不超过 5 个。

减少同数据库的交互次数

数据库更适合处理批量操作,合并多个相同的操作到一起,可以提高处理效率。

对应同一列进行 or 判断时,使用 in 代替 or

in 的值不要超过 500 个,in 操作可以更有效的利用索引,or 大多数情况下很少能利用到索引。

禁止使用 order by rand() 进行随机排序

order by rand() 会把表中所有符合条件的数据装载到内存中,然后在内存中对所有数据根据随机生成的值进行排序,并且可能会对每一行都生成一个随机值,如果满足条件的数据集非常大,就会消耗大量的 CPU 和 IO 及内存资源。

推荐在程序中获取一个随机值,然后从数据库中获取数据的方式。

WHERE 从句中禁止对列进行函数转换和计算

对列进行函数转换或计算时会导致无法使用索引

不推荐:

1
where date(create_time)='20190101'

推荐:

1
where create_time >= '20190101' and create_time < '20190102'

在明显不会有重复值时使用 UNION ALL 而不是 UNION

  • UNION 会把两个结果集的所有数据放到临时表中后再进行去重操作
  • UNION ALL 不会再对结果集进行去重操作

拆分复杂的大 SQL 为多个小 SQL

  • 大 SQL 逻辑上比较复杂,需要占用大量 CPU 进行计算的 SQL
  • MySQL 中,一个 SQL 只能使用一个 CPU 进行计算
  • SQL 拆分后可以通过并行执行来提高处理效率

程序连接不同的数据库使用不同的账号,禁止跨库查询

  • 为数据库迁移和分库分表留出余地
  • 降低业务耦合度
  • 避免权限过大而产生的安全风险

数据库操作行为规范

超 100 万行的批量写 (UPDATE,DELETE,INSERT) 操作,要分批多次进行操作

大批量操作可能会造成严重的主从延迟

主从环境中,大批量操作可能会造成严重的主从延迟,大批量的写操作一般都需要执行一定长的时间,而只有当主库上执行完成后,才会在其他从库上执行,所以会造成主库与从库长时间的延迟情况

binlog 日志为 row 格式时会产生大量的日志

大批量写操作会产生大量日志,特别是对于 row 格式二进制数据而言,由于在 row 格式中会记录每一行数据的修改,我们一次修改的数据越多,产生的日志量也就会越多,日志的传输和恢复所需要的时间也就越长,这也是造成主从延迟的一个原因

避免产生大事务操作

大批量修改数据,一定是在一个事务中进行的,这就会造成表中大批量数据进行锁定,从而导致大量的阻塞,阻塞会对 MySQL 的性能产生非常大的影响。

特别是长时间的阻塞会占满所有数据库的可用连接,这会使生产环境中的其他应用无法连接到数据库,因此一定要注意大批量写操作要进行分批

对于大表使用 pt-online-schema-change 修改表结构

  • 避免大表修改产生的主从延迟
  • 避免在对表字段进行修改时进行锁表

对大表数据结构的修改一定要谨慎,会造成严重的锁表操作,尤其是生产环境,是不能容忍的。

pt-online-schema-change 它会首先建立一个与原表结构相同的新表,并且在新表上进行表结构的修改,然后再把原表中的数据复制到新表中,并在原表中增加一些触发器。把原表中新增的数据也复制到新表中,在行所有数据复制完成之后,把新表命名成原表,并把原来的表删除掉。把原来一个 DDL 操作,分解成多个小的批次进行。

禁止为程序使用的账号赋予 super 权限

  • 当达到最大连接数限制时,还运行 1 个有 super 权限的用户连接
  • super 权限只能留给 DBA 处理问题的账号使用

对于程序连接数据库账号,遵循权限最小原则

  • 程序使用数据库账号只能在一个 DB 下使用,不准跨库
  • 程序使用的账号原则上不准有 drop 权限

推荐阅读

  • 技术同学必会的 MySQL 设计规约,都是惨痛的教训 - 阿里开发者
  • 聊聊数据库建表的 15 个小技巧

Redis常见阻塞原因总结

发表于 2023-09-15 | 分类于 分布式 , redis | 阅读次数:
字数统计: 2.8k 字 | 阅读时长 ≈ 10 分钟

本文整理完善自:https://mp.weixin.qq.com/s/0Nqfq_eQrUb12QH6eBbHXA ,作者:阿 Q 说代码

这篇文章会详细总结一下可能导致 Redis 阻塞的情况,这些情况也是影响 Redis 性能的关键因素,使用 Redis 的时候应该格外注意!

O(n) 命令

Redis 中的大部分命令都是 O(1)时间复杂度,但也有少部分 O(n) 时间复杂度的命令,例如:

  • KEYS *:会返回所有符合规则的 key。
  • HGETALL:会返回一个 Hash 中所有的键值对。
  • LRANGE:会返回 List 中指定范围内的元素。
  • SMEMBERS:返回 Set 中的所有元素。
  • SINTER/SUNION/SDIFF:计算多个 Set 的交集/并集/差集。
  • ……

由于这些命令时间复杂度是 O(n),有时候也会全表扫描,随着 n 的增大,执行耗时也会越长,从而导致客户端阻塞。不过, 这些命令并不是一定不能使用,但是需要明确 N 的值。另外,有遍历的需求可以使用 HSCAN、SSCAN、ZSCAN 代替。

除了这些 O(n)时间复杂度的命令可能会导致阻塞之外, 还有一些时间复杂度可能在 O(N) 以上的命令,例如:

  • ZRANGE/ZREVRANGE:返回指定 Sorted Set 中指定排名范围内的所有元素。时间复杂度为 O(log(n)+m),n 为所有元素的数量, m 为返回的元素数量,当 m 和 n 相当大时,O(n) 的时间复杂度更小。
  • ZREMRANGEBYRANK/ZREMRANGEBYSCORE:移除 Sorted Set 中指定排名范围/指定 score 范围内的所有元素。时间复杂度为 O(log(n)+m),n 为所有元素的数量, m 被删除元素的数量,当 m 和 n 相当大时,O(n) 的时间复杂度更小。
  • ……

SAVE 创建 RDB 快照

Redis 提供了两个命令来生成 RDB 快照文件:

  • save : 同步保存操作,会阻塞 Redis 主线程;
  • bgsave : fork 出一个子进程,子进程执行,不会阻塞 Redis 主线程,默认选项。

默认情况下,Redis 默认配置会使用 bgsave 命令。如果手动使用 save 命令生成 RDB 快照文件的话,就会阻塞主线程。

AOF

AOF 日志记录阻塞

Redis AOF 持久化机制是在执行完命令之后再记录日志,这和关系型数据库(如 MySQL)通常都是执行命令之前记录日志(方便故障恢复)不同。

AOF 记录日志过程

为什么是在执行完命令之后记录日志呢?

  • 避免额外的检查开销,AOF 记录日志不会对命令进行语法检查;
  • 在命令执行完之后再记录,不会阻塞当前的命令执行。

这样也带来了风险(我在前面介绍 AOF 持久化的时候也提到过):

  • 如果刚执行完命令 Redis 就宕机会导致对应的修改丢失;
  • 可能会阻塞后续其他命令的执行(AOF 记录日志是在 Redis 主线程中进行的)。

AOF 刷盘阻塞

开启 AOF 持久化后每执行一条会更改 Redis 中的数据的命令,Redis 就会将该命令写入到 AOF 缓冲区 server.aof_buf 中,然后再根据 appendfsync 配置来决定何时将其同步到硬盘中的 AOF 文件。

在 Redis 的配置文件中存在三种不同的 AOF 持久化方式( fsync策略),它们分别是:

  1. appendfsync always:主线程调用 write 执行写操作后,后台线程( aof_fsync 线程)立即会调用 fsync 函数同步 AOF 文件(刷盘),fsync 完成后线程返回,这样会严重降低 Redis 的性能(write + fsync)。
  2. appendfsync everysec:主线程调用 write 执行写操作后立即返回,由后台线程( aof_fsync 线程)每秒钟调用 fsync 函数(系统调用)同步一次 AOF 文件(write+fsync,fsync间隔为 1 秒)
  3. appendfsync no:主线程调用 write 执行写操作后立即返回,让操作系统决定何时进行同步,Linux 下一般为 30 秒一次(write但不fsync,fsync 的时机由操作系统决定)。

当后台线程( aof_fsync 线程)调用 fsync 函数同步 AOF 文件时,需要等待,直到写入完成。当磁盘压力太大的时候,会导致 fsync 操作发生阻塞,主线程调用 write 函数时也会被阻塞。fsync 完成后,主线程执行 write 才能成功返回。

关于 AOF 工作流程的详细介绍可以查看:Redis 持久化机制详解,有助于理解 AOF 刷盘阻塞。

AOF 重写阻塞

  1. fork 出一条子线程来将文件重写,在执行 BGREWRITEAOF 命令时,Redis 服务器会维护一个 AOF 重写缓冲区,该缓冲区会在子线程创建新 AOF 文件期间,记录服务器执行的所有写命令。
  2. 当子线程完成创建新 AOF 文件的工作之后,服务器会将重写缓冲区中的所有内容追加到新 AOF 文件的末尾,使得新的 AOF 文件保存的数据库状态与现有的数据库状态一致。
  3. 最后,服务器用新的 AOF 文件替换旧的 AOF 文件,以此来完成 AOF 文件重写操作。

阻塞就是出现在第 2 步的过程中,将缓冲区中新数据写到新文件的过程中会产生阻塞。

相关阅读:Redis AOF 重写阻塞问题分析。

大 Key

如果一个 key 对应的 value 所占用的内存比较大,那这个 key 就可以看作是 bigkey。具体多大才算大呢?有一个不是特别精确的参考标准:

  • string 类型的 value 超过 1MB
  • 复合类型(列表、哈希、集合、有序集合等)的 value 包含的元素超过 5000 个(对于复合类型的 value 来说,不一定包含的元素越多,占用的内存就越多)。

大 key 造成的阻塞问题如下:

  • 客户端超时阻塞:由于 Redis 执行命令是单线程处理,然后在操作大 key 时会比较耗时,那么就会阻塞 Redis,从客户端这一视角看,就是很久很久都没有响应。
  • 引发网络阻塞:每次获取大 key 产生的网络流量较大,如果一个 key 的大小是 1 MB,每秒访问量为 1000,那么每秒会产生 1000MB 的流量,这对于普通千兆网卡的服务器来说是灾难性的。
  • 阻塞工作线程:如果使用 del 删除大 key 时,会阻塞工作线程,这样就没办法处理后续的命令。

查找大 key

当我们在使用 Redis 自带的 --bigkeys 参数查找大 key 时,最好选择在从节点上执行该命令,因为主节点上执行时,会阻塞主节点。

  • 我们还可以使用 SCAN 命令来查找大 key;

  • 通过分析 RDB 文件来找出 big key,这种方案的前提是 Redis 采用的是 RDB 持久化。网上有现成的工具:

    • redis-rdb-tools:Python 语言写的用来分析 Redis 的 RDB 快照文件用的工具
    • rdb_bigkeys:Go 语言写的用来分析 Redis 的 RDB 快照文件用的工具,性能更好。

删除大 key

删除操作的本质是要释放键值对占用的内存空间。

释放内存只是第一步,为了更加高效地管理内存空间,在应用程序释放内存时,操作系统需要把释放掉的内存块插入一个空闲内存块的链表,以便后续进行管理和再分配。这个过程本身需要一定时间,而且会阻塞当前释放内存的应用程序。

所以,如果一下子释放了大量内存,空闲内存块链表操作时间就会增加,相应地就会造成 Redis 主线程的阻塞,如果主线程发生了阻塞,其他所有请求可能都会超时,超时越来越多,会造成 Redis 连接耗尽,产生各种异常。

删除大 key 时建议采用分批次删除和异步删除的方式进行。

清空数据库

清空数据库和上面 bigkey 删除也是同样道理,flushdb、flushall 也涉及到删除和释放所有的键值对,也是 Redis 的阻塞点。

集群扩容

Redis 集群可以进行节点的动态扩容缩容,这一过程目前还处于半自动状态,需要人工介入。

在扩缩容的时候,需要进行数据迁移。而 Redis 为了保证迁移的一致性,迁移所有操作都是同步操作。

执行迁移时,两端的 Redis 均会进入时长不等的阻塞状态,对于小 Key,该时间可以忽略不计,但如果一旦 Key 的内存使用过大,严重的时候会触发集群内的故障转移,造成不必要的切换。

Swap(内存交换)

什么是 Swap? Swap 直译过来是交换的意思,Linux 中的 Swap 常被称为内存交换或者交换分区。类似于 Windows 中的虚拟内存,就是当内存不足的时候,把一部分硬盘空间虚拟成内存使用,从而解决内存容量不足的情况。因此,Swap 分区的作用就是牺牲硬盘,增加内存,解决 VPS 内存不够用或者爆满的问题。

Swap 对于 Redis 来说是非常致命的,Redis 保证高性能的一个重要前提是所有的数据在内存中。如果操作系统把 Redis 使用的部分内存换出硬盘,由于内存与硬盘的读写速度差几个数量级,会导致发生交换后的 Redis 性能急剧下降。

识别 Redis 发生 Swap 的检查方法如下:

1、查询 Redis 进程号

1
2
redis-cli -p 6383 info server | grep process_id
process_id: 4476

2、根据进程号查询内存交换信息

1
2
3
4
5
6
7
cat /proc/4476/smaps | grep Swap
Swap: 0kB
Swap: 0kB
Swap: 4kB
Swap: 0kB
Swap: 0kB
.....

如果交换量都是 0KB 或者个别的是 4KB,则正常。

预防内存交换的方法:

  • 保证机器充足的可用内存
  • 确保所有 Redis 实例设置最大可用内存(maxmemory),防止极端情况 Redis 内存不可控的增长
  • 降低系统使用 swap 优先级,如echo 10 > /proc/sys/vm/swappiness

CPU 竞争

Redis 是典型的 CPU 密集型应用,不建议和其他多核 CPU 密集型服务部署在一起。当其他进程过度消耗 CPU 时,将严重影响 Redis 的吞吐量。

可以通过redis-cli --stat获取当前 Redis 使用情况。通过top命令获取进程对 CPU 的利用率等信息 通过info commandstats统计信息分析出命令不合理开销时间,查看是否是因为高算法复杂度或者过度的内存优化问题。

网络问题

连接拒绝、网络延迟,网卡软中断等网络问题也可能会导致 Redis 阻塞。

参考

  • Redis 阻塞的 6 大类场景分析与总结:https://mp.weixin.qq.com/s/eaZCEtTjTuEmXfUubVHjew
  • Redis 开发与运维笔记-Redis 的噩梦-阻塞:https://mp.weixin.qq.com/s/TDbpz9oLH6ifVv6ewqgSgA

Java并发常见面试题总结(中)

发表于 2023-09-06 | 分类于 Java , 并发 | 阅读次数:
字数统计: 11.5k 字 | 阅读时长 ≈ 43 分钟

⭐️JMM(Java 内存模型)

JMM(Java 内存模型)相关的问题比较多,也比较重要,于是我单独抽了一篇文章来总结 JMM 相关的知识点和问题:JMM(Java 内存模型)详解 。

⭐️volatile 关键字

如何保证变量的可见性?

在 Java 中,volatile 关键字可以保证变量的可见性,如果我们将变量声明为 volatile ,这就指示 JVM,这个变量是共享且不稳定的,每次使用它都到主存中进行读取。

JMM(Java 内存模型)

JMM(Java 内存模型)强制在主存中进行读取

volatile 关键字其实并非是 Java 语言特有的,在 C 语言里也有,它最原始的意义就是禁用 CPU 缓存。如果我们将一个变量使用 volatile 修饰,这就指示 编译器,这个变量是共享且不稳定的,每次使用它都到主存中进行读取。

volatile 关键字能保证数据的可见性,但不能保证数据的原子性。synchronized 关键字两者都能保证。

如何禁止指令重排序?

在 Java 中,volatile 关键字除了可以保证变量的可见性,还有一个重要的作用就是防止 JVM 的指令重排序。 如果我们将变量声明为 volatile ,在对这个变量进行读写操作的时候,会通过插入特定的 内存屏障 的方式来禁止指令重排序。

在 Java 中,Unsafe 类提供了三个开箱即用的内存屏障相关的方法,屏蔽了操作系统底层的差异:

1
2
3
public native void loadFence();
public native void storeFence();
public native void fullFence();

理论上来说,你通过这个三个方法也可以实现和volatile禁止重排序一样的效果,只是会麻烦一些。

下面我以一个常见的面试题为例讲解一下 volatile 关键字禁止指令重排序的效果。

面试中面试官经常会说:“单例模式了解吗?来给我手写一下!给我解释一下双重检验锁方式实现单例模式的原理呗!”

双重校验锁实现对象单例(线程安全):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class Singleton {

private volatile static Singleton uniqueInstance;

private Singleton() {
}

public static Singleton getUniqueInstance() {
//先判断对象是否已经实例过,没有实例化过才进入加锁代码
if (uniqueInstance == null) {
//类对象加锁
synchronized (Singleton.class) {
if (uniqueInstance == null) {
uniqueInstance = new Singleton();
}
}
}
return uniqueInstance;
}
}

uniqueInstance 采用 volatile 关键字修饰也是很有必要的, uniqueInstance = new Singleton(); 这段代码其实是分为三步执行:

  1. 为 uniqueInstance 分配内存空间
  2. 初始化 uniqueInstance
  3. 将 uniqueInstance 指向分配的内存地址

但是由于 JVM 具有指令重排的特性,执行顺序有可能变成 1->3->2。指令重排在单线程环境下不会出现问题,但是在多线程环境下会导致一个线程获得还没有初始化的实例。例如,线程 T1 执行了 1 和 3,此时 T2 调用 getUniqueInstance() 后发现 uniqueInstance 不为空,因此返回 uniqueInstance,但此时 uniqueInstance 还未被初始化。

volatile 可以保证原子性么?

volatile 关键字能保证变量的可见性,但不能保证对变量的操作是原子性的。

我们通过下面的代码即可证明:

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
/**
* 微信搜 JavaGuide 回复"面试突击"即可免费领取个人原创的 Java 面试手册
*
* @author Guide哥
* @date 2022/08/03 13:40
**/
public class VolatileAtomicityDemo {
public volatile static int inc = 0;

public void increase() {
inc++;
}

public static void main(String[] args) throws InterruptedException {
ExecutorService threadPool = Executors.newFixedThreadPool(5);
VolatileAtomicityDemo volatileAtomicityDemo = new VolatileAtomicityDemo();
for (int i = 0; i < 5; i++) {
threadPool.execute(() -> {
for (int j = 0; j < 500; j++) {
volatileAtomicityDemo.increase();
}
});
}
// 等待1.5秒,保证上面程序执行完成
Thread.sleep(1500);
System.out.println(inc);
threadPool.shutdown();
}
}

正常情况下,运行上面的代码理应输出 2500。但你真正运行了上面的代码之后,你会发现每次输出结果都小于 2500。

为什么会出现这种情况呢?不是说好了,volatile 可以保证变量的可见性嘛!

也就是说,如果 volatile 能保证 inc++ 操作的原子性的话。每个线程中对 inc 变量自增完之后,其他线程可以立即看到修改后的值。5 个线程分别进行了 500 次操作,那么最终 inc 的值应该是 5*500=2500。

很多人会误认为自增操作 inc++ 是原子性的,实际上,inc++ 其实是一个复合操作,包括三步:

  1. 读取 inc 的值。
  2. 对 inc 加 1。
  3. 将 inc 的值写回内存。

volatile 是无法保证这三个操作是具有原子性的,有可能导致下面这种情况出现:

  1. 线程 1 对 inc 进行读取操作之后,还未对其进行修改。线程 2 又读取了 inc的值并对其进行修改(+1),再将inc 的值写回内存。
  2. 线程 2 操作完毕后,线程 1 对 inc的值进行修改(+1),再将inc 的值写回内存。

这也就导致两个线程分别对 inc 进行了一次自增操作后,inc 实际上只增加了 1。

其实,如果想要保证上面的代码运行正确也非常简单,利用 synchronized、Lock或者AtomicInteger都可以。

使用 synchronized 改进:

1
2
3
public synchronized void increase() {
inc++;
}

使用 AtomicInteger 改进:

1
2
3
4
5
public AtomicInteger inc = new AtomicInteger();

public void increase() {
inc.getAndIncrement();
}

使用 ReentrantLock 改进:

1
2
3
4
5
6
7
8
9
Lock lock = new ReentrantLock();
public void increase() {
lock.lock();
try {
inc++;
} finally {
lock.unlock();
}
}

⭐️乐观锁和悲观锁

什么是悲观锁?

悲观锁总是假设最坏的情况,认为共享资源每次被访问的时候就会出现问题(比如共享数据被修改),所以每次在获取资源操作的时候都会上锁,这样其他线程想拿到这个资源就会阻塞直到锁被上一个持有者释放。也就是说,共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程。

像 Java 中synchronized和ReentrantLock等独占锁就是悲观锁思想的实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
public void performSynchronisedTask() {
synchronized (this) {
// 需要同步的操作
}
}

private Lock lock = new ReentrantLock();
lock.lock();
try {
// 需要同步的操作
} finally {
lock.unlock();
}

高并发的场景下,激烈的锁竞争会造成线程阻塞,大量阻塞线程会导致系统的上下文切换,增加系统的性能开销。并且,悲观锁还可能会存在死锁问题,影响代码的正常运行。

什么是乐观锁?

乐观锁总是假设最好的情况,认为共享资源每次被访问的时候不会出现问题,线程可以不停地执行,无需加锁也无需等待,只是在提交修改的时候去验证对应的资源(也就是数据)是否被其它线程修改了(具体方法可以使用版本号机制或 CAS 算法)。

在 Java 中java.util.concurrent.atomic包下面的原子变量类(比如AtomicInteger、LongAdder)就是使用了乐观锁的一种实现方式 CAS 实现的。
JUC原子类概览

1
2
3
4
// LongAdder 在高并发场景下会比 AtomicInteger 和 AtomicLong 的性能更好
// 代价就是会消耗更多的内存空间(空间换时间)
LongAdder sum = new LongAdder();
sum.increment();

高并发的场景下,乐观锁相比悲观锁来说,不存在锁竞争造成线程阻塞,也不会有死锁的问题,在性能上往往会更胜一筹。但是,如果冲突频繁发生(写占比非常多的情况),会频繁失败和重试,这样同样会非常影响性能,导致 CPU 飙升。

不过,大量失败重试的问题也是可以解决的,像我们前面提到的 LongAdder以空间换时间的方式就解决了这个问题。

理论上来说:

  • 悲观锁通常多用于写比较多的情况(多写场景,竞争激烈),这样可以避免频繁失败和重试影响性能,悲观锁的开销是固定的。不过,如果乐观锁解决了频繁失败和重试这个问题的话(比如LongAdder),也是可以考虑使用乐观锁的,要视实际情况而定。
  • 乐观锁通常多用于写比较少的情况(多读场景,竞争较少),这样可以避免频繁加锁影响性能。不过,乐观锁主要针对的对象是单个共享变量(参考java.util.concurrent.atomic包下面的原子变量类)。

如何实现乐观锁?

乐观锁一般会使用版本号机制或 CAS 算法实现,CAS 算法相对来说更多一些,这里需要格外注意。

版本号机制

一般是在数据表中加上一个数据版本号 version 字段,表示数据被修改的次数。当数据被修改时,version 值会加一。当线程 A 要更新数据值时,在读取数据的同时也会读取 version 值,在提交更新时,若刚才读取到的 version 值为当前数据库中的 version 值相等时才更新,否则重试更新操作,直到更新成功。

举一个简单的例子:假设数据库中帐户信息表中有一个 version 字段,当前值为 1 ;而当前帐户余额字段( balance )为 $100 。

  1. 操作员 A 此时将其读出( version=1 ),并从其帐户余额中扣除 $50( $100-$50 )。
  2. 在操作员 A 操作的过程中,操作员 B 也读入此用户信息( version=1 ),并从其帐户余额中扣除 $20 ( $100-$20 )。
  3. 操作员 A 完成了修改工作,将数据版本号( version=1 ),连同帐户扣除后余额( balance=$50 ),提交至数据库更新,此时由于提交数据版本等于数据库记录当前版本,数据被更新,数据库记录 version 更新为 2 。
  4. 操作员 B 完成了操作,也将版本号( version=1 )试图向数据库提交数据( balance=$80 ),但此时比对数据库记录版本时发现,操作员 B 提交的数据版本号为 1 ,数据库记录当前版本也为 2 ,不满足 “ 提交版本必须等于当前版本才能执行更新 “ 的乐观锁策略,因此,操作员 B 的提交被驳回。

这样就避免了操作员 B 用基于 version=1 的旧数据修改的结果覆盖操作员 A 的操作结果的可能。

CAS 算法

CAS 的全称是 Compare And Swap(比较与交换) ,用于实现乐观锁,被广泛应用于各大框架中。CAS 的思想很简单,就是用一个预期值和要更新的变量值进行比较,两值相等才会进行更新。

CAS 是一个原子操作,底层依赖于一条 CPU 的原子指令。

原子操作 即最小不可拆分的操作,也就是说操作一旦开始,就不能被打断,直到操作完成。

CAS 涉及到三个操作数:

  • V:要更新的变量值(Var)
  • E:预期值(Expected)
  • N:拟写入的新值(New)

当且仅当 V 的值等于 E 时,CAS 通过原子方式用新值 N 来更新 V 的值。如果不等,说明已经有其它线程更新了 V,则当前线程放弃更新。

举一个简单的例子:线程 A 要修改变量 i 的值为 6,i 原值为 1(V = 1,E=1,N=6,假设不存在 ABA 问题)。

  1. i 与 1 进行比较,如果相等, 则说明没被其他线程修改,可以被设置为 6 。
  2. i 与 1 进行比较,如果不相等,则说明被其他线程修改,当前线程放弃更新,CAS 操作失败。

当多个线程同时使用 CAS 操作一个变量时,只有一个会胜出,并成功更新,其余均会失败,但失败的线程并不会被挂起,仅是被告知失败,并且允许再次尝试,当然也允许失败的线程放弃操作。

Java 语言并没有直接实现 CAS,CAS 相关的实现是通过 C++ 内联汇编的形式实现的(JNI 调用)。因此, CAS 的具体实现和操作系统以及 CPU 都有关系。

sun.misc包下的Unsafe类提供了compareAndSwapObject、compareAndSwapInt、compareAndSwapLong方法来实现的对Object、int、long类型的 CAS 操作

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* CAS
* @param o 包含要修改field的对象
* @param offset 对象中某field的偏移量
* @param expected 期望值
* @param update 更新值
* @return true | false
*/
public final native boolean compareAndSwapObject(Object o, long offset, Object expected, Object update);

public final native boolean compareAndSwapInt(Object o, long offset, int expected,int update);

public final native boolean compareAndSwapLong(Object o, long offset, long expected, long update);

关于 Unsafe 类的详细介绍可以看这篇文章:Java 魔法类 Unsafe 详解 - JavaGuide - 2022 。

Java 中 CAS 是如何实现的?

在 Java 中,实现 CAS(Compare-And-Swap, 比较并交换)操作的一个关键类是Unsafe。

Unsafe类位于sun.misc包下,是一个提供低级别、不安全操作的类。由于其强大的功能和潜在的危险性,它通常用于 JVM 内部或一些需要极高性能和底层访问的库中,而不推荐普通开发者在应用程序中使用。关于 Unsafe类的详细介绍,可以阅读这篇文章:📌Java 魔法类 Unsafe 详解。

sun.misc包下的Unsafe类提供了compareAndSwapObject、compareAndSwapInt、compareAndSwapLong方法来实现的对Object、int、long类型的 CAS 操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/**
* 以原子方式更新对象字段的值。
*
* @param o 要操作的对象
* @param offset 对象字段的内存偏移量
* @param expected 期望的旧值
* @param x 要设置的新值
* @return 如果值被成功更新,则返回 true;否则返回 false
*/
boolean compareAndSwapObject(Object o, long offset, Object expected, Object x);

/**
* 以原子方式更新 int 类型的对象字段的值。
*/
boolean compareAndSwapInt(Object o, long offset, int expected, int x);

/**
* 以原子方式更新 long 类型的对象字段的值。
*/
boolean compareAndSwapLong(Object o, long offset, long expected, long x);

Unsafe类中的 CAS 方法是native方法。native关键字表明这些方法是用本地代码(通常是 C 或 C++)实现的,而不是用 Java 实现的。这些方法直接调用底层的硬件指令来实现原子操作。也就是说,Java 语言并没有直接用 Java 实现 CAS,而是通过 C++ 内联汇编的形式实现的(通过 JNI 调用)。因此,CAS 的具体实现与操作系统以及 CPU 密切相关。

java.util.concurrent.atomic 包提供了一些用于原子操作的类。这些类利用底层的原子指令,确保在多线程环境下的操作是线程安全的。

JUC原子类概览

关于这些 Atomic 原子类的介绍和使用,可以阅读这篇文章:Atomic 原子类总结。

AtomicInteger是 Java 的原子类之一,主要用于对 int 类型的变量进行原子操作,它利用Unsafe类提供的低级别原子操作方法实现无锁的线程安全性。

下面,我们通过解读AtomicInteger的核心源码(JDK1.8),来说明 Java 如何使用Unsafe类的方法来实现原子操作。

AtomicInteger核心源码如下:

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
31
32
33
34
35
// 获取 Unsafe 实例
private static final Unsafe unsafe = Unsafe.getUnsafe();
private static final long valueOffset;

static {
try {
// 获取“value”字段在AtomicInteger类中的内存偏移量
valueOffset = unsafe.objectFieldOffset
(AtomicInteger.class.getDeclaredField("value"));
} catch (Exception ex) { throw new Error(ex); }
}
// 确保“value”字段的可见性
private volatile int value;

// 如果当前值等于预期值,则原子地将值设置为newValue
// 使用 Unsafe#compareAndSwapInt 方法进行CAS操作
public final boolean compareAndSet(int expect, int update) {
return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
}

// 原子地将当前值加 delta 并返回旧值
public final int getAndAdd(int delta) {
return unsafe.getAndAddInt(this, valueOffset, delta);
}

// 原子地将当前值加 1 并返回加之前的值(旧值)
// 使用 Unsafe#getAndAddInt 方法进行CAS操作。
public final int getAndIncrement() {
return unsafe.getAndAddInt(this, valueOffset, 1);
}

// 原子地将当前值减 1 并返回减之前的值(旧值)
public final int getAndDecrement() {
return unsafe.getAndAddInt(this, valueOffset, -1);
}

Unsafe#getAndAddInt源码:

1
2
3
4
5
6
7
8
9
10
// 原子地获取并增加整数值
public final int getAndAddInt(Object o, long offset, int delta) {
int v;
do {
// 以 volatile 方式获取对象 o 在内存偏移量 offset 处的整数值
v = getIntVolatile(o, offset);
} while (!compareAndSwapInt(o, offset, v, v + delta));
// 返回旧值
return v;
}

可以看到,getAndAddInt 使用了 do-while 循环:在compareAndSwapInt操作失败时,会不断重试直到成功。也就是说,getAndAddInt方法会通过 compareAndSwapInt 方法来尝试更新 value 的值,如果更新失败(当前值在此期间被其他线程修改),它会重新获取当前值并再次尝试更新,直到操作成功。

由于 CAS 操作可能会因为并发冲突而失败,因此通常会与while循环搭配使用,在失败后不断重试,直到操作成功。这就是 自旋锁机制 。

CAS 算法存在哪些问题?

ABA 问题是 CAS 算法最常见的问题。

ABA 问题

如果一个变量 V 初次读取的时候是 A 值,并且在准备赋值的时候检查到它仍然是 A 值,那我们就能说明它的值没有被其他线程修改过了吗?很明显是不能的,因为在这段时间它的值可能被改为其他值,然后又改回 A,那 CAS 操作就会误认为它从来没有被修改过。这个问题被称为 CAS 操作的 “ABA”问题。

ABA 问题的解决思路是在变量前面追加上版本号或者时间戳。JDK 1.5 以后的 AtomicStampedReference 类就是用来解决 ABA 问题的,其中的 compareAndSet() 方法就是首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。

1
2
3
4
5
6
7
8
9
10
11
12
public boolean compareAndSet(V   expectedReference,
V newReference,
int expectedStamp,
int newStamp) {
Pair<V> current = pair;
return
expectedReference == current.reference &&
expectedStamp == current.stamp &&
((newReference == current.reference &&
newStamp == current.stamp) ||
casPair(current, Pair.of(newReference, newStamp)));
}

循环时间长开销大

CAS 经常会用到自旋操作来进行重试,也就是不成功就一直循环执行直到成功。如果长时间不成功,会给 CPU 带来非常大的执行开销。

如果 JVM 能够支持处理器提供的pause指令,那么自旋操作的效率将有所提升。pause指令有两个重要作用:

  1. 延迟流水线执行指令:pause指令可以延迟指令的执行,从而减少 CPU 的资源消耗。具体的延迟时间取决于处理器的实现版本,在某些处理器上,延迟时间可能为零。
  2. 避免内存顺序冲突:在退出循环时,pause指令可以避免由于内存顺序冲突而导致的 CPU 流水线被清空,从而提高 CPU 的执行效率。

只能保证一个共享变量的原子操作

CAS 操作仅能对单个共享变量有效。当需要操作多个共享变量时,CAS 就显得无能为力。不过,从 JDK 1.5 开始,Java 提供了AtomicReference类,这使得我们能够保证引用对象之间的原子性。通过将多个变量封装在一个对象中,我们可以使用AtomicReference来执行 CAS 操作。

除了 AtomicReference 这种方式之外,还可以利用加锁来保证。

synchronized 关键字

synchronized 是什么?有什么用?

synchronized 是 Java 中的一个关键字,翻译成中文是同步的意思,主要解决的是多个线程之间访问资源的同步性,可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。

在 Java 早期版本中,synchronized 属于 重量级锁,效率低下。这是因为监视器锁(monitor)是依赖于底层的操作系统的 Mutex Lock 来实现的,Java 的线程是映射到操作系统的原生线程之上的。如果要挂起或者唤醒一个线程,都需要操作系统帮忙完成,而操作系统实现线程之间的切换时需要从用户态转换到内核态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高。

不过,在 Java 6 之后, synchronized 引入了大量的优化如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销,这些优化让 synchronized 锁的效率提升了很多。因此, synchronized 还是可以在实际项目中使用的,像 JDK 源码、很多开源框架都大量使用了 synchronized 。

关于偏向锁多补充一点:由于偏向锁增加了 JVM 的复杂性,同时也并没有为所有应用都带来性能提升。因此,在 JDK15 中,偏向锁被默认关闭(仍然可以使用 -XX:+UseBiasedLocking 启用偏向锁),在 JDK18 中,偏向锁已经被彻底废弃(无法通过命令行打开)。

如何使用 synchronized?

synchronized 关键字的使用方式主要有下面 3 种:

  1. 修饰实例方法
  2. 修饰静态方法
  3. 修饰代码块

1、修饰实例方法 (锁当前对象实例)

给当前对象实例加锁,进入同步代码前要获得 当前对象实例的锁 。

1
2
3
synchronized void method() {
//业务代码
}

2、修饰静态方法 (锁当前类)

给当前类加锁,会作用于类的所有对象实例 ,进入同步代码前要获得 当前 class 的锁。

这是因为静态成员不属于任何一个实例对象,归整个类所有,不依赖于类的特定实例,被类的所有实例共享。

1
2
3
synchronized static void method() {
//业务代码
}

静态 synchronized 方法和非静态 synchronized 方法之间的调用互斥么?不互斥!如果一个线程 A 调用一个实例对象的非静态 synchronized 方法,而线程 B 需要调用这个实例对象所属类的静态 synchronized 方法,是允许的,不会发生互斥现象,因为访问静态 synchronized 方法占用的锁是当前类的锁,而访问非静态 synchronized 方法占用的锁是当前实例对象锁。

3、修饰代码块 (锁指定对象/类)

对括号里指定的对象/类加锁:

  • synchronized(object) 表示进入同步代码库前要获得 给定对象的锁。
  • synchronized(类.class) 表示进入同步代码前要获得 给定 Class 的锁
1
2
3
synchronized(this) {
//业务代码
}

总结:

  • synchronized 关键字加到 static 静态方法和 synchronized(class) 代码块上都是是给 Class 类上锁;
  • synchronized 关键字加到实例方法上是给对象实例上锁;
  • 尽量不要使用 synchronized(String a) 因为 JVM 中,字符串常量池具有缓存功能。

构造方法可以用 synchronized 修饰么?

构造方法不能使用 synchronized 关键字修饰。不过,可以在构造方法内部使用 synchronized 代码块。

另外,构造方法本身是线程安全的,但如果在构造方法中涉及到共享资源的操作,就需要采取适当的同步措施来保证整个构造过程的线程安全。

⭐️synchronized 底层原理了解吗?

synchronized 关键字底层原理属于 JVM 层面的东西。

synchronized 同步语句块的情况

1
2
3
4
5
6
7
public class SynchronizedDemo {
public void method() {
synchronized (this) {
System.out.println("synchronized 代码块");
}
}
}

通过 JDK 自带的 javap 命令查看 SynchronizedDemo 类的相关字节码信息:首先切换到类的对应目录执行 javac SynchronizedDemo.java 命令生成编译后的 .class 文件,然后执行javap -c -s -v -l SynchronizedDemo.class。

synchronized关键字原理

从上面我们可以看出:**synchronized 同步语句块的实现使用的是 monitorenter 和 monitorexit 指令,其中 monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置。**

上面的字节码中包含一个 monitorenter 指令以及两个 monitorexit 指令,这是为了保证锁在同步代码块代码正常执行以及出现异常的这两种情况下都能被正确释放。

当执行 monitorenter 指令时,线程试图获取锁也就是获取 对象监视器 monitor 的持有权。

在 Java 虚拟机(HotSpot)中,Monitor 是基于 C++实现的,由ObjectMonitor实现的。每个对象中都内置了一个 ObjectMonitor对象。

另外,wait/notify等方法也依赖于monitor对象,这就是为什么只有在同步的块或者方法中才能调用wait/notify等方法,否则会抛出java.lang.IllegalMonitorStateException的异常的原因。

在执行monitorenter时,会尝试获取对象的锁,如果锁的计数器为 0 则表示锁可以被获取,获取后将锁计数器设为 1 也就是加 1。

执行 monitorenter 获取锁

对象锁的的拥有者线程才可以执行 monitorexit 指令来释放锁。在执行 monitorexit 指令后,将锁计数器设为 0,表明锁被释放,其他线程可以尝试获取锁。

执行 monitorexit 释放锁

如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另外一个线程释放为止。

synchronized 修饰方法的的情况

1
2
3
4
5
6
public class SynchronizedDemo2 {
public synchronized void method() {
System.out.println("synchronized 方法");
}
}

synchronized关键字原理

synchronized 修饰的方法并没有 monitorenter 指令和 monitorexit 指令,取而代之的是 ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法。JVM 通过该 ACC_SYNCHRONIZED 访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。

如果是实例方法,JVM 会尝试获取实例对象的锁。如果是静态方法,JVM 会尝试获取当前 class 的锁。

总结

synchronized 同步语句块的实现使用的是 monitorenter 和 monitorexit 指令,其中 monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置。

synchronized 修饰的方法并没有 monitorenter 指令和 monitorexit 指令,取而代之的是 ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法。

不过,两者的本质都是对对象监视器 monitor 的获取。

相关推荐:Java 锁与线程的那些事 - 有赞技术团队 。

🧗🏻 进阶一下:学有余力的小伙伴可以抽时间详细研究一下对象监视器 monitor。

JDK1.6 之后的 synchronized 底层做了哪些优化?锁升级原理了解吗?

在 Java 6 之后, synchronized 引入了大量的优化如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销,这些优化让 synchronized 锁的效率提升了很多(JDK18 中,偏向锁已经被彻底废弃,前面已经提到过了)。

锁主要存在四种状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的激烈而逐渐升级。注意锁可以升级不可降级,这种策略是为了提高获得锁和释放锁的效率。

synchronized 锁升级是一个比较复杂的过程,面试也很少问到,如果你想要详细了解的话,可以看看这篇文章:浅析 synchronized 锁升级的原理与实现。

synchronized 的偏向锁为什么被废弃了?

Open JDK 官方声明:JEP 374: Deprecate and Disable Biased Locking

在 JDK15 中,偏向锁被默认关闭(仍然可以使用 -XX:+UseBiasedLocking 启用偏向锁),在 JDK18 中,偏向锁已经被彻底废弃(无法通过命令行打开)。

在官方声明中,主要原因有两个方面:

  • 性能收益不明显:

偏向锁是 HotSpot 虚拟机的一项优化技术,可以提升单线程对同步代码块的访问性能。

受益于偏向锁的应用程序通常使用了早期的 Java 集合 API,例如 HashTable、Vector,在这些集合类中通过 synchronized 来控制同步,这样在单线程频繁访问时,通过偏向锁会减少同步开销。

随着 JDK 的发展,出现了 ConcurrentHashMap 高性能的集合类,在集合类内部进行了许多性能优化,此时偏向锁带来的性能收益就不明显了。

偏向锁仅仅在单线程访问同步代码块的场景中可以获得性能收益。

如果存在多线程竞争,就需要 撤销偏向锁 ,这个操作的性能开销是比较昂贵的。偏向锁的撤销需要等待进入到全局安全点(safe point),该状态下所有线程都是暂停的,此时去检查线程状态并进行偏向锁的撤销。

  • JVM 内部代码维护成本太高:

偏向锁将许多复杂代码引入到同步子系统,并且对其他的 HotSpot 组件也具有侵入性。这种复杂性为理解代码、系统重构带来了困难,因此, OpenJDK 官方希望禁用、废弃并删除偏向锁。

⭐️synchronized 和 volatile 有什么区别?

synchronized 关键字和 volatile 关键字是两个互补的存在,而不是对立的存在!

  • volatile 关键字是线程同步的轻量级实现,所以 volatile性能肯定比synchronized关键字要好 。但是 volatile 关键字只能用于变量而 synchronized 关键字可以修饰方法以及代码块 。
  • volatile 关键字能保证数据的可见性,但不能保证数据的原子性。synchronized 关键字两者都能保证。
  • volatile关键字主要用于解决变量在多个线程之间的可见性,而 synchronized 关键字解决的是多个线程之间访问资源的同步性。

ReentrantLock

ReentrantLock 是什么?

ReentrantLock 实现了 Lock 接口,是一个可重入且独占式的锁,和 synchronized 关键字类似。不过,ReentrantLock 更灵活、更强大,增加了轮询、超时、中断、公平锁和非公平锁等高级功能。

1
public class ReentrantLock implements Lock, java.io.Serializable {}

ReentrantLock 里面有一个内部类 Sync,Sync 继承 AQS(AbstractQueuedSynchronizer),添加锁和释放锁的大部分操作实际上都是在 Sync 中实现的。Sync 有公平锁 FairSync 和非公平锁 NonfairSync 两个子类。

ReentrantLock 默认使用非公平锁,也可以通过构造器来显式的指定使用公平锁。

1
2
3
4
// 传入一个 boolean 值,true 时为公平锁,false 时为非公平锁
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}

从上面的内容可以看出, ReentrantLock 的底层就是由 AQS 来实现的。关于 AQS 的相关内容推荐阅读 AQS 详解 这篇文章。

公平锁和非公平锁有什么区别?

  • 公平锁 : 锁被释放之后,先申请的线程先得到锁。性能较差一些,因为公平锁为了保证时间上的绝对顺序,上下文切换更频繁。
  • 非公平锁:锁被释放之后,后申请的线程可能会先获取到锁,是随机或者按照其他优先级排序的。性能更好,但可能会导致某些线程永远无法获取到锁。

⭐️synchronized 和 ReentrantLock 有什么区别?

两者都是可重入锁

可重入锁 也叫递归锁,指的是线程可以再次获取自己的内部锁。比如一个线程获得了某个对象的锁,此时这个对象锁还没有释放,当其再次想要获取这个对象的锁的时候还是可以获取的,如果是不可重入锁的话,就会造成死锁。

JDK 提供的所有现成的 Lock 实现类,包括 synchronized 关键字锁都是可重入的。

在下面的代码中,method1() 和 method2()都被 synchronized 关键字修饰,method1()调用了method2()。

1
2
3
4
5
6
7
8
9
10
public class SynchronizedDemo {
public synchronized void method1() {
System.out.println("方法1");
method2();
}

public synchronized void method2() {
System.out.println("方法2");
}
}

由于 synchronized锁是可重入的,同一个线程在调用method1() 时可以直接获得当前对象的锁,执行 method2() 的时候可以再次获取这个对象的锁,不会产生死锁问题。假如synchronized是不可重入锁的话,由于该对象的锁已被当前线程所持有且无法释放,这就导致线程在执行 method2()时获取锁失败,会出现死锁问题。

synchronized 依赖于 JVM 而 ReentrantLock 依赖于 API

synchronized 是依赖于 JVM 实现的,前面我们也讲到了 虚拟机团队在 JDK1.6 为 synchronized 关键字进行了很多优化,但是这些优化都是在虚拟机层面实现的,并没有直接暴露给我们。

ReentrantLock 是 JDK 层面实现的(也就是 API 层面,需要 lock() 和 unlock() 方法配合 try/finally 语句块来完成),所以我们可以通过查看它的源代码,来看它是如何实现的。

ReentrantLock 比 synchronized 增加了一些高级功能

相比synchronized,ReentrantLock增加了一些高级功能。主要来说主要有三点:

  • 等待可中断 : ReentrantLock提供了一种能够中断等待锁的线程的机制,通过 lock.lockInterruptibly() 来实现这个机制。也就是说当前线程在等待获取锁的过程中,如果其他线程中断当前线程「 interrupt() 」,当前线程就会抛出 InterruptedException 异常,可以捕捉该异常进行相应处理。
  • 可实现公平锁 : ReentrantLock可以指定是公平锁还是非公平锁。而synchronized只能是非公平锁。所谓的公平锁就是先等待的线程先获得锁。ReentrantLock默认情况是非公平的,可以通过 ReentrantLock类的ReentrantLock(boolean fair)构造方法来指定是否是公平的。
  • 可实现选择性通知(锁可以绑定多个条件): synchronized关键字与wait()和notify()/notifyAll()方法相结合可以实现等待/通知机制。ReentrantLock类当然也可以实现,但是需要借助于Condition接口与newCondition()方法。
  • 支持超时 :ReentrantLock 提供了 tryLock(timeout) 的方法,可以指定等待获取锁的最长等待时间,如果超过了等待时间,就会获取锁失败,不会一直等待。

如果你想使用上述功能,那么选择 ReentrantLock 是一个不错的选择。

关于 Condition接口的补充:

Condition是 JDK1.5 之后才有的,它具有很好的灵活性,比如可以实现多路通知功能也就是在一个Lock对象中可以创建多个Condition实例(即对象监视器),线程对象可以注册在指定的Condition中,从而可以有选择性的进行线程通知,在调度线程上更加灵活。 在使用notify()/notifyAll()方法进行通知时,被通知的线程是由 JVM 选择的,用ReentrantLock类结合Condition实例可以实现“选择性通知” ,这个功能非常重要,而且是 Condition 接口默认提供的。而synchronized关键字就相当于整个 Lock 对象中只有一个Condition实例,所有的线程都注册在它一个身上。如果执行notifyAll()方法的话就会通知所有处于等待状态的线程,这样会造成很大的效率问题。而Condition实例的signalAll()方法,只会唤醒注册在该Condition实例中的所有等待线程。

关于 等待可中断 的补充:

lockInterruptibly() 会让获取锁的线程在阻塞等待的过程中可以响应中断,即当前线程在获取锁的时候,发现锁被其他线程持有,就会阻塞等待。

在阻塞等待的过程中,如果其他线程中断当前线程 interrupt() ,就会抛出 InterruptedException 异常,可以捕获该异常,做一些处理操作。

为了更好理解这个方法,借用 Stack Overflow 上的一个案例,可以更好地理解 lockInterruptibly() 可以响应中断:

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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
public class MyRentrantlock {
Thread t = new Thread() {
@Override
public void run() {
ReentrantLock r = new ReentrantLock();
// 1.1、第一次尝试获取锁,可以获取成功
r.lock();

// 1.2、此时锁的重入次数为 1
System.out.println("lock() : lock count :" + r.getHoldCount());

// 2、中断当前线程,通过 Thread.currentThread().isInterrupted() 可以看到当前线程的中断状态为 true
interrupt();
System.out.println("Current thread is intrupted");

// 3.1、尝试获取锁,可以成功获取
r.tryLock();
// 3.2、此时锁的重入次数为 2
System.out.println("tryLock() on intrupted thread lock count :" + r.getHoldCount());
try {
// 4、打印线程的中断状态为 true,那么调用 lockInterruptibly() 方法就会抛出 InterruptedException 异常
System.out.println("Current Thread isInterrupted:" + Thread.currentThread().isInterrupted());
r.lockInterruptibly();
System.out.println("lockInterruptibly() --NOt executable statement" + r.getHoldCount());
} catch (InterruptedException e) {
r.lock();
System.out.println("Error");
} finally {
r.unlock();
}

// 5、打印锁的重入次数,可以发现 lockInterruptibly() 方法并没有成功获取到锁
System.out.println("lockInterruptibly() not able to Acqurie lock: lock count :" + r.getHoldCount());

r.unlock();
System.out.println("lock count :" + r.getHoldCount());
r.unlock();
System.out.println("lock count :" + r.getHoldCount());
}
};
public static void main(String str[]) {
MyRentrantlock m = new MyRentrantlock();
m.t.start();
}
}

输出:

1
2
3
4
5
6
7
8
lock() : lock count :1
Current thread is intrupted
tryLock() on intrupted thread lock count :2
Current Thread isInterrupted:true
Error
lockInterruptibly() not able to Acqurie lock: lock count :2
lock count :1
lock count :0

关于 支持超时 的补充:

为什么需要 tryLock(timeout) 这个功能呢?

tryLock(timeout) 方法尝试在指定的超时时间内获取锁。如果成功获取锁,则返回 true;如果在锁可用之前超时,则返回 false。此功能在以下几种场景中非常有用:

  • 防止死锁: 在复杂的锁场景中,tryLock(timeout) 可以通过允许线程在合理的时间内放弃并重试来帮助防止死锁。
  • 提高响应速度: 防止线程无限期阻塞。
  • 处理时间敏感的操作: 对于具有严格时间限制的操作,tryLock(timeout) 允许线程在无法及时获取锁时继续执行替代操作。

可中断锁和不可中断锁有什么区别?

  • 可中断锁:获取锁的过程中可以被中断,不需要一直等到获取锁之后 才能进行其他逻辑处理。ReentrantLock 就属于是可中断锁。
  • 不可中断锁:一旦线程申请了锁,就只能等到拿到锁以后才能进行其他的逻辑处理。 synchronized 就属于是不可中断锁。

ReentrantReadWriteLock

ReentrantReadWriteLock 在实际项目中使用的并不多,面试中也问的比较少,简单了解即可。JDK 1.8 引入了性能更好的读写锁 StampedLock 。

ReentrantReadWriteLock 是什么?

ReentrantReadWriteLock 实现了 ReadWriteLock ,是一个可重入的读写锁,既可以保证多个线程同时读的效率,同时又可以保证有写入操作时的线程安全。

1
2
3
4
5
6
7
public class ReentrantReadWriteLock
implements ReadWriteLock, java.io.Serializable{
}
public interface ReadWriteLock {
Lock readLock();
Lock writeLock();
}
  • 一般锁进行并发控制的规则:读读互斥、读写互斥、写写互斥。
  • 读写锁进行并发控制的规则:读读不互斥、读写互斥、写写互斥(只有读读不互斥)。

ReentrantReadWriteLock 其实是两把锁,一把是 WriteLock (写锁),一把是 ReadLock(读锁) 。读锁是共享锁,写锁是独占锁。读锁可以被同时读,可以同时被多个线程持有,而写锁最多只能同时被一个线程持有。

和 ReentrantLock 一样,ReentrantReadWriteLock 底层也是基于 AQS 实现的。

ReentrantReadWriteLock 也支持公平锁和非公平锁,默认使用非公平锁,可以通过构造器来显示的指定。

1
2
3
4
5
6
// 传入一个 boolean 值,true 时为公平锁,false 时为非公平锁
public ReentrantReadWriteLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
readerLock = new ReadLock(this);
writerLock = new WriteLock(this);
}

ReentrantReadWriteLock 适合什么场景?

由于 ReentrantReadWriteLock 既可以保证多个线程同时读的效率,同时又可以保证有写入操作时的线程安全。因此,在读多写少的情况下,使用 ReentrantReadWriteLock 能够明显提升系统性能。

共享锁和独占锁有什么区别?

  • 共享锁:一把锁可以被多个线程同时获得。
  • 独占锁:一把锁只能被一个线程获得。

线程持有读锁还能获取写锁吗?

  • 在线程持有读锁的情况下,该线程不能取得写锁(因为获取写锁的时候,如果发现当前的读锁被占用,就马上获取失败,不管读锁是不是被当前线程持有)。
  • 在线程持有写锁的情况下,该线程可以继续获取读锁(获取读锁时如果发现写锁被占用,只有写锁没有被当前线程占用的情况才会获取失败)。

读写锁的源码分析,推荐阅读 聊聊 Java 的几把 JVM 级锁 - 阿里巴巴中间件 这篇文章,写的很不错。

读锁为什么不能升级为写锁?

写锁可以降级为读锁,但是读锁却不能升级为写锁。这是因为读锁升级为写锁会引起线程的争夺,毕竟写锁属于是独占锁,这样的话,会影响性能。

另外,还可能会有死锁问题发生。举个例子:假设两个线程的读锁都想升级写锁,则需要对方都释放自己锁,而双方都不释放,就会产生死锁。

StampedLock

StampedLock 面试中问的比较少,不是很重要,简单了解即可。

StampedLock 是什么?

StampedLock 是 JDK 1.8 引入的性能更好的读写锁,不可重入且不支持条件变量 Condition。

不同于一般的 Lock 类,StampedLock 并不是直接实现 Lock或 ReadWriteLock接口,而是基于 CLH 锁 独立实现的(AQS 也是基于这玩意)。

1
2
public class StampedLock implements java.io.Serializable {
}

StampedLock 提供了三种模式的读写控制模式:读锁、写锁和乐观读。

  • 写锁:独占锁,一把锁只能被一个线程获得。当一个线程获取写锁后,其他请求读锁和写锁的线程必须等待。类似于 ReentrantReadWriteLock 的写锁,不过这里的写锁是不可重入的。
  • 读锁 (悲观读):共享锁,没有线程获取写锁的情况下,多个线程可以同时持有读锁。如果己经有线程持有写锁,则其他线程请求获取该读锁会被阻塞。类似于 ReentrantReadWriteLock 的读锁,不过这里的读锁是不可重入的。
  • 乐观读:允许多个线程获取乐观读以及读锁。同时允许一个写线程获取写锁。

另外,StampedLock 还支持这三种锁在一定条件下进行相互转换 。

1
2
3
long tryConvertToWriteLock(long stamp){}
long tryConvertToReadLock(long stamp){}
long tryConvertToOptimisticRead(long stamp){}

StampedLock 在获取锁的时候会返回一个 long 型的数据戳,该数据戳用于稍后的锁释放参数,如果返回的数据戳为 0 则表示锁获取失败。当前线程持有了锁再次获取锁还是会返回一个新的数据戳,这也是StampedLock不可重入的原因。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 写锁
public long writeLock() {
long s, next; // bypass acquireWrite in fully unlocked case only
return ((((s = state) & ABITS) == 0L &&
U.compareAndSwapLong(this, STATE, s, next = s + WBIT)) ?
next : acquireWrite(false, 0L));
}
// 读锁
public long readLock() {
long s = state, next; // bypass acquireRead on common uncontended case
return ((whead == wtail && (s & ABITS) < RFULL &&
U.compareAndSwapLong(this, STATE, s, next = s + RUNIT)) ?
next : acquireRead(false, 0L));
}
// 乐观读
public long tryOptimisticRead() {
long s;
return (((s = state) & WBIT) == 0L) ? (s & SBITS) : 0L;
}

StampedLock 的性能为什么更好?

相比于传统读写锁多出来的乐观读是StampedLock比 ReadWriteLock 性能更好的关键原因。StampedLock 的乐观读允许一个写线程获取写锁,所以不会导致所有写线程阻塞,也就是当读多写少的时候,写线程有机会获取写锁,减少了线程饥饿的问题,吞吐量大大提高。

StampedLock 适合什么场景?

和 ReentrantReadWriteLock 一样,StampedLock 同样适合读多写少的业务场景,可以作为 ReentrantReadWriteLock的替代品,性能更好。

不过,需要注意的是StampedLock不可重入,不支持条件变量 Condition,对中断操作支持也不友好(使用不当容易导致 CPU 飙升)。如果你需要用到 ReentrantLock 的一些高级性能,就不太建议使用 StampedLock 了。

另外,StampedLock 性能虽好,但使用起来相对比较麻烦,一旦使用不当,就会出现生产问题。强烈建议你在使用StampedLock 之前,看看 StampedLock 官方文档中的案例。

StampedLock 的底层原理了解吗?

StampedLock 不是直接实现 Lock或 ReadWriteLock接口,而是基于 CLH 锁 实现的(AQS 也是基于这玩意),CLH 锁是对自旋锁的一种改良,是一种隐式的链表队列。StampedLock 通过 CLH 队列进行线程的管理,通过同步状态值 state 来表示锁的状态和类型。

StampedLock 的原理和 AQS 原理比较类似,这里就不详细介绍了,感兴趣的可以看看下面这两篇文章:

  • AQS 详解
  • StampedLock 底层原理分析

如果你只是准备面试的话,建议多花点精力搞懂 AQS 原理即可,StampedLock 底层原理在面试中遇到的概率非常小。

Atomic 原子类

Atomic 原子类部分的内容我单独写了一篇文章来总结:Atomic 原子类总结 。

参考

  • 《深入理解 Java 虚拟机》
  • 《实战 Java 高并发程序设计》
  • Guide to the Volatile Keyword in Java - Baeldung:https://www.baeldung.com/java-volatile
  • 不可不说的 Java“锁”事 - 美团技术团队:https://tech.meituan.com/2018/11/15/java-lock.html
  • 在 ReadWriteLock 类中读锁为什么不能升级为写锁?:https://cloud.tencent.com/developer/article/1176230
  • 高性能解决线程饥饿的利器 StampedLock:https://mp.weixin.qq.com/s/2Acujjr4BHIhlFsCLGwYSg
  • 理解 Java 中的 ThreadLocal - 技术小黑屋:https://droidyue.com/blog/2016/03/13/learning-threadlocal-in-java/
  • ThreadLocal (Java Platform SE 8 ) - Oracle Help Center:https://docs.oracle.com/javase/8/docs/api/java/lang/ThreadLocal.html

Spring 事务详解

发表于 2023-08-28 | 分类于 Java , 框架 , spring | 阅读次数:
字数统计: 6.9k 字 | 阅读时长 ≈ 26 分钟

前段时间答应读者的 Spring 事务 分析总结终于来了。这部分内容比较重要,不论是对于工作还是面试,但是网上比较好的参考资料比较少。

什么是事务?

事务是逻辑上的一组操作,要么都执行,要么都不执行。

相信大家应该都能背上面这句话了,下面我结合我们日常的真实开发来谈一谈。

我们系统的每个业务方法可能包括了多个原子性的数据库操作,比如下面的 savePerson() 方法中就有两个原子性的数据库操作。这些原子性的数据库操作是有依赖的,它们要么都执行,要不就都不执行。

1
2
3
4
public void savePerson() {
personDao.save(person);
personDetailDao.save(personDetail);
}

另外,需要格外注意的是:事务能否生效数据库引擎是否支持事务是关键。比如常用的 MySQL 数据库默认使用支持事务的 innodb引擎。但是,如果把数据库引擎变为 myisam,那么程序也就不再支持事务了!

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

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

万一在这两个操作之间突然出现错误比如银行系统崩溃或者网络故障,导致小明余额减少而小红的余额没有增加,这样就不对了。事务就是保证这两个关键操作要么都成功,要么都要失败。

事务示意图

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class OrdersService {
private AccountDao accountDao;

public void setOrdersDao(AccountDao accountDao) {
this.accountDao = accountDao;
}

@Transactional(propagation = Propagation.REQUIRED,
isolation = Isolation.DEFAULT, readOnly = false, timeout = -1)
public void accountMoney() {
//小红账户多1000
accountDao.addMoney(1000,xiaohong);
//模拟突然出现的异常,比如银行中可能为突然停电等等
//如果没有配置事务管理的话会造成,小红账户多了1000而小明账户没有少钱
int i = 10 / 0;
//小王账户少1000
accountDao.reduceMoney(1000,xiaoming);
}
}

另外,数据库事务的 ACID 四大特性是事务的基础,下面简单来了解一下。

事务的特性(ACID)了解么?

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

🌈 这里要额外补充一点:只有保证了事务的持久性、原子性、隔离性之后,一致性才能得到保障。也就是说 A、I、D 是手段,C 是目的! 想必大家也和我一样,被 ACID 这个概念被误导了很久! 我也是看周志明老师的公开课《周志明的软件架构课》才搞清楚的(多看好书!!!)。

AID->C

另外,DDIA 也就是 《Designing Data-Intensive Application(数据密集型应用系统设计)》 的作者在他的这本书中如是说:

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 。

《Designing Data-Intensive Application(数据密集型应用系统设计)》这本书强推一波,值得读很多遍!豆瓣有接近 90% 的人看了这本书之后给了五星好评。另外,中文翻译版本已经在 GitHub 开源,地址:https://github.com/Vonng/ddia 。

详谈 Spring 对事务的支持

⚠️ 再提醒一次:你的程序是否支持事务首先取决于数据库 ,比如使用 MySQL 的话,如果你选择的是 innodb 引擎,那么恭喜你,是可以支持事务的。但是,如果你的 MySQL 数据库使用的是 myisam 引擎的话,那不好意思,从根上就是不支持事务的。

这里再多提一下一个非常重要的知识点:MySQL 怎么保证原子性的?

我们知道如果想要保证事务的原子性,就需要在异常发生时,对已经执行的操作进行回滚,在 MySQL 中,恢复机制是通过 回滚日志(undo log) 实现的,所有事务进行的修改都会先记录到这个回滚日志中,然后再执行相关的操作。如果执行过程中遇到异常的话,我们直接利用 回滚日志 中的信息将数据回滚到修改之前的样子即可!并且,回滚日志会先于数据持久化到磁盘上。这样就保证了即使遇到数据库突然宕机等情况,当用户再次启动数据库的时候,数据库还能够通过查询回滚日志来回滚之前未完成的事务。

Spring 支持两种方式的事务管理

编程式事务管理

通过 TransactionTemplate或者TransactionManager手动管理事务,实际应用中很少使用,但是对于你理解 Spring 事务管理原理有帮助。

使用TransactionTemplate 进行编程式事务管理的示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Autowired
private TransactionTemplate transactionTemplate;
public void testTransaction() {

transactionTemplate.execute(new TransactionCallbackWithoutResult() {
@Override
protected void doInTransactionWithoutResult(TransactionStatus transactionStatus) {

try {

// .... 业务代码
} catch (Exception e){
//回滚
transactionStatus.setRollbackOnly();
}

}
});
}

使用 TransactionManager 进行编程式事务管理的示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
@Autowired
private PlatformTransactionManager transactionManager;

public void testTransaction() {

TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition());
try {
// .... 业务代码
transactionManager.commit(status);
} catch (Exception e) {
transactionManager.rollback(status);
}
}

声明式事务管理

推荐使用(代码侵入性最小),实际是通过 AOP 实现(基于@Transactional 的全注解方式使用最多)。

使用 @Transactional注解进行事务管理的示例代码如下:

1
2
3
4
5
6
7
8
@Transactional(propagation = Propagation.REQUIRED)
public void aMethod {
//do something
B b = new B();
C c = new C();
b.bMethod();
c.cMethod();
}

Spring 事务管理接口介绍

Spring 框架中,事务管理相关最重要的 3 个接口如下:

  • **PlatformTransactionManager**:(平台)事务管理器,Spring 事务策略的核心。
  • **TransactionDefinition**:事务定义信息(事务隔离级别、传播行为、超时、只读、回滚规则)。
  • **TransactionStatus**:事务运行状态。

我们可以把 PlatformTransactionManager 接口可以被看作是事务上层的管理者,而 TransactionDefinition 和 TransactionStatus 这两个接口可以看作是事务的描述。

PlatformTransactionManager 会根据 TransactionDefinition 的定义比如事务超时时间、隔离级别、传播行为等来进行事务管理 ,而 TransactionStatus 接口则提供了一些方法来获取事务相应的状态比如是否新事务、是否可以回滚等等。

PlatformTransactionManager:事务管理接口

Spring 并不直接管理事务,而是提供了多种事务管理器 。Spring 事务管理器的接口是:**PlatformTransactionManager** 。

通过这个接口,Spring 为各个平台如:JDBC(DataSourceTransactionManager)、Hibernate(HibernateTransactionManager)、JPA(JpaTransactionManager)等都提供了对应的事务管理器,但是具体的实现就是各个平台自己的事情了。

PlatformTransactionManager 接口的具体实现如下:

PlatformTransactionManager接口中定义了三个方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
package org.springframework.transaction;

import org.springframework.lang.Nullable;

public interface PlatformTransactionManager {
//获得事务
TransactionStatus getTransaction(@Nullable TransactionDefinition var1) throws TransactionException;
//提交事务
void commit(TransactionStatus var1) throws TransactionException;
//回滚事务
void rollback(TransactionStatus var1) throws TransactionException;
}

这里多插一嘴。为什么要定义或者说抽象出来PlatformTransactionManager这个接口呢?

主要是因为要将事务管理行为抽象出来,然后不同的平台去实现它,这样我们可以保证提供给外部的行为不变,方便我们扩展。

我前段时间在我的知识星球分享过:“为什么我们要用接口?” 。

《设计模式》(GOF 那本)这本书在很多年前都提到过说要基于接口而非实现编程,你真的知道为什么要基于接口编程么?

纵观开源框架和项目的源码,接口是它们不可或缺的重要组成部分。要理解为什么要用接口,首先要搞懂接口提供了什么功能。我们可以把接口理解为提供了一系列功能列表的约定,接口本身不提供功能,它只定义行为。但是谁要用,就要先实现我,遵守我的约定,然后再自己去实现我定义的要实现的功能。

举个例子,我上个项目有发送短信的需求,为此,我们定了一个接口,接口只有两个方法:

1.发送短信 2.处理发送结果的方法。

刚开始我们用的是阿里云短信服务,然后我们实现这个接口完成了一个阿里云短信的服务。后来,我们突然又换到了别的短信服务平台,我们这个时候只需要再实现这个接口即可。这样保证了我们提供给外部的行为不变。几乎不需要改变什么代码,我们就轻松完成了需求的转变,提高了代码的灵活性和可扩展性。

什么时候用接口?当你要实现的功能模块设计抽象行为的时候,比如发送短信的服务,图床的存储服务等等。

TransactionDefinition:事务属性

事务管理器接口 PlatformTransactionManager 通过 getTransaction(TransactionDefinition definition) 方法来得到一个事务,这个方法里面的参数是 TransactionDefinition 类 ,这个类就定义了一些基本的事务属性。

什么是事务属性呢? 事务属性可以理解成事务的一些基本配置,描述了事务策略如何应用到方法上。

事务属性包含了 5 个方面:

  • 隔离级别
  • 传播行为
  • 回滚规则
  • 是否只读
  • 事务超时

TransactionDefinition 接口中定义了 5 个方法以及一些表示事务属性的常量比如隔离级别、传播行为等等。

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
package org.springframework.transaction;

import org.springframework.lang.Nullable;

public interface TransactionDefinition {
int PROPAGATION_REQUIRED = 0;
int PROPAGATION_SUPPORTS = 1;
int PROPAGATION_MANDATORY = 2;
int PROPAGATION_REQUIRES_NEW = 3;
int PROPAGATION_NOT_SUPPORTED = 4;
int PROPAGATION_NEVER = 5;
int PROPAGATION_NESTED = 6;
int ISOLATION_DEFAULT = -1;
int ISOLATION_READ_UNCOMMITTED = 1;
int ISOLATION_READ_COMMITTED = 2;
int ISOLATION_REPEATABLE_READ = 4;
int ISOLATION_SERIALIZABLE = 8;
int TIMEOUT_DEFAULT = -1;
// 返回事务的传播行为,默认值为 REQUIRED。
int getPropagationBehavior();
//返回事务的隔离级别,默认值是 DEFAULT
int getIsolationLevel();
// 返回事务的超时时间,默认值为-1。如果超过该时间限制但事务还没有完成,则自动回滚事务。
int getTimeout();
// 返回是否为只读事务,默认值为 false
boolean isReadOnly();

@Nullable
String getName();
}

TransactionStatus:事务状态

TransactionStatus接口用来记录事务的状态 该接口定义了一组方法,用来获取或判断事务的相应状态信息。

PlatformTransactionManager.getTransaction(…)方法返回一个 TransactionStatus 对象。

TransactionStatus 接口内容如下:

1
2
3
4
5
6
7
public interface TransactionStatus{
boolean isNewTransaction(); // 是否是新的事务
boolean hasSavepoint(); // 是否有恢复点
void setRollbackOnly(); // 设置为只回滚
boolean isRollbackOnly(); // 是否为只回滚
boolean isCompleted; // 是否已完成
}

事务属性详解

实际业务开发中,大家一般都是使用 @Transactional 注解来开启事务,很多人并不清楚这个注解里面的参数是什么意思,有什么用。为了更好的在项目中使用事务管理,强烈推荐好好阅读一下下面的内容。

事务传播行为

事务传播行为是为了解决业务层方法之间互相调用的事务问题。

当事务方法被另一个事务方法调用时,必须指定事务应该如何传播。例如:方法可能继续在现有事务中运行,也可能开启一个新事务,并在自己的事务中运行。

举个例子:我们在 A 类的aMethod()方法中调用了 B 类的 bMethod() 方法。这个时候就涉及到业务层方法之间互相调用的事务问题。如果我们的 bMethod()如果发生异常需要回滚,如何配置事务传播行为才能让 aMethod()也跟着回滚呢?这个时候就需要事务传播行为的知识了,如果你不知道的话一定要好好看一下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Service
Class A {
@Autowired
B b;
@Transactional(propagation = Propagation.xxx)
public void aMethod {
//do something
b.bMethod();
}
}

@Service
Class B {
@Transactional(propagation = Propagation.xxx)
public void bMethod {
//do something
}
}

在TransactionDefinition定义中包括了如下几个表示传播行为的常量:

1
2
3
4
5
6
7
8
9
10
public interface TransactionDefinition {
int PROPAGATION_REQUIRED = 0;
int PROPAGATION_SUPPORTS = 1;
int PROPAGATION_MANDATORY = 2;
int PROPAGATION_REQUIRES_NEW = 3;
int PROPAGATION_NOT_SUPPORTED = 4;
int PROPAGATION_NEVER = 5;
int PROPAGATION_NESTED = 6;
......
}

不过,为了方便使用,Spring 相应地定义了一个枚举类:Propagation

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
31
32
package org.springframework.transaction.annotation;

import org.springframework.transaction.TransactionDefinition;

public enum Propagation {

REQUIRED(TransactionDefinition.PROPAGATION_REQUIRED),

SUPPORTS(TransactionDefinition.PROPAGATION_SUPPORTS),

MANDATORY(TransactionDefinition.PROPAGATION_MANDATORY),

REQUIRES_NEW(TransactionDefinition.PROPAGATION_REQUIRES_NEW),

NOT_SUPPORTED(TransactionDefinition.PROPAGATION_NOT_SUPPORTED),

NEVER(TransactionDefinition.PROPAGATION_NEVER),

NESTED(TransactionDefinition.PROPAGATION_NESTED);

private final int value;

Propagation(int value) {
this.value = value;
}

public int value() {
return this.value;
}

}

正确的事务传播行为可能的值如下:

1.TransactionDefinition.PROPAGATION_REQUIRED

使用的最多的一个事务传播行为,我们平时经常使用的@Transactional注解默认使用就是这个事务传播行为。如果当前存在事务,则加入该事务;如果当前没有事务,则创建一个新的事务。也就是说:

  • 如果外部方法没有开启事务的话,Propagation.REQUIRED修饰的内部方法会新开启自己的事务,且开启的事务相互独立,互不干扰。
  • 如果外部方法开启事务并且被Propagation.REQUIRED的话,所有Propagation.REQUIRED修饰的内部方法和外部方法均属于同一事务 ,只要一个方法回滚,整个事务均回滚。

举个例子:如果我们上面的aMethod()和bMethod()使用的都是PROPAGATION_REQUIRED传播行为的话,两者使用的就是同一个事务,只要其中一个方法回滚,整个事务均回滚。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Service
Class A {
@Autowired
B b;
@Transactional(propagation = Propagation.REQUIRED)
public void aMethod {
//do something
b.bMethod();
}
}
@Service
Class B {
@Transactional(propagation = Propagation.REQUIRED)
public void bMethod {
//do something
}
}

2.TransactionDefinition.PROPAGATION_REQUIRES_NEW

创建一个新的事务,如果当前存在事务,则把当前事务挂起。也就是说不管外部方法是否开启事务,Propagation.REQUIRES_NEW修饰的内部方法会新开启自己的事务,且开启的事务相互独立,互不干扰。

举个例子:如果我们上面的bMethod()使用PROPAGATION_REQUIRES_NEW事务传播行为修饰,aMethod还是用PROPAGATION_REQUIRED修饰的话。如果aMethod()发生异常回滚,bMethod()不会跟着回滚,因为 bMethod()开启了独立的事务。但是,如果 bMethod()抛出了未被捕获的异常并且这个异常满足事务回滚规则的话,aMethod()同样也会回滚,因为这个异常被 aMethod()的事务管理机制检测到了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Service
Class A {
@Autowired
B b;
@Transactional(propagation = Propagation.REQUIRED)
public void aMethod {
//do something
b.bMethod();
}
}

@Service
Class B {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void bMethod {
//do something
}
}

3.TransactionDefinition.PROPAGATION_NESTED:

如果当前存在事务,就在嵌套事务内执行;如果当前没有事务,就执行与TransactionDefinition.PROPAGATION_REQUIRED类似的操作。也就是说:

  • 在外部方法开启事务的情况下,在内部开启一个新的事务,作为嵌套事务存在。
  • 如果外部方法无事务,则单独开启一个事务,与 PROPAGATION_REQUIRED 类似。

这里还是简单举个例子:如果 bMethod() 回滚的话,aMethod()不会回滚。如果 aMethod() 回滚的话,bMethod()会回滚。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Service
Class A {
@Autowired
B b;
@Transactional(propagation = Propagation.REQUIRED)
public void aMethod {
//do something
b.bMethod();
}
}

@Service
Class B {
@Transactional(propagation = Propagation.NESTED)
public void bMethod {
//do something
}
}

4.TransactionDefinition.PROPAGATION_MANDATORY

如果当前存在事务,则加入该事务;如果当前没有事务,则抛出异常。(mandatory:强制性)

这个使用的很少,就不举例子来说了。

若是错误的配置以下 3 种事务传播行为,事务将不会发生回滚,这里不对照案例讲解了,使用的很少。

  • TransactionDefinition.PROPAGATION_SUPPORTS: 如果当前存在事务,则加入该事务;如果当前没有事务,则以非事务的方式继续运行。
  • TransactionDefinition.PROPAGATION_NOT_SUPPORTED: 以非事务方式运行,如果当前存在事务,则把当前事务挂起。
  • TransactionDefinition.PROPAGATION_NEVER: 以非事务方式运行,如果当前存在事务,则抛出异常。

更多关于事务传播行为的内容请看这篇文章:《太难了~面试官让我结合案例讲讲自己对 Spring 事务传播行为的理解。》

事务隔离级别

TransactionDefinition 接口中定义了五个表示隔离级别的常量:

1
2
3
4
5
6
7
8
9
public interface TransactionDefinition {
......
int ISOLATION_DEFAULT = -1;
int ISOLATION_READ_UNCOMMITTED = 1;
int ISOLATION_READ_COMMITTED = 2;
int ISOLATION_REPEATABLE_READ = 4;
int ISOLATION_SERIALIZABLE = 8;
......
}

和事务传播行为那块一样,为了方便使用,Spring 也相应地定义了一个枚举类:Isolation

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public enum Isolation {

DEFAULT(TransactionDefinition.ISOLATION_DEFAULT),

READ_UNCOMMITTED(TransactionDefinition.ISOLATION_READ_UNCOMMITTED),

READ_COMMITTED(TransactionDefinition.ISOLATION_READ_COMMITTED),

REPEATABLE_READ(TransactionDefinition.ISOLATION_REPEATABLE_READ),

SERIALIZABLE(TransactionDefinition.ISOLATION_SERIALIZABLE);

private final int value;

Isolation(int value) {
this.value = value;
}

public int value() {
return this.value;
}

}

下面我依次对每一种事务隔离级别进行介绍:

  • TransactionDefinition.ISOLATION_DEFAULT :使用后端数据库默认的隔离级别,MySQL 默认采用的 REPEATABLE_READ 隔离级别 Oracle 默认采用的 READ_COMMITTED 隔离级别.
  • TransactionDefinition.ISOLATION_READ_UNCOMMITTED :最低的隔离级别,使用这个隔离级别很少,因为它允许读取尚未提交的数据变更,可能会导致脏读、幻读或不可重复读
  • TransactionDefinition.ISOLATION_READ_COMMITTED : 允许读取并发事务已经提交的数据,可以阻止脏读,但是幻读或不可重复读仍有可能发生
  • TransactionDefinition.ISOLATION_REPEATABLE_READ : 对同一字段的多次读取结果都是一致的,除非数据是被本身事务自己所修改,可以阻止脏读和不可重复读,但幻读仍有可能发生。
  • TransactionDefinition.ISOLATION_SERIALIZABLE : 最高的隔离级别,完全服从 ACID 的隔离级别。所有的事务依次逐个执行,这样事务之间就完全不可能产生干扰,也就是说,该级别可以防止脏读、不可重复读以及幻读。但是这将严重影响程序的性能。通常情况下也不会用到该级别。

相关阅读:MySQL 事务隔离级别详解。

事务超时属性

所谓事务超时,就是指一个事务所允许执行的最长时间,如果超过该时间限制但事务还没有完成,则自动回滚事务。在 TransactionDefinition 中以 int 的值来表示超时时间,其单位是秒,默认值为-1,这表示事务的超时时间取决于底层事务系统或者没有超时时间。

事务只读属性

1
2
3
4
5
6
7
8
9
10
package org.springframework.transaction;

import org.springframework.lang.Nullable;

public interface TransactionDefinition {
......
// 返回是否为只读事务,默认值为 false
boolean isReadOnly();

}

对于只有读取数据查询的事务,可以指定事务类型为 readonly,即只读事务。只读事务不涉及数据的修改,数据库会提供一些优化手段,适合用在有多条数据库查询操作的方法中。

很多人就会疑问了,为什么我一个数据查询操作还要启用事务支持呢?

拿 MySQL 的 innodb 举例子,根据官网 https://dev.mysql.com/doc/refman/5.7/en/innodb-autocommit-commit-rollback.html 描述:

MySQL 默认对每一个新建立的连接都启用了autocommit模式。在该模式下,每一个发送到 MySQL 服务器的sql语句都会在一个单独的事务中进行处理,执行结束后会自动提交事务,并开启一个新的事务。

但是,如果你给方法加上了Transactional注解的话,这个方法执行的所有sql会被放在一个事务中。如果声明了只读事务的话,数据库就会去优化它的执行,并不会带来其他的什么收益。

如果不加Transactional,每条sql会开启一个单独的事务,中间被其它事务改了数据,都会实时读取到最新值。

分享一下关于事务只读属性,其他人的解答:

  • 如果你一次执行单条查询语句,则没有必要启用事务支持,数据库默认支持 SQL 执行期间的读一致性;
  • 如果你一次执行多条查询语句,例如统计查询,报表查询,在这种场景下,多条查询 SQL 必须保证整体的读一致性,否则,在前条 SQL 查询之后,后条 SQL 查询之前,数据被其他用户改变,则该次整体的统计查询将会出现读数据不一致的状态,此时,应该启用事务支持

事务回滚规则

这些规则定义了哪些异常会导致事务回滚而哪些不会。默认情况下,事务只有遇到运行期异常(RuntimeException 的子类)时才会回滚,Error 也会导致事务回滚,但是,在遇到检查型(Checked)异常时不会回滚。

如果你想要回滚你定义的特定的异常类型的话,可以这样:

1
@Transactional(rollbackFor= MyException.class)

@Transactional 注解使用详解

@Transactional 的作用范围

  1. 方法:推荐将注解使用于方法上,不过需要注意的是:该注解只能应用到 public 方法上,否则不生效。
  2. 类:如果这个注解使用在类上的话,表明该注解对该类中所有的 public 方法都生效。
  3. 接口:不推荐在接口上使用。

@Transactional 的常用配置参数

@Transactional注解源码如下,里面包含了基本事务属性的配置:

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
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Documented
public @interface Transactional {

@AliasFor("transactionManager")
String value() default "";

@AliasFor("value")
String transactionManager() default "";

Propagation propagation() default Propagation.REQUIRED;

Isolation isolation() default Isolation.DEFAULT;

int timeout() default TransactionDefinition.TIMEOUT_DEFAULT;

boolean readOnly() default false;

Class<? extends Throwable>[] rollbackFor() default {};

String[] rollbackForClassName() default {};

Class<? extends Throwable>[] noRollbackFor() default {};

String[] noRollbackForClassName() default {};

}

@Transactional 的常用配置参数总结(只列出了 5 个我平时比较常用的):

属性名 说明
propagation 事务的传播行为,默认值为 REQUIRED,可选的值在上面介绍过
isolation 事务的隔离级别,默认值采用 DEFAULT,可选的值在上面介绍过
timeout 事务的超时时间,默认值为-1(不会超时)。如果超过该时间限制但事务还没有完成,则自动回滚事务。
readOnly 指定事务是否为只读事务,默认值为 false。
rollbackFor 用于指定能够触发事务回滚的异常类型,并且可以指定多个异常类型。

@Transactional 事务注解原理

面试中在问 AOP 的时候可能会被问到的一个问题。简单说下吧!

我们知道,**@Transactional 的工作机制是基于 AOP 实现的,AOP 又是使用动态代理实现的。如果目标对象实现了接口,默认情况下会采用 JDK 的动态代理,如果目标对象没有实现了接口,会使用 CGLIB 动态代理。**

🤐 多提一嘴:createAopProxy() 方法 决定了是使用 JDK 还是 Cglib 来做动态代理,源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class DefaultAopProxyFactory implements AopProxyFactory, Serializable {

@Override
public AopProxy createAopProxy(AdvisedSupport config) throws AopConfigException {
if (config.isOptimize() || config.isProxyTargetClass() || hasNoUserSuppliedProxyInterfaces(config)) {
Class<?> targetClass = config.getTargetClass();
if (targetClass == null) {
throw new AopConfigException("TargetSource cannot determine target class: " +
"Either an interface or a target is required for proxy creation.");
}
if (targetClass.isInterface() || Proxy.isProxyClass(targetClass)) {
return new JdkDynamicAopProxy(config);
}
return new ObjenesisCglibAopProxy(config);
}
else {
return new JdkDynamicAopProxy(config);
}
}
.......
}

如果一个类或者一个类中的 public 方法上被标注@Transactional 注解的话,Spring 容器就会在启动的时候为其创建一个代理类,在调用被@Transactional 注解的 public 方法的时候,实际调用的是,TransactionInterceptor 类中的 invoke()方法。这个方法的作用就是在目标方法之前开启事务,方法执行过程中如果遇到异常的时候回滚事务,方法调用完成之后提交事务。

TransactionInterceptor 类中的 invoke()方法内部实际调用的是 TransactionAspectSupport 类的 invokeWithinTransaction()方法。由于新版本的 Spring 对这部分重写很大,而且用到了很多响应式编程的知识,这里就不列源码了。

Spring AOP 自调用问题

当一个方法被标记了@Transactional 注解的时候,Spring 事务管理器只会在被其他类方法调用的时候生效,而不会在一个类中方法调用生效。

这是因为 Spring AOP 工作原理决定的。因为 Spring AOP 使用动态代理来实现事务的管理,它会在运行的时候为带有 @Transactional 注解的方法生成代理对象,并在方法调用的前后应用事物逻辑。如果该方法被其他类调用我们的代理对象就会拦截方法调用并处理事务。但是在一个类中的其他方法内部调用的时候,我们代理对象就无法拦截到这个内部调用,因此事务也就失效了。

MyService 类中的method1()调用method2()就会导致method2()的事务失效。

1
2
3
4
5
6
7
8
9
10
11
12
@Service
public class MyService {

private void method1() {
method2();
//......
}
@Transactional
public void method2() {
//......
}
}

解决办法就是避免同一类中自调用或者使用 AspectJ 取代 Spring AOP 代理。

issue #2091补充了一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
@Service
public class MyService {

private void method1() {
((MyService)AopContext.currentProxy()).method2(); // 先获取该类的代理对象,然后通过代理对象调用method2。
//......
}
@Transactional
public void method2() {
//......
}
}

上面的代码确实可以在自调用的时候开启事务,但是这是因为使用了 AopContext.currentProxy() 方法来获取当前类的代理对象,然后通过代理对象调用 method2()。这样就相当于从外部调用了 method2(),所以事务注解才会生效。我们一般也不会在代码中这么写,所以可以忽略这个特殊的例子。

@Transactional 的使用注意事项总结

  • @Transactional 注解只有作用到 public 方法上事务才生效,不推荐在接口上使用;
  • 避免同一个类中调用 @Transactional 注解的方法,这样会导致事务失效;
  • 正确的设置 @Transactional 的 rollbackFor 和 propagation 属性,否则事务可能会回滚失败;
  • 被 @Transactional 注解的方法所在的类必须被 Spring 管理,否则不生效;
  • 底层使用的数据库必须支持事务机制,否则不生效;
  • ……

参考

  • [总结]Spring 事务管理中@Transactional 的参数:http://www.mobabel.net/spring 事务管理中 transactional 的参数/
  • Spring 官方文档:https://docs.spring.io/spring/docs/4.2.x/spring-framework-reference/html/transaction.html
  • 《Spring5 高级编程》
  • 透彻的掌握 Spring 中@transactional 的使用: https://www.ibm.com/developerworks/cn/java/j-master-spring-transactional-use/index.html
  • Spring 事务的传播特性:https://github.com/love-somnus/Spring/wiki/Spring 事务的传播特性
  • Spring 事务传播行为详解:https://segmentfault.com/a/1190000013341344
  • 全面分析 Spring 的编程式事务管理及声明式事务管理:https://www.ibm.com/developerworks/cn/education/opensource/os-cn-spring-trans/index.html
<i class="fa fa-angle-left"></i>1…789…27<i class="fa fa-angle-right"></i>

264 日志
34 分类
38 标签
GitHub Zhihu Wechat
© 2024 史海杰 | Site words total count: 722k
由 Hexo 强力驱动
|
主题 — NexT.Pisces v5.1.4