Qida's Blog

纸上得来终觉浅,绝知此事要躬行。

流是从支持数据处理操作的源生成的元素序列,源可以是数组、集合、文件、函数。流不是集合元素,它不是数据结构并不保存数据,它的主要目的在于计算。

本文总结下 Stream API:

java.util.stream

创建流

数组

通过 Arrays.stream 方法生成流,并且该方法生成的流是数值流(即 IntStream 而不是 Stream<Integer>)。使用数值流可以避免计算过程中的拆箱装箱,提高性能。Stream API 提供了 mapToIntmapToDoublemapToLong 三种方式将对象流(Stream<T>)转换成对应的数值流,同时提供了 boxed 方法将数值流转换为对象流:

1
2
3
4
5
6
7
8
int[] intArr = new int[]{1, 2, 3, 4, 5};
IntStream intStream = Arrays.stream(intArr);

long[] longArr = new long[]{1L, 2L, 3L, 4L, 5L};
LongStream longStream = Arrays.stream(longArr);

double[] doubleArr = new double[]{1.0, 2.0, 3.0, 4.0, 5.0};
DoubleStream doubleStream = Arrays.stream(doubleArr);

集合

通过集合生成,最常用的一种:

1
2
List<Integer> integerList = Arrays.asList(1, 2, 3, 4, 5);
Stream<Integer> stream = integerList.stream();

文件

通过文件生成,得到的每个流是给定文件中的每一行:

1
2
3
4
5
6
// [1, 2, 3, 4, 5]
Stream<String> stream1 = Files.lines(Paths.get("E:\\data.txt"), Charset.defaultCharset());

// [1, 2, 3, 4, 5]
BufferedReader reader = new BufferedReader(new FileReader("E:\\data.txt"));
Stream<String> stream2 = reader.lines();

函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// []
Stream<Integer> stream3 = Stream.empty();

// [1, 2, 3, 4, 5]
Stream<Integer> stream4 = Stream.of(1, 2, 3, 4, 5);

// iterate 方法接受两个参数,第一个为初始化值,第二个为进行的函数操作,因为 iterate 生成的流为无限流,因此通过 limit 方法对流进行了截断,只生成 5 个偶数
// [0, 2, 4, 6, 8]
Stream<Integer> stream5 = Stream.iterate(0, n -> n + 2).limit(5);

// generate 方法接受一个参数,方法参数类型为 Supplier<T> ,由它为流提供值。generate 生成的流也是无限流,因此通过 limit 对流进行了截断
// [0.0819448251044178, 0.9273399484995596, 0.3050941986467305, 0.824966110053092, 0.6101914799225238]
Stream<Double> stream6 = Stream.generate(Math::random).limit(5);

// 使用 builder 模式创建流
// [1, 2]
Stream<Integer> stream8 = Stream.<Integer>builder().add(1).add(2).build();

// 使用 concat 方法拼接两个流
// [1, 2, 3, 4, 5]
Stream<Integer> stream7 = Stream.concat(Stream.of(1, 2), Stream.of(3, 4, 5));

中间操作

一个流可以后面跟随零个或多个中间操作。其目的主要是打开流,做出某种程度的数据映射/过滤,然后返回一个新的流,交给下一个操作使用。这类操作都是惰性化的,仅仅调用到这类方法,并没有真正开始流的遍历,真正的遍历需等到终结操作。

终止操作

一个流有且只能有一个终结操作,当这个操作执行后,流就被关闭,无法再被操作了。

转换为数组(toArray)

1
2
3
Object[] objects = Stream.of(1, 2, 3, 4, 5).toArray();
Integer[] integers = Stream.of(1, 2, 3, 4, 5).toArray(Integer[]::new);
int[] arr = IntStream.of(1, 2, 3, 4, 5).toArray();

转换为列表(toList)

1
2
3
4
5
6
7
8
9
10
11
// Java 8, modifiable List
List<String> result = list.stream()
.collect(Collectors.toList());

// Java 10, unmodifiable List
List<String> result = list.stream()
.collect(Collectors.toUnmodifiableList());

// Java 16, unmodifiable List
List<String> result = list.stream()
.toList();

转换为键值对(toMap)

测试数据如下:

1
2
3
List<Pair<String, Integer>> peoples = Arrays.asList(Pair.of("Lucy", 10),
Pair.of("Lucy", 30),
Pair.of("Peter", 18));

需求:按 key 分组,key 冲突则保留 value 最大的。

1
2
3
4
5
6
7
8
// 演示 `mergeFunction`
// {Peter=(Peter,18), Lucy=(Lucy,30)}
Map<String, Pair<String, Integer>> map = peoples.stream()
.collect(Collectors.toMap(
Pair::getKey,
Function.identity(),
(people1, people2) -> people1.getValue() > people2.getValue() ? people1 : people2)
);

分组统计(groupingBy)

需求:按 key 分组统计总个数、总和、平均数、最大值、最小值。

方式一,各项单独统计:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// {Lucy=2, Peter=1}
Map<String, Long> counting = peoples.stream()
.collect(Collectors.groupingBy(Pair::getKey, Collectors.counting()));
// {Lucy=40, Peter=18}
Map<String, Integer> summing = peoples.stream()
.collect(Collectors.groupingBy(Pair::getKey, Collectors.summingInt(Pair::getValue)));
// {Lucy=20.0, Peter=18.0}
Map<String, Double> averaging = peoples.stream()
.collect(Collectors.groupingBy(Pair::getKey, Collectors.averagingDouble(Pair::getValue)));
// {Lucy=Optional[(Lucy,30)], Peter=Optional[(Peter,18)]}
Map<String, Optional<Pair<String, Integer>>> max = peoples.stream()
.collect(Collectors.groupingBy(Pair::getKey, Collectors.maxBy(Comparator.comparing(Pair::getValue))));
// {Lucy=Optional[(Lucy,10)], Peter=Optional[(Peter,18)]}
Map<String, Optional<Pair<String, Integer>>> min = peoples.stream()
.collect(Collectors.groupingBy(Pair::getKey, Collectors.minBy(Comparator.comparing(Pair::getValue))));

方式二,汇总统计:

1
2
3
4
5
6
// {
// Lucy=IntSummaryStatistics{count=2, sum=40, min=10, average=20.000000, max=30},
// Peter=IntSummaryStatistics{count=1, sum=18, min=18, average=18.000000, max=18}
// }
Map<String, IntSummaryStatistics> summary = peoples.stream()
.collect(Collectors.groupingBy(Pair::getKey, Collectors.summarizingInt(Pair::getValue)));

参考:https://www.baeldung.com/java-groupingby-collector

常见问题

获取列表索引

forEach 方法入参缺少列表索引,无法实现某些特殊需求。

解决方案一,通过 IntStream 获取索引 index:

1
2
IntStream.range(0, elements.size())
.forEach(index -> downloadFile(elements.get(index), index));

解决方案二,自定义工具类通过 BiConsumer 传参,获取索引 index 和元素 element:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class IterateUtil {
public static <E> void forEach(
Iterable<? extends E> elements, BiConsumer<Integer, ? super E> action) {
Objects.requireNonNull(elements);
Objects.requireNonNull(action);

int index = 0;
for (E element : elements) {
action.accept(index++, element);
}
}
}

// 使用方式
IterateUtil.forEach(
elements,
(index, element) -> downloadFile(element, index)
);

异常处理

Exceptions in Java 8 Lambda Expressions

Stream 中异常处理的四种方式

参考

https://docs.oracle.com/javase/8/docs/api/java/util/stream/package-summary.html

https://docs.oracle.com/javase/tutorial/collections/streams/index.html

https://github.com/amaembo/streamex

https://www.baeldung.com/category/java/tag/java-streams/

用了 Stream API 之后,代码反而越写越丑?——写出具有可维护性的 Stream API 代码

IntelliJ IDEA 如何优雅的调试 Java Stream 操作?

Java 8 引入了 Optional 类用于解决臭名昭著的空指针异常。它本质上是一个可以为 null 的容器对象,并提供了很多有用的方法,以函数式编程的风格简化 null 处理。

Optional 示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
String nullStr = null;

// 初始化 Optional
Optional<String> a = Optional.of("a");
Optional<String> b = Optional.empty();
Optional<String> c = Optional.ofNullable(nullStr);

String s0 = a.get(); // a
String s1 = b.orElse("other"); // other
String s2 = b.orElseGet(this::someExpensiveOperation); // 方法引用的返回值
String s3 = b.orElseGet(() -> someExpensiveOperation()); // lambda 表达式的返回值
String s4 = b.orElseThrow(IllegalArgumentException::new); // 抛异常
String s5 = b.orElseThrow(() -> new IllegalArgumentException("非法参数")); // 抛异常

boolean isPresent = a.isPresent(); // true
a.ifPresent(System.out::println); // a
a.filter(String::isEmpty).ifPresent(System.out::println); // 条件不匹配,无打印
a.map(String::toUpperCase).ifPresent(System.out::println); // 映射为大写字母 A 并打印

上述示例中,Optional 几个关键方法主要使用到这几个函数式接口:

  • orElseGet 使用到: java.util.function.Supplier
  • orElseThrow 使用到: java.util.function.Supplier
  • ifPresent 使用到:java.util.function.Consumer
  • filter 使用到:java.util.function.Predicate
  • map 使用到:java.util.function.Function

Optional 几个关键方法的源码如下:

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
46
47
48
49
50
51
52
53
54
55
56
57
58
59
public final class Optional<T> {

/**
* Return the value if present, otherwise invoke {@code other} and return
* the result of that invocation.
*/
public T orElseGet(Supplier<? extends T> other) {
return value != null ? value : other.get();
}

/**
* Return the contained value, if present, otherwise throw an exception
* to be created by the provided supplier.
*/
public <X extends Throwable> T orElseThrow(Supplier<? extends X> exceptionSupplier) throws X {
if (value != null) {
return value;
} else {
throw exceptionSupplier.get();
}
}

/**
* If a value is present, invoke the specified consumer with the value,
* otherwise do nothing.
*/
public void ifPresent(Consumer<? super T> consumer) {
if (value != null)
consumer.accept(value);
}

/**
* If a value is present, and the value matches the given predicate,
* return an {@code Optional} describing the value, otherwise return an
* empty {@code Optional}.
*
*/
public Optional<T> filter(Predicate<? super T> predicate) {
Objects.requireNonNull(predicate);
if (!isPresent())
return this;
else
return predicate.test(value) ? this : empty();
}

/**
* If a value is present, apply the provided mapping function to it,
* and if the result is non-null, return an {@code Optional} describing the
* result. Otherwise return an empty {@code Optional}.
*/
public<U> Optional<U> map(Function<? super T, ? extends U> mapper) {
Objects.requireNonNull(mapper);
if (!isPresent())
return empty();
else {
return Optional.ofNullable(mapper.apply(value));
}
}
}

Lambda 表达式

Lambda 表达式总结:

lambda

Java 8 为函数式编程新增的重点 API:

api

函数式接口

函数式接口是只有一个抽象方法的接口,作为 Lambda 表达式和方法引用的目标类型

JDK 8 新增了 9 组共 43 个通用型函数式接口,位于 java.util.function 包下,用来支持 Java 的函数式编程。接口如此之多的原因有二:

  • 为了支持不同的参数个数。如 UnaryOperator<T> 仅支持一个参数,而 BinaryOperator<T> 支持两个参数。这一点从接口命名及函数签名也能看出:

    • Unary 一元
    • Binary 二元
    • Ternary 三元
    • Quaternary 四元
    • ……
  • 泛型不支持原始数据类型。而在面对大数据量的流式 API 运算时,为了解决包装类在自动拆装箱的性能消耗,引入了 intlongDouble 原始数据类型的函数式接口。

    千万不要用带包装类型的基础函数接口来代替基本类型的函数接口。虽然可行,但它破坏了第 61 条的规则“基本类型优于装箱基本类型”。使用装箱基本类型进行批量操作处理,最终会导致致命的性能问题。——《Effective Java》

这些接口统计如下:

接口 函数签名 范例 范例 基本类型特化
Predicate<T> boolean test(T t) String::isEmpty 符合某个条件吗? IntPredicate
LongPredicate
DoublePredicate
BiPredicate<T, U> boolean test(T t, U u)
Supplier<T> T get() Instant::now 无参的工厂方法 BooleanSupplier
IntSupplier
LongSupplier
DoubleSupplier
Consumer<T> void accept(T t) System.out::println 输出一个值 IntConsumer
LongConsumer
DoubleConsumer
BiConsumer<T, U> void accept(T t, U u) ObjIntConsumer<T>
ObjLongConsumer<T>
ObjDoubleConsumer<T>
Function<T, R> R apply(T t) Arrays::asList 类型转换 IntFunction<R>
IntToLongFunction
IntToDoubleFunction
LongFunction<R>
LongToIntFunction
LongToDoubleFunction
DoubleFunction<R>
DoubleToIntFunction
DoubleToLongFunction
ToIntFunction<T>
ToLongFunction<T>
ToDoubleFunction<T>
BiFunction<T, U, R> R apply(T t, U u) ToIntBiFunction<T, U>
ToLongBiFunction<T, U>
ToDoubleBiFunction<T, U>
UnaryOperator<T> T apply(T t) String::toUpperCase 格式转换 IntUnaryOperator
LongUnaryOperator
DoubleUnaryOperator
BinaryOperator<T> T apply(T t1, T t2) BigInteger::add 求两个数的加减乘除 IntBinaryOperator
LongBinaryOperator
DoubleBinaryOperator

以上接口都标注了 @FunctionalInterface。这是 Java 8 为函数式接口引入的一个新注解,有两个目的:

  • 告诉这个接口及其文档的读者,这个接口是针对 Lambda 设计的;
  • 用于编译级错误检查。加上该注解,当你写的接口不符合函数式接口定义的时候,编译器会报错。该注解会强制 javac 检查一个接口是否符合函数式接口的标准。如果该注释添加给一个枚举类型、类或另一个注解,或者接口包含不止一个抽象方法javac 就会报错。重构代码时,使用它能很容易发现问题,因此建议必须始终用 @FunctionalInterface 注解对自己编写的函数式接口进行标注。

此外,函数式接口允许:

  • 函数式接口里允许定义默认方法,因为默认方法不是抽象方法,其有一个默认实现,所以是符合函数式接口的定义的。
  • 函数式接口里允许定义静态方法,因为静态方法不能是抽象方法,是一个已经实现了的方法,所以是符合函数式接口的定义的。
  • 函数式接口里允许定义 java.lang.Object 里的 public 方法。

方法引用

方法引用通过方法的名字来指向一个方法,可以使语言的构造更紧凑简洁,进一步减少冗余代码,尤其是 Lambda 表达式。

方法引用使用一对冒号 ::

下面对比下方法引用简化 Lambda 表达式的例子:

方法引用类型 方法引用范例 Lambda 表达式
静态 Integer::parseInt str -> Integer.parseInt(str)
有限制 Instant.now()::isAfter Instant then = Instant.now();
t -> then.isAfter(t)
无限制 String::toLowerCase str -> str.toLowerCase()
类构造器 TreeMap<K, V>::new () -> new TreeMap<K, V>
数组构造器 int[]::new len -> new int[len]

例子

这里对比 JavaScript 和 Java 两门语言的例子,方便对比学习。

JavaScript 箭头函数

在 JavaScript 语言中,函数是一等公民(参考为何钟爱一等公民知乎)。ES6 新特性允许使用“箭头”(=>)定义函数,语法简洁,使用如下:

没有参数,需要空括号:

1
2
3
4
5
6
7
var f = () => 5;

// 等同于
var f = function () { return 5 };

// 5
var result = f();

一个参数,无需括号:

1
2
3
4
5
6
7
var f = v => v;

// 等同于
var f = function (v) { return v; };

// 10
var result = f(10);

多个参数,需要括号:

1
2
3
4
5
6
7
var sum = (num1, num2) => num1 + num2;

// 等同于
var sum = function(num1, num2) { return num1 + num2; };

// 20
var result = sum(10, 10);

箭头函数的一个用处是简化回调函数

例子 1:

1
2
3
4
5
6
7
// 正常函数写法
[1, 2, 3].forEach(function (x) {
console.log(x);
})

// 箭头函数写法,结果 1 2 3
[1, 2, 3].forEach(x => console.log(x));

例子 2:

1
2
3
4
5
6
7
// 正常函数写法
var result = [1, 2, 3].map(function (x) {
return x * x;
});

// 箭头函数写法,结果 [1, 4, 9]
var result = [1, 2, 3].map(x => x * x);

例子 3:

1
2
3
4
5
6
7
// 正常函数写法
var result = [2, 3, 1].sort(function (a, b) {
return a - b;
});

// 箭头函数写法,结果 [1, 2, 3]
var result = [2, 3, 1].sort((a, b) => a - b);

例子 4:

1
2
3
4
5
6
7
// 正常函数写法
var result = [1, 2, 3].filter(function (x) {
return x > 1;
});

// 箭头函数写法,结果 [2, 3]
var result = [1, 2, 3].filter(x => x > 1);

Java Lambda 表达式

然而在 Java 语言中,函数并非一等公民。但可以利用 Lambda 表达式 + 函数式接口来模拟 JavaScript 类似的语法,对比如下:

没有参数,需要空括号:

1
2
3
4
IntSupplier f = () -> 5;

// 5
int result = f.getAsInt();

一个参数,无需括号:

1
2
3
4
ToIntFunction<Integer> f = i -> i;

// 10
int result = f.applyAsInt(10);

多个参数,需要括号:

1
2
3
4
5
6
7
IntBinaryOperator sum = (num1, num2) -> num1 + num2;

// 使用方法引用进一步简化语法
// IntBinaryOperator sum = Integer::sum;

// 20
int result = sum.applyAsInt(10, 10);

Lambda 表达式同样可以简化回调函数

例子 1:

1
2
3
4
5
// 1 2 3
IntStream.of(1, 2, 3).forEach(x -> System.out.println(x));

// 使用方法引用进一步简化语法
// IntStream.of(1, 2, 3).forEach(System.out::println);

例子 2:

1
2
// [1, 4, 9]
int[] result = IntStream.of(1, 2, 3).map(x -> x * x).toArray();

例子 3:

1
2
3
4
5
6
7
8
9
10
11
// [1, 2, 3]
int[] result = Stream.of(2, 3, 1)
.sorted((a, b) -> a - b)
.mapToInt(Integer::intValue)
.toArray();

// 使用方法引用进一步简化语法
// int[] result = Stream.of(2, 3, 1)
// .sorted(Comparator.naturalOrder())
// .mapToInt(Integer::intValue)
// .toArray();

例子 4:

1
2
// [2, 3]
int[] result = IntStream.of(1, 2, 3).filter(x -> x > 1).toArray();

使用场景

  • Java 8 引入了 Optional 类用于解决臭名昭著的空指针异常。它本质上是一个可以为 null 的容器对象,并提供了很多有用的方法,以函数式编程的风格简化 null 处理。
  • Stream API 是一种基于函数式编程的模型,用于增强集合处理。

参考

https://docs.oracle.com/javase/tutorial/java/javaOO/lambdaexpressions.html

https://docs.oracle.com/javase/tutorial/java/javaOO/methodreferences.html

https://docs.oracle.com/javase/tutorial/collections/streams/index.html

《Effective Java 第三版》:

  • 第 42 条:Lambda 优先于匿名类
  • 第 43 条:方法引用优先于 Lambda
  • 第 44 条:坚持使用标准的函数式接口(包括基本数据类型的函数式接口)
  • 第 45 条:谨慎使用 Stream(必要时也需要使用 Iterator 外部迭代器)
  • 第 46 条:优先选择 Stream 中无副作用的函数(使用收集器 Collectors 而不是 forEach
  • 第 47 条:Stream 要优先用 Collection 作为返回类型
  • 第 48 条:谨慎使用 Stream 并行

《Java 8 函数式编程》

《Java 8 实战》

万字长文详解 Java lambda 表达式 | 阿里技术

Java8 Lambda 实现源码解析 | 阿里技术

事务的自动提交机制

InnoDB,所有用户活动都发生在事务中。

InnoDB 默认采用事务自动提交autocommit)机制。也就是说,如果不是显式开启一个事务,则每条 SQL 语句都形成独立事务。如果该语句执行后没有返回错误,MySQL 会自动执行 COMMIT。但如果该语句返回错误,则根据错误情况执行 COMMITROLLBACK

如何修改当前会话的提交模式?

1
2
3
4
5
6
7
8
9
SHOW VARIABLES LIKE 'AUTOCOMMIT';
+---------------+-------+
| Variable_name | Value |
+---------------+-------+
| autocommit | ON |
+---------------+-------+

--1 或者 ON 表示启用, 0 或者 OFF 表示禁用
SET AUTOCOMMIT = 0;

注意:

  • 关闭后,会话将始终开启一个事务。直到你显式提交或回滚该事务后,一个新事务又被开启。
  • 如果一个关闭了 autocommit 的会话没有显式提交事务,然后会话被关闭,MySQL 将回滚该事务。
  • 有一些命令,在执行之后会强制执行 COMMIT 提交当前的活动事务。例如:
    • ALTER TABLE
    • LOCK TABLES

提交多语句事务

如何在一个事务中组合多条 SQL 语句(multiple-statement transaction)?有两种方式:

  1. 方式一:显式关闭当前会话的 autocommit,然后提交或回滚事务。

    1
    2
    3
    SET autocommit=0;
    INSERT INTO parent VALUES (10, 'Heikki');
    COMMIT;
  2. 方式二:如果不想关闭 autocommit,可以通过 START TRANSACTIONBEGIN 语句显式开启事务,然后通过 COMMITROLLBACK 语句显式结束事务。

    1
    2
    3
    4
    5
    START TRANSACTION;
    INSERT INTO parent VALUES (15, 'John');
    INSERT INTO parent VALUES (20, 'Paul');
    DELETE FROM parent WHERE b = 'Heikki';
    ROLLBACK;

最终结果:

1
2
3
4
5
6
SELECT * FROM parent;
+------+--------+
| id | name |
+------+--------+
| 10 | Heikki |
+------+--------+

在事务中混合使用存储引擎问题

MySQL 服务器层不管理事务,事务是由下层的存储引擎实现的。所以在同一个事务中,使用多种存储引擎是不可靠的。

如果在事务中混合使用了事务型和非事务型的表(例如 InnoDBMyISAM 表),可能会有意想不到的情况发生。请看下例:

1
2
3
4
5
6
7
--引入一张 MyISAM 表
CREATE TABLE `people` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`last_name` varchar(50) NOT NULL,
`first_name` varchar(50) NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=MyISAM;

示例一,在事务中混合使用存储引擎,出现报错:

1
2
3
4
5
6
START TRANSACTION;
--执行成功
INSERT INTO parent(name) VALUES('Heikki');
--执行失败
INSERT INTO people(last_name, first_name) VALUES('pete', 'Lee');
1785 - When @@GLOBAL.ENFORCE_GTID_CONSISTENCY = 1, updates to non-transactional tables can only be done in either autocommitted statements or single-statement transactions, and never in the same statement as updates to transactional tables.

示例二,当事务回滚,非事务型的表上的变更无法撤销,这会导致数据库处于不一致的状态,这种情况很难修复,事务的最终结果将无法确定。

1
2
3
4
5
START TRANSACTION;
INSERT INTO people(last_name, first_name) VALUES('pete', 'Lee');
INSERT INTO parent(name) VALUES('Heikki');
--parent表(InnoDB)回滚成功,people表(MyISAM)回滚失败
ROLLBACK;

所以,为每张表选择合适的存储引擎非常重要。

参考

《高性能 MySQL》

https://dev.mysql.com/doc/refman/5.7/en/innodb-consistent-read.html

https://dev.mysql.com/doc/refman/5.7/en/sql-syntax-transactions.html

前文总结了 MySQL 事务的一些概念,下面总结下如何进行实操。

开启事务、提交与回滚

1
2
3
4
5
6
7
8
9
10
11
12
13
START TRANSACTION
[transaction_characteristic [, transaction_characteristic] ...]

transaction_characteristic: {
WITH CONSISTENT SNAPSHOT
| READ WRITE
| READ ONLY
}

BEGIN
COMMIT
ROLLBACK
SET autocommit = {0 | 1}

主要语法作用如下:

  • START TRANSACTIONBEGIN 开启新的事务。
  • COMMIT 提交当前事务,使其更改持久化。
  • ROLLBACK 回滚当前事务,取消其更改。
  • SET autocommit 禁用或启用当前会话的默认自动提交模式。

START TRANSACTION 是标准的 SQL 语法,推荐使用。它支持以下 BEGIN 语法所不支持的修饰符:

  • WITH CONSISTENT SNAPSHOT 在事务开启同时创建快照(一致性视图),主要用于可重复读(RR)。
  • READ WRITE 读写模式,默认值。
  • READ ONLY 只读模式,有助于提升存储引擎的性能表现。

SET TRANSACTION 语法

可以通过 SET TRANSACTION 语句设置事务的特性,包括隔离级别和读写模式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
SET [GLOBAL | SESSION] TRANSACTION
transaction_characteristic [, transaction_characteristic] ...

transaction_characteristic: {
ISOLATION LEVEL level
| access_mode
}

level: {
REPEATABLE READ
| READ COMMITTED
| READ UNCOMMITTED
| SERIALIZABLE
}

access_mode: {
READ WRITE
| READ ONLY
}

事务特性范围(作用域)

您可以设置事务特性的作用域为全局、当前会话或仅针对下一个事务,其优先级为事务 > 会话 > 全局:

  • 使用 GLOBAL 关键字:

    • 全局应用于所有后续会话。
    • 现有会话不受影响。
    • 全局设置要求 SUPER 权限。
  • 使用 SESSION 关键字:

    • 应用于当前会话中执行的所有后续事务。
    • 不影响正在进行的事务。
  • 没有 SESSIONGLOBAL 关键字:

    • 仅应用于当前会话中执行的下一个事务。

    • 后续事务将恢复为当前会话的默认值。

    • 事务中不允许使用该语句:

      1
      2
      3
      START TRANSACTION;
      SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
      ERROR 1568 (25001): Transaction characteristics can't be changed while a transaction is in progress

语法总结如下:

语法 作用域
SET GLOBAL TRANSACTION transaction_characteristic Global
SET SESSION TRANSACTION transaction_characteristic Session
SET TRANSACTION transaction_characteristic Next transaction only

事务隔离级别

MySQL 能够识别所有的四个事务隔离级别,InnoDB 引擎也支持所有的隔离级别。可以使用 ISOLATION LEVEL level 子句进行设置:

1
2
3
4
SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; --读未提交
SET TRANSACTION ISOLATION LEVEL READ COMMITTED; --读已提交
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ; --可重复读
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE; --串行化

事务读写模式

MySQL 支持两种事务读写模式,其设置方式如下:

1
2
SET TRANSACTION READ WRITE; --读写模式,默认值
SET TRANSACTION READ ONLY; --只读模式,有助于提升存储引擎的性能表现

如果要单独为某个事务指定读写模式,搭配 START TRANSACTION 使用。

SET 语法

也可以通过 SET 语句直接进行各种变量赋值,语法总结如下:

语法 作用域
SET GLOBAL var_name = value Global
SET @@GLOBAL.var_name = value Global
SET SESSION var_name = value Session
SET @@SESSION.var_name = value Session
SET var_name = value Session
SET @@var_name = value Next transaction only

变量的查询语法如下,例如 transaction_isolationtransaction_read_only

1
2
SELECT @@GLOBAL.transaction_isolation, @@GLOBAL.transaction_read_only;
SELECT @@SESSION.transaction_isolation, @@SESSION.transaction_read_only;

启动时设置

上面介绍的两种语法都是用于运行时设置,下面介绍两种方式用于在服务启动时设置:

命令行参数

1
2
--transaction-isolation=REPEATABLE-READ
--transaction-read-only=OFF

配置文件

1
2
3
[mysqld]
transaction-isolation = REPEATABLE-READ
transaction-read-only = OFF

参考

https://dev.mysql.com/doc/refman/5.7/en/commit.html

https://dev.mysql.com/doc/refman/5.7/en/set-transaction.html

https://dev.mysql.com/doc/refman/5.7/en/set-variable.html

https://dev.mysql.com/doc/refman/5.7/en/innodb-performance-ro-txn.html

本文大纲

Transaction Isolation

事务的隔离性

上文提到,数据库的事务隔离性,主要解决以下问题:

  • 防止多个事务并发执行时由于交叉执行而导致的数据不一致问题。
  • 解决同一事务内的多次相同查询,数据不一致问题。

有哪些数据不一致的情况?

  • 脏读
  • 不可重复读
  • 幻读

为了数据不一致问题,引入了四个隔离级别,随着隔离级别的提升,可以解决上述更多情况。它们所使用的 SELECT 模式分别如下:

隔离级别 SELECT 默认模式 备注
读未提交
READ UNCOMMITTED
/
读已提交
READ COMMITTED
使用一致性非加锁读(Consistent Non-locking Reads)
总是使用最新快照
可重复读
REPEATABLE READ
使用一致性非加锁读(Consistent Non-locking Reads)
同一事务内总是使用首次快照,确保可重复读。
一致性读取不会在它访问的数据上加任何锁,因此其它事务可以自由地同时修改那些数据,同一份数据在 undo log 会存在多份历史版本。(即通过多版本并发控制(MVCC)实现可重复读)
串行化
SERIALIZABLE
加共享锁读
(S-Locking reads)
加锁读会给数据加共享锁,其它事务读取时可以继续加共享锁,但修改会阻塞等待以获取排它锁,保证读写的串行化,因此同一份数据只存在一份当前版本。(即通过读写锁实现可重复读)

InnoDB 可重复读实现

下面重点看下 MySQL InnoDB 如何实现可重复读这个隔离级别。它使用了一致性非加锁读(Consistent Non-locking Reads)实现多版本并发控制(MVCC),这种方法不会在它访问的数据上设置任何锁,因此其它事务可以自由地同时修改那些表,并发性能高。

示例

下图展示了两个事务并发执行时,最终会出现的五种情况:

consistent read examples

即:

事务的可重复读的能力是怎么实现的?

可重复读的核心就是一致性读(consistent read);而事务更新数据的时候,只能用当前读(current read)。如果当前的记录的行锁被其他事务占用的话,就需要进入锁等待。

Current Read

数据库快照适用于同一事务内的 SELECT 语句,而不一定适用于 DML 语句。不同事务间的增删改操作还是会相互影响的,因为 DML 与 SELECT 语句不同,使用的是 current read。例如:

  • 尽管事务 A 创建一致性视图时查不到 xyz 记录,但如果此后其它事务插入了 xyz 记录并提交事务,事务 A 仍然可以将它们删除:

    1
    2
    3
    4
    SELECT COUNT(c1) FROM t1 WHERE c1 = 'xyz';
    -- Returns 0: no rows match.
    DELETE FROM t1 WHERE c1 = 'xyz';
    -- Deletes several rows recently committed by other transaction.
  • 尽管事务 A 创建一致性视图时查不到 abc 记录,但如果此后其它事务插入了 abc 记录并提交事务,事务 A 仍然可以修改这些记录,并看到本事务内的修改:

    1
    2
    3
    4
    5
    6
    SELECT COUNT(c2) FROM t1 WHERE c2 = 'abc';
    -- Returns 0: no rows match.
    UPDATE t1 SET c2 = 'cba' WHERE c2 = 'abc';
    -- Affects 10 rows: another txn just committed 10 rows with 'abc' values.
    SELECT COUNT(c2) FROM t1 WHERE c2 = 'cba';
    -- Returns 10: this txn can now see the rows it just updated.

Consistent Read 实现原理

Consistent Read 实现依赖于 Undo Log 和 Consistent Read-View。

Undo Log 是什么?

A storage area that holds copies of data modified by active transactions. If another transaction needs to see the original data (as part of a consistent read operation), the unmodified data is retrieved from this storage area.

In MySQL 5.6 and MySQL 5.7, you can use the innodb_undo_tablespaces variable have undo logs reside in undo tablespaces, which can be placed on another storage device such as an SSD. In MySQL 8.0, undo logs reside in two default undo tablespaces that are created when MySQL is initialized, and additional undo tablespaces can be created using CREATE UNDO TABLESPACE syntax.

The undo log is split into separate portions, the insert undo buffer and the update undo buffer.

Consistent Read-View 是什么?

在实现上, InnoDB 为每个事务构造了一个数组,用来保存这个事务启动瞬间,当前正在“活跃”的所有事务 ID。“活跃”指的就是,启动了但还没提交。

数组里面事务 ID 的最小值记为低水位,当前系统里面已经创建过的事务 ID 的最大值加 1 记为高水位。

这个视图数组和高水位,就组成了当前事务的一致性视图(consistent read-view)。

这个视图数组把所有的 row trx_id 分成了几种不同的情况。如下图:

consistent-read-view

以下表事务为例,对于当前事务 105 来说,一致性视图为:[100,103,104,105],106,其中低水位为 100,高水位为 106。这些事务分布如上图。

row trx_id committed? remark
100 N
101 Y
102 Y
103 N
104 N
105 N current trx

对于当前事务 ID 105,根据以下流程图,就只能看到已提交事务 1-99, 101, 102

consistent read process

数据版本的可见性规则,就是基于数据的 row trx_id 和这个一致性视图的对比结果得到的。假如事务 ID 100-104 依次修改了同一份数据(如上图右),虽然数据当前版本为 104,但对于当前事务 ID 105 来说,也只能看到版本链上事务 ID 102 提交的数据版本。

如何查看最新快照

如果要查看最新快照,可以通过以下三个方法:

  • 使用 READ COMMITTED 隔离级别
  • 提交当前事务并发起新查询,刷新时间点
  • 使用加锁读(读锁或写锁)

下例展示了第二种方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
             Session A              Session B

START TRANSACTION; START TRANSACTION;
time
| SELECT * FROM t;
| empty set
| INSERT INTO t VALUES (1, 2);
|
v SELECT * FROM t;
empty set
COMMIT;

SELECT * FROM t;
empty set

COMMIT;

SELECT * FROM t;
---------------------
| 1 | 2 |
---------------------

参考

《高性能 MySQL》

https://dev.mysql.com/doc/refman/5.7/en/innodb-multi-versioning.html

https://dev.mysql.com/doc/refman/5.7/en/innodb-consistent-read.html

https://time.geekbang.org/column/article/70562

ACID 模型

维基百科关于 ACID 的定义:

ACID 是数据库事务的一组属性,旨在即使在发生错误、电源故障等情况下也能保证数据有效性。在数据库环境中,一系列满足 ACID 属性的数据库操作(可以视作对数据的单个逻辑操作)称为事务。例如,将资金从某个银行账户转账到另一个银行账户。

下面重点讨论 MySQL InnoDB 存储引擎如何与 ACID 模型进行交互:

原子性(Atomicity)

一个事务必须被视为一个不可分割的最小工作单元,整个事务中的所有操作要么全部成功提交,要么全部失败回滚,对于一个事务来说,不可能只执行其中的一部分操作。

相关的 MySQL 功能包括:

  • 事务的自动提交(autocommit)设置。
  • START TRANSACTIONCOMMITROLLBACK 语句。

一致性(Consistency)

数据库总是从一个一致性的状态转换到另外一个一致性的状态,即使出现系统崩溃等异常情况。

相关的 MySQL 功能包括:

隔离性(Isolation)

隔离性可以防止多个事务并发执行时由于交叉执行而导致的数据不一致问题。事务隔离分为不同级别,详见下述隔离级别

相关的 MySQL 功能包括:

  • 事务的自动提交(autocommit)设置。
  • SET TRANSACTION ISOLATION LEVEL 语句。

持久性(Durability)

一旦事务提交,则其所做的修改会永久保存到数据库中。此时即使系统崩溃、修改的数据也不会丢失。

持久性是个有点模糊的概念,因为实际上持久性也分很多不同的级别。有些持久性策略能够提供非常强的安全保障,而有些则未必。而且不可能有能做到 100% 的持久性保证的策略,否则为何还要做数据库备份?

与持久性相关的 MySQL 功能比较多,这里不做讨论。

读现象问题

我们重点来关注下隔离性。隔离性可以防止多个事务并发执行时由于交叉执行而导致的数据不一致问题。因此如果不考虑隔离性,会引发如下问题:

脏读(Dirty reads)

一个事务能够看到其它事务尚未提交的修改。例如:

脏读

不可重复读(Non-repeatable reads)

一个事务的两次查询返回不同的结果。例如:

不可重复读

有两种策略可以避免不可重复读:

  • 采用共享锁(s-lock)或排它锁(x-lock),进行加锁读(Locking reads)。
  • 采用多版本并发控制(MVCC),进行一致性非加锁读(Consistent Non-locking Reads)。

幻读(Phantom reads)

一个事务的两次查询返回不同的结果集。例如:

幻读

隔离级别

通过提升事务的隔离级别(Isolation Level),可以逐一解决上述问题。所谓隔离级别,就是在数据库事务中,为保证多个事务并发读写数据的正确性而提出的定义,它并不是 MySQL 专有的概念,而是源于 ANSI/ISO 制定的 SQL-92 标准。

每种关系型数据库都提供了各自特色的隔离级别实现,虽然在通常的隔离级别定义中是以锁为实现单元,但实际的实现千差万别。以最常见的 MySQL InnoDB 存储引擎为例,它是基于 MVCC(Multi-Versioning Concurrency Control)和锁的复合实现,性能较高。MySQL InnoDB 存储引擎的事务隔离级别及其解决问题如下:

隔离级别 脏读
(Dirty reads)
不可重复读
(Non-repeatable reads)
幻读
(Phantom reads)
SELECT 默认模式
读未提交
READ UNCOMMITTED
读已提交
READ COMMITTED
× 使用一致性非加锁读(Consistent Non-locking Reads (MVCC))
总是使用最新快照
可重复读
REPEATABLE READ
× × ×(InnoDB 特有)
使用 gap lock 或 next-key lock
使用一致性非加锁读(Consistent Non-locking Reads (MVCC))
同一事务内总是使用首次快照,确保可重复读。
串行化
SERIALIZABLE
× × ×
使用 gap lock 或 next-key lock
加共享锁读
(S-Locking reads)

读未提交(READ UNCOMMITTED)

一个事务能够看到其它事务尚未提交的修改,这是最低的隔离水平,允许脏读出现。

这个级别会导致很多问题,从性能上来说,也不会比其它级别好太多,但却缺乏其它级别的很多好处,实际应用中很少使用。

读已提交(READ COMMITTED)

事务能够看到的数据都是其它事务已经提交的修改,也就是保证不会看到任何中间性状态,因此不会出现脏读问题。但读已提交仍然是比较低的隔离级别,并不保证再次读取时能够获取同样的数据,也就是允许其它事务并发修改数据,允许不可重复读和幻读出现。

Tips: 事务隔离级别越高,就越能保证数据的完整性一致性,但同时对并发性能的影响也越大。通常,对于绝大多数的应用程序来说,在非 MySQL 数据库的情况下,可以优先考虑将数据库系统的隔离级别设置为读已提交,这能够在避免起码的脏读的同时,保证较好的并发性能。尽管这种事务隔离级别会导致不可重复读、幻读,但较为科学的做法是在可能出现这类问题的个别场合中,由应用程序主动采取读锁或写锁来进行事务控制。

MySQL 读已提交的默认行为如下:

  • 同一事务中的一致性读取(Consistent read)总是会设置和读取自己的最新快照(snapshot),因此会产生不可重复读问题,因为其它事务可能会并发修改数据。

  • 对于加锁读、UPDATEDELETE 语句,InnoDB 仅锁定匹配的索引记录。由于禁用了 gap lock,因此会产生幻读问题,因为其它事务可以在间隙(gap)中插入新行。

    gap:

    A place in an InnoDB index data structure where new values could be inserted. When you lock a set of rows with a statement such as SELECT ... FOR UPDATE, InnoDB can create locks that apply to the gaps as well as the actual values in the index.

    gap lock:

    A lock on a gap between index records, or a lock on the gap before the first or after the last index record.

可重复读(REPEATABLE READ)

这是 MySQL InnoDB 存储引擎默认的隔离级别

  • 同一事务中的一致性读取(Consistent read)总是会读取第一次读取时首次建立的快照(snapshot)。这意味着如果你在同一事务中发起多个普通(非加锁) SELECT 语句,其查询结果是相互一致的。一致性读取机制保证了同一事务中可重复读,避免了不可重复读问题,不管其它事务是否提交了 INSERTDELETEUPDATE 操作。如果想每次 SELECT 都返回最新快照,要么隔离级别降为 READ COMMITTED,要么使用加锁读。

  • 对于加锁读、UPDATEDELETE 语句,加锁行为取决于语句是使用具有唯一搜索条件的唯一索引还是范围搜索条件:

    • 对于具有唯一搜索条件的唯一索引, InnoDB 仅锁定匹配的索引记录。例如:

      1
      2
      3
      4
      -- 事务 T1 的 x-lock 会阻止其它事务加锁读或修改 id = 10 的记录
      SELECT * FROM parent WHERE id = 10 FOR UPDATE;
      -- 事务 T2 无法修改 id = 10 的记录,直到事务 T1 结束
      UPDATE parent SET name = 'Pete' WHERE id = 10;
    • 对于范围搜索条件,InnoDB 使用 gap locknext-key lock 锁定扫描到的索引范围, 以阻止其它会话插入被范围所覆盖的间隙。这是 InnoDB 和其它一些数据库实现的不同,解决了可重复读级别下的幻读问题。例如:

      1
      2
      3
      4
      5
      6
      -- 事务 T1 的 gap lock 会阻止其它事务插入 id > 10 的记录
      SELECT * FROM parent WHERE id > 10 FOR UPDATE;
      -- 事务 T2 无法插入 id > 10 的新记录,直到事务 T1 结束
      INSERT INTO parent(id, name) VALUES(11, 'Pete');
      -- 事务 T2 可以插入 id <= 9 的新记录,无需等待事务 T1
      INSERT INTO parent(id, name) VALUES(9, 'Pete');

串行化(SERIALIZABLE)

并发事务之间的读写操作是串行化的,通常意味着读取需要获取共享锁(读锁),更新需要获取排他锁(写锁),如果 SQL 使用 WHERE 语句,还会获取 gap lock 和 next-key lock,可能导致大量的超时和锁争用的问题。

这是最高的隔离级别,实际应用中很少使用,只有在非常需要确保数据一致性而且可以接受没有并发的情况下,才会考虑。

参考

《高性能 MySQL》

https://en.wikipedia.org/wiki/Isolation_(database_systems)#Isolation_levels

https://en.wikipedia.org/wiki/Isolation_(database_systems)#Dirty_reads

https://dev.mysql.com/doc/refman/5.7/en/glossary.html#glos_dirty_read

https://dev.mysql.com/doc/refman/5.7/en/glossary.html#glos_non_repeatable_read

https://dev.mysql.com/doc/refman/5.7/en/glossary.html#glos_phantom

https://dev.mysql.com/doc/refman/5.7/en/mysql-acid.html

https://dev.mysql.com/doc/refman/5.7/en/innodb-locking-transaction-model.html

死锁是指两个或多个事务在同一资源上相互占用,并请求锁定对方占用的资源,从而导致恶性循环的现象。

为了解决这种问题,数据库系统实现了各种死锁检测死锁超时机制。越复杂的系统,比如 InnoDB 存储引擎,越能检测到死锁的循环依赖,并立即返回一个错误。这种解决方式很有效,否则死锁会导致出现非常慢的查询。还有一种解决方式,就是当查询的时间达到锁等待超时的设定后放弃锁请求,例如:

1
1205 - Lock wait timeout exceeded; try restarting transaction

InnoDB 目前处理死锁的方法是,将持有最少行级排它锁的事务进行回滚,这是相对比较简单的死锁回滚算法。

锁的行为和顺序是和存储引擎相关的。以同样的顺序执行语句,有些存储引擎会产生死锁,有些则不会。死锁的产生有双重原因:

  1. 有些是因为真正的数据冲突,这种情况通常很难避免。
  2. 有些则完全是由于存储引擎的实现方式导致的。

死锁发生以后,只有部分或者完全回滚其中一个事务,才能打破死锁。对于事务型的系统,这是无法避免的,所以应用程序在设计时必须考虑如何处理死锁。大多数情况下只需要重新执行因死锁回滚的事务即可。

同时,为了避免产生死锁问题,根源在于程序设计时要注意不同事务间 SQL 语句的执行顺序,避免互相锁住对方的资源。

参考

https://dev.mysql.com/doc/refman/5.7/en/innodb-deadlocks.html

锁的粒度

所谓的锁粒度,就是在锁的开销数据的安全性之间寻求平衡,这种平衡当然也会影响到性能:

一种提高共享资源并发性的方式就是让锁定对象更有选择性。尽量只锁定需要修改的部分数据,而不是所有资源。更理想的方式是,只对会修改的数据片进行精确的锁定。任何时候,在给定的资源上,锁定的数据量越少,则系统的并发程度越高,只要相互之间不发生冲突即可。

问题是加锁也需要消耗资源。锁的各种操作,包括获得锁、检查锁是否已经解除、释放锁等,都会增加系统的开销。如果系统花费大量的时间来管理锁,而不是存取数据,那么系统的性能可能会因此受到影响。

InnoDB 存储引擎目前有以下两种锁粒度:

表锁

表锁(Table Lock)是 MySQL 中最基本的锁粒度,并且是开销最小的粒度。MyISAM 存储引擎仅支持表锁。

行锁

行锁(Row Lock)可以最大程度的支持并发处理,同时也带来了最大的锁开销。行锁只在存储引擎层实现,而不在 MySQL 服务器层。InnoDB 存储引擎支持行锁级别。

锁粒度与索引的关系

以一个例子总结锁粒度与索引的关系:

1
2
3
4
5
6
7
CREATE TABLE `child` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`parent_id` bigint(20) NOT NULL,
`name` varchar(255) NOT NULL,
PRIMARY KEY (`id`),
KEY `idx_parent_id` (`parent_id`) USING BTREE
) ENGINE=InnoDB;

1、InnoDB 行锁是通过给索引上的索引项加锁来实现的,只有通过索引条件检索数据,InnoDB 才使用行锁,否则,InnoDB 将使用表锁:

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
--开启事务 T1
START TRANSACTION;

--查看执行计划,全表扫描(type=ALL)
EXPLAIN SELECT * FROM child WHERE name = 'D' FOR UPDATE;
+----+-------------+-------+------+---------------+------+---------+------+------+-------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+----+-------------+-------+------+---------------+------+---------+------+------+-------------+
| 1 | SIMPLE | child | ALL | NULL | NULL | NULL | NULL | 5 | Using where |
+----+-------------+-------+------+---------------+------+---------+------+------+-------------+

--执行查询,加表锁
SELECT * FROM child WHERE name = 'D' FOR UPDATE;
+----+-----------+------+
| id | parent_id | name |
+----+-----------+------+
| 4 | 3 | D |
+----+-----------+------+

--开启事务 T2
START TRANSACTION;

--查看执行计划,命中索引 idx_parent_id
EXPLAIN SELECT * FROM child WHERE parent_id = 4 FOR UPDATE;
+----+-------------+-------+------+---------------+---------------+---------+-------+------+-------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+----+-------------+-------+------+---------------+---------------+---------+-------+------+-------+
| 1 | SIMPLE | child | ref | idx_parent_id | idx_parent_id | 8 | const | 1 | NULL |
+----+-------------+-------+------+---------------+---------------+---------+-------+------+-------+

--执行查询,由于事务 T1 加了表锁,事务 T2 对 parent_id = 4 索引项的行锁被阻塞,一直等待
SELECT * FROM child WHERE parent_id = 4 FOR UPDATE;

2、由于 MySQL 的行锁是针对索引加的锁,不是针对记录加的锁,所以虽然是访问不同行的记录,但是如果是使用相同的索引键,是会出现锁冲突的。应用设计的时候要注意这一点:

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
--开启事务 T1
START TRANSACTION;

--查看执行计划,命中索引 idx_parent_id
EXPLAIN SELECT * FROM child WHERE parent_id = 2 AND name = 'A' FOR UPDATE;
+----+-------------+-------+------+---------------+---------------+---------+-------+------+-------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+----+-------------+-------+------+---------------+---------------+---------+-------+------+-------------+
| 1 | SIMPLE | child | ref | idx_parent_id | idx_parent_id | 8 | const | 2 | Using where |
+----+-------------+-------+------+---------------+---------------+---------+-------+------+-------------+

--执行查询
SELECT * FROM child WHERE parent_id = 2 AND name = 'A' FOR UPDATE;
+----+-----------+------+
| id | parent_id | name |
+----+-----------+------+
| 1 | 2 | A |
+----+-----------+------+

--开启事务 T2
START TRANSACTION;

--查看执行计划,命中索引 idx_parent_id
EXPLAIN SELECT * FROM child WHERE parent_id = 2 AND name = 'C' FOR UPDATE;
+----+-------------+-------+------+---------------+---------------+---------+-------+------+-------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+----+-------------+-------+------+---------------+---------------+---------+-------+------+-------------+
| 1 | SIMPLE | child | ref | idx_parent_id | idx_parent_id | 8 | const | 2 | Using where |
+----+-------------+-------+------+---------------+---------------+---------+-------+------+-------------+

-- 执行查询,虽然 T1、T2 访问不同行的记录,但由于使用了相同的索引键 parent_id = 2,出现锁冲突,从而阻塞,一直等待
SELECT * FROM child WHERE parent_id = 2 AND name = 'C' FOR UPDATE;

3、当表有多个索引的时候,不同的事务可以使用不同的索引锁定不同的行,另外,不论是使用主键索引、唯一索引或普通索引,InnoDB 都会使用行锁来对数据加锁。

4、即便在条件中使用了索引字段,但是否使用索引来检索数据是由 MySQL 通过判断不同执行计划的代价来决定的,如果 MySQL 认为全表扫描效率更高,比如对一些很小的表,它就不会使用索引,这种情况下 InnoDB 将使用表锁,而不是行锁。因此,在分析锁冲突时,别忘了检查 SQL 的执行计划,以确认是否真正使用了索引:

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
--开启事务 T1
START TRANSACTION;

--查看执行计划,虽然使用了索引 idx_parent_id,但 MySQL 认为全表扫描效率更高,因此实际上没有使用索引
EXPLAIN SELECT * FROM child WHERE parent_id = 2 FOR UPDATE;
+----+-------------+-------+------+---------------+------+---------+------+------+-------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+----+-------------+-------+------+---------------+------+---------+------+------+-------------+
| 1 | SIMPLE | child | ALL | idx_parent_id | NULL | NULL | NULL | 5 | Using where |
+----+-------------+-------+------+---------------+------+---------+------+------+-------------+

--虽然使用了索引 idx_parent_id,但由于进行了全表扫描,因此实际使用表锁
SELECT * FROM child WHERE parent_id = 2 FOR UPDATE;
+----+-----------+------+
| id | parent_id | name |
+----+-----------+------+
| 1 | 2 | A |
| 2 | 2 | C |
| 3 | 2 | C |
+----+-----------+------+

--开启事务 T2
START TRANSACTION;

--执行查询,由于事务 T1 加了表锁,事务 T2 对 parent_id = 4 索引项的行锁被阻塞,一直等待
SELECT * FROM child WHERE parent_id = 4 FOR UPDATE;

参考

《高性能 MySQL》

MySQL 支持两种读机制:

  • 一致性非加锁读(Consistent Non-locking Reads),是 InnoDB 在 RR 隔离级别下处理 SELECT 查询语句的默认模式,用于实现多版本并发控制(MVCC)以解决不可重复读问题。由于无锁,并发性能高。
  • 加锁读(Locking Reads),是 InnoDB 在 SERIALIZABLE 隔离级别下处理 SELECT 查询语句的默认模式,查询默认加共享锁读(S-Locking reads)。由于有锁,并发性能低(因为获取写锁需阻塞等待读锁释放)。

加锁读机制

InnoDB 支持两种类型的 加锁读(Locking Reads),为事务操作提供额外的安全性

  • 共享锁(Shared Lock, S-Lock),也叫读锁(Read Lock)
    • 语法:SELECT ... LOCK IN SHARE MODE or SELECT ... FOR SHARE in MySQL 8.0.1,在检索行上设置共享锁(s-lock)
    • 其它事务允许读取检索行,但不允许更新或删除,更新或删除会一直阻塞等待,直到该事务结束。
  • 排它锁(Exclusive Lock, X-Lock),也叫写锁(Write Lock)
    • 语法:SELECT ... FOR UPDATE 在检索行上设置排它锁(x-lock)
    • 其它事务不允许更新或删除
    • 不允许加共享锁读取 SELECT ... LOCK IN SHARE MODE
    • 如果事务隔离级别为 SERIALIZABLE,不允许读取(因为该级别的读取默认需要获得共享读锁)
    • 上述操作将一直阻塞等待,直到该事务结束。

共享锁和排它锁之间存在冲突的四种情况总结如下:

T1 持有共享锁(S-Lock) T1 持有排它锁(X-Lock)
T2 获取共享锁(S-Lock) 兼容 冲突
T2 获取排它锁(X-Lock) 冲突 冲突

下面进一步分析共享锁和排它锁:

共享锁(读锁)

共享锁是共享性的,或者说是相互不阻塞的。持有该锁的多个事务允许同时读取同一个资源,而互不干扰。

举个例子,如果事务 T1 持有对行 r 的共享锁,那么来自另一个事务 T2 的锁请求,将按如下两种方式处理:

  • T2 的共享锁请求能够立即授予。其结果是,T1T2 都持有对行 r 的共享锁。
  • T2 的排它锁请求不被授予。

排它锁(写锁)

排它锁是排它性的,也就是说一个排它锁会阻塞其它的共享锁和排它锁,这是出于安全策略的考虑,只有这样,才能确保在给定的时间里,有且只有一个持有该锁的事务执行更新或删除操作,并防止其它事务读取正在操作的同一资源。

举个例子,如果事务 T1 持有对行 r 的排它锁,那么来自另一个事务 T2任一锁请求都不被授予。相反,事务 T2 必须等待事务 T1 直到其释放对行 r 的锁定。

锁定方式

大多数时候,MySQL 锁的内部管理都是透明的,其表现如下:

  • SELECTInnoDB 的读已提交(READ COMMITTED)、可重复读(REPEATABLE READ)这两种事务隔离级别下,默认采用一致性非加锁读取,因此无需加锁即可读取所需数据
  • 如果需要使用加锁读提升数据安全性,实现悲观并发控制,可采用共享锁(LOCK IN SHARE MODE)或排它锁(FOR UDPATE)进行显式锁定。
  • UPDATEDELETE 默认采用排它锁,隐式锁定。

总结如下:

语句 锁的类型 锁定方式
SELECT ... FROM 如果事务隔离为 SERIALIZABLE,使用共享锁。否则无锁。 隐式锁定
SELECT ... LOCK IN SHARE MODE 共享锁(shared next-key lock) 显式锁定
SELECT ... FOR UDPATE 排它锁(exclusive next-key lock) 显式锁定
UPDATE ... WHERE ... 排它锁(exclusive next-key lock) 隐式锁定
DELETE FROM ... WHERE ... 排它锁(exclusive next-key lock) 隐式锁定
INSERT 排它锁(exclusive index-record lock) 隐式锁定

隐式锁定

InnoDB 采用的是两阶段锁定协议(Two-phase locking protocol)。在事务执行过程中,随时都可以执行锁定,锁只有在执行 COMMIT 或者 ROLLBACK 的时候才会释放,并且所有的锁是在同一时刻被释放。InnoDB 会根据隔离级别在需要的时候自动加锁,例如下列操作:

  • UPDATEDELETE

显式锁定

InnoDB 也支持通过特定语句进行显式锁定,这些语句不属于 SQL 规范:

  • SELECT ... LOCK IN SHARE MODE(共享锁)
  • SELECT ... FOR UDPATE(排它锁)

MySQL 也支持 LOCK TABLESUNLOCK TABLE 语句,这是在服务器层实现的,和存储引擎无关。它们有自己的用途,但并不能替代事务。如果应用需要用到事务,还是应该选择事务型存储引擎。

经常可以发现,应用已经将表从 MyISAM 转换到 InnoDB,但还是显示地使用 LOCK TABLE 语句。这不但没有必要,还会严重影响性能,实际上 InnoDB 的行级锁工作得更好。

例子

这里举个例子,有一张 parent 和 child 表:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
-- parnet 表
CREATE TABLE `parent` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`name` varchar(255) NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB;

-- child 表,其中 parent_id 字段外键关联 parent 表的 id 主键
CREATE TABLE `child` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`parent_id` bigint(20) NOT NULL,
`name` varchar(255) NOT NULL,
PRIMARY KEY (`id`),
KEY `fk_parent_id` (`parent_id`),
CONSTRAINT `fk_parent_id` FOREIGN KEY (`parent_id`) REFERENCES `parent` (`id`)
) ENGINE=InnoDB;

如果在同一事务中先查询、后插入或更新相关数据,常规的 SELECT 语句无法得到足够保护。因为在此期间其它事务可能对同一资源进行更新或删除。例如:

1
2
3
4
5
6
7
8
9
10
11
12
--开启事务 T1
START TRANSACTION;
--为变量@id赋值
set @id=0;
SELECT @id:=id FROM parent WHERE name = 'Heikki';

--在此期间,某个事务 T2 成功删除了同一资源
DELETE FROM parent WHERE name = 'Heikki';

--事务 T1 插入失败:外键关联错误
INSERT INTO child(parend_id, name) VALUES(@id, 'Baby');
1452 - Cannot add or update a child row: a foreign key constraint fails (`test`.`child`, CONSTRAINT `fk_parent_id` FOREIGN KEY (`parent_id`) REFERENCES `parent` (`id`))

下面分别看下如何用共享锁和排它锁解决这个问题:

LOCK IN SHARE MODE

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
46
47
--开启事务 T1
START TRANSACTION;
select * from child where parent_id = 2;
+----+-----------+-------+
| id | parent_id | name |
+----+-----------+-------+
| 1 | 2 | Baby |
| 2 | 2 | Baby5 |
+----+-----------+-------+
2 rows in set

--在此期间,某个事务 T2 能够成功删除同一资源
delete from child where id = 1;
Query OK, 1 row affected

--事务 T1 如果继续使用一致性非加锁读,将会得到第一次读取时的快照,因为 InnoDB 当前隔离级别为 RR
select * from child where parent_id = 2;
+----+-----------+-------+
| id | parent_id | name |
+----+-----------+-------+
| 1 | 2 | Baby |
| 2 | 2 | Baby5 |
+----+-----------+-------+
2 rows in set

--事务 T1 如果使用加锁读,将会得到最新快照。同时事务 T1 获取该行的共享锁,其它任何事务都只能读、不能写该行,直到事务 T1 结束,释放共享锁
select * from child where parent_id = 2 lock in share mode;
+----+-----------+-------+
| id | parent_id | name |
+----+-----------+-------+
| 2 | 2 | Baby5 |
+----+-----------+-------+
1 row in set

--在此期间,事务 T3 可以删除未被锁定的行
delete from child where id = 3;
Query OK, 1 row affected

--在此期间,事务 T3 无法删除带锁的行。因为它无法获取该行的排它锁,因此会阻塞直到事务 T1 解锁该行。如果等待超时,则事务回滚
delete from child where id = 2
1205 - Lock wait timeout exceeded; try restarting transaction

--事务 T1 提交,释放共享锁
commit;

--事务 T3 如果没有超时,则操作成功
Query OK, 1 row affected

FOR UPDATE

1
2
3
4
5
6
7
8
9
10
11
12
13
--开启事务 T1
START TRANSACTION;
--事务 T1 获取该行的排它锁
select * from child where parent_id = 2 for update;

--在此期间,事务 T2 可以非加锁读,因为无需先获取该行的锁
select * from child where parent_id = 2;
--也可以加共享锁读非锁定行
select * from child where parent_id = 3 lock in share mode;
--但无法加共享锁读锁定行
select * from child where parent_id = 2 lock in share mode;
--也无法获取排它锁进行修改
update child set name = 'Hello' where parent_id = 2;

————分割线————

可见,通过共享锁和排它锁都能解决这个问题。下例演示通过 SELECT ... LOCK IN SHARE MODE 设置共享锁解决开头那个问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
--开启事务 T1
START TRANSACTION;
--为变量@id赋值
set @id=0;
SELECT @id:=id FROM parent WHERE NAME = 'Heikki' LOCK IN SHARE MODE;

--在此期间,某个事务 T2 无法删除同一资源。因为 T2 会一直等待,直到 T1 事务完成,所有数据都处于一致状态,并释放共享锁之后,T2 才能获取排它锁,并对数据进行修改
DELETE FROM parent WHERE name = 'Heikki';

--事务 T1 插入成功
INSERT INTO child(parend_id, name) VALUES(@id, 'Baby');
--提交事务 T1,写库
COMMIT;

T1 成功提交事务并释放共享锁之后,T2 获得排它锁。但由于 T1child 表中写入了一条对 parent 表的外键关联记录,所以 T2 删除失败:

1
1451 - Cannot delete or update a parent row: a foreign key constraint fails (`test`.`child`, CONSTRAINT `fk_parent_id` FOREIGN KEY (`parent_id`) REFERENCES `parent` (`id`))

最后,提几个注意点:

  • 只有在通过以下方式之一禁用自动提交(autocommit)时,才能加锁读:
  • 加锁读有可能产生死锁,具体取决于事务的隔离级别。

参考

《高性能 MySQL》

https://en.wikipedia.org/wiki/Two-phase_locking

https://dev.mysql.com/doc/refman/5.7/en/innodb-consistent-read.html

https://dev.mysql.com/doc/refman/5.7/en/innodb-locking-reads.html

https://blog.csdn.net/claram/article/details/54023216