From a17a00fb22e38c47f53c339f8a980dfbf9852d63 Mon Sep 17 00:00:00 2001 From: CyC2018 <1029579233@qq.com> Date: Tue, 7 Aug 2018 12:37:46 +0800 Subject: [PATCH] auto commit --- notes/Java IO.md | 549 --------------- notes/Java 基础.md | 34 +- notes/Java 容器.md | 1052 ----------------------------- notes/Redis.md | 602 ----------------- notes/SQL.md | 763 --------------------- notes/分布式.md | 355 ---------- notes/数据库系统原理.md | 576 ---------------- notes/正则表达式.md | 385 ----------- notes/消息队列.md | 76 --- notes/算法.md | 2 +- notes/系统设计基础.md | 100 --- notes/缓存.md | 13 +- notes/计算机网络.md | 886 ------------------------ notes/重构.md | 1416 --------------------------------------- notes/集群.md | 190 ------ 15 files changed, 27 insertions(+), 6972 deletions(-) diff --git a/notes/Java IO.md b/notes/Java IO.md index 08dc80f4..649f563a 100644 --- a/notes/Java IO.md +++ b/notes/Java IO.md @@ -1,553 +1,4 @@ -* [一、概览](#一概览) -* [二、磁盘操作](#二磁盘操作) -* [三、字节操作](#三字节操作) -* [四、字符操作](#四字符操作) -* [五、对象操作](#五对象操作) -* [六、网络操作](#六网络操作) - * [InetAddress](#inetaddress) - * [URL](#url) - * [Sockets](#sockets) - * [Datagram](#datagram) -* [七、NIO](#七nio) - * [流与块](#流与块) - * [通道与缓冲区](#通道与缓冲区) - * [缓冲区状态变量](#缓冲区状态变量) - * [文件 NIO 实例](#文件-nio-实例) - * [选择器](#选择器) - * [套接字 NIO 实例](#套接字-nio-实例) - * [内存映射文件](#内存映射文件) - * [对比](#对比) -* [八、参考资料](#八参考资料) -# 一、概览 - -Java 的 I/O 大概可以分成以下几类: - -- 磁盘操作:File -- 字节操作:InputStream 和 OutputStream -- 字符操作:Reader 和 Writer -- 对象操作:Serializable -- 网络操作:Socket -- 新的输入/输出:NIO - -# 二、磁盘操作 - -File 类可以用于表示文件和目录的信息,但是它不表示文件的内容。 - -递归地输出一个目录下所有文件: - -```java -public static void listAllFiles(File dir) -{ - if (dir == null || !dir.exists()) { - return; - } - if (dir.isFile()) { - System.out.println(dir.getName()); - return; - } - for (File file : dir.listFiles()) { - listAllFiles(file); - } -} -``` - -# 三、字节操作 - -使用字节流操作进行文件复制: - -```java -public static void copyFile(String src, String dist) throws IOException -{ - FileInputStream in = new FileInputStream(src); - FileOutputStream out = new FileOutputStream(dist); - byte[] buffer = new byte[20 * 1024]; - // read() 最多读取 buffer.length 个字节 - // 返回的是实际读取的个数 - // 返回 -1 的时候表示读到 eof,即文件尾 - while (in.read(buffer, 0, buffer.length) != -1) { - out.write(buffer); - } - in.close(); - out.close(); -} -``` - -

- -Java I/O 使用了装饰者模式来实现。以 InputStream 为例,InputStream 是抽象组件,FileInputStream 是 InputStream 的子类,属于具体组件,提供了字节流的输入操作。FilterInputStream 属于抽象装饰者,装饰者用于装饰组件,为组件提供额外的功能,例如 BufferedInputStream 为 FileInputStream 提供缓存的功能。 - -实例化一个具有缓存功能的字节流对象时,只需要在 FileInputStream 对象上再套一层 BufferedInputStream 对象即可。 - -```java -FileInputStream fileInputStream = new FileInputStream(filePath); -BufferedInputStream bufferedInputStream = new BufferedInputStream(fileInputStream); -``` - -DataInputStream 装饰者提供了对更多数据类型进行输入的操作,比如 int、double 等基本类型。 - -# 四、字符操作 - -不管是磁盘还是网络传输,最小的存储单元都是字节,而不是字符。但是在程序中操作的通常是字符形式的数据,因此需要提供对字符进行操作的方法。 - -- InputStreamReader 实现从字节流解码成字符流; -- OutputStreamWriter 实现字符流编码成为字节流。 - -逐行输出文本文件的内容: - -```java -public static void readFileContent(String filePath) throws IOException -{ - FileReader fileReader = new FileReader(filePath); - BufferedReader bufferedReader = new BufferedReader(fileReader); - String line; - while ((line = bufferedReader.readLine()) != null) { - System.out.println(line); - } - // 装饰者模式使得 BufferedReader 组合了一个 Reader 对象 - // 在调用 BufferedReader 的 close() 方法时会去调用 fileReader 的 close() 方法 - // 因此只要一个 close() 调用即可 - bufferedReader.close(); -} -``` - -编码就是把字符转换为字节,而解码是把字节重新组合成字符。 - -如果编码和解码过程使用不同的编码方式那么就出现了乱码。 - -- GBK 编码中,中文字符占 2 个字节,英文字符占 1 个字节; -- UTF-8 编码中,中文字符占 3 个字节,英文字符占 1 个字节; -- UTF-16be 编码中,中文字符和英文字符都占 2 个字节。 - -UTF-16be 中的 be 指的是 Big Endian,也就是大端。相应地也有 UTF-16le,le 指的是 Little Endian,也就是小端。 - -Java 使用双字节编码 UTF-16be,这不是指 Java 只支持这一种编码方式,而是说 char 这种类型使用 UTF-16be 进行编码。char 类型占 16 位,也就是两个字节,Java 使用这种双字节编码是为了让一个中文或者一个英文都能使用一个 char 来存储。 - -String 可以看成一个字符序列,可以指定一个编码方式将它转换为字节序列,也可以指定一个编码方式将一个字节序列转换为 String。 - -```java -String str1 = "中文"; -byte[] bytes = str1.getBytes("UTF-8"); -String str2 = new String(bytes, "UTF-8"); -System.out.println(str2); -``` - -在调用无参数 getBytes() 方法时,默认的编码方式不是 UTF-16be。双字节编码的好处是可以使用一个 char 存储中文和英文,而将 String 转为 bytes[] 字节数组就不再需要这个好处,因此也就不再需要双字节编码。getBytes() 的默认编码方式与平台有关,一般为 UTF-8。 - -```java -byte[] bytes = str1.getBytes(); -``` - -# 五、对象操作 - -序列化就是将一个对象转换成字节序列,方便存储和传输。 - -- 序列化:ObjectOutputStream.writeObject() -- 反序列化:ObjectInputStream.readObject() - -序列化的类需要实现 Serializable 接口,它只是一个标准,没有任何方法需要实现,但是如果不去实现它的话而进行序列化,会抛出异常。 - -```java -public static void main(String[] args) throws IOException, ClassNotFoundException -{ - A a1 = new A(123, "abc"); - String objectFile = "file/a1"; - ObjectOutputStream objectOutputStream = new ObjectOutputStream(new FileOutputStream(objectFile)); - objectOutputStream.writeObject(a1); - objectOutputStream.close(); - - ObjectInputStream objectInputStream = new ObjectInputStream(new FileInputStream(objectFile)); - A a2 = (A) objectInputStream.readObject(); - objectInputStream.close(); - System.out.println(a2); -} - -private static class A implements Serializable -{ - private int x; - private String y; - - A(int x, String y) - { - this.x = x; - this.y = y; - } - - @Override - public String toString() - { - return "x = " + x + " " + "y = " + y; - } -} -``` - -不会对静态变量进行序列化,因为序列化只是保存对象的状态,静态变量属于类的状态。 - -transient 关键字可以使一些属性不会被序列化。 - -**ArrayList 序列化和反序列化的实现** :ArrayList 中存储数据的数组是用 transient 修饰的,因为这个数组是动态扩展的,并不是所有的空间都被使用,因此就不需要所有的内容都被序列化。通过重写序列化和反序列化方法,使得可以只序列化数组中有内容的那部分数据。 - -```java -private transient Object[] elementData; -``` - -# 六、网络操作 - -Java 中的网络支持: - -- InetAddress:用于表示网络上的硬件资源,即 IP 地址; -- URL:统一资源定位符; -- Sockets:使用 TCP 协议实现网络通信; -- Datagram:使用 UDP 协议实现网络通信。 - -## InetAddress - -没有公有构造函数,只能通过静态方法来创建实例。 - -```java -InetAddress.getByName(String host); -InetAddress.getByAddress(byte[] address); -``` - -## URL - -可以直接从 URL 中读取字节流数据。 - -```java -public static void main(String[] args) throws IOException -{ - URL url = new URL("http://www.baidu.com"); - // 字节流 - InputStream is = url.openStream(); - // 字符流 - InputStreamReader isr = new InputStreamReader(is, "utf-8"); - BufferedReader br = new BufferedReader(isr); - String line = br.readLine(); - while (line != null) { - System.out.println(line); - line = br.readLine(); - } - br.close(); -} -``` - -## Sockets - -- ServerSocket:服务器端类 -- Socket:客户端类 -- 服务器和客户端通过 InputStream 和 OutputStream 进行输入输出。 - -

- -## Datagram - -- DatagramPacket:数据包类 -- DatagramSocket:通信类 - -# 七、NIO - -- [Java NIO Tutorial](http://tutorials.jenkov.com/java-nio/index.html) -- [Java NIO 浅析](https://tech.meituan.com/nio.html) -- [IBM: NIO 入门](https://www.ibm.com/developerworks/cn/education/java/j-nio/j-nio.html) - -新的输入/输出 (NIO) 库是在 JDK 1.4 中引入的。NIO 弥补了原来的 I/O 的不足,提供了高速的、面向块的 I/O。 - -## 流与块 - -I/O 与 NIO 最重要的区别是数据打包和传输的方式,I/O 以流的方式处理数据,而 NIO 以块的方式处理数据。 - -面向流的 I/O 一次处理一个字节数据:一个输入流产生一个字节数据,一个输出流消费一个字节数据。为流式数据创建过滤器非常容易,链接几个过滤器,以便每个过滤器只负责复杂处理机制的一部分。不利的一面是,面向流的 I/O 通常相当慢。 - -面向块的 I/O 一次处理一个数据块,按块处理数据比按流处理数据要快得多。但是面向块的 I/O 缺少一些面向流的 I/O 所具有的优雅性和简单性。 - -I/O 包和 NIO 已经很好地集成了,java.io.\* 已经以 NIO 为基础重新实现了,所以现在它可以利用 NIO 的一些特性。例如,java.io.\* 包中的一些类包含以块的形式读写数据的方法,这使得即使在面向流的系统中,处理速度也会更快。 - -## 通道与缓冲区 - -### 1. 通道 - -通道 Channel 是对原 I/O 包中的流的模拟,可以通过它读取和写入数据。 - -通道与流的不同之处在于,流只能在一个方向上移动,(一个流必须是 InputStream 或者 OutputStream 的子类),而通道是双向的,可以用于读、写或者同时用于读写。 - -通道包括以下类型: - -- FileChannel:从文件中读写数据; -- DatagramChannel:通过 UDP 读写网络中数据; -- SocketChannel:通过 TCP 读写网络中数据; -- ServerSocketChannel:可以监听新进来的 TCP 连接,对每一个新进来的连接都会创建一个 SocketChannel。 - -### 2. 缓冲区 - -发送给一个通道的所有数据都必须首先放到缓冲区中,同样地,从通道中读取的任何数据都要先读到缓冲区中。也就是说,不会直接对通道进行读写数据,而是要先经过缓冲区。 - -缓冲区实质上是一个数组,但它不仅仅是一个数组。缓冲区提供了对数据的结构化访问,而且还可以跟踪系统的读/写进程。 - -缓冲区包括以下类型: - -- ByteBuffer -- CharBuffer -- ShortBuffer -- IntBuffer -- LongBuffer -- FloatBuffer -- DoubleBuffer - -## 缓冲区状态变量 - -- capacity:最大容量; -- position:当前已经读写的字节数; -- limit:还可以读写的字节数。 - -状态变量的改变过程举例: - -① 新建一个大小为 8 个字节的缓冲区,此时 position 为 0,而 limit = capacity = 8。capacity 变量不会改变,下面的讨论会忽略它。 - -

- -② 从输入通道中读取 5 个字节数据写入缓冲区中,此时 position 移动设置为 5,limit 保持不变。 - -

- -③ 在将缓冲区的数据写到输出通道之前,需要先调用 flip() 方法,这个方法将 limit 设置为当前 position,并将 position 设置为 0。 - -

- -④ 从缓冲区中取 4 个字节到输出缓冲中,此时 position 设为 4。 - -

- -⑤ 最后需要调用 clear() 方法来清空缓冲区,此时 position 和 limit 都被设置为最初位置。 - -

- -## 文件 NIO 实例 - -以下展示了使用 NIO 快速复制文件的实例: - -```java -public static void fastCopy(String src, String dist) throws IOException -{ - FileInputStream fin = new FileInputStream(src); /* 获取源文件的输入字节流 */ - FileChannel fcin = fin.getChannel(); /* 获取输入字节流的文件通道 */ - FileOutputStream fout = new FileOutputStream(dist); /* 获取目标文件的输出字节流 */ - FileChannel fcout = fout.getChannel(); /* 获取输出字节流的通道 */ - ByteBuffer buffer = ByteBuffer.allocateDirect(1024); /* 为缓冲区分配 1024 个字节 */ - while (true) { - int r = fcin.read(buffer); /* 从输入通道中读取数据到缓冲区中 */ - if (r == -1) { /* read() 返回 -1 表示 EOF */ - break; - } - buffer.flip(); /* 切换读写 */ - fcout.write(buffer); /* 把缓冲区的内容写入输出文件中 */ - buffer.clear(); /* 清空缓冲区 */ - } -} -``` - -## 选择器 - -NIO 常常被叫做非阻塞 IO,主要是因为 NIO 在网络通信中的非阻塞特性被广泛使用。 - -NIO 实现了 IO 多路复用中的 Reactor 模型,一个线程 Thread 使用一个选择器 Selector 通过轮询的方式去监听多个通道 Channel 上的事件,从而让一个线程就可以处理多个事件。 - -通过配置监听的通道 Channel 为非阻塞,那么当 Channel 上的 IO 事件还未到达时,就不会进入阻塞状态一直等待,而是继续轮询其它 Channel,找到 IO 事件已经到达的 Channel 执行。 - -因为创建和切换线程的开销很大,因此使用一个线程来处理多个事件而不是一个线程处理一个事件具有更好的性能。 - -应该注意的是,只有套接字 Channel 才能配置为非阻塞,而 FileChannel 不能,为 FileChannel 配置非阻塞也没有意义。 - -

- -### 1. 创建选择器 - -```java -Selector selector = Selector.open(); -``` - -### 2. 将通道注册到选择器上 - -```java -ServerSocketChannel ssChannel = ServerSocketChannel.open(); -ssChannel.configureBlocking(false); -ssChannel.register(selector, SelectionKey.OP_ACCEPT); -``` - -通道必须配置为非阻塞模式,否则使用选择器就没有任何意义了,因为如果通道在某个事件上被阻塞,那么服务器就不能响应其它事件,必须等待这个事件处理完毕才能去处理其它事件,显然这和选择器的作用背道而驰。 - -在将通道注册到选择器上时,还需要指定要注册的具体事件,主要有以下几类: - -- SelectionKey.OP_CONNECT -- SelectionKey.OP_ACCEPT -- SelectionKey.OP_READ -- SelectionKey.OP_WRITE - -它们在 SelectionKey 的定义如下: - -```java -public static final int OP_READ = 1 << 0; -public static final int OP_WRITE = 1 << 2; -public static final int OP_CONNECT = 1 << 3; -public static final int OP_ACCEPT = 1 << 4; -``` - -可以看出每个事件可以被当成一个位域,从而组成事件集整数。例如: - -```java -int interestSet = SelectionKey.OP_READ | SelectionKey.OP_WRITE; -``` - -### 3. 监听事件 - -```java -int num = selector.select(); -``` - -使用 select() 来监听到达的事件,它会一直阻塞直到有至少一个事件到达。 - -### 4. 获取到达的事件 - -```java -Set keys = selector.selectedKeys(); -Iterator keyIterator = keys.iterator(); -while (keyIterator.hasNext()) { - SelectionKey key = keyIterator.next(); - if (key.isAcceptable()) { - // ... - } else if (key.isReadable()) { - // ... - } - keyIterator.remove(); -} -``` - -### 5. 事件循环 - -因为一次 select() 调用不能处理完所有的事件,并且服务器端有可能需要一直监听事件,因此服务器端处理事件的代码一般会放在一个死循环内。 - -```java -while (true) { - int num = selector.select(); - Set keys = selector.selectedKeys(); - Iterator keyIterator = keys.iterator(); - while (keyIterator.hasNext()) { - SelectionKey key = keyIterator.next(); - if (key.isAcceptable()) { - // ... - } else if (key.isReadable()) { - // ... - } - keyIterator.remove(); - } -} -``` - -## 套接字 NIO 实例 - -```java -public class NIOServer -{ - public static void main(String[] args) throws IOException - { - Selector selector = Selector.open(); - - ServerSocketChannel ssChannel = ServerSocketChannel.open(); - ssChannel.configureBlocking(false); - ssChannel.register(selector, SelectionKey.OP_ACCEPT); - - ServerSocket serverSocket = ssChannel.socket(); - InetSocketAddress address = new InetSocketAddress("127.0.0.1", 8888); - serverSocket.bind(address); - - while (true) { - selector.select(); - Set keys = selector.selectedKeys(); - Iterator keyIterator = keys.iterator(); - while (keyIterator.hasNext()) { - SelectionKey key = keyIterator.next(); - if (key.isAcceptable()) { - ServerSocketChannel ssChannel1 = (ServerSocketChannel) key.channel(); - // 服务器会为每个新连接创建一个 SocketChannel - SocketChannel sChannel = ssChannel1.accept(); - sChannel.configureBlocking(false); - // 这个新连接主要用于从客户端读取数据 - sChannel.register(selector, SelectionKey.OP_READ); - } else if (key.isReadable()) { - SocketChannel sChannel = (SocketChannel) key.channel(); - System.out.println(readDataFromSocketChannel(sChannel)); - sChannel.close(); - } - keyIterator.remove(); - } - } - } - - private static String readDataFromSocketChannel(SocketChannel sChannel) throws IOException { - ByteBuffer buffer = ByteBuffer.allocate(1024); - StringBuilder data = new StringBuilder(); - while (true) { - buffer.clear(); - int n = sChannel.read(buffer); - if (n == -1) { - break; - } - buffer.flip(); - int limit = buffer.limit(); - char[] dst = new char[limit]; - for (int i = 0; i < limit; i++) { - dst[i] = (char) buffer.get(i); - } - data.append(dst); - buffer.clear(); - } - return data.toString(); - } -} -``` - -```java -public class NIOClient -{ - public static void main(String[] args) throws IOException - { - Socket socket = new Socket("127.0.0.1", 8888); - OutputStream out = socket.getOutputStream(); - String s = "hello world"; - out.write(s.getBytes()); - out.close(); - } -} -``` - -## 内存映射文件 - -内存映射文件 I/O 是一种读和写文件数据的方法,它可以比常规的基于流或者基于通道的 I/O 快得多。 - -向内存映射文件写入可能是危险的,仅只是改变数组的单个元素这样的简单操作,就可能会直接修改磁盘上的文件。修改数据与将数据保存到磁盘是没有分开的。 - -下面代码行将文件的前 1024 个字节映射到内存中,map() 方法返回一个 MappedByteBuffer,它是 ByteBuffer 的子类。因此,您可以像使用其他任何 ByteBuffer 一样使用新映射的缓冲区,操作系统会在需要时负责执行映射。 - -```java -MappedByteBuffer mbb = fc.map(FileChannel.MapMode.READ_WRITE, 0, 1024); -``` - -## 对比 - -NIO 与普通 I/O 的区别主要有以下两点: - -- NIO 是非阻塞的 -- NIO 面向块,I/O 面向流 - -# 八、参考资料 - -- Eckel B, 埃克尔, 昊鹏, 等. Java 编程思想 [M]. 机械工业出版社, 2002. -- [IBM: NIO 入门](https://www.ibm.com/developerworks/cn/education/java/j-nio/j-nio.html) -- [IBM: 深入分析 Java I/O 的工作机制](https://www.ibm.com/developerworks/cn/java/j-lo-javaio/index.html) -- [IBM: 深入分析 Java 中的中文编码问题](https://www.ibm.com/developerworks/cn/java/j-lo-chinesecoding/index.htm) -- [IBM: Java 序列化的高级认识](https://www.ibm.com/developerworks/cn/java/j-lo-serial/index.html) -- [NIO 与传统 IO 的区别](http://blog.csdn.net/shimiso/article/details/24990499) -- [Decorator Design Pattern](http://stg-tud.github.io/sedc/Lecture/ws13-14/5.3-Decorator.html#mode=document) -- [Socket Multicast](http://labojava.blogspot.com/2012/12/socket-multicast.html) diff --git a/notes/Java 基础.md b/notes/Java 基础.md index f5133a6f..bcb94acc 100644 --- a/notes/Java 基础.md +++ b/notes/Java 基础.md @@ -4,7 +4,7 @@ * [缓存池](#缓存池) * [二、String](#二string) * [概览](#概览) - * [String 不可变的好处](#string-不可变的好处) + * [不可变的好处](#不可变的好处) * [String, StringBuffer and StringBuilder](#string,-stringbuffer-and-stringbuilder) * [String.intern()](#stringintern) * [三、运算](#三运算) @@ -153,7 +153,7 @@ public final class String private final char value[]; ``` -## String 不可变的好处 +## 不可变的好处 **1. 可以缓存 hash 值** @@ -212,7 +212,7 @@ String s5 = "bbb"; System.out.println(s4 == s5); // true ``` -在 Java 7 之前,字符串常量池被放在运行时常量池中,它属于永久代。而在 Java 7,字符串常量池被放在堆中。这是因为永久代的空间有限,在大量使用字符串的场景下会导致 OutOfMemoryError 错误。 +在 Java 7 之前,字符串常量池被放在运行时常量池中,它属于永久代。而在 Java 7,字符串常量池被移到 Native Method 中。这是因为永久代的空间有限,在大量使用字符串的场景下会导致 OutOfMemoryError 错误。 - [StackOverflow : What is String interning?](https://stackoverflow.com/questions/10578984/what-is-string-interning) - [深入解析 String#intern](https://tech.meituan.com/in_depth_understanding_string_intern.html) @@ -583,19 +583,7 @@ protected void finalize() throws Throwable {} ## equals() -**1. equals() 与 == 的区别** - -- 对于基本类型,== 判断两个值是否相等,基本类型没有 equals() 方法。 -- 对于引用类型,== 判断两个实例是否引用同一个对象,而 equals() 判断引用的对象是否等价,根据引用的对象的 equals() 方法的具体实现来进行比较。 - -```java -Integer x = new Integer(1); -Integer y = new Integer(1); -System.out.println(x.equals(y)); // true -System.out.println(x == y); // false -``` - -**2. 等价关系** +**1. 等价关系** (一)自反性 @@ -632,6 +620,18 @@ x.equals(y) == x.equals(y); // true x.equals(null); // false; ``` +**2. equals() 与 ==** + +- 对于基本类型,== 判断两个值是否相等,基本类型没有 equals() 方法。 +- 对于引用类型,== 判断两个实例是否引用同一个对象,而 equals() 判断引用的对象是否等价,根据引用对象 equals() 方法的具体实现来进行比较。 + +```java +Integer x = new Integer(1); +Integer y = new Integer(1); +System.out.println(x.equals(y)); // true +System.out.println(x == y); // false +``` + **3. 实现** - 检查是否为同一个对象的引用,如果是直接返回 true; @@ -1005,7 +1005,7 @@ public class A { **4. 静态内部类** -非静态内部类依赖于需要外部类的实例,而静态内部类不需要。 +非静态内部类依赖于外部类的实例,而静态内部类不需要。 ```java public class OuterClass { diff --git a/notes/Java 容器.md b/notes/Java 容器.md index c6fc88f4..649f563a 100644 --- a/notes/Java 容器.md +++ b/notes/Java 容器.md @@ -1,1056 +1,4 @@ -* [一、概览](#一概览) - * [Collection](#collection) - * [Map](#map) -* [二、容器中的设计模式](#二容器中的设计模式) - * [迭代器模式](#迭代器模式) - * [适配器模式](#适配器模式) -* [三、源码分析](#三源码分析) - * [ArrayList](#arraylist) - * [Vector](#vector) - * [CopyOnWriteArrayList](#copyonwritearraylist) - * [LinkedList](#linkedlist) - * [HashMap](#hashmap) - * [ConcurrentHashMap](#concurrenthashmap) - * [LinkedHashMap](#linkedhashmap) - * [WeekHashMap](#weekhashmap) -* [参考资料](#参考资料) -# 一、概览 - -容器主要包括 Collection 和 Map 两种,Collection 又包含了 List、Set 以及 Queue。 - -## Collection - -

- -### 1. Set - -- HashSet:基于哈希实现,支持快速查找,但不支持有序性操作,例如根据一个范围查找元素的操作。并且失去了元素的插入顺序信息,也就是说使用 Iterator 遍历 HashSet 得到的结果是不确定的。 - -- TreeSet:基于红黑树实现,支持有序性操作,但是查找效率不如 HashSet,HashSet 查找时间复杂度为 O(1),TreeSet 则为 O(logN)。 - -- LinkedHashSet:具有 HashSet 的查找效率,且内部使用链表维护元素的插入顺序。 - -### 2. List - -- ArrayList:基于动态数组实现,支持随机访问。 - -- Vector:和 ArrayList 类似,但它是线程安全的。 - -- LinkedList:基于双向链表实现,只能顺序访问,但是可以快速地在链表中间插入和删除元素。不仅如此,LinkedList 还可以用作栈、队列和双向队列。 - -### 3. Queue - -- LinkedList:可以用它来实现双向队列。 - -- PriorityQueue:基于堆结构实现,可以用它来实现优先队列。 - -## Map - -

- -- HashMap:基于哈希实现; - -- HashTable:和 HashMap 类似,但它是线程安全的,这意味着同一时刻多个线程可以同时写入 HashTable 并且不会导致数据不一致。它是遗留类,不应该去使用它。现在可以使用 ConcurrentHashMap 来支持线程安全,并且 ConcurrentHashMap 的效率会更高,因为 ConcurrentHashMap 引入了分段锁。 - -- LinkedHashMap:使用链表来维护元素的顺序,顺序为插入顺序或者最近最少使用(LRU)顺序。 - -- TreeMap:基于红黑树实现。 - -# 二、容器中的设计模式 - -## 迭代器模式 - -

- -Collection 实现了 Iterable 接口,其中的 iterator() 方法能够产生一个 Iterator 对象,通过这个对象就可以迭代遍历 Collection 中的元素。 - -从 JDK 1.5 之后可以使用 foreach 方法来遍历实现了 Iterable 接口的聚合对象。 - -```java -List list = new ArrayList<>(); -list.add("a"); -list.add("b"); -for (String item : list) { - System.out.println(item); -} -``` - -## 适配器模式 - -java.util.Arrays#asList() 可以把数组类型转换为 List 类型。 - -```java -@SafeVarargs -public static List asList(T... a) -``` - -应该注意的是 asList() 的参数为泛型的变长参数,不能使用基本类型数组作为参数,只能使用相应的包装类型数组。 - -```java -Integer[] arr = {1, 2, 3}; -List list = Arrays.asList(arr); -``` - -也可以使用以下方式使用 asList(): - -```java -List list = Arrays.asList(1,2,3); -``` - -# 三、源码分析 - -如果没有特别说明,以下源码分析基于 JDK 1.8。 - -在 IDEA 中 double shift 调出 Search EveryWhere,查找源码文件,找到之后就可以阅读源码。 - -## ArrayList - -### 1. 概览 - -实现了 RandomAccess 接口,因此支持随机访问,这是理所当然的,因为 ArrayList 是基于数组实现的。 - -```java -public class ArrayList extends AbstractList - implements List, RandomAccess, Cloneable, java.io.Serializable -``` - -数组的默认大小为 10。 - -```java -private static final int DEFAULT_CAPACITY = 10; -``` - -### 2. 序列化 - -基于数组实现,保存元素的数组使用 transient 修饰,该关键字声明数组默认不会被序列化。ArrayList 具有动态扩容特性,因此保存元素的数组不一定都会被使用,那么就没必要全部进行序列化。ArrayList 重写了 writeObject() 和 readObject() 来控制只序列化数组中有元素填充那部分内容。 - -```java -transient Object[] elementData; // non-private to simplify nested class access -``` - -### 3. 扩容 - -添加元素时使用 ensureCapacityInternal() 方法来保证容量足够,如果不够时,需要使用 grow() 方法进行扩容,新容量的大小为 `oldCapacity + (oldCapacity >> 1)`,也就是旧容量的 1.5 倍。 - -扩容操作需要调用 `Arrays.copyOf()` 把原数组整个复制到新数组中,这个操作代价很高,因此最好在创建 ArrayList 对象时就指定大概的容量大小,减少扩容操作的次数。 - -```java -public boolean add(E e) { - ensureCapacityInternal(size + 1); // Increments modCount!! - elementData[size++] = e; - return true; -} - -private void ensureCapacityInternal(int minCapacity) { - if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) { - minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity); - } - ensureExplicitCapacity(minCapacity); -} - -private void ensureExplicitCapacity(int minCapacity) { - modCount++; - // overflow-conscious code - if (minCapacity - elementData.length > 0) - grow(minCapacity); -} - -private void grow(int minCapacity) { - // overflow-conscious code - int oldCapacity = elementData.length; - int newCapacity = oldCapacity + (oldCapacity >> 1); - if (newCapacity - minCapacity < 0) - newCapacity = minCapacity; - if (newCapacity - MAX_ARRAY_SIZE > 0) - newCapacity = hugeCapacity(minCapacity); - // minCapacity is usually close to size, so this is a win: - elementData = Arrays.copyOf(elementData, newCapacity); -} -``` - -### 4. 删除元素 - -需要调用 System.arraycopy() 将 index+1 后面的元素都复制到 index 位置上。 - -```java -public E remove(int index) { - rangeCheck(index); - modCount++; - E oldValue = elementData(index); - int numMoved = size - index - 1; - if (numMoved > 0) - System.arraycopy(elementData, index+1, elementData, index, numMoved); - elementData[--size] = null; // clear to let GC do its work - return oldValue; -} -``` - -### 5. Fail-Fast - -modCount 用来记录 ArrayList 结构发生变化的次数。结构发生变化是指添加或者删除至少一个元素的所有操作,或者是调整内部数组的大小,仅仅只是设置元素的值不算结构发生变化。 - -在进行序列化或者迭代等操作时,需要比较操作前后 modCount 是否改变,如果改变了需要抛出 ConcurrentModificationException。 - -```java -private void writeObject(java.io.ObjectOutputStream s) - throws java.io.IOException{ - // Write out element count, and any hidden stuff - int expectedModCount = modCount; - s.defaultWriteObject(); - - // Write out size as capacity for behavioural compatibility with clone() - s.writeInt(size); - - // Write out all elements in the proper order. - for (int i=0; i= elementCount) - throw new ArrayIndexOutOfBoundsException(index); - - return elementData(index); -} -``` - -### 2. 与 ArrayList 的区别 - -- Vector 是同步的,因此开销就比 ArrayList 要大,访问速度更慢。最好使用 ArrayList 而不是 Vector,因为同步操作完全可以由程序员自己来控制; -- Vector 每次扩容请求其大小的 2 倍空间,而 ArrayList 是 1.5 倍。 - -### 3. 替代方案 - -为了获得线程安全的 ArrayList,可以使用 `Collections.synchronizedList();` 得到一个线程安全的 ArrayList。 - -```java -List list = new ArrayList<>(); -List synList = Collections.synchronizedList(list); -``` - -也可以使用 concurrent 并发包下的 CopyOnWriteArrayList 类。 - -```java -List list = new CopyOnWriteArrayList<>(); -``` - -## CopyOnWriteArrayList - -### 读写分离 - -写操作在一个复制的数组上进行,读操作还是在原始数组中进行,读写分离,互不影响。 - -写操作需要加锁,防止同时并发写入时导致的写入数据丢失。 - -写操作结束之后需要把原始数组指向新的复制数组。 - -```java -public boolean add(E e) { - final ReentrantLock lock = this.lock; - lock.lock(); - try { - Object[] elements = getArray(); - int len = elements.length; - Object[] newElements = Arrays.copyOf(elements, len + 1); - newElements[len] = e; - setArray(newElements); - return true; - } finally { - lock.unlock(); - } -} - -final void setArray(Object[] a) { - array = a; -} -``` - -```java -@SuppressWarnings("unchecked") -private E get(Object[] a, int index) { - return (E) a[index]; -} -``` - -### 适用场景 - -CopyOnWriteArrayList 在写操作的同时允许读操作,大大提高了读操作的性能,因此很适合读多写少的应用场景。 - -但是 CopyOnWriteArrayList 有其缺陷: - -- 内存占用:在写操作时需要复制一个新的数组,使得内存占用为原来的两倍左右; -- 数据不一致:读操作不能读取实时性的数据,因为部分写操作的数据还未同步到读数组中。 - -所以 CopyOnWriteArrayList 不适合内存敏感以及对实时性要求很高的场景。 - -## LinkedList - -### 1. 概览 - -基于双向链表实现,内部使用 Node 来存储链表节点信息。 - -```java -private static class Node { - E item; - Node next; - Node prev; -} -``` - -每个链表存储了 first 和 last 指针: - -```java -transient Node first; -transient Node last; -``` - -

- -### 2. ArrayList 与 LinkedList - -- ArrayList 基于动态数组实现,LinkedList 基于双向链表实现; -- ArrayList 支持随机访问,LinkedList 不支持; -- LinkedList 在任意位置添加删除元素更快。 - -## HashMap - -为了便于理解,以下源码分析以 JDK 1.7 为主。 - -### 1. 存储结构 - -内部包含了一个 Entry 类型的数组 table。 - -```java -transient Entry[] table; -``` - -其中,Entry 就是存储数据的键值对,它包含了四个字段。从 next 字段我们可以看出 Entry 是一个链表,即数组中的每个位置被当成一个桶,一个桶存放一个链表,链表中存放哈希值相同的 Entry。也就是说,HashMap 使用拉链法来解决冲突。 - -

- -```java -static class Entry implements Map.Entry { - final K key; - V value; - Entry next; - int hash; - - Entry(int h, K k, V v, Entry n) { - value = v; - next = n; - key = k; - hash = h; - } - - public final K getKey() { - return key; - } - - public final V getValue() { - return value; - } - - public final V setValue(V newValue) { - V oldValue = value; - value = newValue; - return oldValue; - } - - public final boolean equals(Object o) { - if (!(o instanceof Map.Entry)) - return false; - Map.Entry e = (Map.Entry)o; - Object k1 = getKey(); - Object k2 = e.getKey(); - if (k1 == k2 || (k1 != null && k1.equals(k2))) { - Object v1 = getValue(); - Object v2 = e.getValue(); - if (v1 == v2 || (v1 != null && v1.equals(v2))) - return true; - } - return false; - } - - public final int hashCode() { - return Objects.hashCode(getKey()) ^ Objects.hashCode(getValue()); - } - - public final String toString() { - return getKey() + "=" + getValue(); - } - - /** - * This method is invoked whenever the value in an entry is - * overwritten by an invocation of put(k,v) for a key k that's already - * in the HashMap. - */ - void recordAccess(HashMap m) { - } - - /** - * This method is invoked whenever the entry is - * removed from the table. - */ - void recordRemoval(HashMap m) { - } -} -``` - -### 2. 拉链法的工作原理 - -```java -HashMap map = new HashMap<>(); -map.put("K1", "V1"); -map.put("K2", "V2"); -map.put("K3", "V3"); -``` - -- 新建一个 HashMap,默认大小为 16; -- 插入 <K1,V1> 键值对,先计算 K1 的 hashCode 为 115,使用除留余数法得到所在的桶下标 115%16=3。 -- 插入 <K2,V2> 键值对,先计算 K2 的 hashCode 为 118,使用除留余数法得到所在的桶下标 118%16=6。 -- 插入 <K3,V3> 键值对,先计算 K3 的 hashCode 为 118,使用除留余数法得到所在的桶下标 118%16=6,插在 <K2,V2> 前面。 - -应该注意到链表的插入是以头插法方式进行的,例如上面的 <K3,V3> 不是插在 <K2,V2> 后面,而是插入在链表头部。 - -查找需要分成两步进行: - -- 计算键值对所在的桶; -- 在链表上顺序查找,时间复杂度显然和链表的长度成正比。 - -

- -### 3. put 操作 - -```java -public V put(K key, V value) { - if (table == EMPTY_TABLE) { - inflateTable(threshold); - } - // 键为 null 单独处理 - if (key == null) - return putForNullKey(value); - int hash = hash(key); - // 确定桶下标 - int i = indexFor(hash, table.length); - // 先找出是否已经存在键为 key 的键值对,如果存在的话就更新这个键值对的值为 value - for (Entry e = table[i]; e != null; e = e.next) { - Object k; - if (e.hash == hash && ((k = e.key) == key || key.equals(k))) { - V oldValue = e.value; - e.value = value; - e.recordAccess(this); - return oldValue; - } - } - - modCount++; - // 插入新键值对 - addEntry(hash, key, value, i); - return null; -} -``` - -HashMap 允许插入键为 null 的键值对。因为无法调用 null 的 hashCode(),也就无法确定该键值对的桶下标,只能通过强制指定一个桶下标来存放。HashMap 使用第 0 个桶存放键为 null 的键值对。 - -```java -private V putForNullKey(V value) { - for (Entry e = table[0]; e != null; e = e.next) { - if (e.key == null) { - V oldValue = e.value; - e.value = value; - e.recordAccess(this); - return oldValue; - } - } - modCount++; - addEntry(0, null, value, 0); - return null; -} -``` - -使用链表的头插法,也就是新的键值对插在链表的头部,而不是链表的尾部。 - -```java -void addEntry(int hash, K key, V value, int bucketIndex) { - if ((size >= threshold) && (null != table[bucketIndex])) { - resize(2 * table.length); - hash = (null != key) ? hash(key) : 0; - bucketIndex = indexFor(hash, table.length); - } - - createEntry(hash, key, value, bucketIndex); -} - -void createEntry(int hash, K key, V value, int bucketIndex) { - Entry e = table[bucketIndex]; - // 头插法,链表头部指向新的键值对 - table[bucketIndex] = new Entry<>(hash, key, value, e); - size++; -} -``` - -```java -Entry(int h, K k, V v, Entry n) { - value = v; - next = n; - key = k; - hash = h; -} -``` - -### 4. 确定桶下标 - -很多操作都需要先确定一个键值对所在的桶下标。 - -```java -int hash = hash(key); -int i = indexFor(hash, table.length); -``` - -(一)计算 hash 值 - -```java -final int hash(Object k) { - int h = hashSeed; - if (0 != h && k instanceof String) { - return sun.misc.Hashing.stringHash32((String) k); - } - - h ^= k.hashCode(); - - // This function ensures that hashCodes that differ only by - // constant multiples at each bit position have a bounded - // number of collisions (approximately 8 at default load factor). - h ^= (h >>> 20) ^ (h >>> 12); - return h ^ (h >>> 7) ^ (h >>> 4); -} -``` - -```java -public final int hashCode() { - return Objects.hashCode(key) ^ Objects.hashCode(value); -} -``` - -(二)取模 - -令 x = 1<<4,即 x 为 2 的 4 次方,它具有以下性质: - -``` -x : 00010000 -x-1 : 00001111 -``` - -令一个数 y 与 x-1 做与运算,可以去除 y 位级表示的第 4 位以上数: - -``` -y : 10110010 -x-1 : 00001111 -y&(x-1) : 00000010 -``` - -这个性质和 y 对 x 取模效果是一样的: - -``` -x : 00010000 -y : 10110010 -y%x : 00000010 -``` - -我们知道,位运算的代价比求模运算小的多,因此在进行这种计算时用位运算的话能带来更高的性能。 - -确定桶下标的最后一步是将 key 的 hash 值对桶个数取模:hash%capacity,如果能保证 capacity 为 2 的 n 次方,那么就可以将这个操作转换为位运算。 - -```java -static int indexFor(int h, int length) { - return h & (length-1); -} -``` - -### 5. 扩容-基本原理 - -设 HashMap 的 table 长度为 M,需要存储的键值对数量为 N,如果哈希函数满足均匀性的要求,那么每条链表的长度大约为 N/M,因此平均查找次数的复杂度为 O(N/M)。 - -为了让查找的成本降低,应该尽可能使得 N/M 尽可能小,因此需要保证 M 尽可能大,也就是说 table 要尽可能大。HashMap 采用动态扩容来根据当前的 N 值来调整 M 值,使得空间效率和时间效率都能得到保证。 - -和扩容相关的参数主要有:capacity、size、threshold 和 load_factor。 - -| 参数 | 含义 | -| :--: | :-- | -| capacity | table 的容量大小,默认为 16,需要注意的是 capacity 必须保证为 2 的 n 次方。| -| size | table 的实际使用量。 | -| threshold | size 的临界值,size 必须小于 threshold,如果大于等于,就必须进行扩容操作。 | -| load_factor | 装载因子,table 能够使用的比例,threshold = capacity * load_factor。| - -```java -static final int DEFAULT_INITIAL_CAPACITY = 16; - -static final int MAXIMUM_CAPACITY = 1 << 30; - -static final float DEFAULT_LOAD_FACTOR = 0.75f; - -transient Entry[] table; - -transient int size; - -int threshold; - -final float loadFactor; - -transient int modCount; -``` - -从下面的添加元素代码中可以看出,当需要扩容时,令 capacity 为原来的两倍。 - -```java -void addEntry(int hash, K key, V value, int bucketIndex) { - Entry e = table[bucketIndex]; - table[bucketIndex] = new Entry<>(hash, key, value, e); - if (size++ >= threshold) - resize(2 * table.length); -} -``` - -扩容使用 resize() 实现,需要注意的是,扩容操作同样需要把旧 table 的所有键值对重新插入新的 table 中,因此这一步是很费时的。 - -```java -void resize(int newCapacity) { - Entry[] oldTable = table; - int oldCapacity = oldTable.length; - if (oldCapacity == MAXIMUM_CAPACITY) { - threshold = Integer.MAX_VALUE; - return; - } - Entry[] newTable = new Entry[newCapacity]; - transfer(newTable); - table = newTable; - threshold = (int)(newCapacity * loadFactor); -} - -void transfer(Entry[] newTable) { - Entry[] src = table; - int newCapacity = newTable.length; - for (int j = 0; j < src.length; j++) { - Entry e = src[j]; - if (e != null) { - src[j] = null; - do { - Entry next = e.next; - int i = indexFor(e.hash, newCapacity); - e.next = newTable[i]; - newTable[i] = e; - e = next; - } while (e != null); - } - } -} -``` - -### 6. 扩容-重新计算桶下标 - -在进行扩容时,需要把键值对重新放到对应的桶上。HashMap 使用了一个特殊的机制,可以降低重新计算桶下标的操作。 - -假设原数组长度 capacity 为 8,扩容之后 new capacity 为 16: - -```html -capacity : 00010000 -new capacity : 00100000 -``` - -对于一个 Key,它的哈希值如果在第 6 位上为 0,那么取模得到的结果和之前一样;如果为 1,那么得到的结果为原来的结果 + 8。 - -### 7. 扩容-计算数组容量 - -HashMap 构造函数允许用户传入的容量不是 2 的 n 次方,因为它可以自动地将传入的容量转换为 2 的 n 次方。 - -先考虑如何求一个数的掩码,对于 10010000,它的掩码为 11111111,可以使用以下方法得到: - -``` -mask |= mask >> 1 11011000 -mask |= mask >> 2 11111100 -mask |= mask >> 4 11111111 -``` - -mask+1 是大于原始数字的最小的 2 的 n 次方。 - -``` -num 10010000 -mask+1 100000000 -``` - -以下是 HashMap 中计算数组容量的代码: - -```java -static final int tableSizeFor(int cap) { - int n = cap - 1; - n |= n >>> 1; - n |= n >>> 2; - n |= n >>> 4; - n |= n >>> 8; - n |= n >>> 16; - return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1; -} -``` - -### 8. 链表转红黑树 - -从 JDK 1.8 开始,一个桶存储的链表长度大于 8 时会将链表转换为红黑树。 - -### 9. HashMap 与 HashTable - -- HashTable 使用 synchronized 来进行同步。 -- HashMap 可以插入键为 null 的 Entry。 -- HashMap 的迭代器是 fail-fast 迭代器。 -- HashMap 不能保证随着时间的推移 Map 中的元素次序是不变的。 - -## ConcurrentHashMap - -### 1. 存储结构 - -```java -static final class HashEntry { - final int hash; - final K key; - volatile V value; - volatile HashEntry next; -} -``` - -ConcurrentHashMap 和 HashMap 实现上类似,最主要的差别是 ConcurrentHashMap 采用了分段锁(Segment),每个分段锁维护着几个桶(HashEntry),多个线程可以同时访问不同分段锁上的桶,从而使其并发度更高(并发度就是 Segment 的个数)。 - -Segment 继承自 ReentrantLock。 - -```java -static final class Segment extends ReentrantLock implements Serializable { - - private static final long serialVersionUID = 2249069246763182397L; - - static final int MAX_SCAN_RETRIES = - Runtime.getRuntime().availableProcessors() > 1 ? 64 : 1; - - transient volatile HashEntry[] table; - - transient int count; - - transient int modCount; - - transient int threshold; - - final float loadFactor; -} -``` - -```java -final Segment[] segments; -``` - -默认的并发级别为 16,也就是说默认创建 16 个 Segment。 - -```java -static final int DEFAULT_CONCURRENCY_LEVEL = 16; -``` - -

- -### 2. size 操作 - -每个 Segment 维护了一个 count 变量来统计该 Segment 中的键值对个数。 - -```java -/** - * The number of elements. Accessed only either within locks - * or among other volatile reads that maintain visibility. - */ -transient int count; -``` - -在执行 size 操作时,需要遍历所有 Segment 然后把 count 累计起来。 - -ConcurrentHashMap 在执行 size 操作时先尝试不加锁,如果连续两次不加锁操作得到的结果一致,那么可以认为这个结果是正确的。 - -尝试次数使用 RETRIES_BEFORE_LOCK 定义,该值为 2,retries 初始值为 -1,因此尝试次数为 3。 - -如果尝试的次数超过 3 次,就需要对每个 Segment 加锁。 - -```java - -/** - * Number of unsynchronized retries in size and containsValue - * methods before resorting to locking. This is used to avoid - * unbounded retries if tables undergo continuous modification - * which would make it impossible to obtain an accurate result. - */ -static final int RETRIES_BEFORE_LOCK = 2; - -public int size() { - // Try a few times to get accurate count. On failure due to - // continuous async changes in table, resort to locking. - final Segment[] segments = this.segments; - int size; - boolean overflow; // true if size overflows 32 bits - long sum; // sum of modCounts - long last = 0L; // previous sum - int retries = -1; // first iteration isn't retry - try { - for (;;) { - // 超过尝试次数,则对每个 Segment 加锁 - if (retries++ == RETRIES_BEFORE_LOCK) { - for (int j = 0; j < segments.length; ++j) - ensureSegment(j).lock(); // force creation - } - sum = 0L; - size = 0; - overflow = false; - for (int j = 0; j < segments.length; ++j) { - Segment seg = segmentAt(segments, j); - if (seg != null) { - sum += seg.modCount; - int c = seg.count; - if (c < 0 || (size += c) < 0) - overflow = true; - } - } - // 连续两次得到的结果一致,则认为这个结果是正确的 - if (sum == last) - break; - last = sum; - } - } finally { - if (retries > RETRIES_BEFORE_LOCK) { - for (int j = 0; j < segments.length; ++j) - segmentAt(segments, j).unlock(); - } - } - return overflow ? Integer.MAX_VALUE : size; -} -``` - -### 3. JDK 1.8 的改动 - -JDK 1.7 使用分段锁机制来实现并发更新操作,核心类为 Segment,它继承自重入锁 ReentrantLock,并发程度与 Segment 数量相等。 - -JDK 1.8 使用了 CAS 操作来支持更高的并发度,在 CAS 操作失败时使用内置锁 synchronized。 - -并且 JDK 1.8 的实现也在链表过长时会转换为红黑树。 - -## LinkedHashMap - -### 存储结构 - -继承自 HashMap,因此具有和 HashMap 一样的快速查找特性。 - -```java -public class LinkedHashMap extends HashMap implements Map -``` - -内存维护了一个双向循环链表,用来维护插入顺序或者 LRU 顺序。 - -```java -/** - * The head (eldest) of the doubly linked list. - */ -transient LinkedHashMap.Entry head; - -/** - * The tail (youngest) of the doubly linked list. - */ -transient LinkedHashMap.Entry tail; -``` - -顺序使用 accessOrder 来决定,默认为 false,此时使用的是插入顺序。 - -```java -final boolean accessOrder; -``` - -LinkedHashMap 最重要的是以下用于记录顺序的函数,它们会在 put、get 等方法中调用。 - -```java -void afterNodeAccess(Node p) { } -void afterNodeInsertion(boolean evict) { } -``` - -### afterNodeAccess() - -当一个 Node 被访问时,如果 accessOrder 为 true,会将它移到链表尾部。也就是说指定为 LRU 顺序之后,在每次访问一个节点时,会将这个节点移到链表尾部,保证链表尾部是最近访问的节点,那么链表首部就是最近最久未使用的节点。 - -```java -void afterNodeAccess(Node e) { // move node to last - LinkedHashMap.Entry last; - if (accessOrder && (last = tail) != e) { - LinkedHashMap.Entry p = - (LinkedHashMap.Entry)e, b = p.before, a = p.after; - p.after = null; - if (b == null) - head = a; - else - b.after = a; - if (a != null) - a.before = b; - else - last = b; - if (last == null) - head = p; - else { - p.before = last; - last.after = p; - } - tail = p; - ++modCount; - } -} -``` - -### afterNodeInsertion() - -在 put 等操作之后执行,当 removeEldestEntry() 方法返回 ture 时会移除最晚的节点,也就是链表首部节点 first。 - -evict 只有在构建 Map 的时候才为 false,在这里为 true。 - -```java -void afterNodeInsertion(boolean evict) { // possibly remove eldest - LinkedHashMap.Entry first; - if (evict && (first = head) != null && removeEldestEntry(first)) { - K key = first.key; - removeNode(hash(key), key, null, false, true); - } -} -``` - -removeEldestEntry() 默认为 false,如果需要让它为 true,需要继承 LinkedHashMap 并且覆盖这个方法的实现,这在实现 LRU 的缓存中特别有用,通过移除最近最久未使用的节点,从而保证缓存空间足够,并且缓存的数据都是热点数据。 - -```java -protected boolean removeEldestEntry(Map.Entry eldest) { - return false; - } -``` - -### LRU 缓存 - -以下是使用 LinkedHashMap 实现的一个 LRU 缓存,设定最大缓存空间 MAX_ENTRIES 为 3。使用 LinkedHashMap 的构造函数将 accessOrder 设置为 true,开启 LUR 顺序。覆盖 removeEldestEntry() 方法实现,在节点多于 MAX_ENTRIES 就会将最近最久未使用的数据移除。 - -```java -class LRUCache extends LinkedHashMap { - private static final int MAX_ENTRIES = 3; - - protected boolean removeEldestEntry(Map.Entry eldest) { - return size() > MAX_ENTRIES; - } - - LRUCache() { - super(MAX_ENTRIES, 0.75f, true); - } -} -``` - -```java -public static void main(String[] args) { - LRUCache cache = new LRUCache<>(); - cache.put(1, "a"); - cache.put(2, "b"); - cache.put(3, "c"); - cache.get(1); - cache.put(4, "d"); - System.out.println(cache.keySet()); -} -``` - -```html -[3, 1, 4] -``` - -## WeekHashMap - -### 存储结构 - -WeakHashMap 的 Entry 继承自 WeakReference,被 WeakReference 关联的对象在下一次垃圾回收时会被回收。 - -WeakHashMap 主要用来实现缓存,通过使用 WeakHashMap 来引用缓存对象,由 JVM 对这部分缓存进行回收。 - -```java -private static class Entry extends WeakReference implements Map.Entry -``` - -### ConcurrentCache - -Tomcat 中的 ConcurrentCache 就使用了 WeakHashMap 来实现缓存功能。 - -ConcurrentCache 采取的是分代缓存: - -- 经常使用的对象放入 eden 中,eden 使用 ConcurrentHashMap 实现,不用担心会被回收(伊甸园); -- 不常用的对象放入 longterm,longterm 使用 WeakHashMap 实现,用来存放比较老的对象,这些老对象会被垃圾收集器回收。 - -```java -public final class ConcurrentCache { - - private final int size; - - private final Map eden; - - private final Map longterm; - - public ConcurrentCache(int size) { - this.size = size; - this.eden = new ConcurrentHashMap<>(size); - this.longterm = new WeakHashMap<>(size); - } - - public V get(K k) { - V v = this.eden.get(k); - if (v == null) { - v = this.longterm.get(k); - if (v != null) - this.eden.put(k, v); - } - return v; - } - - public void put(K k, V v) { - if (this.eden.size() >= size) { - this.longterm.putAll(this.eden); - this.eden.clear(); - } - this.eden.put(k, v); - } -} -``` - - -# 参考资料 - -- Eckel B. Java 编程思想 [M]. 机械工业出版社, 2002. -- [Java Collection Framework](https://www.w3resource.com/java-tutorial/java-collections.php) -- [Iterator 模式](https://openhome.cc/Gossip/DesignPattern/IteratorPattern.htm) -- [Java 8 系列之重新认识 HashMap](https://tech.meituan.com/java-hashmap.html) -- [What is difference between HashMap and Hashtable in Java?](http://javarevisited.blogspot.hk/2010/10/difference-between-hashmap-and.html) -- [Java 集合之 HashMap](http://www.zhangchangle.com/2018/02/07/Java%E9%9B%86%E5%90%88%E4%B9%8BHashMap/) -- [The principle of ConcurrentHashMap analysis](http://www.programering.com/a/MDO3QDNwATM.html) -- [探索 ConcurrentHashMap 高并发性的实现机制](https://www.ibm.com/developerworks/cn/java/java-lo-concurrenthashmap/) -- [HashMap 相关面试题及其解答](https://www.jianshu.com/p/75adf47958a7) -- [Java 集合细节(二):asList 的缺陷](http://wiki.jikexueyuan.com/project/java-enhancement/java-thirtysix.html) -- [Java Collection Framework – The LinkedList Class](http://javaconceptoftheday.com/java-collection-framework-linkedlist-class/) - diff --git a/notes/Redis.md b/notes/Redis.md index c54287c8..649f563a 100644 --- a/notes/Redis.md +++ b/notes/Redis.md @@ -1,606 +1,4 @@ -* [一、概述](#一概述) -* [二、数据类型](#二数据类型) - * [STRING](#string) - * [LIST](#list) - * [SET](#set) - * [HASH](#hash) - * [ZSET](#zset) -* [三、数据结构](#三数据结构) - * [字典](#字典) - * [跳跃表](#跳跃表) -* [四、使用场景](#四使用场景) - * [计数器](#计数器) - * [缓存](#缓存) - * [查找表](#查找表) - * [消息队列](#消息队列) - * [会话缓存](#会话缓存) - * [分布式锁实现](#分布式锁实现) - * [其它](#其它) -* [五、Redis 与 Memcached](#五redis-与-memcached) - * [数据类型](#数据类型) - * [数据持久化](#数据持久化) - * [分布式](#分布式) - * [内存管理机制](#内存管理机制) -* [六、键的过期时间](#六键的过期时间) -* [七、数据淘汰策略](#七数据淘汰策略) -* [八、持久化](#八持久化) - * [RDB 持久化](#rdb-持久化) - * [AOF 持久化](#aof-持久化) -* [九、事务](#九事务) -* [十、事件](#十事件) - * [文件事件](#文件事件) - * [时间事件](#时间事件) - * [事件的调度与执行](#事件的调度与执行) -* [十一、复制](#十一复制) - * [连接过程](#连接过程) - * [主从链](#主从链) -* [十二、Sentinel](#十二sentinel) -* [十三、分片](#十三分片) -* [十四、一个简单的论坛系统分析](#十四一个简单的论坛系统分析) - * [文章信息](#文章信息) - * [点赞功能](#点赞功能) - * [对文章进行排序](#对文章进行排序) -* [参考资料](#参考资料) -# 一、概述 - -Redis 是速度非常快的非关系型(NoSQL)内存键值数据库,可以存储键和五种不同类型的值之间的映射。 - -键的类型只能为字符串,值支持的五种类型数据类型为:字符串、列表、集合、有序集合、散列表。 - -Redis 支持很多特性,例如将内存中的数据持久化到硬盘中,使用复制来扩展读性能,使用分片来扩展写性能。 - -# 二、数据类型 - -| 数据类型 | 可以存储的值 | 操作 | -| :--: | :--: | :--: | -| STRING | 字符串、整数或者浮点数 | 对整个字符串或者字符串的其中一部分执行操作
对整数和浮点数执行自增或者自减操作 | -| LIST | 列表 | 从两端压入或者弹出元素
读取单个或者多个元素
进行修剪,只保留一个范围内的元素 | -| SET | 无序集合 | 添加、获取、移除单个元素
检查一个元素是否存在于集合中
计算交集、并集、差集
从集合里面随机获取元素 | -| HASH | 包含键值对的无序散列表 | 添加、获取、移除单个键值对
获取所有键值对
检查某个键是否存在| -| ZSET | 有序集合 | 添加、获取、删除元素
根据分值范围或者成员来获取元素
计算一个键的排名 | - -> [What Redis data structures look like](https://redislabs.com/ebook/part-1-getting-started/chapter-1-getting-to-know-redis/1-2-what-redis-data-structures-look-like/) - -## STRING - -

- -```html -> set hello world -OK -> get hello -"world" -> del hello -(integer) 1 -> get hello -(nil) -``` - -## LIST - -

- -```html -> rpush list-key item -(integer) 1 -> rpush list-key item2 -(integer) 2 -> rpush list-key item -(integer) 3 - -> lrange list-key 0 -1 -1) "item" -2) "item2" -3) "item" - -> lindex list-key 1 -"item2" - -> lpop list-key -"item" - -> lrange list-key 0 -1 -1) "item2" -2) "item" -``` - -## SET - -

- -```html -> sadd set-key item -(integer) 1 -> sadd set-key item2 -(integer) 1 -> sadd set-key item3 -(integer) 1 -> sadd set-key item -(integer) 0 - -> smembers set-key -1) "item" -2) "item2" -3) "item3" - -> sismember set-key item4 -(integer) 0 -> sismember set-key item -(integer) 1 - -> srem set-key item2 -(integer) 1 -> srem set-key item2 -(integer) 0 - -> smembers set-key -1) "item" -2) "item3" -``` - -## HASH - -

- -```html -> hset hash-key sub-key1 value1 -(integer) 1 -> hset hash-key sub-key2 value2 -(integer) 1 -> hset hash-key sub-key1 value1 -(integer) 0 - -> hgetall hash-key -1) "sub-key1" -2) "value1" -3) "sub-key2" -4) "value2" - -> hdel hash-key sub-key2 -(integer) 1 -> hdel hash-key sub-key2 -(integer) 0 - -> hget hash-key sub-key1 -"value1" - -> hgetall hash-key -1) "sub-key1" -2) "value1" -``` - -## ZSET - -

- -```html -> zadd zset-key 728 member1 -(integer) 1 -> zadd zset-key 982 member0 -(integer) 1 -> zadd zset-key 982 member0 -(integer) 0 - -> zrange zset-key 0 -1 withscores -1) "member1" -2) "728" -3) "member0" -4) "982" - -> zrangebyscore zset-key 0 800 withscores -1) "member1" -2) "728" - -> zrem zset-key member1 -(integer) 1 -> zrem zset-key member1 -(integer) 0 - -> zrange zset-key 0 -1 withscores -1) "member0" -2) "982" -``` - -# 三、数据结构 - -## 字典 - -dictht 是一个散列表结构,使用拉链法保存哈希冲突的 dictEntry。 - -```c -/* This is our hash table structure. Every dictionary has two of this as we - * implement incremental rehashing, for the old to the new table. */ -typedef struct dictht { - dictEntry **table; - unsigned long size; - unsigned long sizemask; - unsigned long used; -} dictht; -``` - -```c -typedef struct dictEntry { - void *key; - union { - void *val; - uint64_t u64; - int64_t s64; - double d; - } v; - struct dictEntry *next; -} dictEntry; -``` - -Redis 的字典 dict 中包含两个哈希表 dictht,这是为了方便进行 rehash 操作。在扩容时,将其中一个 dictht 上的键值对 rehash 到另一个 dictht 上面,完成之后释放空间并交换两个 dictht 的角色。 - -```c -typedef struct dict { - dictType *type; - void *privdata; - dictht ht[2]; - long rehashidx; /* rehashing not in progress if rehashidx == -1 */ - unsigned long iterators; /* number of iterators currently running */ -} dict; -``` - -rehash 操作不是一次性完成,而是采用渐进方式,这是为了避免一次性执行过多的 rehash 操作给服务器带来过大的负担。 - -渐进式 rehash 通过记录 dict 的 rehashidx 完成,它从 0 开始,然后每执行一次 rehash 都会递增。例如在一次 rehash 中,要把 dict[0] rehash 到 dict[1],这一次会把 dict[0] 上 table[rehashidx] 的键值对 rehash 到 dict[1] 上,dict[0] 的 table[rehashidx] 指向 null,并令 rehashidx++。 - -在 rehash 期间,每次对字典执行添加、删除、查找或者更新操作时,都会执行一次渐进式 rehash。 - -采用渐进式 rehash 会导致字典中的数据分散在两个 dictht 上,因此对字典的操作也需要到对应的 dictht 去执行。 - -```c -/* Performs N steps of incremental rehashing. Returns 1 if there are still - * keys to move from the old to the new hash table, otherwise 0 is returned. - * - * Note that a rehashing step consists in moving a bucket (that may have more - * than one key as we use chaining) from the old to the new hash table, however - * since part of the hash table may be composed of empty spaces, it is not - * guaranteed that this function will rehash even a single bucket, since it - * will visit at max N*10 empty buckets in total, otherwise the amount of - * work it does would be unbound and the function may block for a long time. */ -int dictRehash(dict *d, int n) { - int empty_visits = n * 10; /* Max number of empty buckets to visit. */ - if (!dictIsRehashing(d)) return 0; - - while (n-- && d->ht[0].used != 0) { - dictEntry *de, *nextde; - - /* Note that rehashidx can't overflow as we are sure there are more - * elements because ht[0].used != 0 */ - assert(d->ht[0].size > (unsigned long) d->rehashidx); - while (d->ht[0].table[d->rehashidx] == NULL) { - d->rehashidx++; - if (--empty_visits == 0) return 1; - } - de = d->ht[0].table[d->rehashidx]; - /* Move all the keys in this bucket from the old to the new hash HT */ - while (de) { - uint64_t h; - - nextde = de->next; - /* Get the index in the new hash table */ - h = dictHashKey(d, de->key) & d->ht[1].sizemask; - de->next = d->ht[1].table[h]; - d->ht[1].table[h] = de; - d->ht[0].used--; - d->ht[1].used++; - de = nextde; - } - d->ht[0].table[d->rehashidx] = NULL; - d->rehashidx++; - } - - /* Check if we already rehashed the whole table... */ - if (d->ht[0].used == 0) { - zfree(d->ht[0].table); - d->ht[0] = d->ht[1]; - _dictReset(&d->ht[1]); - d->rehashidx = -1; - return 0; - } - - /* More to rehash... */ - return 1; -} -``` - -## 跳跃表 - -是有序集合的底层实现之一。 - -跳跃表是基于多指针有序链表实现的,可以看成多个有序链表。 - -

- -在查找时,从上层指针开始查找,找到对应的区间之后再到下一层去查找。下图演示了查找 22 的过程。 - -

- -与红黑树等平衡树相比,跳跃表具有以下优点: - -- 插入速度非常快速,因为不需要进行旋转等操作来维护平衡性; -- 更容易实现; -- 支持无锁操作。 - -# 四、使用场景 - -## 计数器 - -可以对 String 进行自增自减运算,从而实现计数器功能。 - -Redis 这种内存型数据库的读写性能非常高,很适合存储频繁读写的计数量。 - -## 缓存 - -将热点数据放到内存中,设置内存的最大使用量以及淘汰策略来保证缓存的命中率。 - -## 查找表 - -例如 DNS 记录就很适合使用 Redis 进行存储。 - -查找表和缓存类似,也是利用了 Redis 快速的查找特性。但是查找表的内容不能失效,而缓存的内容可以失效,因为缓存不作为可靠的数据来源。 - -## 消息队列 - -List 是一个双向链表,可以通过 lpop 和 lpush 写入和读取消息。 - -不过最好使用 Kafka、RabbitMQ 等消息中间件。 - -## 会话缓存 - -在分布式场景下具有多个应用服务器,可以使用 Redis 来统一存储这些应用服务器的会话信息。 - -当应用服务器不再存储用户的会话信息,也就不再具有状态,一个用户可以请求任意一个应用服务器。 - -## 分布式锁实现 - -在分布式场景下,无法使用单机环境下的锁来对多个节点上的进程进行同步。 - -可以使用 Reids 自带的 SETNX 命令实现分布式锁,除此之外,还可以使用官方提供的 RedLock 分布式锁实现。 - -## 其它 - -Set 可以实现交集、并集等操作,从而实现共同好友等功能。 - -ZSet 可以实现有序性操作,从而实现排行榜等功能。 - -# 五、Redis 与 Memcached - -两者都是非关系型内存键值数据库,主要有以下不同: - -## 数据类型 - -Memcached 仅支持字符串类型,而 Redis 支持五种不同的数据类型,可以更灵活地解决问题。 - -## 数据持久化 - -Redis 支持两种持久化策略:RDB 快照和 AOF 日志,而 Memcached 不支持持久化。 - -## 分布式 - -Memcached 不支持分布式,只能通过在客户端使用一致性哈希来实现分布式存储,这种方式在存储和查询时都需要先在客户端计算一次数据所在的节点。 - -Redis Cluster 实现了分布式的支持。 - -## 内存管理机制 - -- 在 Redis 中,并不是所有数据都一直存储在内存中,可以将一些很久没用的 value 交换到磁盘,而 Memcached 的数据则会一直在内存中。 - -- Memcached 将内存分割成特定长度的块来存储数据,以完全解决内存碎片的问题,但是这种方式会使得内存的利用率不高,例如块的大小为 128 bytes,只存储 100 bytes 的数据,那么剩下的 28 bytes 就浪费掉了。 - -# 六、键的过期时间 - -Redis 可以为每个键设置过期时间,当键过期时,会自动删除该键。 - -对于散列表这种容器,只能为整个键设置过期时间(整个散列表),而不能为键里面的单个元素设置过期时间。 - -# 七、数据淘汰策略 - -可以设置内存最大使用量,当内存使用量超出时,会施行数据淘汰策略。 - -Reids 具体有 6 种淘汰策略: - -| 策略 | 描述 | -| :--: | :--: | -| volatile-lru | 从已设置过期时间的数据集中挑选最近最少使用的数据淘汰 | -| volatile-ttl | 从已设置过期时间的数据集中挑选将要过期的数据淘汰 | -|volatile-random | 从已设置过期时间的数据集中任意选择数据淘汰 | -| allkeys-lru | 从所有数据集中挑选最近最少使用的数据淘汰 | -| allkeys-random | 从所有数据集中任意选择数据进行淘汰 | -| noeviction | 禁止驱逐数据 | - -作为内存数据库,出于对性能和内存消耗的考虑,Redis 的淘汰算法实际实现上并非针对所有 key,而是抽样一小部分 key 从中选出被淘汰 key。 - -使用 Redis 缓存数据时,为了提高缓存命中率,需要保证缓存数据都是热点数据。可以将内存最大使用量设置为热点数据占用的内存量,然后启用 allkeys-lru 淘汰策略,将最近最少使用的数据淘汰。 - -Redis 4.0 引入了 volatile-lfu 和 allkeys-lfu 淘汰策略,LFU 策略通过统计访问频率,将访问频率最少的键值对淘汰。 - -# 八、持久化 - -Redis 是内存型数据库,为了保证数据在断电后不会丢失,需要将内存中的数据持久化到硬盘上。 - -## RDB 持久化 - -将某个时间点的所有数据都存放到硬盘上。 - -可以将快照复制到其它服务器从而创建具有相同数据的服务器副本。 - -如果系统发生故障,将会丢失最后一次创建快照之后的数据。 - -如果数据量很大,保存快照的时间会很长。 - -## AOF 持久化 - -将写命令添加到 AOF 文件(Append Only File)的末尾。 - -使用 AOF 持久化需要设置同步选项,从而确保写命令什么时候会同步到磁盘文件上。这是因为对硬盘的文件进行写入并不会马上将内容同步到磁盘文件上,而是先存储到缓冲区,然后由操作系统决定什么时候同步到硬盘。有以下同步选项: - -| 选项 | 同步频率 | -| :--: | :--: | -| always | 每个写命令都同步 | -| everysec | 每秒同步一次 | -| no | 让操作系统来决定何时同步 | - -- always 选项会严重减低服务器的性能; -- everysec 选项比较合适,可以保证系统奔溃时只会丢失一秒左右的数据,并且 Redis 每秒执行一次同步对服务器性能几乎没有任何影响; -- no 选项并不能给服务器性能带来多大的提升,而且也会增加系统奔溃时数据丢失的数量。 - -随着服务器写请求的增多,AOF 文件会越来越大。Redis 提供了一种将 AOF 重写的特性,能够去除 AOF 文件中的冗余写命令。 - -# 九、事务 - -一个事务包含了多个命令,服务器在执行事务期间,不会改去执行其它客户端的命令请求。 - -事务中的多个命令被一次性发送给服务器,而不是一条一条发送,这种方式被称为流水线,它可以减少客户端与服务器之间的网络通信次数从而提升性能。 - -Redis 最简单的事务实现方式是使用 MULTI 和 EXEC 命令将事务操作包围起来。 - -# 十、事件 - -Redis 服务器是一个事件驱动程序。 - -## 文件事件 - -服务器通过套接字与客户端或者其它服务器进行通信,文件事件就是对套接字操作的抽象。 - -Redis 基于 Reactor 模式开发了自己的网络事件处理器,使用 I/O 多路复用程序来同时监听多个套接字,并将到达的事件传送给文件事件分派器,分派器会根据套接字产生的事件类型调用相应的事件处理器。 - -

- -## 时间事件 - -服务器有一些操作需要在给定的时间点执行,时间事件是对这类定时操作的抽象。 - -时间事件又分为: - -- 定时事件:是让一段程序在指定的时间之内执行一次; -- 周期性事件:是让一段程序每隔指定时间就执行一次。 - -Redis 将所有时间事件都放在一个无序链表中,通过遍历整个链表查找出已到达的时间事件,并调用相应的事件处理器。 - -## 事件的调度与执行 - -服务器需要不断监听文件事件的套接字才能得到待处理的文件事件,但是不能一直监听,否则时间事件无法在规定的时间内执行,因此监听时间应该根据距离现在最近的时间事件来决定。 - -事件调度与执行由 aeProcessEvents 函数负责,伪代码如下: - -```python -def aeProcessEvents(): - # 获取到达时间离当前时间最接近的时间事件 - time_event = aeSearchNearestTimer() - # 计算最接近的时间事件距离到达还有多少毫秒 - remaind_ms = time_event.when - unix_ts_now() - # 如果事件已到达,那么 remaind_ms 的值可能为负数,将它设为 0 - if remaind_ms < 0: - remaind_ms = 0 - # 根据 remaind_ms 的值,创建 timeval - timeval = create_timeval_with_ms(remaind_ms) - # 阻塞并等待文件事件产生,最大阻塞时间由传入的 timeval 决定 - aeApiPoll(timeval) - # 处理所有已产生的文件事件 - procesFileEvents() - # 处理所有已到达的时间事件 - processTimeEvents() -``` - -将 aeProcessEvents 函数置于一个循环里面,加上初始化和清理函数,就构成了 Redis 服务器的主函数,伪代码如下: - -```python -def main(): - # 初始化服务器 - init_server() - # 一直处理事件,直到服务器关闭为止 - while server_is_not_shutdown(): - aeProcessEvents() - # 服务器关闭,执行清理操作 - clean_server() -``` - -从事件处理的角度来看,服务器运行流程如下: - -

- -# 十一、复制 - -通过使用 slaveof host port 命令来让一个服务器成为另一个服务器的从服务器。 - -一个从服务器只能有一个主服务器,并且不支持主主复制。 - -## 连接过程 - -1. 主服务器创建快照文件,发送给从服务器,并在发送期间使用缓冲区记录执行的写命令。快照文件发送完毕之后,开始向从服务器发送存储在缓冲区中的写命令; - -2. 从服务器丢弃所有旧数据,载入主服务器发来的快照文件,之后从服务器开始接受主服务器发来的写命令; - -3. 主服务器每执行一次写命令,就向从服务器发送相同的写命令。 - -## 主从链 - -随着负载不断上升,主服务器可能无法很快地更新所有从服务器,或者重新连接和重新同步从服务器将导致系统超载。为了解决这个问题,可以创建一个中间层来分担主服务器的复制工作。中间层的服务器是最上层服务器的从服务器,又是最下层服务器的主服务器。 - -

- -# 十二、Sentinel - -Sentinel(哨兵)可以监听主服务器,并在主服务器进入下线状态时,自动从从服务器中选举出新的主服务器。 - -# 十三、分片 - -分片是将数据划分为多个部分的方法,可以将数据存储到多台机器里面,也可以从多台机器里面获取数据,这种方法在解决某些问题时可以获得线性级别的性能提升。 - -假设有 4 个 Reids 实例 R0,R1,R2,R3,还有很多表示用户的键 user:1,user:2,... 等等,有不同的方式来选择一个指定的键存储在哪个实例中。最简单的方式是范围分片,例如用户 id 从 0\~1000 的存储到实例 R0 中,用户 id 从 1001\~2000 的存储到实例 R1 中,等等。但是这样需要维护一张映射范围表,维护操作代价很高。还有一种方式是哈希分片,使用 CRC32 哈希函数将键转换为一个数字,再对实例数量求模就能知道应该存储的实例。 - -根据执行分片的位置,可以分为三种分片方式: - -- 客户端分片:客户端使用一致性哈希等算法决定键应当分布到哪个节点。 -- 代理分片:将客户端请求发送到代理上,由代理转发请求到正确的节点上。 -- 服务器分片:Redis Cluster。 - -# 十四、一个简单的论坛系统分析 - -该论坛系统功能如下: - -- 可以发布文章; -- 可以对文章进行点赞; -- 在首页可以按文章的发布时间或者文章的点赞数进行排序显示。 - -## 文章信息 - -文章包括标题、作者、赞数等信息,在关系型数据库中很容易构建一张表来存储这些信息,在 Redis 中可以使用 HASH 来存储每种信息以及其对应的值的映射。 - -Redis 没有关系型数据库中的表这一概念来将同种类型的数据存放在一起,而是使用命名空间的方式来实现这一功能。键名的前面部分存储命名空间,后面部分的内容存储 ID,通常使用 : 来进行分隔。例如下面的 HASH 的键名为 article:92617,其中 article 为命名空间,ID 为 92617。 - -

- -## 点赞功能 - -当有用户为一篇文章点赞时,除了要对该文章的 votes 字段进行加 1 操作,还必须记录该用户已经对该文章进行了点赞,防止用户点赞次数超过 1。可以建立文章的已投票用户集合来进行记录。 - -为了节约内存,规定一篇文章发布满一周之后,就不能再对它进行投票,而文章的已投票集合也会被删除,可以为文章的已投票集合设置一个一周的过期时间就能实现这个规定。 - -

- -## 对文章进行排序 - -为了按发布时间和点赞数进行排序,可以建立一个文章发布时间的有序集合和一个文章点赞数的有序集合。(下图中的 score 就是这里所说的点赞数;下面所示的有序集合分值并不直接是时间和点赞数,而是根据时间和点赞数间接计算出来的) - -

- -# 参考资料 - -- Carlson J L. Redis in Action[J]. Media.johnwiley.com.au, 2013. -- [黄健宏. Redis 设计与实现 [M]. 机械工业出版社, 2014.](http://redisbook.com/index.html) -- [REDIS IN ACTION](https://redislabs.com/ebook/foreword/) -- [Skip Lists: Done Right](http://ticki.github.io/blog/skip-lists-done-right/) -- [论述 Redis 和 Memcached 的差异](http://www.cnblogs.com/loveincode/p/7411911.html) -- [Redis 3.0 中文版- 分片](http://wiki.jikexueyuan.com/project/redis-guide) -- [Redis 应用场景](http://www.scienjus.com/redis-use-case/) -- [Observer vs Pub-Sub](http://developers-club.com/posts/270339/) -- [Using Redis as an LRU cache](https://redis.io/topics/lru-cache) diff --git a/notes/SQL.md b/notes/SQL.md index ca9b7b29..649f563a 100644 --- a/notes/SQL.md +++ b/notes/SQL.md @@ -1,767 +1,4 @@ -* [一、基础](#一基础) -* [二、创建表](#二创建表) -* [三、修改表](#三修改表) -* [四、插入](#四插入) -* [五、更新](#五更新) -* [六、删除](#六删除) -* [七、查询](#七查询) -* [八、排序](#八排序) -* [九、过滤](#九过滤) -* [十、通配符](#十通配符) -* [十一、计算字段](#十一计算字段) -* [十二、函数](#十二函数) -* [十三、分组](#十三分组) -* [十四、子查询](#十四子查询) -* [十五、连接](#十五连接) -* [十六、组合查询](#十六组合查询) -* [十七、视图](#十七视图) -* [十八、存储过程](#十八存储过程) -* [十九、游标](#十九游标) -* [二十、触发器](#二十触发器) -* [二十一、事务处理](#二十一事务处理) -* [二十二、字符集](#二十二字符集) -* [二十三、权限管理](#二十三权限管理) -* [参考资料](#参考资料) -# 一、基础 - -模式定义了数据如何存储、存储什么样的数据以及数据如何分解等信息,数据库和表都有模式。 - -主键的值不允许修改,也不允许复用(不能使用已经删除的主键值赋给新数据行的主键)。 - -SQL(Structured Query Language),标准 SQL 由 ANSI 标准委员会管理,从而称为 ANSI SQL。各个 DBMS 都有自己的实现,如 PL/SQL、Transact-SQL 等。 - -SQL 语句不区分大小写,但是数据库表名、列名和值是否区分依赖于具体的 DBMS 以及配置。 - -SQL 支持以下三种注释: - -```sql -# 注释 -SELECT * -FROM mytable; -- 注释 -/* 注释1 - 注释2 */ -``` - -数据库创建与使用: - -```sql -CREATE DATABASE test; -USE test; -``` - -# 二、创建表 - -```sql -CREATE TABLE mytable ( - id INT NOT NULL AUTO_INCREMENT, - col1 INT NOT NULL DEFAULT 1, - col2 VARCHAR(45) NULL, - col3 DATE NULL, - PRIMARY KEY (`id`)); -``` - -# 三、修改表 - -添加列 - -```sql -ALTER TABLE mytable -ADD col CHAR(20); -``` - -删除列 - -```sql -ALTER TABLE mytable -DROP COLUMN col; -``` - -删除表 - -```sql -DROP TABLE mytable; -``` - -# 四、插入 - -普通插入 - -```sql -INSERT INTO mytable(col1, col2) -VALUES(val1, val2); -``` - -插入检索出来的数据 - -```sql -INSERT INTO mytable1(col1, col2) -SELECT col1, col2 -FROM mytable2; -``` - -将一个表的内容插入到一个新表 - -```sql -CREATE TABLE newtable AS -SELECT * FROM mytable; -``` - -# 五、更新 - -```sql -UPDATE mytable -SET col = val -WHERE id = 1; -``` - -# 六、删除 - -```sql -DELETE FROM mytable -WHERE id = 1; -``` - -**TRUNCATE TABLE** 可以清空表,也就是删除所有行。 - -```sql -TRUNCATE TABLE mytable; -``` - -使用更新和删除操作时一定要用 WHERE 子句,不然会把整张表的数据都破坏。可以先用 SELECT 语句进行测试,防止错误删除。 - -# 七、查询 - -## DISTINCT - -相同值只会出现一次。它作用于所有列,也就是说所有列的值都相同才算相同。 - -```sql -SELECT DISTINCT col1, col2 -FROM mytable; -``` - -## LIMIT - -限制返回的行数。可以有两个参数,第一个参数为起始行,从 0 开始;第二个参数为返回的总行数。 - -返回前 5 行: - -```sql -SELECT * -FROM mytable -LIMIT 5; -``` - -```sql -SELECT * -FROM mytable -LIMIT 0, 5; -``` - -返回第 3 \~ 5 行: - -```sql -SELECT * -FROM mytable -LIMIT 2, 3; -``` - -# 八、排序 - -- **ASC** :升序(默认) -- **DESC** :降序 - -可以按多个列进行排序,并且为每个列指定不同的排序方式: - -```sql -SELECT * -FROM mytable -ORDER BY col1 DESC, col2 ASC; -``` - -# 九、过滤 - -不进行过滤的数据非常大,导致通过网络传输了多余的数据,从而浪费了网络带宽。因此尽量使用 SQL 语句来过滤不必要的数据,而不是传输所有的数据到客户端中然后由客户端进行过滤。 - -```sql -SELECT * -FROM mytable -WHERE col IS NULL; -``` - -下表显示了 WHERE 子句可用的操作符 - -| 操作符 | 说明 | -| :---: | :---: | -| = | 等于 | -| < | 小于 | -| > | 大于 | -| <> != | 不等于 | -| <= !> | 小于等于 | -| >= !< | 大于等于 | -| BETWEEN | 在两个值之间 | -| IS NULL | 为 NULL 值 | - -应该注意到,NULL 与 0、空字符串都不同。 - -**AND 和 OR** 用于连接多个过滤条件。优先处理 AND,当一个过滤表达式涉及到多个 AND 和 OR 时,可以使用 () 来决定优先级,使得优先级关系更清晰。 - -**IN** 操作符用于匹配一组值,其后也可以接一个 SELECT 子句,从而匹配子查询得到的一组值。 - -**NOT** 操作符用于否定一个条件。 - -# 十、通配符 - -通配符也是用在过滤语句中,但它只能用于文本字段。 - -- **%** 匹配 >=0 个任意字符; - -- **\_** 匹配 ==1 个任意字符; - -- **[ ]** 可以匹配集合内的字符,例如 [ab] 将匹配字符 a 或者 b。用脱字符 ^ 可以对其进行否定,也就是不匹配集合内的字符。 - -使用 Like 来进行通配符匹配。 - -```sql -SELECT * -FROM mytable -WHERE col LIKE '[^AB]%'; -- 不以 A 和 B 开头的任意文本 -``` - -不要滥用通配符,通配符位于开头处匹配会非常慢。 - -# 十一、计算字段 - -在数据库服务器上完成数据的转换和格式化的工作往往比客户端上快得多,并且转换和格式化后的数据量更少的话可以减少网络通信量。 - -计算字段通常需要使用 **AS** 来取别名,否则输出的时候字段名为计算表达式。 - -```sql -SELECT col1 * col2 AS alias -FROM mytable; -``` - -**CONCAT()** 用于连接两个字段。许多数据库会使用空格把一个值填充为列宽,因此连接的结果会出现一些不必要的空格,使用 **TRIM()** 可以去除首尾空格。 - -```sql -SELECT CONCAT(TRIM(col1), '(', TRIM(col2), ')') AS concat_col -FROM mytable; -``` - -# 十二、函数 - -各个 DBMS 的函数都是不相同的,因此不可移植,以下主要是 MySQL 的函数。 - -## 汇总 - -|函 数 |说 明| -| :---: | :---: | -| AVG() | 返回某列的平均值 | -| COUNT() | 返回某列的行数 | -| MAX() | 返回某列的最大值 | -| MIN() | 返回某列的最小值 | -| SUM() |返回某列值之和 | - -AVG() 会忽略 NULL 行。 - -使用 DISTINCT 可以让汇总函数值汇总不同的值。 - -```sql -SELECT AVG(DISTINCT col1) AS avg_col -FROM mytable; -``` - -## 文本处理 - -| 函数 | 说明 | -| :---: | :---: | -| LEFT() | 左边的字符 | -| RIGHT() | 右边的字符 | -| LOWER() | 转换为小写字符 | -| UPPER() | 转换为大写字符 | -| LTRIM() | 去除左边的空格 | -| RTRIM() | 去除右边的空格 | -| LENGTH() | 长度 | -| SOUNDEX() | 转换为语音值 | - -其中, **SOUNDEX()** 可以将一个字符串转换为描述其语音表示的字母数字模式。 - -```sql -SELECT * -FROM mytable -WHERE SOUNDEX(col1) = SOUNDEX('apple') -``` - -## 日期和时间处理 - -- 日期格式:YYYY-MM-DD -- 时间格式:HH:MM:SS - -|函 数 | 说 明| -| :---: | :---: | -| AddDate() | 增加一个日期(天、周等)| -| AddTime() | 增加一个时间(时、分等)| -| CurDate() | 返回当前日期 | -| CurTime() | 返回当前时间 | -| Date() |返回日期时间的日期部分| -| DateDiff() |计算两个日期之差| -| Date_Add() |高度灵活的日期运算函数| -| Date_Format() |返回一个格式化的日期或时间串| -| Day()| 返回一个日期的天数部分| -| DayOfWeek() |对于一个日期,返回对应的星期几| -| Hour() |返回一个时间的小时部分| -| Minute() |返回一个时间的分钟部分| -| Month() |返回一个日期的月份部分| -| Now() |返回当前日期和时间| -| Second() |返回一个时间的秒部分| -| Time() |返回一个日期时间的时间部分| -| Year() |返回一个日期的年份部分| - -```sql -mysql> SELECT NOW(); -``` - -``` -2018-4-14 20:25:11 -``` - -## 数值处理 - -| 函数 | 说明 | -| :---: | :---: | -| SIN() | 正弦 | -| COS() | 余弦 | -| TAN() | 正切 | -| ABS() | 绝对值 | -| SQRT() | 平方根 | -| MOD() | 余数 | -| EXP() | 指数 | -| PI() | 圆周率 | -| RAND() | 随机数 | - -# 十三、分组 - -分组就是把具有相同的数据值的行放在同一组中。 - -可以对同一分组数据使用汇总函数进行处理,例如求分组数据的平均值等。 - -指定的分组字段除了能按该字段进行分组,也会自动按该字段进行排序。 - -```sql -SELECT col, COUNT(*) AS num -FROM mytable -GROUP BY col; -``` - -GROUP BY 自动按分组字段进行排序,ORDER BY 也可以按汇总字段来进行排序。 - -```sql -SELECT col, COUNT(*) AS num -FROM mytable -GROUP BY col -ORDER BY num; -``` - -WHERE 过滤行,HAVING 过滤分组,行过滤应当先于分组过滤。 - -```sql -SELECT col, COUNT(*) AS num -FROM mytable -WHERE col > 2 -GROUP BY col -HAVING num >= 2; -``` - -分组规定: - -- GROUP BY 子句出现在 WHERE 子句之后,ORDER BY 子句之前; -- 除了汇总字段外,SELECT 语句中的每一字段都必须在 GROUP BY 子句中给出; -- NULL 的行会单独分为一组; -- 大多数 SQL 实现不支持 GROUP BY 列具有可变长度的数据类型。 - -# 十四、子查询 - -子查询中只能返回一个字段的数据。 - -可以将子查询的结果作为 WHRER 语句的过滤条件: - -```sql -SELECT * -FROM mytable1 -WHERE col1 IN (SELECT col2 - FROM mytable2); -``` - -下面的语句可以检索出客户的订单数量,子查询语句会对第一个查询检索出的每个客户执行一次: - -```sql -SELECT cust_name, (SELECT COUNT(*) - FROM Orders - WHERE Orders.cust_id = Customers.cust_id) - AS orders_num -FROM Customers -ORDER BY cust_name; -``` - -# 十五、连接 - -连接用于连接多个表,使用 JOIN 关键字,并且条件语句使用 ON 而不是 WHERE。 - -连接可以替换子查询,并且比子查询的效率一般会更快。 - -可以用 AS 给列名、计算字段和表名取别名,给表名取别名是为了简化 SQL 语句以及连接相同表。 - -## 内连接 - -内连接又称等值连接,使用 INNER JOIN 关键字。 - -```sql -SELECT A.value, B.value -FROM tablea AS A INNER JOIN tableb AS B -ON A.key = B.key; -``` - -可以不明确使用 INNER JOIN,而使用普通查询并在 WHERE 中将两个表中要连接的列用等值方法连接起来。 - -```sql -SELECT A.value, B.value -FROM tablea AS A, tableb AS B -WHERE A.key = B.key; -``` - -在没有条件语句的情况下返回笛卡尔积。 - -## 自连接 - -自连接可以看成内连接的一种,只是连接的表是自身而已。 - -一张员工表,包含员工姓名和员工所属部门,要找出与 Jim 处在同一部门的所有员工姓名。 - -子查询版本 - -```sql -SELECT name -FROM employee -WHERE department = ( - SELECT department - FROM employee - WHERE name = "Jim"); -``` - -自连接版本 - -```sql -SELECT e1.name -FROM employee AS e1 INNER JOIN employee AS e2 -ON e1.department = e2.department - AND e2.name = "Jim"; -``` - -## 自然连接 - -自然连接是把同名列通过等值测试连接起来的,同名列可以有多个。 - -内连接和自然连接的区别:内连接提供连接的列,而自然连接自动连接所有同名列。 - -```sql -SELECT A.value, B.value -FROM tablea AS A NATURAL JOIN tableb AS B; -``` - -## 外连接 - -外连接保留了没有关联的那些行。分为左外连接,右外连接以及全外连接,左外连接就是保留左表没有关联的行。 - -检索所有顾客的订单信息,包括还没有订单信息的顾客。 - -```sql -SELECT Customers.cust_id, Orders.order_num -FROM Customers LEFT OUTER JOIN Orders -ON Customers.cust_id = Orders.cust_id; -``` - -customers 表: - -| cust_id | cust_name | -| :---: | :---: | -| 1 | a | -| 2 | b | -| 3 | c | - -orders 表: - -| order_id | cust_id | -| :---: | :---: | -|1 | 1 | -|2 | 1 | -|3 | 3 | -|4 | 3 | - -结果: - -| cust_id | cust_name | order_id | -| :---: | :---: | :---: | -| 1 | a | 1 | -| 1 | a | 2 | -| 3 | c | 3 | -| 3 | c | 4 | -| 2 | b | Null | - -# 十六、组合查询 - -使用 **UNION** 来组合两个查询,如果第一个查询返回 M 行,第二个查询返回 N 行,那么组合查询的结果一般为 M+N 行。 - -每个查询必须包含相同的列、表达式和聚集函数。 - -默认会去除相同行,如果需要保留相同行,使用 UNION ALL。 - -只能包含一个 ORDER BY 子句,并且必须位于语句的最后。 - -```sql -SELECT col -FROM mytable -WHERE col = 1 -UNION -SELECT col -FROM mytable -WHERE col =2; -``` - -# 十七、视图 - -视图是虚拟的表,本身不包含数据,也就不能对其进行索引操作。 - -对视图的操作和对普通表的操作一样。 - -视图具有如下好处: - -- 简化复杂的 SQL 操作,比如复杂的连接; -- 只使用实际表的一部分数据; -- 通过只给用户访问视图的权限,保证数据的安全性; -- 更改数据格式和表示。 - -```sql -CREATE VIEW myview AS -SELECT Concat(col1, col2) AS concat_col, col3*col4 AS compute_col -FROM mytable -WHERE col5 = val; -``` - -# 十八、存储过程 - -存储过程可以看成是对一系列 SQL 操作的批处理; - -使用存储过程的好处: - -- 代码封装,保证了一定的安全性; -- 代码复用; -- 由于是预先编译,因此具有很高的性能。 - -命令行中创建存储过程需要自定义分隔符,因为命令行是以 ; 为结束符,而存储过程中也包含了分号,因此会错误把这部分分号当成是结束符,造成语法错误。 - -包含 in、out 和 inout 三种参数。 - -给变量赋值都需要用 select into 语句。 - -每次只能给一个变量赋值,不支持集合的操作。 - -```sql -delimiter // - -create procedure myprocedure( out ret int ) - begin - declare y int; - select sum(col1) - from mytable - into y; - select y*y into ret; - end // - -delimiter ; -``` - -```sql -call myprocedure(@ret); -select @ret; -``` - -# 十九、游标 - -在存储过程中使用游标可以对一个结果集进行移动遍历。 - -游标主要用于交互式应用,其中用户需要对数据集中的任意行进行浏览和修改。 - -使用游标的四个步骤: - -1. 声明游标,这个过程没有实际检索出数据; -2. 打开游标; -3. 取出数据; -4. 关闭游标; - -```sql -delimiter // -create procedure myprocedure(out ret int) - begin - declare done boolean default 0; - - declare mycursor cursor for - select col1 from mytable; - # 定义了一个 continue handler,当 sqlstate '02000' 这个条件出现时,会执行 set done = 1 - declare continue handler for sqlstate '02000' set done = 1; - - open mycursor; - - repeat - fetch mycursor into ret; - select ret; - until done end repeat; - - close mycursor; - end // - delimiter ; -``` - -# 二十、触发器 - -触发器会在某个表执行以下语句时而自动执行:DELETE、INSERT、UPDATE。 - -触发器必须指定在语句执行之前还是之后自动执行,之前执行使用 BEFORE 关键字,之后执行使用 AFTER 关键字。BEFORE 用于数据验证和净化,AFTER 用于审计跟踪,将修改记录到另外一张表中。 - -INSERT 触发器包含一个名为 NEW 的虚拟表。 - -```sql -CREATE TRIGGER mytrigger AFTER INSERT ON mytable -FOR EACH ROW SELECT NEW.col into @result; - -SELECT @result; -- 获取结果 -``` - -DELETE 触发器包含一个名为 OLD 的虚拟表,并且是只读的。 - -UPDATE 触发器包含一个名为 NEW 和一个名为 OLD 的虚拟表,其中 NEW 是可以被修改地,而 OLD 是只读的。 - -MySQL 不允许在触发器中使用 CALL 语句,也就是不能调用存储过程。 - -# 二十一、事务处理 - -基本术语: - -- 事务(transaction)指一组 SQL 语句; -- 回退(rollback)指撤销指定 SQL 语句的过程; -- 提交(commit)指将未存储的 SQL 语句结果写入数据库表; -- 保留点(savepoint)指事务处理中设置的临时占位符(placeholder),你可以对它发布回退(与回退整个事务处理不同)。 - -不能回退 SELECT 语句,回退 SELECT 语句也没意义;也不能回退 CREATE 和 DROP 语句。 - -MySQL 的事务提交默认是隐式提交,每执行一条语句就把这条语句当成一个事务然后进行提交。当出现 START TRANSACTION 语句时,会关闭隐式提交;当 COMMIT 或 ROLLBACK 语句执行后,事务会自动关闭,重新恢复隐式提交。 - -通过设置 autocommit 为 0 可以取消自动提交;autocommit 标记是针对每个连接而不是针对服务器的。 - -如果没有设置保留点,ROLLBACK 会回退到 START TRANSACTION 语句处;如果设置了保留点,并且在 ROLLBACK 中指定该保留点,则会回退到该保留点。 - -```sql -START TRANSACTION -// ... -SAVEPOINT delete1 -// ... -ROLLBACK TO delete1 -// ... -COMMIT -``` - -# 二十二、字符集 - -基本术语: - -- 字符集为字母和符号的集合; -- 编码为某个字符集成员的内部表示; -- 校对字符指定如何比较,主要用于排序和分组。 - -除了给表指定字符集和校对外,也可以给列指定: - -```sql -CREATE TABLE mytable -(col VARCHAR(10) CHARACTER SET latin COLLATE latin1_general_ci ) -DEFAULT CHARACTER SET hebrew COLLATE hebrew_general_ci; -``` - -可以在排序、分组时指定校对: - -```sql -SELECT * -FROM mytable -ORDER BY col COLLATE latin1_general_ci; -``` - -# 二十三、权限管理 - -MySQL 的账户信息保存在 mysql 这个数据库中。 - -```sql -USE mysql; -SELECT user FROM user; -``` - -**创建账户** - -```sql -CREATE USER myuser IDENTIFIED BY 'mypassword'; -``` - -新创建的账户没有任何权限。 - -**修改账户名** - -```sql -RENAME myuser TO newuser; -``` - -**删除账户** - -```sql -DROP USER myuser; -``` - -**查看权限** - -```sql -SHOW GRANTS FOR myuser; -``` - -**授予权限** - -```sql -GRANT SELECT, INSERT ON mydatabase.* TO myuser; -``` - -账户用 username@host 的形式定义,username@% 使用的是默认主机名。 - -**删除权限** - -```sql -REVOKE SELECT, INSERT ON mydatabase.* FROM myuser; -``` - -GRANT 和 REVOKE 可在几个层次上控制访问权限: - -- 整个服务器,使用 GRANT ALL 和 REVOKE ALL; -- 整个数据库,使用 ON database.\*; -- 特定的表,使用 ON database.table; -- 特定的列; -- 特定的存储过程。 - -**更改密码** - -必须使用 Password() 函数 - -```sql -SET PASSWROD FOR myuser = Password('new_password'); -``` - -# 参考资料 - -- BenForta. SQL 必知必会 [M]. 人民邮电出版社, 2013. diff --git a/notes/分布式.md b/notes/分布式.md index 0f136855..649f563a 100644 --- a/notes/分布式.md +++ b/notes/分布式.md @@ -1,359 +1,4 @@ -* [一、分布式锁](#一分布式锁) - * [数据库的唯一索引](#数据库的唯一索引) - * [Redis 的 SETNX 指令](#redis-的-setnx-指令) - * [Redis 的 RedLock 算法](#redis-的-redlock-算法) - * [Zookeeper 的有序节点](#zookeeper-的有序节点) -* [二、分布式事务](#二分布式事务) - * [本地消息表](#本地消息表) - * [2PC](#2pc) -* [三、CAP](#三cap) - * [一致性](#一致性) - * [可用性](#可用性) - * [分区容忍性](#分区容忍性) - * [权衡](#权衡) -* [四、BASE](#四base) - * [基本可用](#基本可用) - * [软状态](#软状态) - * [最终一致性](#最终一致性) -* [五、Paxos](#五paxos) - * [执行过程](#执行过程) - * [约束条件](#约束条件) -* [五、Raft](#五raft) - * [单个 Candidate 的竞选](#单个-candidate-的竞选) - * [多个 Candidate 竞选](#多个-candidate-竞选) - * [日志复制](#日志复制) -# 一、分布式锁 - -在单机场景下,可以使用 Java 提供的内置锁来实现进程同步。但是在分布式场景下,需要同步的进程可能位于不同的节点上,那么就需要使用分布式锁。 - -阻塞锁通常使用互斥量来实现: - -- 互斥量为 1 表示有其它进程在使用锁,此时处于锁定状态; -- 互斥量为 0 表示未锁定状态。 - -1 和 0 可以用一个整型值表示,也可以用某个数据存在或者不存在表示,存在表示互斥量为 1。 - -## 数据库的唯一索引 - -当想要获得锁时,就向表中插入一条记录,释放锁时就删除这条记录。唯一索引可以保证该记录只被插入一次,那么就可以用这个记录是否存在来判断是否存于锁定状态。 - -存在以下几个问题: - -- 锁没有失效时间,解锁失败的话其它进程无法再获得锁。 -- 只能是非阻塞锁,插入失败直接就报错了,无法重试。 -- 不可重入,已经获得锁的进程也必须重新获取锁。 - -## Redis 的 SETNX 指令 - -使用 SETNX(set if not exist)指令插入一个键值对,如果 Key 已经存在,那么会返回 False,否则插入成功并返回 True。 - -SETNX 指令和数据库的唯一索引类似,保证了只存在一个 Key 的键值对,那么可以用一个 Key 的键值对是否存在来判断是否存于锁定状态。 - -EXPIRE 指令可以为一个键值对设置一个过期时间,从而避免了数据库唯一索引实现方式中释放锁失败的问题。 - -## Redis 的 RedLock 算法 - -使用了多个 Redis 实例来实现分布式锁,这是为了保证在发生单点故障时仍然可用。 - -- 尝试从 N 个相互独立 Redis 实例获取锁,如果一个实例不可用,应该尽快尝试下一个; -- 计算获取锁消耗的时间,只有当这个时间小于锁的过期时间,并且从大多数(N / 2 + 1)实例上获取了锁,那么就认为锁获取成功了; -- 如果锁获取失败,会到每个实例上释放锁。 - -## Zookeeper 的有序节点 - -### 1. Zookeeper 抽象模型 - -Zookeeper 提供了一种树形结构级的命名空间,/app1/p_1 节点表示它的父节点为 /app1。 - -

- -### 2. 节点类型 - -- 永久节点:不会因为会话结束或者超时而消失; -- 临时节点:如果会话结束或者超时就会消失; -- 有序节点:会在节点名的后面加一个数字后缀,并且是有序的,例如生成的有序节点为 /lock/node-0000000000,它的下一个有序节点则为 /lock/node-0000000001,以此类推。 - -### 3. 监听器 - -为一个节点注册监听器,在节点状态发生改变时,会给客户端发送消息。 - -### 4. 分布式锁实现 - -- 创建一个锁目录 /lock; -- 当一个客户端需要获取锁时,在 /lock 下创建临时的且有序的子节点; -- 客户端获取 /lock 下的子节点列表,判断自己创建的子节点是否为当前子节点列表中序号最小的子节点,如果是则认为获得锁;否则监听自己的前一个子节点,获得子节点的变更通知后重复此步骤直至获得锁; -- 执行业务代码,完成后,删除对应的子节点。 - -### 5. 会话超时 - -如果一个已经获得锁的会话超时了,因为创建的是临时节点,所以该会话对应的临时节点会被删除,其它会话就可以获得锁了。可以看到,Zookeeper 分布式锁不会出现数据库的唯一索引实现分布式锁的释放锁失败问题。 - -### 6. 羊群效应 - -一个节点未获得锁,需要监听自己的前一个子节点,这是因为如果监听所有的子节点,那么任意一个子节点状态改变,其它所有子节点都会收到通知(羊群效应),而我们只希望它的后一个子节点收到通知。 - -参考: - -- [Distributed locks with Redis](https://redis.io/topics/distlock) -- [浅谈分布式锁](http://www.linkedkeeper.com/detail/blog.action?bid=1023) -- [基于 Zookeeper 的分布式锁](http://www.dengshenyu.com/java/%E5%88%86%E5%B8%83%E5%BC%8F%E7%B3%BB%E7%BB%9F/2017/10/23/zookeeper-distributed-lock.html) - -# 二、分布式事务 - -指事务的操作位于不同的节点上,需要保证事务的 AICD 特性。例如在下单场景下,库存和订单如果不在同一个节点上,就涉及分布式事务。 - -## 本地消息表 - -### 1. 原理 - -本地消息表与业务数据表处于同一个数据库中,这样就能利用本地事务来保证在对这两个表的操作满足事务特性。 - -1. 在分布式事务操作的一方完成写业务数据的操作之后向本地消息表发送一个消息,本地事务能保证这个消息一定会被写入本地消息表中。 -2. 之后将本地消息表中的消息转发到 Kafka 等消息队列中,如果转发成功则将消息从本地消息表中删除,否则继续重新转发。 -3. 在分布式事务操作的另一方从消息队列中读取一个消息,并执行消息中的操作。 - -

- -### 2. 分析 - -本地消息表利用了本地事务来实现分布式事务,并且使用了消息队列来保证最终一致性。 - -## 2PC - -两阶段提交(Two-phase Commit,2PC) - -通过引入协调者(Coordinator)来协调参与者的行为,并最终决定这些参与者是否要真正执行事务。 - -### 1. 运行过程 - -(一)准备阶段 - -协调者询问参与者事务是否执行成功,参与者发回事务执行结果。 - -

- -(二)提交阶段 - -如果事务在每个参与者上都执行成功,事务协调者发送通知让参与者提交事务;否则,协调者发送通知让参与者回滚事务。 - -需要注意的是,在准备阶段,参与者执行了事务,但是还未提交。只有在提交阶段接收到协调者发来的通知后,才进行提交或者回滚。 - -

- -### 2. 存在的问题 - -(一)同步阻塞 - -所有事务参与者在等待其它参与者响应的时候都处于同步阻塞状态,无法进行其它操作。 - -(二)单点问题 - -协调者在 2PC 中起到非常大的作用,发生故障将会造成很大影响,特别是在阶段二发生故障,所有参与者会一直等待状态,无法完成其它操作。 - -(三)数据不一致 - -在阶段二,如果协调者只发送了部分 Commit 消息,此时网络发生异常,那么只有部分参与者接收到 Commit 消息,也就是说只有部分参与者提交了事务,使得系统数据不一致。 - -(四)太过保守 - -任意一个节点失败就会导致整个事务失败,没有完善的容错机制。 - -参考: - -- [聊聊分布式事务,再说说解决方案](https://www.cnblogs.com/savorboard/p/distributed-system-transaction-consistency.html) -- [分布式系统的事务处理](https://coolshell.cn/articles/10910.html) -- [深入理解分布式事务](https://juejin.im/entry/577c6f220a2b5800573492be) - -# 三、CAP - -分布式系统不可能同时满足一致性(C:Consistency)、可用性(A:Availability)和分区容忍性(P:Partition Tolerance),最多只能同时满足其中两项。 - -

- -## 一致性 - -一致性指的是多个数据副本是否能保持一致的特性。 - -在一致性的条件下,系统在执行数据更新操作之后能够从一致性状态转移到另一个一致性状态。 - -对系统的一个数据更新成功之后,如果所有用户都能够读取到最新的值,该系统就被认为具有强一致性。 - -## 可用性 - -可用性指分布式系统在面对各种异常时可以提供正常服务的能力,可以用系统可用时间占总时间的比值来衡量,4 个 9 的可用性表示系统 99.99% 的时间是可用的。 - -在可用性条件下,系统提供的服务一直处于可用的状态,对于用户的每一个操作请求总是能够在有限的时间内返回结果。 - -## 分区容忍性 - -网络分区指分布式系统中的节点被划分为多个区域,每个区域内部可以通信,但是区域之间无法通信。 - -在分区容忍性条件下,分布式系统在遇到任何网络分区故障的时候,仍然需要能对外提供一致性和可用性的服务,除非是整个网络环境都发生了故障。 - -## 权衡 - -在分布式系统中,分区容忍性必不可少,因为需要总是假设网络是不可靠的。因此,CAP 理论实际在是要在可用性和一致性之间做权衡。 - -可用性和一致性往往是冲突的,很难都使它们同时满足。在多个节点之间进行数据同步时, - -* 为了保证一致性(CP),就需要让所有节点下线成为不可用的状态,等待同步完成; -* 为了保证可用性(AP),在同步过程中允许读取所有节点的数据,但是数据可能不一致。 - -

- -参考: - -- 倪超. 从 Paxos 到 ZooKeeper : 分布式一致性原理与实践 [M]. 电子工业出版社, 2015. -- [What is CAP theorem in distributed database system?](http://www.colooshiki.com/index.php/2017/04/20/what-is-cap-theorem-in-distributed-database-system/) - -# 四、BASE - -BASE 是基本可用(Basically Available)、软状态(Soft State)和最终一致性(Eventually Consistent)三个短语的缩写。 - -BASE 理论是对 CAP 中一致性和可用性权衡的结果,它的理论的核心思想是:即使无法做到强一致性,但每个应用都可以根据自身业务特点,采用适当的方式来使系统达到最终一致性。 - -

- -## 基本可用 - -指分布式系统在出现故障的时候,保证核心可用,允许损失部分可用性。 - -例如,电商在做促销时,为了保证购物系统的稳定性,部分消费者可能会被引导到一个降级的页面。 - -## 软状态 - -指允许系统中的数据存在中间状态,并认为该中间状态不会影响系统整体可用性,即允许系统不同节点的数据副本之间进行同步的过程存在延时。 - -## 最终一致性 - -最终一致性强调的是系统中所有的数据副本,在经过一段时间的同步后,最终能达到一致的状态。 - -ACID 要求强一致性,通常运用在传统的数据库系统上。而 BASE 要求最终一致性,通过牺牲强一致性来达到可用性,通常运用在大型分布式系统中。 - -在实际的分布式场景中,不同业务单元和组件对一致性的要求是不同的,因此 ACID 和 BASE 往往会结合在一起使用。 - -# 五、Paxos - -用于达成共识性问题,即对多个节点产生的值,该算法能保证只选出唯一一个值。 - -主要有三类节点: - -- 提议者(Proposer):提议一个值; -- 接受者(Acceptor):对每个提议进行投票; -- 告知者(Learner):被告知投票的结果,不参与投票过程。 - -

- -## 执行过程 - -规定一个提议包含两个字段:[n, v],其中 n 为序号(具有唯一性),v 为提议值。 - -下图演示了两个 Proposer 和三个 Acceptor 的系统中运行该算法的初始过程,每个 Proposer 都会向所有 Acceptor 发送提议请求。 - -

- -当 Acceptor 接收到一个提议请求,包含的提议为 [n1, v1],并且之前还未接收过提议请求,那么发送一个提议响应,设置当前接收到的提议为 [n1, v1],并且保证以后不会再接受序号小于 n1 的提议。 - -如下图,Acceptor X 在收到 [n=2, v=8] 的提议请求时,由于之前没有接收过提议,因此就发送一个 [no previous] 的提议响应,设置当前接收到的提议为 [n=2, v=8],并且保证以后不会再接受序号小于 2 的提议。其它的 Acceptor 类似。 - -

- -如果 Acceptor 接收到一个提议请求,包含的提议为 [n2, v2],并且之前已经接收过提议 [n1, v1]。如果 n1 > n2,那么就丢弃该提议请求;否则,发送提议响应,该提议响应包含之前已经接收过的提议 [n1, v1],设置当前接收到的提议为 [n2, v2],并且保证以后不会再接受序号小于 n2 的提议。 - -如下图,Acceptor Z 收到 Proposer A 发来的 [n=2, v=8] 的提议请求,由于之前已经接收过 [n=4, v=5] 的提议,并且 n > 2,因此就抛弃该提议请求;Acceptor X 收到 Proposer B 发来的 [n=4, v=5] 的提议请求,因为之前接收到的提议为 [n=2, v=8],并且 2 <= 4,因此就发送 [n=2, v=8] 的提议响应,设置当前接收到的提议为 [n=4, v=5],并且保证以后不会再接受序号小于 4 的提议。Acceptor Y 类似。 - -

- -当一个 Proposer 接收到超过一半 Acceptor 的提议响应时,就可以发送接受请求。 - -Proposer A 接收到两个提议响应之后,就发送 [n=2, v=8] 接受请求。该接受请求会被所有 Acceptor 丢弃,因为此时所有 Acceptor 都保证不接受序号小于 4 的提议。 - -Proposer B 过后也收到了两个提议响应,因此也开始发送接受请求。需要注意的是,接受请求的 v 需要取它收到的最大 v 值,也就是 8。因此它发送 [n=4, v=8] 的接受请求。 - -

- -Acceptor 接收到接受请求时,如果序号大于等于该 Acceptor 承诺的最小序号,那么就发送通知给所有的 Learner。当 Learner 发现有大多数的 Acceptor 接收了某个提议,那么该提议的提议值就被 Paxos 选择出来。 - -

- -## 约束条件 - -### 1\. 正确性 - -指只有一个提议值会生效。 - -因为 Paxos 协议要求每个生效的提议被多数 Acceptor 接收,并且 Acceptor 不会接受两个不同的提议,因此可以保证正确性。 - -### 2\. 可终止性 - -指最后总会有一个提议生效。 - -Paxos 协议能够让 Proposer 发送的提议朝着能被大多数 Acceptor 接受的那个提议靠拢,因此能够保证可终止性。 - -参考: - -- [NEAT ALGORITHMS - PAXOS](http://harry.me/blog/2014/12/27/neat-algorithms-paxos/) -- [Paxos By Example](https://angus.nyc/2012/paxos-by-example/) - -# 五、Raft - -Raft 和 Paxos 类似,但是更容易理解,也更容易实现。 - -Raft 主要是用来竞选主节点。 - -## 单个 Candidate 的竞选 - -有三种节点:Follower、Candidate 和 Leader。Leader 会周期性的发送心跳包给 Follower。每个 Follower 都设置了一个随机的竞选超时时间,一般为 150ms\~300ms,如果在这个时间内没有收到 Leader 的心跳包,就会变成 Candidate,进入竞选阶段。 - -* 下图表示一个分布式系统的最初阶段,此时只有 Follower,没有 Leader。Follower A 等待一个随机的竞选超时时间之后,没收到 Leader 发来的心跳包,因此进入竞选阶段。 - -

- -* 此时 A 发送投票请求给其它所有节点。 - -

- -* 其它节点会对请求进行回复,如果超过一半的节点回复了,那么该 Candidate 就会变成 Leader。 - -

- -* 之后 Leader 会周期性地发送心跳包给 Follower,Follower 接收到心跳包,会重新开始计时。 - -

- -## 多个 Candidate 竞选 - -* 如果有多个 Follower 成为 Candidate,并且所获得票数相同,那么就需要重新开始投票,例如下图中 Candidate B 和 Candidate D 都获得两票,因此需要重新开始投票。 - -

- -* 当重新开始投票时,由于每个节点设置的随机竞选超时时间不同,因此能下一次再次出现多个 Candidate 并获得同样票数的概率很低。 - -

- -## 日志复制 - -* 来自客户端的修改都会被传入 Leader。注意该修改还未被提交,只是写入日志中。 - -

- -* Leader 会把修改复制到所有 Follower。 - -

- -* Leader 会等待大多数的 Follower 也进行了修改,然后才将修改提交。 - -

- -* 此时 Leader 会通知的所有 Follower 让它们也提交修改,此时所有节点的值达成一致。 - -

- -参考: - -- [Raft: Understandable Distributed Consensus](http://thesecretlivesofdata.com/raft) - diff --git a/notes/数据库系统原理.md b/notes/数据库系统原理.md index cabc2f97..649f563a 100644 --- a/notes/数据库系统原理.md +++ b/notes/数据库系统原理.md @@ -1,580 +1,4 @@ -* [一、事务](#一事务) - * [概念](#概念) - * [ACID](#acid) - * [AUTOCOMMIT](#autocommit) -* [二、并发一致性问题](#二并发一致性问题) - * [丢失修改](#丢失修改) - * [读脏数据](#读脏数据) - * [不可重复读](#不可重复读) - * [幻影读](#幻影读) -* [三、封锁](#三封锁) - * [封锁粒度](#封锁粒度) - * [封锁类型](#封锁类型) - * [封锁协议](#封锁协议) - * [MySQL 隐式与显示锁定](#mysql-隐式与显示锁定) -* [四、隔离级别](#四隔离级别) - * [未提交读(READ UNCOMMITTED)](#未提交读read-uncommitted) - * [提交读(READ COMMITTED)](#提交读read-committed) - * [可重复读(REPEATABLE READ)](#可重复读repeatable-read) - * [可串行化(SERIALIZABLE)](#可串行化serializable) -* [五、多版本并发控制](#五多版本并发控制) - * [版本号](#版本号) - * [Undo 日志](#undo-日志) - * [实现过程](#实现过程) - * [快照读与当前读](#快照读与当前读) -* [六、Next-Key Locks](#六next-key-locks) - * [Record Locks](#record-locks) - * [Gap Locks](#gap-locks) - * [Next-Key Locks](#next-key-locks) -* [七、关系数据库设计理论](#七关系数据库设计理论) - * [函数依赖](#函数依赖) - * [异常](#异常) - * [范式](#范式) -* [八、ER 图](#八er-图) - * [实体的三种联系](#实体的三种联系) - * [表示出现多次的关系](#表示出现多次的关系) - * [联系的多向性](#联系的多向性) - * [表示子类](#表示子类) -* [参考资料](#参考资料) -# 一、事务 - -## 概念 - -

- -事务指的是满足 ACID 特性的一组操作,可以通过 Commit 提交一个事务,也可以使用 Rollback 进行回滚。 - -## ACID - -### 1. 原子性(Atomicity) - -事务被视为不可分割的最小单元,事务的所有操作要么全部提交成功,要么全部失败回滚。 - -回滚可以用日志来实现,日志记录着事务所执行的修改操作,在回滚时反向执行这些修改操作即可。 - -### 2. 一致性(Consistency) - -数据库在事务执行前后都保持一致性状态。 - -在一致性状态下,所有事务对一个数据的读取结果都是相同的。 - -### 3. 隔离性(Isolation) - -一个事务所做的修改在最终提交以前,对其它事务是不可见的。 - -### 4. 持久性(Durability) - -一旦事务提交,则其所做的修改将会永远保存到数据库中。即使系统发生崩溃,事务执行的结果也不能丢失。 - -可以通过数据库备份和恢复来实现,在系统发生崩溃时,使用备份的数据库进行数据恢复。 - ----- - -事务的 ACID 特性概念简单,但不是很好理解,主要是因为这几个特性不是一种平级关系: - -- 只有满足一致性,事务的执行结果才是正确的。 -- 在无并发的情况下,事务串行执行,隔离性一定能够满足。此时要只要能满足原子性,就一定能满足一致性。 -- 在并发的情况下,多个事务并发执行,事务不仅要满足原子性,还需要满足隔离性,才能满足一致性。 -- 事务满足持久化是为了能应对数据库崩溃的情况。 - -

- -## AUTOCOMMIT - -MySQL 默认采用自动提交模式。也就是说,如果不显式使用`START TRANSACTION`语句来开始一个事务,那么每个查询都会被当做一个事务自动提交。 - -# 二、并发一致性问题 - -在并发环境下,事务的隔离性很难保证,因此会出现很多并发一致性问题。 - -## 丢失修改 - -T1 和 T2 两个事务都对一个数据进行修改,T1 先修改,T2 随后修改,T2 的修改覆盖了 T1 的修改。 - -

- -## 读脏数据 - -T1 修改一个数据,T2 随后读取这个数据。如果 T1 撤销了这次修改,那么 T2 读取的数据是脏数据。 - -

- -## 不可重复读 - -T2 读取一个数据,T1 对该数据做了修改。如果 T2 再次读取这个数据,此时读取的结果和第一次读取的结果不同。 - -

- -## 幻影读 - -T1 读取某个范围的数据,T2 在这个范围内插入新的数据,T1 再次读取这个范围的数据,此时读取的结果和和第一次读取的结果不同。 - -

- ----- - -产生并发不一致性问题主要原因是破坏了事务的隔离性,解决方法是通过并发控制来保证隔离性。并发控制可以通过封锁来实现,但是封锁操作需要用户自己控制,相当复杂。数据库管理系统提供了事务的隔离级别,让用户以一种更轻松的方式处理并发一致性问题。 - -# 三、封锁 - -## 封锁粒度 - -

- -MySQL 中提供了两种封锁粒度:行级锁以及表级锁。 - -应该尽量只锁定需要修改的那部分数据,而不是所有的资源。锁定的数据量越少,发生锁争用的可能就越小,系统的并发程度就越高。 - -但是加锁需要消耗资源,锁的各种操作(包括获取锁、释放锁、以及检查锁状态)都会增加系统开销。因此封锁粒度越小,系统开销就越大。 - -在选择封锁粒度时,需要在锁开销和并发程度之间做一个权衡。 - -## 封锁类型 - -### 1. 读写锁 - -- 排它锁(Exclusive),简写为 X 锁,又称写锁。 -- 共享锁(Shared),简写为 S 锁,又称读锁。 - -有以下两个规定: - -- 一个事务对数据对象 A 加了 X 锁,就可以对 A 进行读取和更新。加锁期间其它事务不能对 A 加任何锁。 -- 一个事务对数据对象 A 加了 S 锁,可以对 A 进行读取操作,但是不能进行更新操作。加锁期间其它事务能对 A 加 S 锁,但是不能加 X 锁。 - -锁的兼容关系如下: - -| - | X | S | -| :--: | :--: | :--: | -|X|NO|NO| -|S|NO|YES| - -### 2. 意向锁 - -使用意向锁(Intention Locks)可以更容易地支持多粒度封锁。 - -在存在行级锁和表级锁的情况下,事务 T 想要对表 A 加 X 锁,就需要先检测是否有其它事务对表 A 或者表 A 中的任意一行加了锁,那么就需要对表 A 的每一行都检测一次,这是非常耗时的。 - -意向锁在原来的 X/S 锁之上引入了 IX/IS,IX/IS 都是表锁,用来表示一个事务想要在表中的某个数据行上加 X 锁或 S 锁。有以下两个规定: - -- 一个事务在获得某个数据行对象的 S 锁之前,必须先获得表的 IS 锁或者更强的锁; -- 一个事务在获得某个数据行对象的 X 锁之前,必须先获得表的 IX 锁。 - -通过引入意向锁,事务 T 想要对表 A 加 X 锁,只需要先检测是否有其它事务对表 A 加了 X/IX/S/IS 锁,如果加了就表示有其它事务正在使用这个表或者表中某一行的锁,因此事务 T 加 X 锁失败。 - -各种锁的兼容关系如下: - -| - | X | IX | S | IS | -| :--: | :--: | :--: | :--: | :--: | -|X |NO |NO |NO | NO| -|IX |NO |YES |NO | YES| -|S |NO |NO |YES | YES| -|IS |NO |YES |YES | YES| - -解释如下: - -- 任意 IS/IX 锁之间都是兼容的,因为它们只是表示想要对表加锁,而不是真正加锁; -- S 锁只与 S 锁和 IS 锁兼容,也就是说事务 T 想要对数据行加 S 锁,其它事务可以已经获得对表或者表中的行的 S 锁。 - -## 封锁协议 - -### 1. 三级封锁协议 - -**一级封锁协议** - -事务 T 要修改数据 A 时必须加 X 锁,直到 T 结束才释放锁。 - -可以解决丢失修改问题,因为不能同时有两个事务对同一个数据进行修改,那么事务的修改就不会被覆盖。 - -| T1 | T2 | -| :--: | :--: | -| lock-x(A) | | -| read A=20 | | -| | lock-x(A) | -| | wait | -| write A=19 |. | -| commit |. | -| unlock-x(A) |. | -| | obtain | -| | read A=19 | -| | write A=21 | -| | commit | -| | unlock-x(A)| - -**二级封锁协议** - -在一级的基础上,要求读取数据 A 时必须加 S 锁,读取完马上释放 S 锁。 - -可以解决读脏数据问题,因为如果一个事务在对数据 A 进行修改,根据 1 级封锁协议,会加 X 锁,那么就不能再加 S 锁了,也就是不会读入数据。 - -| T1 | T2 | -| :--: | :--: | -| lock-x(A) | | -| read A=20 | | -| write A=19 | | -| | lock-s(A) | -| | wait | -| rollback | .| -| A=20 |. | -| unlock-x(A) |. | -| | obtain | -| | read A=20 | -| | commit | -| | unlock-s(A)| - -**三级封锁协议** - -在二级的基础上,要求读取数据 A 时必须加 S 锁,直到事务结束了才能释放 S 锁。 - -可以解决不可重复读的问题,因为读 A 时,其它事务不能对 A 加 X 锁,从而避免了在读的期间数据发生改变。 - -| T1 | T2 | -| :--: | :--: | -| lock-s(A) | | -| read A=20 | | -| |lock-x(A) | -| | wait | -| read A=20| . | -| commit | .| -| unlock-s(A) |. | -| | obtain | -| | read A=20 | -| | write A=19| -| | commit | -| | unlock-X(A)| - -### 2. 两段锁协议 - -加锁和解锁分为两个阶段进行。 - -可串行化调度是指,通过并发控制,使得并发执行的事务结果与某个串行执行的事务结果相同。 - -事务遵循两段锁协议是保证可串行化调度的充分条件。例如以下操作满足两段锁协议,它是可串行化调度。 - -```html -lock-x(A)...lock-s(B)...lock-s(C)...unlock(A)...unlock(C)...unlock(B) -``` - -但不是必要条件,例如以下操作不满足两段锁协议,但是它还是可串行化调度。 - -```html -lock-x(A)...unlock(A)...lock-s(B)...unlock(B)...lock-s(C)...unlock(C) -``` - -## MySQL 隐式与显示锁定 - -MySQL 的 InnoDB 存储引擎采用两段锁协议,会根据隔离级别在需要的时候自动加锁,并且所有的锁都是在同一时刻被释放,这被称为隐式锁定。 - -InnoDB 也可以使用特定的语句进行显示锁定: - -```sql -SELECT ... LOCK In SHARE MODE; -SELECT ... FOR UPDATE; -``` - -# 四、隔离级别 - -## 未提交读(READ UNCOMMITTED) - -事务中的修改,即使没有提交,对其它事务也是可见的。 - -## 提交读(READ COMMITTED) - -一个事务只能读取已经提交的事务所做的修改。换句话说,一个事务所做的修改在提交之前对其它事务是不可见的。 - -## 可重复读(REPEATABLE READ) - -保证在同一个事务中多次读取同样数据的结果是一样的。 - -## 可串行化(SERIALIZABLE) - -强制事务串行执行。 - ----- - -| 隔离级别 | 脏读 | 不可重复读 | 幻影读 | -| :---: | :---: | :---:| :---: | -| 未提交读 | YES | YES | YES | -| 提交读 | NO | YES | YES | -| 可重复读 | NO | NO | YES | -| 可串行化 | NO | NO | NO | - -# 五、多版本并发控制 - -多版本并发控制(Multi-Version Concurrency Control, MVCC)是 MySQL 的 InnoDB 存储引擎实现隔离级别的一种具体方式,用于实现提交读和可重复读这两种隔离级别。而未提交读隔离级别总是读取最新的数据行,无需使用 MVCC;可串行化隔离级别需要对所有读取的行都加锁,单纯使用 MVCC 无法实现。 - -## 版本号 - -- 系统版本号:是一个递增的数字,每开始一个新的事务,系统版本号就会自动递增。 -- 事务版本号:事务开始时的系统版本号。 - -InooDB 的 MVCC 在每行记录后面都保存着两个隐藏的列,用来存储两个版本号: - -- 创建版本号:指示创建一个数据行的快照时的系统版本号; -- 删除版本号:如果该快照的删除版本号大于当前事务版本号表示该快照有效,否则表示该快照已经被删除了。 - -## Undo 日志 - -InnoDB 的 MVCC 使用到的快照存储在 Undo 日志中,该日志通过回滚指针把一个数据行(Record)的所有快照连接起来。 - -

- -## 实现过程 - -以下实现过程针对可重复读隔离级别。 - -### 1. SELECT - -当开始新一个事务时,该事务的版本号肯定会大于当前所有数据行快照的创建版本号,理解这一点很关键。 - -多个事务必须读取到同一个数据行的快照,并且这个快照是距离现在最近的一个有效快照。但是也有例外,如果有一个事务正在修改该数据行,那么它可以读取事务本身所做的修改,而不用和其它事务的读取结果一致。 - -把没有对一个数据行做修改的事务称为 T,T 所要读取的数据行快照的创建版本号必须小于 T 的版本号,因为如果大于或者等于 T 的版本号,那么表示该数据行快照是其它事务的最新修改,因此不能去读取它。 - -除了上面的要求,T 所要读取的数据行快照的删除版本号必须大于 T 的版本号,因为如果小于等于 T 的版本号,那么表示该数据行快照是已经被删除的,不应该去读取它。 - -### 2. INSERT - -将当前系统版本号作为数据行快照的创建版本号。 - -### 3. DELETE - -将当前系统版本号作为数据行快照的删除版本号。 - -### 4. UPDATE - -将当前系统版本号作为更新前的数据行快照的删除版本号,并将当前系统版本号作为更新后的数据行快照的创建版本号。可以理解为先执行 DELETE 后执行 INSERT。 - -## 快照读与当前读 - -### 1. 快照读 - -使用 MVCC 读取的是快照中的数据,这样可以减少加锁所带来的开销。 - -```sql -select * from table ...; -``` - -### 2. 当前读 - -读取的是最新的数据,需要加锁。以下第一个语句需要加 S 锁,其它都需要加 X 锁。 - -```sql -select * from table where ? lock in share mode; -select * from table where ? for update; -insert; -update; -delete; -``` - -# 六、Next-Key Locks - -Next-Key Locks 也是 MySQL 的 InnoDB 存储引擎的一种锁实现。MVCC 不能解决幻读的问题,Next-Key Locks 就是为了解决这个问题而存在的。在可重复读(REPEATABLE READ)隔离级别下,使用 MVCC + Next-Key Locks 可以解决幻读问题。 - -## Record Locks - -锁定整个记录(行)。锁定的对象是记录的索引,而不是记录本身。如果表没有设置索引,InnoDB 会自动在主键上创建隐藏的聚集索引,因此 Record Locks 依然可以使用。 - -## Gap Locks - -锁定一个范围内的索引,例如当一个事务执行以下语句,其它事务就不能在 t.c 中插入 15。 - -```sql -SELECT c FROM t WHERE c BETWEEN 10 and 20 FOR UPDATE; -``` - -## Next-Key Locks - -它是 Record Locks 和 Gap Locks 的结合,不仅锁定一个记录,也锁定范围内的索引。在 user 中有以下记录: - -```sql -| id | last_name | first_name | age | -|------|-------------|--------------|-------| -| 4 | stark | tony | 21 | -| 1 | tom | hiddleston | 30 | -| 3 | morgan | freeman | 40 | -| 5 | jeff | dean | 50 | -| 2 | donald | trump | 80 | -+------|-------------|--------------|-------+ -``` - -那么就需要锁定以下范围: - -```sql -(-∞, 21] -(21, 30] -(30, 40] -(40, 50] -(50, 80] -(80, ∞) -``` - -# 七、关系数据库设计理论 - -## 函数依赖 - -记 A->B 表示 A 函数决定 B,也可以说 B 函数依赖于 A。 - -如果 {A1,A2,... ,An} 是关系的一个或多个属性的集合,该集合函数决定了关系的其它所有属性并且是最小的,那么该集合就称为键码。 - -对于 A->B,如果能找到 A 的真子集 A',使得 A'-> B,那么 A->B 就是部分函数依赖,否则就是完全函数依赖; - -对于 A->B,B->C,则 A->C 是一个传递函数依赖。 - -## 异常 - -以下的学生课程关系的函数依赖为 Sno, Cname -> Sname, Sdept, Mname, Grade,键码为 {Sno, Cname}。也就是说,确定学生和课程之后,就能确定其它信息。 - -| Sno | Sname | Sdept | Mname | Cname | Grade | -| :---: | :---: | :---: | :---: | :---: |:---:| -| 1 | 学生-1 | 学院-1 | 院长-1 | 课程-1 | 90 | -| 2 | 学生-2 | 学院-2 | 院长-2 | 课程-2 | 80 | -| 2 | 学生-2 | 学院-2 | 院长-2 | 课程-1 | 100 | -| 3 | 学生-3 | 学院-2 | 院长-2 | 课程-2 | 95 | - -不符合范式的关系,会产生很多异常,主要有以下四种异常: - -- 冗余数据:例如 学生-2 出现了两次。 -- 修改异常:修改了一个记录中的信息,但是另一个记录中相同的信息却没有被修改。 -- 删除异常:删除一个信息,那么也会丢失其它信息。例如如果删除了 课程-1,需要删除第一行和第三行,那么 学生-1 的信息就会丢失。 -- 插入异常,例如想要插入一个学生的信息,如果这个学生还没选课,那么就无法插入。 - -## 范式 - -范式理论是为了解决以上提到四种异常。 - -高级别范式的依赖于低级别的范式,1NF 是最低级别的范式。 - -

- -### 1. 第一范式 (1NF) - -属性不可分; - -### 2. 第二范式 (2NF) - -每个非主属性完全函数依赖于键码。 - -可以通过分解来满足。 - - **分解前**
- -| Sno | Sname | Sdept | Mname | Cname | Grade | -| :---: | :---: | :---: | :---: | :---: |:---:| -| 1 | 学生-1 | 学院-1 | 院长-1 | 课程-1 | 90 | -| 2 | 学生-2 | 学院-2 | 院长-2 | 课程-2 | 80 | -| 2 | 学生-2 | 学院-2 | 院长-2 | 课程-1 | 100 | -| 3 | 学生-3 | 学院-2 | 院长-2 | 课程-2 | 95 | - -以上学生课程关系中,{Sno, Cname} 为键码,有如下函数依赖: - -- Sno -> Sname, Sdept -- Sdept -> Mname -- Sno, Cname-> Grade - -Grade 完全函数依赖于键码,它没有任何冗余数据,每个学生的每门课都有特定的成绩。 - -Sname, Sdept 和 Mname 都部分依赖于键码,当一个学生选修了多门课时,这些数据就会出现多次,造成大量冗余数据。 - - **分解后**
- -关系-1 - -| Sno | Sname | Sdept | Mname | -| :---: | :---: | :---: | :---: | -| 1 | 学生-1 | 学院-1 | 院长-1 | -| 2 | 学生-2 | 学院-2 | 院长-2 | -| 3 | 学生-3 | 学院-2 | 院长-2 | - -有以下函数依赖: - -- Sno -> Sname, Sdept -- Sdept -> Mname - -关系-2 - -| Sno | Cname | Grade | -| :---: | :---: |:---:| -| 1 | 课程-1 | 90 | -| 2 | 课程-2 | 80 | -| 2 | 课程-1 | 100 | -| 3 | 课程-2 | 95 | - -有以下函数依赖: - -- Sno, Cname -> Grade - -### 3. 第三范式 (3NF) - -非主属性不传递函数依赖于键码。 - -上面的 关系-1 中存在以下传递函数依赖:Sno -> Sdept -> Mname,可以进行以下分解: - -关系-11 - -| Sno | Sname | Sdept | -| :---: | :---: | :---: | -| 1 | 学生-1 | 学院-1 | -| 2 | 学生-2 | 学院-2 | -| 3 | 学生-3 | 学院-2 | - -关系-12 - -| Sdept | Mname | -| :---: | :---: | -| 学院-1 | 院长-1 | -| 学院-2 | 院长-2 | - -# 八、ER 图 - -Entity-Relationship,有三个组成部分:实体、属性、联系。 - -用来进行关系型数据库系统的概念设计。 - -## 实体的三种联系 - -包含一对一,一对多,多对多三种。 - -如果 A 到 B 是一对多关系,那么画个带箭头的线段指向 B;如果是一对一,画两个带箭头的线段;如果是多对多,画两个不带箭头的线段。下图的 Course 和 Student 是一对多的关系。 - -

- -## 表示出现多次的关系 - -一个实体在联系出现几次,就要用几条线连接。下图表示一个课程的先修关系,先修关系出现两个 Course 实体,第一个是先修课程,后一个是后修课程,因此需要用两条线来表示这种关系。 - -

- -## 联系的多向性 - -虽然老师可以开设多门课,并且可以教授多名学生,但是对于特定的学生和课程,只有一个老师教授,这就构成了一个三元联系。 - -

- -一般只使用二元联系,可以把多元关系转换为二元关系。 - -

- -## 表示子类 - -用一个三角形和两条线来连接类和子类,与子类有关的属性和联系都连到子类上,而与父类和子类都有关的连到父类上。 - -

- -# 参考资料 - -- AbrahamSilberschatz, HenryF.Korth, S.Sudarshan, 等. 数据库系统概念 [M]. 机械工业出版社, 2006. -- 施瓦茨. 高性能 MYSQL(第3版)[M]. 电子工业出版社, 2013. -- 史嘉权. 数据库系统概论[M]. 清华大学出版社有限公司, 2006. -- [The InnoDB Storage Engine](https://dev.mysql.com/doc/refman/5.7/en/innodb-storage-engine.html) -- [Transaction isolation levels](https://www.slideshare.net/ErnestoHernandezRodriguez/transaction-isolation-levels) -- [Concurrency Control](http://scanftree.com/dbms/2-phase-locking-protocol) -- [The Nightmare of Locking, Blocking and Isolation Levels!](https://www.slideshare.net/brshristov/the-nightmare-of-locking-blocking-and-isolation-levels-46391666) -- [Database Normalization and Normal Forms with an Example](https://aksakalli.github.io/2012/03/12/database-normalization-and-normal-forms-with-an-example.html) -- [The basics of the InnoDB undo logging and history system](https://blog.jcole.us/2014/04/16/the-basics-of-the-innodb-undo-logging-and-history-system/) -- [MySQL locking for the busy web developer](https://www.brightbox.com/blog/2013/10/31/on-mysql-locks/) -- [浅入浅出 MySQL 和 InnoDB](https://draveness.me/mysql-innodb) -- [Innodb 中的事务隔离级别和锁的关系](https://tech.meituan.com/innodb-lock.html) diff --git a/notes/正则表达式.md b/notes/正则表达式.md index c6c21f2c..649f563a 100644 --- a/notes/正则表达式.md +++ b/notes/正则表达式.md @@ -1,389 +1,4 @@ -* [一、概述](#一概述) -* [二、匹配单个字符](#二匹配单个字符) -* [三、匹配一组字符](#三匹配一组字符) -* [四、使用元字符](#四使用元字符) -* [五、重复匹配](#五重复匹配) -* [六、位置匹配](#六位置匹配) -* [七、使用子表达式](#七使用子表达式) -* [八、回溯引用](#八回溯引用) -* [九、前后查找](#九前后查找) -* [十、嵌入条件](#十嵌入条件) -* [参考资料](#参考资料) -# 一、概述 - -正则表达式用于文本内容的查找和替换。 - -正则表达式内置于其它语言或者软件产品中,它本身不是一种语言或者软件。 - -[正则表达式在线工具](https://regexr.com/) - -# 二、匹配单个字符 - -正则表达式一般是区分大小写的,但是也有些实现是不区分。 - -**.** 可以用来匹配任何的单个字符,但是在绝大多数实现里面,不能匹配换行符; - -**\\** 是元字符,表示它有特殊的含义,而不是字符本身的含义。如果需要匹配 . ,那么要用 \ 进行转义,即在 . 前面加上 \ 。 - -**正则表达式** - -``` -nam. -``` - -**匹配结果** - -My **name** is Zheng. - -# 三、匹配一组字符 - -**[ ]** 定义一个字符集合; - -0-9、a-z 定义了一个字符区间,区间使用 ASCII 码来确定,字符区间只能用在 [ ] 之间。 - -**-** 元字符只有在 [ ] 之间才是元字符,在 [ ] 之外就是一个普通字符; - -**^** 在 [ ] 字符集合中是取非操作。 - -**应用** - -匹配以 abc 为开头,并且最后一个字母不为数字的字符串: - -**正则表达式** - -``` -abc[^0-9] -``` - -**匹配结果** - -1. **abcd** -2. abc1 -3. abc2 - -# 四、使用元字符 - -## 匹配空白字符 - -| 元字符 | 说明 | -| :---: | :---: | -| [\b] | 回退(删除)一个字符 | -| \f | 换页符 | -| \n | 换行符 | -| \r | 回车符 | -| \t | 制表符 | -| \v | 垂直制表符 | - -\r\n 是 Windows 中的文本行结束标签,在 Unix/Linux 则是 \n ;\r\n\r\n 可以匹配 Windows 下的空白行,因为它将匹配两个连续的行尾标签,而这正是两条记录之间的空白行; - -. 是元字符,前提是没有对它们进行转义;f 和 n 也是元字符,但是前提是对它们进行了转义。 - -## 匹配特定的字符类别 - -### 1. 数字元字符 - -| 元字符 | 说明 | -| :---: | :---: | -| \d | 数字字符,等价于 [0-9] | -| \D | 非数字字符,等价于 [^0-9] | - -### 2. 字母数字元字符 - -| 元字符 | 说明 | -| :---: | :---: | -| \w | 大小写字母,下划线和数字,等价于 [a-zA-Z0-9\_] | -| \W | 对 \w 取非 | - -### 3. 空白字符元字符 - -| 元字符 | 说明 | -| :---: | :---: | -| \s | 任何一个空白字符,等价于 [\f\n\r\t\v] | -| \S | 对 \s 取非 | - -\x 匹配十六进制字符,\0 匹配八进制,例如 \x0A 对应 ASCII 字符 10 ,等价于 \n,也就是它会匹配 \n 。 - -# 五、重复匹配 - -**\+** 匹配 1 个或者多个字符, **\*** 匹配 0 个或者多个,**?** 匹配 0 个或者 1 个。 - -**应用** - -匹配邮箱地址。 - -**正则表达式** - -``` -[\w.]+@\w+\.\w+ -``` - -[\w.] 匹配的是字母数字或者 . ,在其后面加上 + ,表示匹配多次。在字符集合 [ ] 里,. 不是元字符; - -**匹配结果** - -**abc.def@qq.com** - -为了可读性,常常把转义的字符放到字符集合 [ ] 中,但是含义是相同的。 - -``` -[\w.]+@\w+\.\w+ -[\w.]+@[\w]+[\.][\w]+ -``` - -**{n}** 匹配 n 个字符,**{m, n}** 匹配 m\~n 个字符,**{m,}** 至少匹配 m 个字符; - -\* 和 + 都是贪婪型元字符,会匹配最多的内容,在元字符后面加 ? 可以转换为懒惰型元字符,例如 \*?、+? 和 {m, n}? 。 - -**正则表达式** - -``` -a.+c -``` - -由于 + 是贪婪型的,因此 .+ 会匹配更可能多的内容,所以会把整个 abcabcabc 文本都匹配,而不是只匹配前面的 abc 文本。用懒惰型可以实现匹配前面的。 - -**匹配结果** - -**abcabcabc** - -# 六、位置匹配 - -## 单词边界 - -**\b** 可以匹配一个单词的边界,边界是指位于 \w 和 \W 之间的位置;**\B** 匹配一个不是单词边界的位置。 - -\b 只匹配位置,不匹配字符,因此 \babc\b 匹配出来的结果为 3 个字符。 - -## 字符串边界 - -**^** 匹配整个字符串的开头,**$** 匹配结尾。 - -^ 元字符在字符集合中用作求非,在字符集合外用作匹配字符串的开头。 - -分行匹配模式(multiline)下,换行被当做字符串的边界。 - -**应用** - -匹配代码中以 // 开始的注释行 - -**正则表达式** - -``` -^\s*\/\/.*$ -``` - -

- -**匹配结果** - -1. public void fun() { -2.      **// 注释 1** -3.      int a = 1; -4.      int b = 2; -5.      **// 注释 2** -6.      int c = a + b; -7. } - -# 七、使用子表达式 - -使用 **( )** 定义一个子表达式。子表达式的内容可以当成一个独立元素,即可以将它看成一个字符,并且使用 * 等元字符。 - -子表达式可以嵌套,但是嵌套层次过深会变得很难理解。 - -**正则表达式** - -``` -(ab){2,} -``` - -**匹配结果** - -**ababab** - -**|** 是或元字符,它把左边和右边所有的部分都看成单独的两个部分,两个部分只要有一个匹配就行。 - -**正则表达式** - -``` -(19|20)\d{2} -``` - -**匹配结果** - -1. **1900** -2. **2010** -3. 1020 - -**应用** - -匹配 IP 地址。IP 地址中每部分都是 0-255 的数字,用正则表达式匹配时以下情况是合法的: - -- 一位数字 -- 不以 0 开头的两位数字 -- 1 开头的三位数 -- 2 开头,第 2 位是 0-4 的三位数 -- 25 开头,第 3 位是 0-5 的三位数 - -**正则表达式** - -``` -((25[0-5]|(2[0-4]\d)|(1\d{2})|([1-9]\d)|(\d))\.){3}(25[0-5]|(2[0-4]\d)|(1\d{2})|([1-9]\d)|(\d)) -``` - -**匹配结果** - -1. **192.168.0.1** -2. 00.00.00.00 -3. 555.555.555.555 - -# 八、回溯引用 - -回溯引用使用 **\n** 来引用某个子表达式,其中 n 代表的是子表达式的序号,从 1 开始。它和子表达式匹配的内容一致,比如子表达式匹配到 abc,那么回溯引用部分也需要匹配 abc 。 - -**应用** - -匹配 HTML 中合法的标题元素。 - -**正则表达式** - -\1 将回溯引用子表达式 (h[1-6]) 匹配的内容,也就是说必须和子表达式匹配的内容一致。 - -``` -<(h[1-6])>\w*?<\/\1> -``` - -**匹配结果** - -1. **<h1>x</h1>** -2. **<h2>x</h2>** -3. <h3>x</h1> - -## 替换 - -需要用到两个正则表达式。 - -**应用** - -修改电话号码格式。 - -**文本** - -313-555-1234 - -**查找正则表达式** - -``` -(\d{3})(-)(\d{3})(-)(\d{4}) -``` - -**替换正则表达式** - -在第一个子表达式查找的结果加上 () ,然后加一个空格,在第三个和第五个字表达式查找的结果中间加上 - 进行分隔。 - -``` -($1) $3-$5 -``` - -**结果** - -(313) 555-1234 - -## 大小写转换 - -| 元字符 | 说明 | -| :---: | :---: | -| \l | 把下个字符转换为小写 | -| \u| 把下个字符转换为大写 | -| \L | 把\L 和\E 之间的字符全部转换为小写 | -| \U | 把\U 和\E 之间的字符全部转换为大写 | -| \E | 结束\L 或者\U | - -**应用** - -把文本的第二个和第三个字符转换为大写。 - -**文本** - -abcd - -**查找** - -``` -(\w)(\w{2})(\w) -``` - -**替换** - -``` -$1\U$2\E$3 -``` - -**结果** - -aBCd - -# 九、前后查找 - -前后查找规定了匹配的内容首尾应该匹配的内容,但是又不包含首尾匹配的内容。向前查找用 **?=** 来定义,它规定了尾部匹配的内容,这个匹配的内容在 ?= 之后定义。所谓向前查找,就是规定了一个匹配的内容,然后以这个内容为尾部向前面查找需要匹配的内容。向后匹配用 ?<= 定义(注: javaScript 不支持向后匹配, java 对其支持也不完善)。 - -**应用** - -查找出邮件地址 @ 字符前面的部分。 - -**正则表达式** - -``` -\w+(?=@) -``` - -**结果** - -**abc** @qq.com - -对向前和向后查找取非,只要把 = 替换成 ! 即可,比如 (?=) 替换成 (?!) 。取非操作使得匹配那些首尾不符合要求的内容。 - -# 十、嵌入条件 - -## 回溯引用条件 - -条件判断为某个子表达式是否匹配,如果匹配则需要继续匹配条件表达式后面的内容。 - -**正则表达式** - -子表达式 (\\() 匹配一个左括号,其后的 ? 表示匹配 0 个或者 1 个。 ?(1) 为条件,当子表达式 1 匹配时条件成立,需要执行 \) 匹配,也就是匹配右括号。 - -``` -(\()?abc(?(1)\)) -``` - -**结果** - -1. **(abc)** -2. **abc** -3. (abc - -## 前后查找条件 - -条件为定义的首尾是否匹配,如果匹配,则继续执行后面的匹配。注意,首尾不包含在匹配的内容中。 - -**正则表达式** - - ?(?=-) 为前向查找条件,只有在以 - 为前向查找的结尾能匹配 \d{5} ,才继续匹配 -\d{4} 。 - -``` -\d{5}(?(?=-)-\d{4}) -``` - -**结果** - -1. **11111** -2. 22222- -3. **33333-4444** - -# 参考资料 - -- BenForta. 正则表达式必知必会 [M]. 人民邮电出版社, 2007. diff --git a/notes/消息队列.md b/notes/消息队列.md index e28fcd3f..649f563a 100644 --- a/notes/消息队列.md +++ b/notes/消息队列.md @@ -1,80 +1,4 @@ -* [一、消息模型](#一消息模型) - * [点对点](#点对点) - * [发布/订阅](#发布订阅) -* [二、使用场景](#二使用场景) - * [异步处理](#异步处理) - * [流量削锋](#流量削锋) - * [应用解耦](#应用解耦) -* [三、可靠性](#三可靠性) - * [发送端的可靠性](#发送端的可靠性) - * [接收端的可靠性](#接收端的可靠性) -# 一、消息模型 - -## 点对点 - -消息生产者向消息队列中发送了一个消息之后,只能被一个消费者消费一次。 - -

- -## 发布/订阅 - -消息生产者向频道发送一个消息之后,多个消费者可以从该频道订阅到这条消息并消费。 - -

- -发布与订阅模式和观察者模式有以下不同: - -- 观察者模式中,观察者和主题都知道对方的存在;而在发布与订阅模式中,发布者与订阅者不知道对方的存在,它们之间通过频道进行通信。 -- 观察者模式是同步的,当事件触发时,主题会去调用观察者的方法,然后等待方法返回;而发布与订阅模式是异步的,发布者向频道发送一个消息之后,就不需要关心订阅者何时去订阅这个消息。 - -

- -参考: - -- [Observer vs Pub-Sub](http://developers-club.com/posts/270339/) -- [消息队列中点对点与发布订阅区别](https://blog.csdn.net/lizhitao/article/details/47723105) - -# 二、使用场景 - -## 异步处理 - -发送者将消息发送给消息队列之后,不需要同步等待消息接收者处理完毕,而是立即返回进行其它操作。消息接收者从消息队列中订阅消息之后异步处理。 - -例如在注册流程中通常需要发送验证邮件来确保注册用户的身份合法,可以使用消息队列使发送验证邮件的操作异步处理,用户在填写完注册信息之后就可以完成注册,而将发送验证邮件这一消息发送到消息队列中。 - -只有在业务流程允许异步处理的情况下才能这么做,例如上面的注册流程中,如果要求用户对验证邮件进行点击之后才能完成操作的话,就不能再使用消息队列。 - -## 流量削锋 - -在高并发的场景下,如果短时间有大量的请求会压垮服务器。 - -可以将请求发送到消息队列中,服务器按照其处理能力从消息队列中订阅消息进行处理。 - -## 应用解耦 - -如果模块之间不直接进行调用,模块之间耦合度很低,那么修改一个模块或者新增一个模块对其它模块的影响会很小,从而实现可扩展性。 - -通过使用消息队列,一个模块只需要向消息队列中发送消息,其它模块可以选择性地从消息队列中订阅消息从而完成调用。 - -# 三、可靠性 - -## 发送端的可靠性 - -发送端完成操作后一定能将消息成功发送到消息队列中。 - -实现方法: - -- 在本地数据库建一张消息表,将消息数据与业务数据保存在同一数据库实例里,这样就可以利用本地数据库的事务机制。事务提交成功后,将消息表中的消息转移到消息队列中,若转移消息成功则删除消息表中的数据,否则继续重传。 - -## 接收端的可靠性 - -接收端能够从消息中间件成功消费一次消息。 - -实现方法: - -- 保证接收端处理消息的业务逻辑具有幂等性:只要具有幂等性,那么消费多少次消息,最后处理的结果都是一样的。 -- 保证消息具有唯一编号,并使用一张日志表来记录已经消费的消息编号。 diff --git a/notes/算法.md b/notes/算法.md index df5a8049..fb878742 100644 --- a/notes/算法.md +++ b/notes/算法.md @@ -2050,7 +2050,7 @@ public class SparseVector { 如果只有一个圆盘,那么只需要进行一次移动操作。 -从上面的讨论可以知道,n 圆盘需要移动 (n-1)+1+(n-1) = 2n-1 次。 +从上面的讨论可以知道,an = 2 * an-1 + 1,显然 an = 2n - 1,n 个圆盘需要移动 2n - 1 次。

diff --git a/notes/系统设计基础.md b/notes/系统设计基础.md index e132aa2c..649f563a 100644 --- a/notes/系统设计基础.md +++ b/notes/系统设计基础.md @@ -1,104 +1,4 @@ -* [一、性能](#一性能) -* [二、伸缩性](#二伸缩性) -* [三、扩展性](#三扩展性) -* [四、可用性](#四可用性) -* [五、安全性](#五安全性) -# 一、性能 - -## 性能指标 - -### 1. 响应时间 - -指从某个请求发出到接收到响应消耗的时间。 - -在对响应时间进行测试时,通常采用重复请求方式,然后计算平均响应时间。 - -### 2. 吞吐量 - -指系统在单位时间内可以处理的请求数量,通常使用每秒的请求数来衡量。 - -### 3. 并发用户数 - -指系统能同时处理的并发用户请求数量。 - -在没有并发存在的系统中,请求被顺序执行,此时响应时间为吞吐量的倒数。例如系统支持的吞吐量为 100 req/s,那么平均响应时间应该为 0.01s。 - -目前的大型系统都支持多线程来处理并发请求,多线程能够提高吞吐量以及缩短响应时间,主要有两个原因: - -- 多 CPU -- IO 等待时间 - -使用 IO 多路复用等方式,系统在等待一个 IO 操作完成的这段时间内不需要被阻塞,可以去处理其它请求。通过将这个等待时间利用起来,使得 CPU 利用率大大提高。 - -并发用户数不是越高越好,因为如果并发用户数太高,系统来不及处理这么多的请求,会使得过多的请求需要等待,那么响应时间就会大大提高。 - -## 性能优化 - -### 1. 集群 - -将多台服务器组成集群,使用负载均衡将请求转发到集群中,避免单一服务器的负载压力过大导致性能降低。 - -### 2. 缓存 - -缓存能够提高性能的原因如下: - -- 缓存数据通常位于内存等介质中,这种介质对于读操作特别快; -- 缓存数据可以位于靠近用户的地理位置上; -- 可以将计算结果进行缓存,从而避免重复计算。 - -### 3. 异步 - -某些流程可以将操作转换为消息,将消息发送到消息队列之后立即返回,之后这个操作会被异步处理。 - -# 二、伸缩性 - -指不断向集群中添加服务器来缓解不断上升的用户并发访问压力和不断增长的数据存储需求。 - -## 伸缩性与性能 - -如果系统存在性能问题,那么单个用户的请求总是很慢的; - -如果系统存在伸缩性问题,那么单个用户的请求可能会很快,但是在并发数很高的情况下系统会很慢。 - -## 实现伸缩性 - -应用服务器只要不具有状态,那么就可以很容易地通过负载均衡器向集群中添加新的服务器。 - -关系型数据库的伸缩性通过 Sharding 来实现,将数据按一定的规则分布到不同的节点上,从而解决单台存储服务器存储空间限制。 - -对于非关系型数据库,它们天生就是为海量数据而诞生,对伸缩性的支持特别好。 - -# 三、扩展性 - -指的是添加新功能时对现有系统的其它应用无影响,这就要求不同应用具备低耦合的特点。 - -实现可扩展主要有两种方式: - -- 使用消息队列进行解耦,应用之间通过消息传递的方式进行通信; -- 使用分布式服务将业务和可复用的服务分离开来,业务使用分布式服务器框架调用可复用的服务。新增的产品可以用过调用可复用的服务来实现业务逻辑,对其它产品没有影响。 - -# 四、可用性 - -## 冗余 - -保证高可用的主要手段是使用冗余,当某个服务器故障时就请求其它服务器。 - -应用服务器的冗余比较容易实现,只要保证应用服务器不具有状态,那么某个应用服务器故障时,负载均衡器将该应用服务器原先的用户请求转发到另一个应用服务器上不会对用户有任何影响。 - -存储服务器的冗余需要使用主从复制来实现,当主服务器故障时,需要提升从服务器为主服务器,这个过程称为切换。 - -## 监控 - -对 CPU、内存、磁盘、网络等系统负载信息进行监控,当某个数据达到一定阈值时通知运维人员,从而在系统发生故障之前及时发现问题。 - -## 服务降级 - -服务器降级是系统为了应对大量的请求,主动关闭部分功能,从而保证核心功能可用。 - -# 五、安全性 - -要求系统的应对各种攻击手段时能够有可靠的应对措施。 diff --git a/notes/缓存.md b/notes/缓存.md index e60373dd..449598ff 100644 --- a/notes/缓存.md +++ b/notes/缓存.md @@ -163,7 +163,11 @@ public class LRU implements Iterable { ## 分布式缓存 -使用 Redis、Memcache 等分布式缓存将数据缓存在分布式缓存系统中。相对于本地缓存来说,分布式缓存单独部署,可以根据需求分配硬件资源。不仅如此,服务器集群都可以访问分布式缓存,本地缓存需要在服务器集群之间进行同步,实现和性能开销上都非常大。 +使用 Redis、Memcache 等分布式缓存将数据缓存在分布式缓存系统中。 + +相对于本地缓存来说,分布式缓存单独部署,可以根据需求分配硬件资源。 + +不仅如此,服务器集群都可以访问分布式缓存。而本地缓存需要在服务器集群之间进行同步,实现和性能开销上都非常大。 ## 数据库缓存 @@ -171,7 +175,7 @@ MySQL 等数据库管理系统具有自己的查询缓存机制来提高 SQL 查 # 四、CDN -内容分发网络(Content distribution network,CDN)是一种通过互连的网络系统,利用更靠近用户的服务器更快更可靠地将 HTML、CSS、JavaScript、音乐、图片、视频等静态资源其它数据分发给用户。 +内容分发网络(Content distribution network,CDN)是一种通过互连的网络系统,利用更靠近用户的服务器更快更可靠地将 HTML、CSS、JavaScript、音乐、图片、视频等静态资源分发给用户。 CDN 主要有以下优点: @@ -190,7 +194,7 @@ CDN 主要有以下优点: ## 缓存穿透 -指的是对某个一定不存在的数据进行请求,该请求将会穿透缓存来到数据库。 +指的是对某个一定不存在的数据进行请求,该请求将会穿透缓存到达数据库。 解决方案: @@ -207,6 +211,7 @@ CDN 主要有以下优点: - 为了防止缓存在同一时间大面积过期导致的缓存雪崩,可以通过观察用户行为,合理设置缓存过期时间来实现; - 为了防止缓存服务器宕机出现的缓存雪崩,可以使用分布式缓存,分布式缓存中每一个节点只缓存部分的数据,当某个节点宕机时可以保证其它节点的缓存仍然可用。 +- 也可以在进行缓存预热,避免在系统刚启动不久由于还未将大量数据进行缓存而导致缓存雪崩。 ## 缓存一致性 @@ -258,7 +263,7 @@ Distributed Hash Table(DHT) 是一种哈希分布方式,其目的是为了 上面描述的一致性哈希存在数据分布不均匀的问题,节点存储的数据量有可能会存在很大的不同。 -数据不均匀主要是因为节点在哈希环上分布的不均匀,这种情况在节点数量很少的情况下尤其明显。解决方式是通过增加虚拟节点,然后将虚拟节点映射到真实节点上。虚拟节点的数量比真实节点来得大,那么虚拟节点在哈希环上分布的均匀性就会比原来的真是节点号,从而使得数据分布也更加均匀。 +数据不均匀主要是因为节点在哈希环上分布的不均匀,这种情况在节点数量很少的情况下尤其明显。解决方式是通过增加虚拟节点,然后将虚拟节点映射到真实节点上。虚拟节点的数量比真实节点来得大,那么虚拟节点在哈希环上分布的均匀性就会比原来的真是节点好,从而使得数据分布也更加均匀。 参考资料: diff --git a/notes/计算机网络.md b/notes/计算机网络.md index f1460844..649f563a 100644 --- a/notes/计算机网络.md +++ b/notes/计算机网络.md @@ -1,890 +1,4 @@ -* [一、概述](#一概述) - * [网络的网络](#网络的网络) - * [ISP](#isp) - * [主机之间的通信方式](#主机之间的通信方式) - * [电路交换与分组交换](#电路交换与分组交换) - * [时延](#时延) - * [计算机网络体系结构*](#计算机网络体系结构) -* [二、物理层](#二物理层) - * [通信方式](#通信方式) - * [带通调制](#带通调制) -* [三、数据链路层](#三数据链路层) - * [基本问题](#基本问题) - * [信道分类](#信道分类) - * [信道复用技术](#信道复用技术) - * [CSMA/CD 协议*](#csmacd-协议) - * [PPP 协议](#ppp-协议) - * [MAC 地址](#mac-地址) - * [局域网](#局域网) - * [以太网*](#以太网) - * [交换机*](#交换机) - * [虚拟局域网](#虚拟局域网) -* [四、网络层*](#四网络层) - * [概述](#概述) - * [IP 数据报格式](#ip-数据报格式) - * [IP 地址编址方式](#ip-地址编址方式) - * [地址解析协议 ARP](#地址解析协议-arp) - * [网际控制报文协议 ICMP](#网际控制报文协议-icmp) - * [虚拟专用网 VPN](#虚拟专用网-vpn) - * [网络地址转换 NAT](#网络地址转换-nat) - * [路由器的结构](#路由器的结构) - * [路由器分组转发流程](#路由器分组转发流程) - * [路由选择协议](#路由选择协议) -* [五、运输层*](#五运输层) - * [UDP 和 TCP 的特点](#udp-和-tcp-的特点) - * [UDP 首部格式](#udp-首部格式) - * [TCP 首部格式](#tcp-首部格式) - * [TCP 的三次握手](#tcp-的三次握手) - * [TCP 的四次挥手](#tcp-的四次挥手) - * [TCP 滑动窗口](#tcp-滑动窗口) - * [TCP 可靠传输](#tcp-可靠传输) - * [TCP 流量控制](#tcp-流量控制) - * [TCP 拥塞控制](#tcp-拥塞控制) -* [六、应用层](#六应用层) - * [域名系统](#域名系统) - * [文件传送协议](#文件传送协议) - * [远程登录协议](#远程登录协议) - * [电子邮件协议](#电子邮件协议) - * [动态主机配置协议](#动态主机配置协议) - * [常用端口](#常用端口) - * [Web 页面请求过程](#web-页面请求过程) -* [参考资料](#参考资料) -# 一、概述 - -## 网络的网络 - -网络把主机连接起来,而互联网是把多种不同的网络连接起来,因此互联网是网络的网络。 - -

- -## ISP - -互联网服务提供商 ISP 可以从互联网管理机构获得许多 IP 地址,同时拥有通信线路以及路由器等联网设备,个人或机构向 ISP 缴纳一定的费用就可以接入互联网。 - -

- -目前的互联网是一种多层次 ISP 结构,ISP 根据覆盖面积的大小分为第一层 ISP、区域 ISP 和接入 ISP。 - -互联网交换点 IXP 允许两个 ISP 直接相连而不用经过第三个 ISP。 - -

- -## 主机之间的通信方式 - -- 客户-服务器(C/S):客户是服务的请求方,服务器是服务的提供方。 - -- 对等(P2P):不区分客户和服务器。 - -

- -## 电路交换与分组交换 - -### 1. 电路交换 - -电路交换用于电话通信系统,两个用户要通信之前需要建立一条专用的物理链路,并且在整个通信过程中始终占用该链路。由于通信的过程中不可能一直在使用传输线路,因此电路交换对线路的利用率很低,往往不到 10%。 - -### 2. 分组交换 - -每个分组都有首部和尾部,包含了源地址和目的地址等控制信息,在同一个传输线路上同时传输多个分组互相不会影响,因此在同一条传输线路上允许同时传输多个分组,也就是说分组交换不需要占用传输线路。 - -考虑在一个邮局通信系统中,邮局收到一份邮件之后,先存储下来,然后把相同目的地的邮件一起转发到下一个目的地,这个过程就是存储转发过程,分组交换也使用了存储转发过程。 - -## 时延 - -总时延 = 传输时延 + 传播时延 + 处理时延 + 排队时延 - -

- -### 1. 传输时延 - -主机或路由器传输数据帧所需要的时间。 - -

- -其中 l 表示数据帧的长度,v 表示传输速率。 - -### 2. 传播时延 - -电磁波在信道中传播一定的距离需要花费的时间,电磁波传播速度接近光速。 - -

- -其中 l 表示信道长度,v 表示电磁波在信道上的传播速率。 - -### 3. 处理时延 - -主机或路由器收到分组时进行处理所需要的时间,例如分析首部、从分组中提取数据、进行差错检验或查找适当的路由等。 - -### 4. 排队时延 - -分组在路由器的输入队列和输出队列中排队等待的时间,取决于网络当前的通信量。 - -## 计算机网络体系结构* - -

- -### 1. 五层协议 - -- **应用层** :为特定应用程序提供数据传输服务,例如 HTTP、DNS 等。数据单位为报文。 - -- **运输层** :提供的是进程间的通用数据传输服务。由于应用层协议很多,定义通用的运输层协议就可以支持不断增多的应用层协议。运输层包括两种协议:传输控制协议 TCP,提供面向连接、可靠的数据传输服务,数据单位为报文段;用户数据报协议 UDP,提供无连接、尽最大努力的数据传输服务,数据单位为用户数据报。TCP 主要提供完整性服务,UDP 主要提供及时性服务。 - -- **网络层** :为主机间提供数据传输服务,而运输层协议是为主机中的进程提供服务。网络层把运输层传递下来的报文段或者用户数据报封装成分组。 - -- **数据链路层** :网络层针对的还是主机之间的数据传输服务,而主机之间可以有很多链路,链路层协议就是为同一链路的节点提供服务。数据链路层把网络层传来的分组封装成帧。 - -- **物理层** :考虑的是怎样在传输媒体上传输数据比特流,而不是指具体的传输媒体。物理层的作用是尽可能屏蔽传输媒体和通信手段的差异,使数据链路层感觉不到这些差异。 - -### 2. OSI - -其中表示层和会话层用途如下: - -- **表示层** :数据压缩、加密以及数据描述,这使得应用程序不必担心在各台主机中数据内部格式不同的问题。 - -- **会话层** :建立及管理会话。 - -五层协议没有表示层和会话层,而是将这些功能留给应用程序开发者处理。 - -### 3. TCP/IP - -它只有四层,相当于五层协议中数据链路层和物理层合并为网络接口层。 - -现在的 TCP/IP 体系结构不严格遵循 OSI 分层概念,应用层可能会直接使用 IP 层或者网络接口层。 - -

- -TCP/IP 协议族是一种沙漏形状,中间小两边大,IP 协议在其中占用举足轻重的地位。 - -

- -### 4. 数据在各层之间的传递过程 - -在向下的过程中,需要添加下层协议所需要的首部或者尾部,而在向上的过程中不断拆开首部和尾部。 - -路由器只有下面三层协议,因为路由器位于网络核心中,不需要为进程或者应用程序提供服务,因此也就不需要运输层和应用层。 - -

- -# 二、物理层 - -## 通信方式 - -根据信息在传输线上的传送方向,分为以下三种通信方式: - -- 单工通信:单向传输 -- 半双工通信:双向交替传输 -- 全双工通信:双向同时传输 - -## 带通调制 - -模拟信号是连续的信号,数字信号是离散的信号。带通调制把数字信号转换为模拟信号。 - -

- -# 三、数据链路层 - -## 基本问题 - -### 1. 封装成帧 - -将网络层传下来的分组添加首部和尾部,用于标记帧的开始和结束。 - -

- -### 2. 透明传输 - -透明表示一个实际存在的事物看起来好像不存在一样。 - -帧使用首部和尾部进行定界,如果帧的数据部分含有和首部尾部相同的内容,那么帧的开始和结束位置就会被错误的判定。需要在数据部分出现首部尾部相同的内容前面插入转义字符。如果数据部分出现转义字符,那么就在转义字符前面再加个转义字符。在接收端进行处理之后可以还原出原始数据。这个过程透明传输的内容是转义字符,用户察觉不到转义字符的存在。 - -

- -### 3. 差错检测 - -目前数据链路层广泛使用了循环冗余检验(CRC)来检查比特差错。 - -## 信道分类 - -### 1. 广播信道 - -一对多通信,一个节点发送的数据能够被广播信道上所有的节点接收到。 - -所有的节点都在同一个广播信道上发送数据,因此需要有专门的控制方法进行协调,避免发生冲突(冲突也叫碰撞)。 - -主要有两种控制方法进行协调,一个是使用信道复用技术,一是使用 CSMA/CD 协议。 - -### 2. 点对点信道 - -一对一通信。 - -因为不会发生碰撞,因此也比较简单,使用 PPP 协议进行控制。 - -## 信道复用技术 - -### 1. 频分复用 - -频分复用的所有主机在相同的时间占用不同的频率带宽资源。 - -

- -### 2. 时分复用 - -时分复用的所有主机在不同的时间占用相同的频率带宽资源。 - -

- -使用频分复用和时分复用进行通信,在通信的过程中主机会一直占用一部分信道资源。但是由于计算机数据的突发性质,通信过程没必要一直占用信道资源而不让出给其它用户使用,因此这两种方式对信道的利用率都不高。 - -### 3. 统计时分复用 - -是对时分复用的一种改进,不固定每个用户在时分复用帧中的位置,只要有数据就集中起来组成统计时分复用帧然后发送。 - -

- -### 4. 波分复用 - -光的频分复用。由于光的频率很高,因此习惯上用波长而不是频率来表示所使用的光载波。 - -

- -### 5. 码分复用 - -为每个用户分配 m bit 的码片,并且所有的码片正交,对于任意两个码片 有 - -

- -为了讨论方便,取 m=8,设码片 为 00011011。在拥有该码片的用户发送比特 1 时就发送该码片,发送比特 0 时就发送该码片的反码 11100100。 - -在计算时将 00011011 记作 (-1 -1 -1 +1 +1 -1 +1 +1),可以得到 - -

- -

- -其中 的反码。 - -利用上面的式子我们知道,当接收端使用码片 对接收到的数据进行内积运算时,结果为 0 的是其它用户发送的数据,结果为 1 的是用户发送的比特 1,结果为 -1 的是用户发送的比特 0。 - -码分复用需要发送的数据量为原先的 m 倍。 - -

- -## CSMA/CD 协议* - -CSMA/CD 表示载波监听多点接入 / 碰撞检测。 - -- **多点接入** :说明这是总线型网络,许多主机以多点的方式连接到总线上。 -- **载波监听** :每个主机都必须不停地监听信道。在发送前,如果监听到信道正在使用,就必须等待。 -- **碰撞检测** :在发送中,如果监听到信道已有其它主机正在发送数据,就表示发生了碰撞。虽然每个主机在发送数据之前都已经监听到信道为空闲,但是由于电磁波的传播时延的存在,还是有可能会发生碰撞。 - -记端到端的传播时延为 τ,最先发送的站点最多经过 2τ 就可以知道是否发生了碰撞,称 2τ 为 **争用期** 。只有经过争用期之后还没有检测到碰撞,才能肯定这次发送不会发生碰撞。 - -当发生碰撞时,站点要停止发送,等待一段时间再发送。这个时间采用 **截断二进制指数退避算法** 来确定。从离散的整数集合 {0, 1, .., (2k-1)} 中随机取出一个数,记作 r,然后取 r 倍的争用期作为重传等待时间。 - -

- -## PPP 协议 - -互联网用户通常需要连接到某个 ISP 之后才能接入到互联网,PPP 协议是用户计算机和 ISP 进行通信时所使用的数据链路层协议。 - -

- -PPP 的帧格式: - -- F 字段为帧的定界符 -- A 和 C 字段暂时没有意义 -- FCS 字段是使用 CRC 的检验序列 -- 信息部分的长度不超过 1500 - -

- -## MAC 地址 - -MAC 地址是链路层地址,长度为 6 字节(48 位),用于唯一标识网络适配器(网卡)。 - -一台主机拥有多少个适配器就有多少个 MAC 地址。例如笔记本电脑普遍存在无线网络适配器和有线网络适配器,因此就有两个 MAC 地址。 - -## 局域网 - -局域网是一种典型的广播信道,主要特点是网络为一个单位所拥有,且地理范围和站点数目均有限。 - -主要有以太网、令牌环网、FDDI 和 ATM 等局域网技术,目前以太网占领着有线局域网市场。 - -可以按照网络拓结构扑对局域网进行分类: - -

- -## 以太网* - -以太网是一种星型拓扑结构局域网。 - -早期使用集线器进行连接。集线器是一种物理层设备,作用于比特而不是帧,当一个比特到达接口时,集线器重新生成这个比特,并将其能量强度放大,从而扩大网络的传输距离。之后再将这个比特发送到其它所有接口。如果集线器同时收到同时从两个不同接口的帧,那么就发生了碰撞。 - -目前以太网使用交换机替代了集线器。交换机是一种链路层设备,它不会发生碰撞,能根据 MAC 地址进行存储转发。 - -以太网帧格式: - -- **类型** :标记上层使用的协议; -- **数据** :长度在 46-1500 之间,如果太小则需要填充; -- **FCS** :帧检验序列,使用的是 CRC 检验方法; -- **前同步码** :只是为了计算 FCS 临时加入的,计算结束之后会丢弃。 - -

- -## 交换机* - -交换机具有自学习能力,学习的是交换表的内容,交换表中存储着 MAC 地址到接口的映射。正是由于这种自学习能力,因此交换机是一种即插即可即用设备,不需要网络管理员手动配置交换表内容。 - -下图中,交换机有 4 个接口,主机 A 向主机 B 发送数据帧时,交换机把主机 A 到接口 1 的映射写入交换表中。为了发送数据帧到 B,先查交换表,此时没有主机 B 的表项,那么主机 A 就发送广播帧,主机 C 和主机 D 会丢弃该帧。主机 B 收下之后,查找交换表得到主机 A 映射的接口为 1,就发送数据帧到接口 1,同时交换机添加主机 B 到接口 3 的映射。 - -

- -## 虚拟局域网 - -虚拟局域网可以建立与物理位置无关的逻辑组,只有在同一个虚拟局域网中的成员才会收到链路层广播信息。例如下图中 (A1, A2, A3, A4) 属于一个虚拟局域网,A1 发送的广播会被 A2、A3、A4 收到,而其它站点收不到。 - -使用 VLAN 干线连接来建立虚拟局域网,每台交换机上的一个特殊端口被设置为干线端口,以互连 VLAN 交换机。IEEE 定义了一种扩展的以太网帧格式 802.1Q,它在标准以太网帧上加进了 4 字节首部 VLAN 标签,用于表示该帧属于哪一个虚拟局域网。 - -

- -# 四、网络层* - -## 概述 - -因为网络层是整个互联网的核心,因此应当让网络层尽可能简单。网络层向上只提供简单灵活的、无连接的、尽最大努力交互的数据报服务。 - -使用 IP 协议,可以把异构的物理网络连接起来,使得在网络层看起来好像是一个统一的网络。 - -

- -与 IP 协议配套使用的还有三个协议: - -- 地址解析协议 ARP(Address Resolution Protocol) -- 网际控制报文协议 ICMP(Internet Control Message Protocol) -- 网际组管理协议 IGMP(Internet Group Management Protocol) - -

- -## IP 数据报格式 - -

- -- **版本** : 有 4(IPv4)和 6(IPv6)两个值; - -- **首部长度** : 占 4 位,因此最大值为 15。值为 1 表示的是 1 个 32 位字的长度,也就是 4 字节。因为首部固定长度为 20 字节,因此该值最小为 5。如果可选字段的长度不是 4 字节的整数倍,就用尾部的填充部分来填充。 - -- **区分服务** : 用来获得更好的服务,一般情况下不使用。 - -- **总长度** : 包括首部长度和数据部分长度。 - -- **生存时间** :TTL,它的存在是为了防止无法交付的数据报在互联网中不断兜圈子。以路由器跳数为单位,当 TTL 为 0 时就丢弃数据报。 - -- **协议** :指出携带的数据应该上交给哪个协议进行处理,例如 ICMP、TCP、UDP 等。 - -- **首部检验和** :因为数据报每经过一个路由器,都要重新计算检验和,因此检验和不包含数据部分可以减少计算的工作量。 - -- **标识** : 在数据报长度过长从而发生分片的情况下,相同数据报的不同分片具有相同的标识符。 - -- **片偏移** : 和标识符一起,用于发生分片的情况。片偏移的单位为 8 字节。 - -

- -## IP 地址编址方式 - -IP 地址的编址方式经历了三个历史阶段: - -- 分类 -- 子网划分 -- 无分类 - -### 1. 分类 - -由两部分组成,网络号和主机号,其中不同分类具有不同的网络号长度,并且是固定的。 - -IP 地址 ::= {< 网络号 >, < 主机号 >} - -

- -### 2. 子网划分 - -通过在主机号字段中拿一部分作为子网号,把两级 IP 地址划分为三级 IP 地址。注意,外部网络看不到子网的存在。 - -IP 地址 ::= {< 网络号 >, < 子网号 >, < 主机号 >} - -要使用子网,必须配置子网掩码。一个 B 类地址的默认子网掩码为 255.255.0.0,如果 B 类地址的子网占两个比特,那么子网掩码为 11111111 11111111 11000000 00000000,也就是 255.255.192.0。 - -### 3. 无分类 - -无分类编址 CIDR 消除了传统 A 类、B 类和 C 类地址以及划分子网的概念,使用网络前缀和主机号来对 IP 地址进行编码,网络前缀的长度可以根据需要变化。 - -IP 地址 ::= {< 网络前缀号 >, < 主机号 >} - -CIDR 的记法上采用在 IP 地址后面加上网络前缀长度的方法,例如 128.14.35.7/20 表示前 20 位为网络前缀。 - -CIDR 的地址掩码可以继续称为子网掩码,子网掩码首 1 长度为网络前缀的长度。 - -一个 CIDR 地址块中有很多地址,一个 CIDR 表示的网络就可以表示原来的很多个网络,并且在路由表中只需要一个路由就可以代替原来的多个路由,减少了路由表项的数量。把这种通过使用网络前缀来减少路由表项的方式称为路由聚合,也称为 **构成超网** 。 - -在路由表中的项目由“网络前缀”和“下一跳地址”组成,在查找时可能会得到不止一个匹配结果,应当采用最长前缀匹配来确定应该匹配哪一个。 - -## 地址解析协议 ARP - -网络层实现主机之间的通信,而链路层实现具体每段链路之间的通信。因此在通信过程中,IP 数据报的源地址和目的地址始终不变,而 MAC 地址随着链路的改变而改变。 - -

- -ARP 实现由 IP 地址得到 MAC 地址。 - -

- -每个主机都有一个 ARP 高速缓存,里面有本局域网上的各主机和路由器的 IP 地址到硬件地址的映射表。 - -如果主机 A 知道主机 B 的 IP 地址,但是 ARP 高速缓存中没有该 IP 地址到 MAC 地址的映射,此时主机 A 通过广播的方式发送 ARP 请求分组,主机 B 收到该请求后会发送 ARP 响应分组给主机 A 告知其 MAC 地址,随后主机 A 向其高速缓存中写入主机 B 的 IP 地址到 MAC 地址的映射。 - -

- -## 网际控制报文协议 ICMP - -ICMP 是为了更有效地转发 IP 数据报和提高交付成功的机会。它封装在 IP 数据报中,但是不属于高层协议。 - -

- -ICMP 报文分为差错报告报文和询问报文。 - -

- -### 1. Ping - -Ping 是 ICMP 的一个重要应用,主要用来测试两台主机之间的连通性。 - -Ping 发送的 IP 数据报封装的是无法交付的 UDP 用户数据报。 - -### 2. Traceroute - -Traceroute 是 ICMP 的另一个应用,用来跟踪一个分组从源点到终点的路径。 - -- 源主机向目的主机发送一连串的 IP 数据报。第一个数据报 P1 的生存时间 TTL 设置为 1,当 P1 到达路径上的第一个路由器 R1 时,R1 收下它并把 TTL 减 1,此时 TTL 等于 0,R1 就把 P1 丢弃,并向源主机发送一个 ICMP 时间超过差错报告报文; -- 源主机接着发送第二个数据报 P2,并把 TTL 设置为 2。P2 先到达 R1,R1 收下后把 TTL 减 1 再转发给 R2,R2 收下后也把 TTL 减 1,由于此时 TTL 等于 0,R2 就丢弃 P2,并向源主机发送一个 ICMP 时间超过差错报文。 -- 不断执行这样的步骤,直到最后一个数据报刚刚到达目的主机,主机不转发数据报,也不把 TTL 值减 1。但是因为数据报封装的是无法交付的 UDP,因此目的主机要向源主机发送 ICMP 终点不可达差错报告报文。 -- 之后源主机知道了到达目的主机所经过的路由器 IP 地址以及到达每个路由器的往返时间。 - -## 虚拟专用网 VPN - -由于 IP 地址的紧缺,一个机构能申请到的 IP 地址数往往远小于本机构所拥有的主机数。并且一个机构并不需要把所有的主机接入到外部的互联网中,机构内的计算机可以使用仅在本机构有效的 IP 地址(专用地址)。 - -有三个专用地址块: - -- 10.0.0.0 \~ 10.255.255.255 -- 172.16.0.0 \~ 172.31.255.255 -- 192.168.0.0 \~ 192.168.255.255 - -VPN 使用公用的互联网作为本机构各专用网之间的通信载体。专用指机构内的主机只与本机构内的其它主机通信;虚拟指“好像是”,而实际上并不是,它有经过公用的互联网。 - -下图中,场所 A 和 B 的通信经过互联网,如果场所 A 的主机 X 要和另一个场所 B 的主机 Y 通信,IP 数据报的源地址是 10.1.0.1,目的地址是 10.2.0.3。数据报先发送到与互联网相连的路由器 R1,R1 对内部数据进行加密,然后重新加上数据报的首部,源地址是路由器 R1 的全球地址 125.1.2.3,目的地址是路由器 R2 的全球地址 194.4.5.6。路由器 R2 收到数据报后将数据部分进行解密,恢复原来的数据报,此时目的地址为 10.2.0.3,就交付给 Y。 - -

- -## 网络地址转换 NAT - -专用网内部的主机使用本地 IP 地址又想和互联网上的主机通信时,可以使用 NAT 来将本地 IP 转换为全球 IP。 - -在以前,NAT 将本地 IP 和全球 IP 一一对应,这种方式下拥有 n 个全球 IP 地址的专用网内最多只可以同时有 n 台主机接入互联网。为了更有效地利用全球 IP 地址,现在常用的 NAT 转换表把运输层的端口号也用上了,使得多个专用网内部的主机共用一个全球 IP 地址。使用端口号的 NAT 也叫做网络地址与端口转换 NAPT。 - -

- -## 路由器的结构 - -路由器从功能上可以划分为:路由选择和分组转发。 - -分组转发结构由三个部分组成:交换结构、一组输入端口和一组输出端口。 - -

- -## 路由器分组转发流程 - -- 从数据报的首部提取目的主机的 IP 地址 D,得到目的网络地址 N。 -- 若 N 就是与此路由器直接相连的某个网络地址,则进行直接交付; -- 若路由表中有目的地址为 D 的特定主机路由,则把数据报传送给表中所指明的下一跳路由器; -- 若路由表中有到达网络 N 的路由,则把数据报传送给路由表中所指明的下一跳路由器; -- 若路由表中有一个默认路由,则把数据报传送给路由表中所指明的默认路由器; -- 报告转发分组出错。 - -

- -## 路由选择协议 - -路由选择协议都是自适应的,能随着网络通信量和拓扑结构的变化而自适应地进行调整。 - -互联网可以划分为许多较小的自治系统 AS,一个 AS 可以使用一种和别的 AS 不同的路由选择协议。 - -可以把路由选择协议划分为两大类: - -- 自治系统内部的路由选择:RIP 和 OSPF -- 自治系统间的路由选择:BGP - -### 1. 内部网关协议 RIP - -RIP 是一种基于距离向量的路由选择协议。距离是指跳数,直接相连的路由器跳数为 1。跳数最多为 15,超过 15 表示不可达。 - -RIP 按固定的时间间隔仅和相邻路由器交换自己的路由表,经过若干次交换之后,所有路由器最终会知道到达本自治系统中任何一个网络的最短距离和下一跳路由器地址。 - -距离向量算法: - -- 对地址为 X 的相邻路由器发来的 RIP 报文,先修改报文中的所有项目,把下一跳字段中的地址改为 X,并把所有的距离字段加 1; -- 对修改后的 RIP 报文中的每一个项目,进行以下步骤: - - 若原来的路由表中没有目的网络 N,则把该项目添加到路由表中; - - 否则:若下一跳路由器地址是 X,则把收到的项目替换原来路由表中的项目;否则:若收到的项目中的距离 d 小于路由表中的距离,则进行更新(例如原始路由表项为 Net2, 5, P,新表项为 Net2, 4, X,则更新);否则什么也不做。 -- 若 3 分钟还没有收到相邻路由器的更新路由表,则把该相邻路由器标为不可达,即把距离置为 16。 - -RIP 协议实现简单,开销小。但是 RIP 能使用的最大距离为 15,限制了网络的规模。并且当网络出现故障时,要经过比较长的时间才能将此消息传送到所有路由器。 - -### 2. 内部网关协议 OSPF - -开放最短路径优先 OSPF,是为了克服 RIP 的缺点而开发出来的。 - -开放表示 OSPF 不受某一家厂商控制,而是公开发表的;最短路径优先表示使用了 Dijkstra 提出的最短路径算法 SPF。 - -OSPF 具有以下特点: - -- 向本自治系统中的所有路由器发送信息,这种方法是洪泛法。 -- 发送的信息就是与相邻路由器的链路状态,链路状态包括与哪些路由器相连以及链路的度量,度量用费用、距离、时延、带宽等来表示。 -- 只有当链路状态发生变化时,路由器才会发送信息。 - -所有路由器都具有全网的拓扑结构图,并且是一致的。相比于 RIP,OSPF 的更新过程收敛的很快。 - -### 3. 外部网关协议 BGP - -BGP(Border Gateway Protocol,边界网关协议) - -AS 之间的路由选择很困难,主要是由于: - -- 互联网规模很大; -- 各个 AS 内部使用不同的路由选择协议,无法准确定义路径的度量; -- AS 之间的路由选择必须考虑有关的策略,比如有些 AS 不愿意让其它 AS 经过。 - -BGP 只能寻找一条比较好的路由,而不是最佳路由。 - -每个 AS 都必须配置 BGP 发言人,通过在两个相邻 BGP 发言人之间建立 TCP 连接来交换路由信息。 - -

- -# 五、运输层* - -网络层只把分组发送到目的主机,但是真正通信的并不是主机而是主机中的进程。运输层提供了进程间的逻辑通信,运输层向高层用户屏蔽了下面网络层的核心细节,使应用程序看起来像是在两个运输层实体之间有一条端到端的逻辑通信信道。 - -## UDP 和 TCP 的特点 - -- 用户数据报协议 UDP(User Datagram Protocol)是无连接的,尽最大可能交付,没有拥塞控制,面向报文(对于应用程序传下来的报文不合并也不拆分,只是添加 UDP 首部),支持一对一、一对多、多对一和多对多的交互通信。 - -- 传输控制协议 TCP(Transmission Control Protocol)是面向连接的,提供可靠交付,有流量控制,拥塞控制,提供全双工通信,面向字节流(把应用层传下来的报文看成字节流,把字节流组织成大小不等的数据块),每一条 TCP 连接只能是点对点的(一对一)。 - -## UDP 首部格式 - -

- -首部字段只有 8 个字节,包括源端口、目的端口、长度、检验和。12 字节的伪首部是为了计算检验和临时添加的。 - -## TCP 首部格式 - -

- -- **序号** :用于对字节流进行编号,例如序号为 301,表示第一个字节的编号为 301,如果携带的数据长度为 100 字节,那么下一个报文段的序号应为 401。 - -- **确认号** :期望收到的下一个报文段的序号。例如 B 正确收到 A 发送来的一个报文段,序号为 501,携带的数据长度为 200 字节,因此 B 期望下一个报文段的序号为 701,B 发送给 A 的确认报文段中确认号就为 701。 - -- **数据偏移** :指的是数据部分距离报文段起始处的偏移量,实际上指的是首部的长度。 - -- **确认 ACK** :当 ACK=1 时确认号字段有效,否则无效。TCP 规定,在连接建立后所有传送的报文段都必须把 ACK 置 1。 - -- **同步 SYN** :在连接建立时用来同步序号。当 SYN=1,ACK=0 时表示这是一个连接请求报文段。若对方同意建立连接,则响应报文中 SYN=1,ACK=1。 - -- **终止 FIN** :用来释放一个连接,当 FIN=1 时,表示此报文段的发送方的数据已发送完毕,并要求释放连接。 - -- **窗口** :窗口值作为接收方让发送方设置其发送窗口的依据。之所以要有这个限制,是因为接收方的数据缓存空间是有限的。 - -## TCP 的三次握手 - -

- -假设 A 为客户端,B 为服务器端。 - -- 首先 B 处于 LISTEN(监听)状态,等待客户的连接请求。 - -- A 向 B 发送连接请求报文段,SYN=1,ACK=0,选择一个初始的序号 x。 - -- B 收到连接请求报文段,如果同意建立连接,则向 A 发送连接确认报文段,SYN=1,ACK=1,确认号为 x+1,同时也选择一个初始的序号 y。 - -- A 收到 B 的连接确认报文段后,还要向 B 发出确认,确认号为 y+1,序号为 x+1。 - -- B 收到 A 的确认后,连接建立。 - -**三次握手的原因** - -第三次握手是为了防止失效的连接请求到达服务器,让服务器错误打开连接。 - -客户端发送的连接请求如果在网络中滞留,那么就会隔很长一段时间才能收到服务器端发回的连接确认。客户端等待一个超时重传时间之后,就会重新请求连接。但是这个滞留的连接请求最后还是会到达服务器,如果不进行三次握手,那么服务器就会打开两个连接。如果有第三次握手,客户端会忽略服务器之后发送的对滞留连接请求的连接确认,不进行第三次握手,因此就不会再次打开连接。 - -## TCP 的四次挥手 - -

- -以下描述不讨论序号和确认号,因为序号和确认号的规则比较简单。并且不讨论 ACK,因为 ACK 在连接建立之后都为 1。 - -- A 发送连接释放报文段,FIN=1。 - -- B 收到之后发出确认,此时 TCP 属于半关闭状态,B 能向 A 发送数据但是 A 不能向 B 发送数据。 - -- 当 B 不再需要连接时,发送连接释放请求报文段,FIN=1。 - -- A 收到后发出确认,进入 TIME-WAIT 状态,等待 2 MSL(最大报文存活时间)后释放连接。 - -- B 收到 A 的确认后释放连接。 - -**四次挥手的原因** - -客户端发送了 FIN 连接释放报文之后,服务器收到了这个报文,就进入了 CLOSE-WAIT 状态。这个状态是为了让服务器端发送还未传送完毕的数据,传送完毕之后,服务器会发送 FIN 连接释放报文。 - -**TIME_WAIT** - -客户端接收到服务器端的 FIN 报文后进入此状态,此时并不是直接进入 CLOSED 状态,还需要等待一个时间计时器设置的时间 2MSL。这么做有两个理由: - -- 确保最后一个确认报文段能够到达。如果 B 没收到 A 发送来的确认报文段,那么就会重新发送连接释放请求报文段,A 等待一段时间就是为了处理这种情况的发生。 - -- 等待一段时间是为了让本连接持续时间内所产生的所有报文段都从网络中消失,使得下一个新的连接不会出现旧的连接请求报文段。 - -## TCP 滑动窗口 - -

- -窗口是缓存的一部分,用来暂时存放字节流。发送方和接收方各有一个窗口,接收方通过 TCP 报文段中的窗口字段告诉发送方自己的窗口大小,发送方根据这个值和其它信息设置自己的窗口大小。 - -发送窗口内的字节都允许被发送,接收窗口内的字节都允许被接收。如果发送窗口左部的字节已经发送并且收到了确认,那么就将发送窗口向右滑动一定距离,直到左部第一个字节不是已发送并且已确认的状态;接收窗口的滑动类似,接收窗口左部字节已经发送确认并交付主机,就向右滑动接收窗口。 - -接收窗口只会对窗口内最后一个按序到达的字节进行确认,例如接收窗口已经收到的字节为 {31, 34, 35},其中 {31} 按序到达,而 {34, 35} 就不是,因此只对字节 31 进行确认。发送方得到一个字节的确认之后,就知道这个字节之前的所有字节都已经被接收。 - -## TCP 可靠传输 - -TCP 使用超时重传来实现可靠传输:如果一个已经发送的报文段在超时时间内没有收到确认,那么就重传这个报文段。 - -一个报文段从发送再到接收到确认所经过的时间称为往返时间 RTT,加权平均往返时间 RTTs 计算如下: - -

- -超时时间 RTO 应该略大于 RTTs,TCP 使用的超时时间计算如下: - -

- -其中 RTTd 为偏差。 - -## TCP 流量控制 - -流量控制是为了控制发送方发送速率,保证接收方来得及接收。 - -接收方发送的确认报文中的窗口字段可以用来控制发送方窗口大小,从而影响发送方的发送速率。将窗口字段设置为 0,则发送方不能发送数据。 - -## TCP 拥塞控制 - -如果网络出现拥塞,分组将会丢失,此时发送方会继续重传,从而导致网络拥塞程度更高。因此当出现拥塞时,应当控制发送方的速率。这一点和流量控制很像,但是出发点不同。流量控制是为了让接收方能来得及接收,而拥塞控制是为了降低整个网络的拥塞程度。 - -

- -TCP 主要通过四种算法来进行拥塞控制:慢开始、拥塞避免、快重传、快恢复。 - -发送方需要维护一个叫做拥塞窗口(cwnd)的状态变量,注意拥塞窗口与发送方窗口的区别:拥塞窗口只是一个状态变量,实际决定发送方能发送多少数据的是发送方窗口。 - -为了便于讨论,做如下假设: - -- 接收方有足够大的接收缓存,因此不会发生流量控制; -- 虽然 TCP 的窗口基于字节,但是这里设窗口的大小单位为报文段。 - -

- -### 1. 慢开始与拥塞避免 - -发送的最初执行慢开始,令 cwnd=1,发送方只能发送 1 个报文段;当收到确认后,将 cwnd 加倍,因此之后发送方能够发送的报文段数量为:2、4、8 ... - -注意到慢开始每个轮次都将 cwnd 加倍,这样会让 cwnd 增长速度非常快,从而使得发送方发送的速度增长速度过快,网络拥塞的可能也就更高。设置一个慢开始门限 ssthresh,当 cwnd >= ssthresh 时,进入拥塞避免,每个轮次只将 cwnd 加 1。 - -如果出现了超时,则令 ssthresh = cwnd/2,然后重新执行慢开始。 - -### 2. 快重传与快恢复 - -在接收方,要求每次接收到报文段都应该对最后一个已收到的有序报文段进行确认。例如已经接收到 M1 和 M2,此时收到 M4,应当发送对 M2 的确认。 - -在发送方,如果收到三个重复确认,那么可以知道下一个报文段丢失,此时执行快重传,立即重传下一个报文段。例如收到三个 M2,则 M3 丢失,立即重传 M3。 - -在这种情况下,只是丢失个别报文段,而不是网络拥塞。因此执行快恢复,令 ssthresh = cwnd/2 ,cwnd = ssthresh,注意到此时直接进入拥塞避免。慢开始和快恢复的快慢指的是 cwnd 的设定值,而不是 cwnd 的增长速率。慢开始 cwnd 设定为 1,而快恢复 cwnd 设定为 ssthresh。 - -

- -# 六、应用层 - -## 域名系统 - -DNS 是一个分布式数据库,提供了主机名和 IP 地址之间的转换。这里的分布式数据库是指,每个站点只保留它自己的那部分数据。 - -域名具有层次结构,从上到下依次为:根域名、顶级域名、第二级域名。 - -

- -DNS 可以使用 UDP 或者 TCP 进行传输,使用的端口号都为 53。大多数情况下 DNS 使用 UDP 进行传输,这就要求域名解析器和域名服务器都必须自己处理超时和重传来保证可靠性。在两种情况下会使用 TCP 进行传输: - -- 因为 UDP 最大只支持 512 字节的数据,如果返回的响应超过的 512 字节就改用 TCP 进行传输。 -- 区域传送是主域名服务器向辅助域名服务器传送变化的那部分数据,区域传送需要使用 TCP 进行传输。 - -## 文件传送协议 - -FTP 使用 TCP 进行连接,它需要两个连接来传送一个文件: - -- 控制连接:服务器以打开端口号 21 等待客户端的连接,客户端主动建立连接后,使用这个连接将客户端的命令传送给服务器,并传回服务器的应答。 -- 数据连接:用来传送一个文件。 - -根据数据连接是否是服务器端主动建立,FTP 有主动和被动两种模式: - -- 主动模式:服务器端主动建立数据连接,其中服务器端的端口号为 20,客户端的端口号随机,但是必须大于 1024,因为 0\~1023 是熟知端口号。 - -

- -- 被动模式:客户端主动建立数据连接,其中客户端的端口号由客户端自己指定,服务器端的端口号随机。 - -

- -主动模式要求客户端开放端口号给服务器端,需要去配置客户端的防火墙。被动模式只需要服务器端开放端口号即可,无需客户端配置防火墙。但是被动模式会导致服务器端的安全性减弱,因为开放了过多的端口号。 - -## 远程登录协议 - -TELNET 用于登录到远程主机上,并且远程主机上的输出也会返回。 - -TELNET 可以适应许多计算机和操作系统的差异,例如不同操作系统系统的换行符定义。 - -## 电子邮件协议 - -一个电子邮件系统由三部分组成:用户代理、邮件服务器以及邮件协议。 - -邮件协议包含发送协议和读取协议,发送协议常用 SMTP,读取协议常用 POP3 和 IMAP。 - -

- -### 1. POP3 - -POP3 的特点是只要用户从服务器上读取了邮件,就把该邮件删除。 - -### 2. IMAP - -IMAP 协议中客户端和服务器上的邮件保持同步,如果不去手动删除邮件,那么服务器上的邮件也不会被删除。IMAP 这种做法可以让用户随时随地去访问服务器上的邮件。 - -### 3. SMTP - -SMTP 只能发送 ASCII 码,而互联网邮件扩充 MIME 可以发送二进制文件。MIME 并没有改动或者取代 SMTP,而是增加邮件主体的结构,定义了非 ASCII 码的编码规则。 - -

- -## 动态主机配置协议 - -DHCP (Dynamic Host Configuration Protocol) 提供了即插即用的连网方式,用户不再需要去手动配置 IP 地址等信息。DHCP 配置的内容不仅是 IP 地址,还包括子网掩码、网关 IP 地址。 - -DHCP 工作过程如下: - -1. 客户端发送 Discover 报文,该报文的目的地址为 255.255.255.255:67,源地址为 0.0.0.0:68,被放入 UDP 中,该报文被广播到同一个子网的所有主机上。 -2. DHCP 服务器收到 Discover 报文之后,发送 Offer 报文给客户端,该报文包含了客户端所需要的信息。因为客户端可能收到多个 DHCP 服务器提供的信息,因此客户端需要进行选择。 -3. 如果客户端选择了某个 DHCP 服务器提供的信息,那么就发送 Request 报文给该 DHCP 服务器。 -4. DHCP 服务器发送 Ack 报文,表示客户端此时可以使用提供给它的信息。 - -

- -如果客户端和 DHCP 服务器不在同一个子网,就需要使用中继代理。 - -## 常用端口 - -|应用| 应用层协议 | 端口号 | 运输层协议 | 备注 | -| :---: | :--: | :--: | :--: | :--: -| 域名解析 | DNS | 53 | UDP/TCP | 长度超过 512 字节时使用 TCP | -| 动态主机配置协议 | DHCP | 67/68 | UDP | | -| 简单网络管理协议 | SNMP | 161/162 | UDP | | -| 文件传送协议 | FTP | 20/21 | TCP | 控制连接 21,数据连接 20 -| 远程终端协议 | TELNET | 23 | TCP | | -| 超文本传送协议 | HTTP | 80 | TCP | | -| 简单邮件传送协议 | SMTP | 25 | TCP | | -| 邮件读取协议 | POP3 | 110 | TCP | | -| 网际报文存取协议 | IMAP | 143 | TCP | | - -## Web 页面请求过程 - -### 1. DHCP 配置主机信息 - -- 假设主机最开始没有 IP 地址以及其它信息,那么就需要先使用 DHCP 来获取。 - -- 主机生成一个 DHCP 请求报文,并将这个报文放入具有目的端口 67 和源端口 68 的 UDP 报文段中。 - -- 该报文段则被放入在一个具有广播 IP 目的地址(255.255.255.255) 和源 IP 地址(0.0.0.0)的 IP 数据报中。 - -- 该数据报则被放置在 MAC 帧中,该帧具有目的地址 FF:FF:FF:FF:FF:FF,将广播到与交换机连接的所有设备。 - -- 连接在交换机的 DHCP 服务器收到广播帧之后,不断地向上分解得到 IP 数据报、UDP 报文段、DHCP 请求报文,之后生成 DHCP ACK 报文,该报文包含以下信息:IP 地址、DNS 服务器的 IP 地址、默认网关路由器的 IP 地址和子网掩码。该报文被放入 UDP 报文段中,UDP 报文段有被放入 IP 数据报中,最后放入 MAC 帧中。 - -- 该帧的目的地址是请求主机的 MAC 地址,因为交换机具有自学习能力,之前主机发送了广播帧之后就记录了 MAC 地址到其转发接口的交换表项,因此现在交换机就可以直接知道应该向哪个接口发送该帧。 - -- 主机收到该帧后,不断分解得到 DHCP 报文。之后就配置它的 IP 地址、子网掩码和 DNS 服务器的 IP 地址,并在其 IP 转发表中安装默认网关。 - -### 2. ARP 解析 MAC 地址 - -- 主机通过浏览器生成一个 TCP 套接字,套接字向 HTTP 服务器发送 HTTP 请求。为了生成该套接字,主机需要知道网站的域名对应的 IP 地址。 - -- 主机生成一个 DNS 查询报文,该报文具有 53 号端口,因为 DNS 服务器的端口号是 53。 - -- 该 DNS 查询报文被放入目的地址为 DNS 服务器 IP 地址的 IP 数据报中。 - -- 该 IP 数据报被放入一个以太网帧中,该帧将发送到网关路由器。 - -- DHCP 过程只知道网关路由器的 IP 地址,为了获取网关路由器的 MAC 地址,需要使用 ARP 协议。 - -- 主机生成一个包含目的地址为网关路由器 IP 地址的 ARP 查询报文,将该 ARP 查询报文放入一个具有广播目的地址(FF:FF:FF:FF:FF:FF)的以太网帧中,并向交换机发送该以太网帧,交换机将该帧转发给所有的连接设备,包括网关路由器。 - -- 网关路由器接收到该帧后,不断向上分解得到 ARP 报文,发现其中的 IP 地址与其接口的 IP 地址匹配,因此就发送一个 ARP 回答报文,包含了它的 MAC 地址,发回给主机。 - -### 3. DNS 解析域名 - -- 知道了网关路由器的 MAC 地址之后,就可以继续 DNS 的解析过程了。 - -- 网关路由器接收到包含 DNS 查询报文的以太网帧后,抽取出 IP 数据报,并根据转发表决定该 IP 数据报应该转发的路由器。 - -- 因为路由器具有内部网关协议(RIP、OSPF)和外部网关协议(BGP)这两种路由选择协议,因此路由表中已经配置了网关路由器到达 DNS 服务器的路由表项。 - -- 到达 DNS 服务器之后,DNS 服务器抽取出 DNS 查询报文,并在 DNS 数据库中查找待解析的域名。 - -- 找到 DNS 记录之后,发送 DNS 回答报文,将该回答报文放入 UDP 报文段中,然后放入 IP 数据报中,通过路由器反向转发回网关路由器,并经过以太网交换机到达主机。 - -### 4. HTTP 请求页面 - -- 有了 HTTP 服务器的 IP 地址之后,主机就能够生成 TCP 套接字,该套接字将用于向 Web 服务器发送 HTTP GET 报文。 - -- 在生成 TCP 套接字之前,必须先与 HTTP 服务器进行三次握手来建立连接。生成一个具有目的端口 80 的 TCP SYN 报文段,并向 HTTP 服务器发送该报文段。 - -- HTTP 服务器收到该报文段之后,生成 TCP SYN ACK 报文段,发回给主机。 - -- 连接建立之后,浏览器生成 HTTP GET 报文,并交付给 HTTP 服务器。 - -- HTTP 服务器从 TCP 套接字读取 HTTP GET 报文,生成一个 HTTP 响应报文,将 Web 页面内容放入报文主体中,发回给主机。 - -- 浏览器收到 HTTP 响应报文后,抽取出 Web 页面内容,之后进行渲染,显示 Web 页面。 - - -# 参考资料 - -- 计算机网络, 谢希仁 -- JamesF.Kurose, KeithW.Ross, 库罗斯, 等. 计算机网络: 自顶向下方法 [M]. 机械工业出版社, 2014. -- W.RichardStevens. TCP/IP 详解. 卷 1, 协议 [M]. 机械工业出版社, 2006. -- [Active vs Passive FTP Mode: Which One is More Secure?](https://securitywing.com/active-vs-passive-ftp-mode/) -- [Active and Passive FTP Transfers Defined - KB Article #1138](http://www.serv-u.com/kb/1138/active-and-passive-ftp-transfers-defined) -- [How DHCP works and DHCP Interview Questions and Answers](http://webcache.googleusercontent.com/search?q=cache:http://anandgiria.blogspot.com/2013/09/windows-dhcp-interview-questions-and.html) -- [What is process of DORA in DHCP?](https://www.quora.com/What-is-process-of-DORA-in-DHCP) -- [What is DHCP Server ?](https://tecadmin.net/what-is-dhcp-server/) -- [Tackling emissions targets in Tokyo](http://www.climatechangenews.com/2011/html/university-tokyo.html) -- [What does my ISP know when I use Tor?](http://www.climatechangenews.com/2011/html/university-tokyo.html) -- [Technology-Computer Networking[1]-Computer Networks and the Internet](http://www.linyibin.cn/2017/02/12/technology-ComputerNetworking-Internet/) -- [P2P 网络概述.](http://slidesplayer.com/slide/11616167/) -- [Circuit Switching (a) Circuit switching. (b) Packet switching.](http://slideplayer.com/slide/5115386/) diff --git a/notes/重构.md b/notes/重构.md index 9a3242ba..649f563a 100644 --- a/notes/重构.md +++ b/notes/重构.md @@ -1,1420 +1,4 @@ -* [一、第一个案例](#一第一个案例) -* [二、重构原则](#二重构原则) - * [定义](#定义) - * [为何重构](#为何重构) - * [三次法则](#三次法则) - * [间接层与重构](#间接层与重构) - * [修改接口](#修改接口) - * [何时不该重构](#何时不该重构) - * [重构与设计](#重构与设计) - * [重构与性能](#重构与性能) -* [三、代码的坏味道](#三代码的坏味道) - * [1. 重复代码](#1-重复代码) - * [2. 过长函数](#2-过长函数) - * [3. 过大的类](#3-过大的类) - * [4. 过长的参数列表](#4-过长的参数列表) - * [5. 发散式变化](#5-发散式变化) - * [6. 散弹式修改](#6-散弹式修改) - * [7. 依恋情结](#7-依恋情结) - * [8. 数据泥团](#8-数据泥团) - * [9. 基本类型偏执](#9-基本类型偏执) - * [10. switch 惊悚现身](#10-switch-惊悚现身) - * [11. 平行继承体系](#11-平行继承体系) - * [12. 冗余类](#12-冗余类) - * [13. 夸夸其谈未来性](#13-夸夸其谈未来性) - * [14. 令人迷惑的暂时字段](#14-令人迷惑的暂时字段) - * [15. 过度耦合的消息链](#15-过度耦合的消息链) - * [16. 中间人](#16-中间人) - * [17. 狎昵关系](#17-狎昵关系) - * [18. 异曲同工的类](#18-异曲同工的类) - * [19. 不完美的类库](#19-不完美的类库) - * [20. 幼稚的数据类](#20-幼稚的数据类) - * [21. 被拒绝的馈赠](#21-被拒绝的馈赠) - * [22. 过多的注释](#22-过多的注释) -* [四、构筑测试体系](#四构筑测试体系) -* [五、重新组织函数](#五重新组织函数) - * [1. 提炼函数](#1-提炼函数) - * [2. 内联函数](#2-内联函数) - * [3. 内联临时变量](#3-内联临时变量) - * [4. 以查询取代临时变量](#4-以查询取代临时变量) - * [5. 引起解释变量](#5-引起解释变量) - * [6. 分解临时变量](#6-分解临时变量) - * [7. 移除对参数的赋值](#7-移除对参数的赋值) - * [8. 以函数对象取代函数](#8-以函数对象取代函数) - * [9. 替换算法](#9-替换算法) -* [六、在对象之间搬移特性](#六在对象之间搬移特性) - * [1. 搬移函数](#1-搬移函数) - * [2. 搬移字段](#2-搬移字段) - * [3. 提炼类](#3-提炼类) - * [4. 将类内联化](#4-将类内联化) - * [5. 隐藏委托关系](#5-隐藏委托关系) - * [6. 移除中间人](#6-移除中间人) - * [7. 引入外加函数](#7-引入外加函数) - * [8. 引入本地扩展](#8-引入本地扩展) -* [七、重新组织数据](#七重新组织数据) - * [1. 自封装字段](#1-自封装字段) - * [2. 以对象取代数据值](#2-以对象取代数据值) - * [3. 将值对象改成引用对象](#3-将值对象改成引用对象) - * [4. 将引用对象改为值对象](#4-将引用对象改为值对象) - * [5. 以对象取代数组](#5-以对象取代数组) - * [6. 赋值被监视数据](#6-赋值被监视数据) - * [7. 将单向关联改为双向关联](#7-将单向关联改为双向关联) - * [8. 将双向关联改为单向关联](#8-将双向关联改为单向关联) - * [9. 以字面常量取代魔法数](#9-以字面常量取代魔法数) - * [10. 封装字段](#10-封装字段) - * [11. 封装集合](#11-封装集合) - * [12. 以数据类取代记录](#12-以数据类取代记录) - * [13. 以类取代类型码](#13-以类取代类型码) - * [14. 以子类取代类型码](#14-以子类取代类型码) - * [15. 以 State/Strategy 取代类型码](#15-以-statestrategy-取代类型码) - * [16. 以字段取代子类](#16-以字段取代子类) -* [八、简化条件表达式](#八简化条件表达式) - * [1. 分解条件表达式](#1-分解条件表达式) - * [2. 合并条件表达式](#2-合并条件表达式) - * [3. 合并重复的条件片段](#3-合并重复的条件片段) - * [4. 移除控制标记](#4-移除控制标记) - * [5. 以卫语句取代嵌套条件表达式](#5-以卫语句取代嵌套条件表达式) - * [6. 以多态取代条件表达式](#6-以多态取代条件表达式) - * [7. 引入 Null 对象](#7-引入-null-对象) - * [8. 引入断言](#8-引入断言) -* [九、简化函数调用](#九简化函数调用) - * [1. 函数改名](#1-函数改名) - * [2. 添加参数](#2-添加参数) - * [3. 移除参数](#3-移除参数) - * [4. 将查询函数和修改函数分离](#4-将查询函数和修改函数分离) - * [5. 令函数携带参数](#5-令函数携带参数) - * [6. 以明确函数取代参数](#6-以明确函数取代参数) - * [7. 保持对象完整](#7-保持对象完整) - * [8. 以函数取代参数](#8-以函数取代参数) - * [9. 引入参数对象](#9-引入参数对象) - * [10. 移除设值函数](#10-移除设值函数) - * [11. 隐藏函数](#11-隐藏函数) - * [12. 以工厂函数取代构造函数](#12-以工厂函数取代构造函数) - * [13. 封装向下转型](#13-封装向下转型) - * [14. 以异常取代错误码](#14-以异常取代错误码) - * [15. 以测试取代异常](#15-以测试取代异常) -* [十、处理概括关系](#十处理概括关系) - * [1. 字段上移](#1-字段上移) - * [2. 函数上移](#2-函数上移) - * [3. 构造函数本体上移](#3-构造函数本体上移) - * [4. 函数下移](#4-函数下移) - * [5. 字段下移](#5-字段下移) - * [6. 提炼子类](#6-提炼子类) - * [7. 提炼超类](#7-提炼超类) - * [8. 提炼接口](#8-提炼接口) - * [9. 折叠继承体系](#9-折叠继承体系) - * [10. 塑造模板函数](#10-塑造模板函数) - * [11. 以委托取代继承](#11-以委托取代继承) - * [12. 以继承取代委托](#12-以继承取代委托) -* [参考资料](#参考资料) -# 一、第一个案例 - -如果你发现自己需要为程序添加一个特性,而代码结构使你无法很方便地达成目的,那就先重构这个程序。 - -在重构前,需要先构建好可靠的测试环境,确保安全地重构。 - -重构需要以微小的步伐修改程序,如果重构过程发生错误,很容易就能发现错误。 - -**案例分析** - -影片出租店应用程序,需要计算每位顾客的消费金额。 - -包括三个类:Movie、Rental 和 Customer。 - -

- -最开始的实现是把所有的计费代码都放在 Customer 类中。可以发现,该代码没有使用 Customer 类中的任何信息,更多的是使用 Rental 类的信息,因此第一个可以重构的点就是把具体计费的代码移到 Rental 类中,然后 Customer 类的 getTotalCharge() 方法只需要调用 Rental 类中的计费方法即可。 - -```java -class Customer { - - private List rentals = new ArrayList<>(); - - void addRental(Rental rental) { - rentals.add(rental); - } - - double getTotalCharge() { - double totalCharge = 0.0; - for (Rental rental : rentals) { - switch (rental.getMovie().getMovieType()) { - case Movie.Type1: - totalCharge += rental.getDaysRented(); - break; - case Movie.Type2: - totalCharge += rental.getDaysRented() * 2; - break; - case Movie.Type3: - totalCharge += rental.getDaysRented() * 3; - break; - } - } - return totalCharge; - } -} -``` - -```java -class Rental { - private int daysRented; - - private Movie movie; - - Rental(int daysRented, Movie movie) { - this.daysRented = daysRented; - this.movie = movie; - } - - Movie getMovie() { - return movie; - } - - int getDaysRented() { - return daysRented; - } -} -``` - -```java -class Movie { - - static final int Type1 = 0, Type2 = 1, Type3 = 2; - - private int type; - - Movie(int type) { - this.type = type; - } - - int getMovieType() { - return type; - } -} -``` - -```java -public class App { - public static void main(String[] args) { - Customer customer = new Customer(); - Rental rental1 = new Rental(1, new Movie(Movie.Type1)); - Rental rental2 = new Rental(2, new Movie(Movie.Type2)); - customer.addRental(rental1); - customer.addRental(rental2); - System.out.println(customer.getTotalCharge()); - } -} -``` - -```html -5 -``` - -使用 switch 的准则是:只使用 switch 所在类的数据。解释如下:switch 使用的数据通常是一组相关的数据,例如 getTotalCharge() 代码使用了 Movie 的多种类别数据。当这组类别的数据发生改变时,例如增加 Movie 的类别或者修改一种 Movie 类别的计费方法,就需要修改 switch 代码。如果违反了准则,就会有多个地方的 switch 使用了这部分的数据,那么这些 swtich 都需要进行修改,这些代码可能遍布在各个地方,修改工作往往会很难进行。上面的实现违反了这一准则,因此需要重构。 - -以下是继承 Movie 的多态解决方案,这种方案可以解决上述的 switch 问题,因为每种电影类别的计费方式都被放到了对应 Movie 子类中,当变化发生时,只需要去修改对应子类中的代码即可。 - -

- -有一条设计原则指示应该多用组合少用继承,这是因为组合比继承具有更高的灵活性。例如上面的继承方案,一部电影要改变它的计费方式,就要改变它所属的类,但是对象所属的类在编译时期就确定了,无法在运行过程中改变。(运行时多态可以在运行过程中改变一个父类引用指向的子类对象,但是无法改变一个对象所属的类。) - -策略模式就是使用组合替代继承的一种解决方案。引入 Price 类,它有多种实现。Movie 组合了一个 Price 对象,并且在运行时可以改变组合的 Price 对象,从而使得它的计费方式发生改变。 - -

- -重构后整体的类图和时序图如下: - -

- -

- -重构后的代码: - -```java -class Customer { - private List rentals = new ArrayList<>(); - - void addRental(Rental rental) { - rentals.add(rental); - } - - double getTotalCharge() { - double totalCharge = 0.0; - for (Rental rental : rentals) { - totalCharge += rental.getCharge(); - } - return totalCharge; - } -} -``` - -```java -class Rental { - private int daysRented; - - private Movie movie; - - Rental(int daysRented, Movie movie) { - this.daysRented = daysRented; - this.movie = movie; - } - - double getCharge() { - return daysRented * movie.getCharge(); - } -} -``` - -```java -interface Price { - double getCharge(); -} -``` - -```java -class Price1 implements Price { - @Override - public double getCharge() { - return 1; - } -} -``` - -```java -class Price2 implements Price { - @Override - public double getCharge() { - return 2; - } -} -``` - -```java -package imp2; - -class Price3 implements Price { - @Override - public double getCharge() { - return 3; - } -} -``` - -```java -class Movie { - - private Price price; - - Movie(Price price) { - this.price = price; - } - - double getCharge() { - return price.getCharge(); - } -} -``` - -```java -class App { - - public static void main(String[] args) { - Customer customer = new Customer(); - Rental rental1 = new Rental(1, new Movie(new Price1())); - Rental rental2 = new Rental(2, new Movie(new Price2())); - customer.addRental(rental1); - customer.addRental(rental2); - System.out.println(customer.getTotalCharge()); - } -} -``` - -# 二、重构原则 - -## 定义 - -重构是对软件内部结构的一种调整,目的是在不改变软件可观察行为的前提下,提高其可理解性,降低其修改成本。 - -## 为何重构 - -- 改进软件设计 -- 使软件更容易理解 -- 帮助找到 Bug -- 提高编程速度 - -## 三次法则 - -第一次做某件事时只管去做;第二次做类似事情时可以去做;第三次再做类似的事,就应该重构。 - -## 间接层与重构 - -计算机科学中的很多问题可以通过增加一个间接层来解决,间接层具有以下价值: - -- 允许逻辑共享 -- 分开解释意图和实现 -- 隔离变化 -- 封装条件逻辑 - -重构可以理解为在适当的位置插入间接层以及在不需要时移除间接层。 - -## 修改接口 - -如果重构手法改变了已发布的接口,就必须维护新旧两个接口。可以保留旧接口,让旧接口去调用新接口,并且使用 Java 提供的 @deprecation 将旧接口标记为弃用。 - -可见修改接口特别麻烦,因此除非真有必要,否则不要发布接口,并且不要过早发布接口。 - -## 何时不该重构 - -当现有代码过于混乱时,应当重写而不是重构。 - -一个折中的办法是,将代码封装成一个个组件,然后对各个组件做重写或者重构的决定。 - -## 重构与设计 - -软件开发无法预先设计,因为开发过程有很多变化发生,在最开始不可能都把所有情况考虑进去。 - -重构可以简化设计,重构在一个简单的设计上进行修修改改,当变化发生时,以一种灵活的方式去应对变化,进而带来更好的设计。 - -## 重构与性能 - -为了软代码更容易理解,重构可能会导致性能减低。 - -在编写代码时,不用对性能过多关注,只有在最后性能优化阶段再考虑性能问题。 - -应当只关注关键代码的性能,并且只有一小部分的代码是关键代码。 - -# 三、代码的坏味道 - -本章主要介绍一些不好的代码,也就是说这些代码应该被重构。 - -## 1. 重复代码 - -> Duplicated Code - -同一个类的两个函数有相同表达式,则用 Extract Method 提取出重复代码; - -两个互为兄弟的子类含有相同的表达式,先使用 Extract Method,然后把提取出来的函数 Pull Up Method 推入超类。 - -如果只是部分相同,用 Extract Method 分离出相似部分和差异部分,然后使用 Form Template Method 这种模板方法设计模式。 - -如果两个毫不相关的类出现重复代码,则使用 Extract Class 方法将重复代码提取到一个独立类中。 - -## 2. 过长函数 - -> Long Method - -函数应该尽可能小,因为小函数具有解释能力、共享能力、选择能力。 - -分解长函数的原则:当需要用注释来说明一段代码时,就需要把这部分代码写入一个独立的函数中。 - -Extract Method 会把很多参数和临时变量都当做参数,可以用 Replace Temp with Query 消除临时变量,Introduce Parameter Object 和 Preserve Whole Object 可以将过长的参数列变得更简洁。 - -条件和循环语句往往也需要提取到新的函数中。 - -## 3. 过大的类 - -> Large Class - -应该尽可能让一个类只做一件事,而过大的类做了过多事情,需要使用 Extract Class 或 Extract Subclass。 - -先确定客户端如何使用该类,然后运用 Extract Interface 为每一种使用方式提取出一个接口。 - -## 4. 过长的参数列表 - -> Long Parameter List - -太长的参数列表往往会造成前后不一致,不易使用。 - -面向对象程序中,函数所需要的数据通常能在宿主类中找到。 - -## 5. 发散式变化 - -> Divergent Change - -设计原则:一个类应该只有一个引起改变的原因。也就是说,针对某一外界变化所有相应的修改,都只应该发生在单一类中。 - -针对某种原因的变化,使用 Extract Class 将它提炼到一个类中。 - -## 6. 散弹式修改 - -> Shotgun Surgery - -一个变化引起多个类修改。 - -使用 Move Method 和 Move Field 把所有需要修改的代码放到同一个类中。 - -## 7. 依恋情结 - -> Feature Envy - -一个函数对某个类的兴趣高于对自己所处类的兴趣,通常是过多访问其它类的数据, - -使用 Move Method 将它移到该去的地方,如果对多个类都有 Feature Envy,先用 Extract Method 提取出多个函数。 - -## 8. 数据泥团 - -> Data Clumps - -有些数据经常一起出现,比如两个类具有相同的字段、许多函数有相同的参数,这些绑定在一起出现的数据应该拥有属于它们自己的对象。 - -使用 Extract Class 将它们放在一起。 - -## 9. 基本类型偏执 - -> Primitive Obsession - -使用类往往比使用基本类型更好,使用 Replace Data Value with Object 将数据值替换为对象。 - -## 10. switch 惊悚现身 - -> Switch Statements - -具体参见第一章的案例。 - -## 11. 平行继承体系 - -> Parallel Inheritance Hierarchies - -每当为某个类增加一个子类,必须也为另一个类相应增加一个子类。 - -这种结果会带来一些重复性,消除重复性的一般策略:让一个继承体系的实例引用另一个继承体系的实例。 - -## 12. 冗余类 - -> Lazy Class - -如果一个类没有做足够多的工作,就应该消失。 - -## 13. 夸夸其谈未来性 - -> Speculative Generality - -有些内容是用来处理未来可能发生的变化,但是往往会造成系统难以理解和维护,并且预测未来可能发生的改变很可能和最开始的设想相反。因此,如果不是必要,就不要这么做。 - -## 14. 令人迷惑的暂时字段 - -> Temporary Field - -某个字段仅为某种特定情况而设,这样的代码不易理解,因为通常认为对象在所有时候都需要它的所有字段。 - -把这种字段和特定情况的处理操作使用 Extract Class 提炼到一个独立类中。 - -## 15. 过度耦合的消息链 - -> Message Chains - -一个对象请求另一个对象,然后再向后者请求另一个对象,然后...,这就是消息链。采用这种方式,意味着客户代码将与对象间的关系紧密耦合。 - -改用函数链,用函数委托另一个对象来处理。 - -## 16. 中间人 - -> Middle Man - -中间人负责处理委托给它的操作,如果一个类中有过多的函数都委托给其它类,那就是过度运用委托,应当 Remove Middle Man,直接与负责的对象打交道。 - -## 17. 狎昵关系 - -> Inappropriate Intimacy - -两个类多于亲密,花费太多时间去探讨彼此的 private 成分。 - -## 18. 异曲同工的类 - -> Alernative Classes with Different Interfaces - -两个函数做同一件事,却有着不同的签名。 - -使用 Rename Method 根据它们的用途重新命名。 - -## 19. 不完美的类库 - -> Incomplete Library Class - -类库的设计者不可能设计出完美的类库,当我们需要对类库进行一些修改时,可以使用以下两种方法:如果只是修改一两个函数,使用 Introduce Foreign Method;如果要添加一大堆额外行为,使用 Introduce Local Extension。 - -## 20. 幼稚的数据类 - -> Data Class - -它只拥有一些数据字段,以及用于访问这些字段的函数,除此之外一无长物。 - -找出字段使用的地方,然后把相应的操作移到 Data Class 中。 - -## 21. 被拒绝的馈赠 - -> Refused Bequest - -子类不想继承超类的所有函数和数据。 - -为子类新建一个兄弟类,不需要的函数或数据使用 Push Down Method 和 Push Down Field 下推给那个兄弟。 - -## 22. 过多的注释 - -> Comments - -使用 Extract Method 提炼出需要注释的部分,然后用函数名来解释函数的行为。 - -# 四、构筑测试体系 - -Java 可以使用 Junit 进行单元测试。 - -测试应该能够完全自动化,并能检查测试的结果。 - -小步修改,频繁测试。 - -单元测试的对象是类的方法,而功能测是以客户的角度保证软件正常运行。 - -应当集中测试可能出错的边界条件。 - -# 五、重新组织函数 - -## 1. 提炼函数 - -> Extract Method - -将这段代码放进一个独立函数中,并让函数名称解释该函数的用途。 - -## 2. 内联函数 - -> Inline Method - -一个函数的本体与名称同样清楚易懂。 - -在函数调用点插入函数本体,然后移除该函数。 - -## 3. 内联临时变量 - -> Inline Temp - -一个临时变量,只被简单表达式赋值一次,而它妨碍了其它重构手法。 - -将所有对该变量的引用替换为对它赋值的那个表达式自身。 - -```java -double basePrice = anOrder.basePrice(); -return basePrice > 1000; -``` - -```java -return anOrder.basePrice() > 1000; -``` - -## 4. 以查询取代临时变量 - -> Replace Temp with Query - -以临时变量保存某一表达式的运算结果,将这个表达式提炼到一个独立函数中,将所有对临时变量的引用点替换为对新函数的调用。 - -Replace Temp with Query 往往是 Extract Method 之前必不可少的一个步骤,因为局部变量会使代码难以提炼。 - -```java -double basePrice = quantity * itemPrice; -if (basePrice > 1000) - return basePrice * 0.95; -else - return basePrice * 0.98; -``` - -```java -if (basePrice() > 1000) - return basePrice() * 0.95; -else - return basePrice() * 0.98; - -// ... -double basePrice(){ - return quantity * itemPrice; -} -``` - -## 5. 引起解释变量 - -> Introduce Explaining Variable - -将复杂表达式(或其中一部分)的结果放进一个临时变量, 以此变量名称来解释表达式用途。 - -```java -if ((platform.toUpperCase().indexOf("MAC") > -1) && - (browser.toUpperCase().indexOf("IE") > -1) && - wasInitialized() && resize > 0) { - // do something -} -``` - -```java -final boolean isMacOS = platform.toUpperCase().indexOf("MAC") > -1; -final boolean isIEBrower = browser.toUpperCase().indexOf("IE") > -1; -final boolean wasResized = resize > 0; - -if (isMacOS && isIEBrower && wasInitialized() && wasResized) { - // do something -} -``` - -## 6. 分解临时变量 - -> Split Temporary Variable - -某个临时变量被赋值超过一次,它既不是循环变量,也不是用于收集计算结果。 - -针对每次赋值,创造一个独立、对应的临时变量,每个临时变量只承担一个责任。 - -## 7. 移除对参数的赋值 - -> Remove Assigments to Parameters - -以一个临时变量取代对该参数的赋值。 - -```java -int discount (int inputVal, int quentity, int yearToDate) { - if (inputVal > 50) inputVal -= 2; - ... -} -``` - -```java -int discount (int inputVal, int quentity, int yearToDate) { - int result = inputVal; - if (inputVal > 50) result -= 2; - ... -} -``` - -## 8. 以函数对象取代函数 - -> Replace Method with Method Object - -当对一个大型函数采用 Extract Method 时,由于包含了局部变量使得很难进行该操作。 - -将这个函数放进一个单独对象中,如此一来局部变量就成了对象内的字段。然后可以在同一个对象中将这个大型函数分解为多个小型函数。 - -## 9. 替换算法 - -> Subsititute Algorithn - -# 六、在对象之间搬移特性 - -## 1. 搬移函数 - -> Move Method - -类中的某个函数与另一个类进行更多交流:调用后者或者被后者调用。 - -将这个函数搬移到另一个类中。 - -## 2. 搬移字段 - -> Move Field - -类中的某个字段被另一个类更多地用到,这里的用到是指调用取值设值函数,应当把该字段移到另一个类中。 - -## 3. 提炼类 - -> Extract Class - -某个类做了应当由两个类做的事。 - -应当建立一个新类,将相关的字段和函数从旧类搬移到新类。 - -## 4. 将类内联化 - -> Inline Class - -与 Extract Class 相反。 - -## 5. 隐藏委托关系 - -> Hide Delegate - -建立所需的函数,隐藏委托关系。 - -```java -class Person { - Department department; - - public Department getDepartment() { - return department; - } -} - -class Department { - private Person manager; - - public Person getManager() { - return manager; - } -} -``` - -如果客户希望知道某人的经理是谁,必须获得 Department 对象,这样就对客户揭露了 Department 的工作原理。 - -```java -Person manager = john.getDepartment().getManager(); -``` - -通过为 Peron 建立一个函数来隐藏这种委托关系。 - -```java -public Person getManager() { - return department.getManager(); -} -``` - -## 6. 移除中间人 - -> Remove Middle Man - -与 Hide Delegate 相反,本方法需要移除委托函数,让客户直接调用委托类。 - -Hide Delegate 有很大好处,但是它的代价是:每当客户要使用受托类的新特性时,就必须在服务器端添加一个简单的委托函数。随着受委托的特性越来越多,服务器类完全变成了一个“中间人”。 - -## 7. 引入外加函数 - -> Introduce Foreign Method - -需要为提供服务的类添加一个函数,但是无法修改这个类。 - -可以在客户类中建立一个函数,并以第一参数形式传入一个服务类的实例,让客户类组合服务器实例。 - -## 8. 引入本地扩展 - -> Introduce Local Extension - -和 Introduce Foreign Method 目的一样,但是 Introduce Local Extension 通过建立新的类来实现。有两种方式:子类或者包装类,子类就是通过继承实现,包装类就是通过组合实现。 - -# 七、重新组织数据 - -## 1. 自封装字段 - -> Self Encapsulate Field - -为字段建立取值/设值函数,并用这些函数来访问字段。只有当子类想访问超类的一个字段,又想在子类中将对这个字段访问改为一个计算后的值,才使用这种方式,否则直接访问字段的方式简洁明了。 - -## 2. 以对象取代数据值 - -> Replace Data Value with Object - -在开发初期,往往会用简单的数据项表示简单的情况,但是随着开发的进行,一些简单数据项会具有一些特殊行为。比如一开始会把电话号码存成字符串,但是随后发现电话号码需要“格式化”、“抽取区号”之类的特殊行为。 - -## 3. 将值对象改成引用对象 - -> Change Value to Reference - -将彼此相等的实例替换为同一个对象。这就要用一个工厂来创建这种唯一对象,工厂类中需要保留一份已经创建对象的列表,当要创建一个对象时,先查找这份列表中是否已经存在该对象,如果存在,则返回列表中的这个对象;否则,新建一个对象,添加到列表中,并返回该对象。 - -## 4. 将引用对象改为值对象 - -> Change Reference to value - -以 Change Value to Reference 相反。值对象有个非常重要的特性:它是不可变的,不可变表示如果要改变这个对象,必须用一个新的对象来替换旧对象,而不是修改旧对象。 - -需要为值对象实现 equals() 和 hashCode() 方法。 - -## 5. 以对象取代数组 - -> Replace Array with Object - -有一个数组,其中的元素各自代表不同的东西。 - -以对象替换数组,对于数组中的每个元素,以一个字段来表示,这样方便操作,也更容易理解。 - -## 6. 赋值被监视数据 - -> Duplicate Observed Data - -一些领域数据置身于 GUI 控件中,而领域函数需要访问这些数据。 - -将该数据赋值到一个领域对象中,建立一个 Oberver 模式,用于同步领域对象和 GUI 对象内的重复数据。 - -

- -## 7. 将单向关联改为双向关联 - -> Change Unidirectional Association to Bidirectional - -当两个类都需要对方的特性时,可以使用双向关联。 - -有两个类,分别为订单 Order 和客户 Customer,Order 引用了 Customer,Customer 也需要引用 Order 来查看其所有订单详情。 - -```java -class Order { - private Customer customer; - public void setCustomer(Customer customer) { - if (this.customer != null) - this.customer.removeOrder(this); - this.customer = customer; - this.customer.add(this); - } -} -``` -```java -class Curstomer { - private Set orders = new HashSet<>(); - public void removeOrder(Order order) { - orders.remove(order); - } - public void addOrder(Order order) { - orders.add(order); - } -} -``` - -注意到,这里让 Curstomer 类来控制关联关系。有以下原则来决定哪个类来控制关联关系:如果某个对象是组成另一个对象的部件,那么由后者负责控制关联关系;如果是一对多关系,则由单一引用那一方来控制关联关系。 - -## 8. 将双向关联改为单向关联 - -> Change Bidirectional Association to Unidirectional - -和 Change Unidirectional Association to Bidirectiona 为反操作。 - -双向关联维护成本高,并且也不易于理解。大量的双向连接很容易造成“僵尸对象”:某个对象本身已经死亡了,却保留在系统中,因为它的引用还没有全部完全清除。 - -## 9. 以字面常量取代魔法数 - -> Replace Magic Number with Symbolic Constant - -创建一个常量,根据其意义为它命名,并将字面常量换为这个常量。 - -## 10. 封装字段 - -> Encapsulate Field - -public 字段应当改为 private,并提供相应的访问函数。 - -## 11. 封装集合 - -> Encapsulate Collection - -函数返回集合的一个只读副本,并在这个类中提供添加/移除集合元素的函数。如果函数返回集合自身,会让用户得以修改集合内容而集合拥有者却一无所知。 - -## 12. 以数据类取代记录 - -> Replace Record with Data Class - -## 13. 以类取代类型码 - -> Replace Type Code with Class - -类中有一个数值类型码,但它并不影响类的行为,就用一个新类替换该数值类型码。如果类型码出现在 switch 语句中,需要使用 Replace Conditional with Polymorphism 去掉 switch,首先必须运用 Replace Type Code with Subcalss 或 Replace Type Code with State/Strategy 去掉类型码。 - -

- -## 14. 以子类取代类型码 - -> Replace Type Code with Subcalsses - -有一个不可变的类型码,它会影响类的行为,以子类取代这个类型码。 - -

- -## 15. 以 State/Strategy 取代类型码 - -> Replace Type Code with State/Strategy - -有一个可变的类型码,它会影响类的行为,以状态对象取代类型码。 - -和 Replace Type Code with Subcalsses 的区别是 Replace Type Code with State/Strategy 的类型码是动态可变的,前者通过继承的方式来实现,后者通过组合的方式来实现。因为类型码可变,如果通过继承的方式,一旦一个对象的类型码改变,那么就要改变用新的对象来取代旧对象,而客户端难以改变新的对象。但是通过组合的方式,改变引用的状态类是很容易的。 - -

- -## 16. 以字段取代子类 - -> Replace Subclass with Fields - -各个子类的唯一差别只在“返回常量数据”的函数上。 - -

- -# 八、简化条件表达式 - -## 1. 分解条件表达式 - -> Decompose Conditional - -对于一个复杂的条件语句,可以从 if、then、else 三个段落中分别提炼出独立函数。 - -```java -if (data.befor(SUMMER_START) || data.after(SUMMER_END)) - charge = quantity * winterRate + winterServiceCharge; -else - charge = quantity * summerRate; -``` - -```java -if (notSummer(date)) - charge = winterCharge(quantity); -else - charge = summerCharge(quantity); -``` - -## 2. 合并条件表达式 - -> Consolidate Conditional Expression - -有一系列条件测试,都得到相同结果。 - -将这些测试合并为一个条件表达式,并将这个条件表达式提炼成为一个独立函数。 - -```java -double disabilityAmount() { - if (seniority < 2) return 0; - if (monthsDisabled > 12 ) return 0; - if (isPartTime) return 0; - // ... -} -``` -```java -double disabilityAmount() { - if (isNotEligibleForDisability()) return 0; - // ... -} -``` - -## 3. 合并重复的条件片段 - -> Consolidate Duplicate Conditional Fragments - -在条件表达式的每个分支上有着相同的一段代码。 - -将这段重复代码搬移到条件表达式之外。 - -```java -if (isSpecialDeal()) { - total = price * 0.95; - send(); -} else { - total = price * 0.98; - send(); -} -``` - -```java -if (isSpecialDeal()) { - total = price * 0.95; -} else { - total = price * 0.98; -} -send(); -``` - -## 4. 移除控制标记 - -> Remove Control Flag - -在一系列布尔表达式中,某个变量带有“控制标记”的作用。 - -用 break 语句或 return 语句来取代控制标记。 - -## 5. 以卫语句取代嵌套条件表达式 - -> Replace Nested Conditional with Guard Clauses - -如果某个条件极其罕见,就应该单独检查该条件,并在该条件为真时立刻从函数中返回,这样的单独检查常常被称为“卫语句”(guard clauses)。 - -条件表达式通常有两种表现形式。第一种形式是:所有分支都属于正常行为。第二种形式则是:条件表达式提供的答案中只有一种是正常行为,其他都是不常见的情况,可以使用卫语句表现所有特殊情况。 - -```java -double getPayAmount() { - double result; - if (isDead) result = deadAmount(); - else { - if (isSeparated) result = separatedAmount(); - else { - if (isRetired) result = retiredAmount(); - else result = normalPayAmount(); - }; - } - return result; -}; -``` - -```java -double getPayAmount() { - if (isDead) return deadAmount(); - if (isSeparated) return separatedAmount(); - if (isRetired) return retiredAmount(); - return normalPayAmount(); -}; -``` - -## 6. 以多态取代条件表达式 - -> Replace Conditional with Polymorphism - -将这个条件表达式的每个分支放进一个子类内的覆写函数中,然后将原始函数声明为抽象函数。需要先使用 Replace Type Code with Subclass 或 Replace Type Code with State/Strategy 来建立继承结果。 - -```java -double getSpeed() { - switch (type) { - case EUROPEAN: - return getBaseSpeed(); - case AFRICAN: - return getBaseSpeed()- getLoadFactor()* numberOfCoconuts; - case NORWEGIAN_BLUE: - return isNailed ? 0 : getBaseSpeed(voltage); - } - throw new RuntimeException("Should be unreachable"); -} -``` - -

- - -## 7. 引入 Null 对象 - -> Introduce Null Object - -将 null 值替换为 null 对象。这样做的好处在于,不需要询问对象是否为空,直接调用就行。 - -```java -if (customer == null) plan = BillingPlan.basic(); -else plan = customer.getPlan(); -``` - -## 8. 引入断言 - -> Introduce Assertion - -以断言明确表现某种假设。断言只能用于开发过程中,产品代码中不会有断言。 - -```java -double getExpenseLimit() { - // should have either expense limit or a primary project - return (expenseLimit != NULL_EXPENSE) ? expenseLimit : primaryProject.getMemberExpenseLimit(); -} -``` - -```java -double getExpenseLimit() { - Assert.isTrue (expenseLimit != NULL_EXPENSE || primaryProject != null); - return (expenseLimit != NULL_EXPENSE) ? expenseLimit : primaryProject.getMemberExpenseLimit(); -} -``` - -# 九、简化函数调用 - -## 1. 函数改名 - -> Rename Method - -使函数名能解释函数的用途。 - -## 2. 添加参数 - -> Add Parameter - -使函数不需要通过调用获得某个信息。 - -## 3. 移除参数 - -> Remove Parameter - -与 Add Parameter 相反,改用调用的方式来获得某个信息。 - -## 4. 将查询函数和修改函数分离 - -> Separate Query from Modifier - -某个函数即返回对象状态值,又修改对象状态。 - -应当建立两个不同的函数,其中一个负责查询,另一个负责修改。任何有返回值的函数,都不应该有看得到的副作用。 - -```java -getTotalOutstandingAndSetReadyForSummaries(); -``` - -```java -getTotalOutstanding(); -setReadyForSummaries(); -``` - -## 5. 令函数携带参数 - -> Parameterize Method - -若干函数做了类似的工作,但在函数本体中却包含了不同的值。 - -建立单一函数,以参数表达那些不同的值。 - -```java -fivePercentRaise(); -tenPercentRaise(); -``` -```java -raise(percentage); -``` - -## 6. 以明确函数取代参数 - -> Replace Parameter with Explicit Methods - -有一个函数,完全取决于参数值而采取不同行为。 - -针对该参数的每一个可能值,建立一个独立函数。 - -```java -void setValue(String name, int value){ - if (name.equals("height")){ - height = value; - return; - } - if (name.equals("width")){ - width = value; - return; - } - Assert.shouldNeverReachHere(); -} -``` - -```java -void setHeight(int arg){ - height = arg; -} -void setWidth(int arg){ - width = arg; -} -``` - -## 7. 保持对象完整 - -> Preserve Whole Object - -从某个对象中取出若干值,将它们作为某一次函数调用时的参数。 - -改为传递整个对象。 - -```java -int low = daysTempRange().getLow(); -int high = daysTempRange().getHigh(); -withinPlan = plan.withinRange(low, high); -``` - -```java -withinPlan = plan.withinRange(daysTempRange()); -``` - -## 8. 以函数取代参数 - -> Replace Parameter with Methods - -对象调用某个函数,并将所得结果作为参数,传递给另一个函数。而接受该参数的函数本身也能够调用前一个函数。 - -让参数接收者去除该项参数,而是直接调用前一个函数。 - -```java -int basePrice = _quantity * _itemPrice; -discountLevel = getDiscountLevel(); -double finalPrice = discountedPrice (basePrice, discountLevel); -``` - -```java -int basePrice = _quantity * _itemPrice; -double finalPrice = discountedPrice (basePrice); -``` - -## 9. 引入参数对象 - -> Introduce Parameter Object - -某些参数总是很自然地同时出现,这些参数就是 Data Clumps。 - -以一个对象取代这些参数。 - -

- -## 10. 移除设值函数 - -> Remove Setting Method - -类中的某个字段应该在对象创建时被设值,然后就不再改变。 - -去掉该字段的所有设值函数,并将该字段设为 final。 - -## 11. 隐藏函数 - -> Hide Method - -有一个函数,从来没有被其他任何类用到。 - -将这个函数修改为 private。 - -## 12. 以工厂函数取代构造函数 - -> Replace Constructor with Factory Method - -希望在创建对象时不仅仅是做简单的建构动作。 - -将构造函数替换为工厂函数。 - -## 13. 封装向下转型 - -> Encapsulate Downcast - -某个函数返回的对象,需要由函数调用者执行向下转型(downcast)。 - -将向下转型动作移到函数中。 - -```java -Object lastReading() { - return readings.lastElement(); -} -``` -```java -Reading lastReading() { - return (Reading)readings.lastElement(); -} -``` - -## 14. 以异常取代错误码 - -> Replace Error Code with Exception - -某个函数返回一个特定的代码,用以表示某种错误情况。 - -改用异常,异常将普通程序和错误处理分开,使代码更容易理解。 - -## 15. 以测试取代异常 - -> Replace Exception with Test - -面对一个调用者可以预先检查的条件,你抛出了一个异常。 - -修改调用者,使它在调用函数之前先做检查。 - -```java -double getValueForPeriod(int periodNumber) { - try { - return values[periodNumber]; - } catch (ArrayIndexOutOfBoundsException e) { - return 0; - } -} -``` -```java -double getValueForPeriod(int periodNumber) { - if (periodNumber >= values.length) return 0; - return values[periodNumber]; -``` - -# 十、处理概括关系 - -## 1. 字段上移 - -> Pull Up Field - -两个子类拥有相同的字段。 - -将该字段移至超类。 - -## 2. 函数上移 - -> Pull Up Method - -有些函数,在各个子类中产生完全相同的结果。 - -将该函数移至超类。 - -## 3. 构造函数本体上移 - -> Pull Up Constructor Body - -你在各个子类中拥有一些构造函数,它们的本体几乎完全一致。 - -在超类中新建一个构造函数,并在子类构造函数中调用它。 - -```java -class Manager extends Employee... - -public Manager(String name, String id, int grade) { - this.name = name; - this.id = id; - this.grade = grade; -} -``` - -```java -public Manager(String name, String id, int grade) { - super(name, id); - this.grade = grade; -} -``` - -## 4. 函数下移 - -> Push Down Method - -超类中的某个函数只与部分子类有关。 - -将这个函数移到相关的那些子类去。 - -## 5. 字段下移 - -> Push Down Field - -超类中的某个字段只被部分子类用到。 - -将这个字段移到需要它的那些子类去。 - -## 6. 提炼子类 - -> Extract Subclass - -类中的某些特性只被某些实例用到。 - -新建一个子类,将上面所说的那一部分特性移到子类中。 - -## 7. 提炼超类 - -> Extract Superclass - -两个类有相似特性。 - -为这两个类建立一个超类,将相同特性移至超类。 - -## 8. 提炼接口 - -> Extract Interface - -若干客户使用类接口中的同一子集,或者两个类的接口有部分相同。 - -将相同的子集提炼到一个独立接口中。 - -## 9. 折叠继承体系 - -> Collapse Hierarchy - -超类和子类之间无太大区别。 - -将它们合为一体。 - -## 10. 塑造模板函数 - -> Form Template Method - -你有一些子类,其中相应的某些函数以相同顺序执行类似的操作,但各个操作的细节上有所不同。 - -将这些操作分别放进独立函数中,并保持它们都有相同的签名,于是原函数也就变得相同了。然后将原函数上移至超类。(模板方法模式) - -## 11. 以委托取代继承 - -> Replace Inheritance with Delegation - -某个子类只使用超类接口中的一部分,或是根本不需要继承而来的数据。 - -在子类中新建一个字段用以保存超类,调整子类函数,令它改而委托超类,然后去掉两者之间的继承关系。 - -## 12. 以继承取代委托 - -> Replace Delegation with Inheritance - -你在两个类之间使用委托关系,并经常为整个接口编写许多极简单的委托函数。 - -让委托类继承受托类。 - -# 参考资料 - -- MartinFowler, 福勒, 贝克, 等. 重构: 改善既有代码的设计 [M]. 电子工业出版社, 2011. diff --git a/notes/集群.md b/notes/集群.md index b36a0bfe..649f563a 100644 --- a/notes/集群.md +++ b/notes/集群.md @@ -1,194 +1,4 @@ -* [一、负载均衡](#一负载均衡) - * [算法实现](#算法实现) - * [转发实现](#转发实现) -* [二、集群下的 Session 管理](#二集群下的-session-管理) - * [Sticky Sessions](#sticky-sessions) - * [Session Replication](#session-replication) - * [Session Server](#session-server) -# 一、负载均衡 - -集群中的应用服务器通常被设计成无状态,用户可以请求任何一个节点(应用服务器)。 - -负载均衡器会根据集群中每个节点的负载情况,将用户请求转发到合适的节点上。 - -负载均衡器可以用来实现高可用以及伸缩性: - -- 高可用:当某个节点故障时,负载均衡器不会将用户请求转发到该节点上,从而保证所有服务持续可用; -- 伸缩性:可以很容易地添加移除节点。 - -负载均衡运行过程包含两个部分: - -1. 根据负载均衡算法得到应该将请求转发到哪个节点上; -2. 将请求进行转发; - -## 算法实现 - -### 1. 轮询(Round Robin) - -轮询算法把每个请求轮流发送到每个服务器上。下图中,一共有 6 个客户端产生了 6 个请求,这 6 个请求按 (1, 2, 3, 4, 5, 6) 的顺序发送。最后,(1, 3, 5) 的请求会被发送到服务器 1,(2, 4, 6) 的请求会被发送到服务器 2。 - -

- -该算法比较适合每个服务器的性能差不多的场景,如果有性能存在差异的情况下,那么性能较差的服务器可能无法承担过大的负载(下图的 Server 2)。 - -

- -### 2. 加权轮询(Weighted Round Robbin) - -加权轮询是在轮询的基础上,根据服务器的性能差异,为服务器赋予一定的权值,性能高的服务器分配更高的权值。例如下图中,服务器 1 被赋予的权值为 5,服务器 2 被赋予的权值为 1,那么 (1, 2, 3, 4, 5) 请求会被发送到服务器 1,(6) 请求会被发送到服务器 2。 - -

- -### 3. 最少连接(least Connections) - -由于每个请求的连接时间不一样,使用轮询或者加权轮询算法的话,可能会让一台服务器当前连接数过大,而另一台服务器的连接过小,造成负载不均衡。例如下图中,(1, 3, 5) 请求会被发送到服务器 1,但是 (1, 3) 很快就断开连接,此时只有 (5) 请求连接服务器 1;(2, 4, 6) 请求被发送到服务器 2,只有 (2) 的连接断开。该系统继续运行时,服务器 2 会承担过大的负载。 - -

- -最少连接算法就是将请求发送给当前最少连接数的服务器上。例如下图中,服务器 1 当前连接数最小,那么新到来的请求 6 就会被发送到服务器 1 上。 - -

- -### 4. 加权最少连接(Weighted Least Connection) - -在最少连接的基础上,根据服务器的性能为每台服务器分配权重,再根据权重计算出每台服务器能处理的连接数。 - -

- -### 5. 随机算法(Random) - -把请求随机发送到服务器上。和轮询算法类似,该算法比较适合服务器性能差不多的场景。 - -

- -### 6. 源地址哈希法 (IP Hash) - -源地址哈希通过对客户端 IP 计算哈希值之后,再对服务器数量进行取模运算得到目标服务器的序号。可以保证同一 IP 的客户端的请求会转发到同一台服务器上,可以用来实现会话粘滞(Sticky Session) - -

- -## 转发实现 - -### 1. HTTP 重定向 - -HTTP 重定向负载均衡服务器使用某种负载均衡算法计算得到服务器的 IP 地址之后,将该地址写入 HTTP 重定向报文中,状态码为 302。客户端收到重定向报文之后,需要重新向服务器发起请求。 - -缺点: - -- 需要两次请求,因此访问延迟比较高; -- HTTP 负载均衡器处理能力有限,会限制集群的规模。 - -该负载均衡转发的缺点比较明显,实际场景中很少使用它。 - -

- -### 2. DNS 域名解析 - -在 DNS 解析域名的同时使用负载均衡算法计算服务器地址。 - -优点: - -- DNS 能够根据地理位置进行域名解析,返回离用户最近的服务器 IP 地址。 - -缺点: - -- 由于 DNS 具有多级结构,每一级的域名记录都可能被缓存,当下线一台服务器需要修改 DNS 记录时,需要过很长一段时间才能生效; - -大型网站基本使用了 DNS 做为第一级负载均衡手段,然后在内部使用其它方式做第二级负载均衡。也就是说,域名解析的结果为内部的负载均衡服务器 IP 地址。 - -

- -### 3. 反向代理服务器 - -首先了解一下正向代理与反向代理的区别: - -- 正向代理:发生在客户端,是由用户主动发起的。比如翻墙,客户端通过主动访问代理服务器,让代理服务器获得需要的外网数据,然后转发回客户端; -- 反向代理:发生在服务器端,用户不知道代理的存在。 - -反向代理服务器位于源服务器前面,用户的请求需要先经过反向代理服务器才能到达源服务器。反向代理可以用来进行缓存、日志记录等,同时也可以用来做为负载均衡服务器。 - -在这种负载均衡转发方式下,客户端不直接请求源服务器,因此源服务器不需要外部 IP 地址,而反向代理需要配置内部和外部两套 IP 地址。 - -优点: - -- 与其它功能集成在一起,部署简单。 - -缺点: - -- 所有请求和响应都需要经过反向代理服务器,它可能会成为性能瓶颈。 - -### 4. 网络层 - -负载均衡服务器在操作系统内核进程获取网络数据包,根据负载均衡算法计算源服务器的 IP 地址,并修改请求数据包的目的 IP 地址,最后进行转发。 - -源服务器返回的响应也需要经过负载均衡服务器,通常是让负载均衡服务器同时作为集群的网关服务器来实现。 - -优点: - -- 在内核进程中进行处理,性能比较高。 - -缺点: - -- 和反向代理一样,所有的请求和相应都经过负载均衡服务器,会成为性能瓶颈。 - -### 5. 链路层 - -在链路层根据负载均衡算法计算源服务器的 MAC 地址,并修改请求数据包的目的 MAC 地址,并进行转发。 - -通过配置源服务器的虚拟 IP 地址和负载均衡服务器的 IP 地址一致,从而不需要需要 IP 地址就可以进行转发。也正因为 IP 地址一样,所以源服务器的响应不需要转发回负载均衡服务器,直接转发给客户端,避免了负载均衡服务器的成为瓶颈。这是一种三角传输模式,被称为直接路由,对于提供下载和视频服务的网站来说,直接路由避免了大量的网络传输数据经过负载均衡服务器。 - -这是目前大型网站使用最广负载均衡转发方式,在 Linux 平台可以使用 LVS(Linux Virtual Server)。 - -参考: - -- [Comparing Load Balancing Algorithms](http://www.jscape.com/blog/load-balancing-algorithms) -- [Redirection and Load Balancing](http://slideplayer.com/slide/6599069/#) - -# 二、集群下的 Session 管理 - -一个用户的 Session 信息如果存储在一个服务器上,那么当负载均衡器把用户的下一个请求转发到另一个服务器,由于服务器没有用户的 Session 信息,那么该用户就需要重新进行登录等操作。 - -## Sticky Sessions - -需要配置负载均衡器,使得一个用户的所有请求都路由到同一个服务器,这样就可以把用户的 Session 存放在该服务器中。 - -缺点: - -- 当服务器宕机时,将丢失该服务器上的所有 Session。 - -

- -## Session Replication - -在服务器之间进行 Session 同步操作,每个服务器都有所有用户的 Session 信息,因此用户可以向任何一个服务器进行请求。 - -缺点: - -- 占用过多内存; -- 同步过程占用网络带宽以及服务器处理器时间。 - - -

- -## Session Server - -使用一个单独的服务器存储 Session 数据,可以使用 MySQL,也使用 Redis 或者 Memcached 这种内存型数据库。 - -优点: - -- 为了使得大型网站具有伸缩性,集群中的应用服务器通常需要保持无状态,那么应用服务器不能存储用户的会话信息。Session Server 将用户的会话信息单独进行存储,从而保证了应用服务器的无状态。 - -缺点: - -- 需要去实现存取 Session 的代码。 - -

- -参考: - -- [Session Management using Spring Session with JDBC DataStore](https://sivalabs.in/2018/02/session-management-using-spring-session-jdbc-datastore/) -