From 68e6fbb757a006ffbea3c517ccb2f613e48da9c8 Mon Sep 17 00:00:00 2001 From: "ying.liu" Date: Thu, 9 May 2019 16:46:09 -0700 Subject: [PATCH 001/231] draft 1 connection pool --- backend/database/database-connection-pool.md | 32 ++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 backend/database/database-connection-pool.md diff --git a/backend/database/database-connection-pool.md b/backend/database/database-connection-pool.md new file mode 100644 index 0000000..2443b17 --- /dev/null +++ b/backend/database/database-connection-pool.md @@ -0,0 +1,32 @@ +# 数据库连接池 + +几乎所有的商业应用都有大量数据库访问,通常这些应用会采用数据库连接池。理解问什么需要连接池,连接池的实现原理和参数设置对于写出正确、高效的程序很有帮助。理解这些概念对于理解分布式计算也很有帮助。 + +## 为什么需要连接池 + +任何数据库的访问都需要建立连接。这是一个复杂、缓慢的处理。牵涉到通信连接建立(包括 TCP 的三次握手)、认证、授权、资源的初始化和分配等一系列任务。而且数据库服务器通常和应用服务器是分开的,所有的操作都是分布式网络请求和处理。[建立数据库连接时间](https://stackoverflow.com/questions/2188611/how-long-does-it-take-to-create-a-new-database-connection-to-sql)通常在 100ms 或更长。而通常小数据的 CRUD 数据库操作是 ms 级或更短,加上网络延迟一般 10 到 50 个 ms 就可以返回多数请求处理结果。在应用启动时预先建立一些数据库连接,应用程序使用已有的连接可以极大提高相应速度。另外,Web 服务应用当客户很多时,有很多线程,连接数目过多以及频繁创建/删除连接也会影响数据库的性能。 + +总结起来,采用数据库连接有如下好处: + +- 节省了创建数据库连接的时间,通常这个时间大大超过处理数据访问请求的时间。 +- 统一管理数据库请求连接,避免了过多连接或频繁创建/删除连接带来的性能问题。 + +## 实现原理 + +如同多数分布式基础构件,连接池的原理比较简单,但是牵涉到数据库,操作系统,编程语言,运维以及应用场景的不同特点,具体实现比较复杂。从数据库诞生就有的广泛需求,半个世纪后还有不断改进提高的余地。 + +原理上,在应用开始时创建一组数据库的连接。也可以动态创建但是复用已有的连接。这些连接被存储到一个共享的数据结构,称为连接池。每个线程在需要访问数据库时借用(borrow)一个连接,使用完成则释放(release)连接回到连接池供其他线程使用。比较好的线程池构件会有二个参数动态控制线程池的大小:最小数量和最大数量。最小数量指即使负载很轻,也保持一个最小数目的数据库连接以备不时之需。当同时访问数据库的线程数超过最小数量时,则动态创建更多连接。最大数量则是允许的最大数据库连接数量,当最大数目的连接都在使用而有新的线程需要访问数据库时,则新的线程会被阻塞直到有连接被释放回连接池。当负载变低,池里的连接数目超过最小数目而只有低于或等于最小数目的连接被使用时,超过最小数目的连接会被关闭和删除以便节省系统资源。 + +具体的线程池实现需要考虑很多应用细节。 + +- 多余的连接不会立即关闭,而是会等待一段空闲时间(idle time)再关闭。 +- 连接有最长生命时间限制,即使连接池不管,数据库也会自动关闭超过生命时间的连接。在 MySql 里面,连接最长生命时间是 8 个小时。连接池需要定期监控清理无效的连接。 +- 当连接数小于最大数目而需要为新线程创建连接时,新线程应该等待池里第一个可用的连接而不必等待因它而创建的线程。HikariCP 的文档[Welcome to the Jungle](https://github.com/brettwooldridge/HikariCP/blob/dev/documents/Welcome-To-The-Jungle.md) 比较了这种实现的优点:可以避免创建很多不必要的连接并且有更好的性能。 +- 数据库各种异常的处理。[Bad Behavior: Handling Database Down](https://github.com/brettwooldridge/HikariCP/wiki/Bad-Behavior:-Handling-Database-Down) 给出里不同连接池构件实现对于线程阻塞 timeout 的不同处理方式。很多不能正确处理。 +- 线程池的监控监测。[HikariCP Dropwizard HealthChecks](https://github.com/brettwooldridge/HikariCP/wiki/Dropwizard-HealthChecks)是一个例子。 +- 线程池的性能检测。[HikariCP Dropwizard Metrics](https://github.com/brettwooldridge/HikariCP/wiki/Dropwizard-Metrics) 给出了检测的性能指标。 +- 线程阻塞的机制以及相关数据结构对连接池的性能有很大影响。[Down the Rabbit Hole](https://github.com/brettwooldridge/HikariCP/wiki/Down-the-Rabbit-Hole)给出了 Java 里的优化方法。当然,里面有很多优化过于琐碎使得代码晦涩难懂而且需要很多维护。 + +## 配置数据库连接池 + +先说句题外话,老刘没有想到这么简单的一个配置问题竟然多数人都不清楚甚至搞错,这些糊涂的人甚至包括负责连接池编码的程序员。[About Pool Sizing](https://github.com/brettwooldridge/HikariCP/wiki/About-Pool-Sizing)的作者是广泛使用的 HikariCP 的程序员。很不幸,有实现连接池的编码经验,面对很多数据,他给出里错误的结论。不过,他的糟糕的代码风格也预示着他的条理性不好。 From 1970d60216d6ca95db49cc5bb8107e9f3f0a09dc Mon Sep 17 00:00:00 2001 From: "ying.liu" Date: Thu, 9 May 2019 23:13:08 -0700 Subject: [PATCH 002/231] first draft done --- backend/database/database-connection-pool.md | 72 ++++++++++++++++++-- 1 file changed, 65 insertions(+), 7 deletions(-) diff --git a/backend/database/database-connection-pool.md b/backend/database/database-connection-pool.md index 2443b17..b8bc38f 100644 --- a/backend/database/database-connection-pool.md +++ b/backend/database/database-connection-pool.md @@ -1,10 +1,12 @@ # 数据库连接池 -几乎所有的商业应用都有大量数据库访问,通常这些应用会采用数据库连接池。理解问什么需要连接池,连接池的实现原理和参数设置对于写出正确、高效的程序很有帮助。理解这些概念对于理解分布式计算也很有帮助。 +几乎所有的商业应用都有大量数据库访问,通常这些应用会采用数据库连接池。理解为什么需要连接池,连接池的实现原理和参数设置对于写出正确、高效的程序很有帮助。这些概念可以用于系统运行参数的配置,同时对于理解并发和分布式处理也很有帮助。 + +说句题外话,没有想到这么简单的一个数据库连接池配置问题竟然多数人都不清楚甚至搞错。犯糊涂的人甚至包括负责连接池实现的程序员。[About Pool Sizing](https://github.com/brettwooldridge/HikariCP/wiki/About-Pool-Sizing)的作者是广泛使用的 HikariCP 的程序员。很不幸,有实现连接池的编码经验,面对很多数据,他给出里错误的结论和公式。不过不很意外,他的糟糕的代码风格也预示着他的条理性不好。 ## 为什么需要连接池 -任何数据库的访问都需要建立连接。这是一个复杂、缓慢的处理。牵涉到通信连接建立(包括 TCP 的三次握手)、认证、授权、资源的初始化和分配等一系列任务。而且数据库服务器通常和应用服务器是分开的,所有的操作都是分布式网络请求和处理。[建立数据库连接时间](https://stackoverflow.com/questions/2188611/how-long-does-it-take-to-create-a-new-database-connection-to-sql)通常在 100ms 或更长。而通常小数据的 CRUD 数据库操作是 ms 级或更短,加上网络延迟一般 10 到 50 个 ms 就可以返回多数请求处理结果。在应用启动时预先建立一些数据库连接,应用程序使用已有的连接可以极大提高相应速度。另外,Web 服务应用当客户很多时,有很多线程,连接数目过多以及频繁创建/删除连接也会影响数据库的性能。 +任何数据库的访问都需要首先建立数据库连接。这是一个复杂、缓慢的处理。牵涉到通信建立(包括 TCP 的三次握手)、认证、授权、资源的初始化和分配等一系列任务。而且数据库服务器通常和应用服务器是分开的,所有的操作都是分布式网络请求和处理。[建立数据库连接时间](https://stackoverflow.com/questions/2188611/how-long-does-it-take-to-create-a-new-database-connection-to-sql)通常在 100ms 或更长。而通常小数据的 CRUD 数据库操作是 ms 级或更短,加上网络延迟一般 10 到 50 个 ms 就可以完成多数数据库处理结果。在应用启动时预先建立一些数据库连接,应用程序使用已有的连接可以极大提高响应速度。另外,Web 服务应用当客户很多时,有很多线程,连接数目过多以及频繁创建/删除连接也会影响数据库的性能。 总结起来,采用数据库连接有如下好处: @@ -22,11 +24,67 @@ - 多余的连接不会立即关闭,而是会等待一段空闲时间(idle time)再关闭。 - 连接有最长生命时间限制,即使连接池不管,数据库也会自动关闭超过生命时间的连接。在 MySql 里面,连接最长生命时间是 8 个小时。连接池需要定期监控清理无效的连接。 - 当连接数小于最大数目而需要为新线程创建连接时,新线程应该等待池里第一个可用的连接而不必等待因它而创建的线程。HikariCP 的文档[Welcome to the Jungle](https://github.com/brettwooldridge/HikariCP/blob/dev/documents/Welcome-To-The-Jungle.md) 比较了这种实现的优点:可以避免创建很多不必要的连接并且有更好的性能。 -- 数据库各种异常的处理。[Bad Behavior: Handling Database Down](https://github.com/brettwooldridge/HikariCP/wiki/Bad-Behavior:-Handling-Database-Down) 给出里不同连接池构件实现对于线程阻塞 timeout 的不同处理方式。很多不能正确处理。 -- 线程池的监控监测。[HikariCP Dropwizard HealthChecks](https://github.com/brettwooldridge/HikariCP/wiki/Dropwizard-HealthChecks)是一个例子。 -- 线程池的性能检测。[HikariCP Dropwizard Metrics](https://github.com/brettwooldridge/HikariCP/wiki/Dropwizard-Metrics) 给出了检测的性能指标。 -- 线程阻塞的机制以及相关数据结构对连接池的性能有很大影响。[Down the Rabbit Hole](https://github.com/brettwooldridge/HikariCP/wiki/Down-the-Rabbit-Hole)给出了 Java 里的优化方法。当然,里面有很多优化过于琐碎使得代码晦涩难懂而且需要很多维护。 +- 数据库各种异常的处理。[Bad Behavior: Handling Database Down](https://github.com/brettwooldridge/HikariCP/wiki/Bad-Behavior:-Handling-Database-Down) 给出里不同连接池构件实现对于线程阻塞 timeout 的不同处理方式。很多连接池构件不能正确处理。 +- 线程池的健康监控。[HikariCP Dropwizard HealthChecks](https://github.com/brettwooldridge/HikariCP/wiki/Dropwizard-HealthChecks)是一个例子。 +- 线程池的性能监视。[HikariCP Dropwizard Metrics](https://github.com/brettwooldridge/HikariCP/wiki/Dropwizard-Metrics) 给出了监视的性能指标。 +- 线程阻塞的机制以及相关数据结构对连接池的性能有很大影响。[Down the Rabbit Hole](https://github.com/brettwooldridge/HikariCP/wiki/Down-the-Rabbit-Hole)给出了 Java 里的优化方法。坏处是里面有些优化过于琐碎,使得代码晦涩难懂而且需要额外维护工作。 + +## 数据库连接池的系统架构 + +连接池的本质是一个共享的数据结构。其核心管理功能是从池中分配一个数据库连接给需要的线程,线程用完后回收连接到池中。由于连接池有限,可以并行进行数据库访问的线程数量最多是连接池的最大尺寸。如果考虑到一个应用线程可能会用到多个数据库连接的可能性,则可以并发访问数据库的线程数目会更少。 + +连接池的使用者是业务应用程序。通常有二种:一种是基于用户/服务请求的 HTTP 服务线程,通常采用线程池。特点是线程数目动态变化很大,数据库的访问模式比较多样,处理时间也有长有短,可能有很大差别。另一种是后台服务,其线程数目比较固定,数据库访问模式和处理时间也比较稳定。 + +连接池只是给业务应用提供已建立的连接,所有的访问请求都通过连接转发到后台数据库服务器。数据库服务器通常也采用线程(PostfreSQL 用进程)池处理所有的访问请求。 + +具体来说,连接池是两个线程池的中间通道。连接池和应用服务线程池在同一个进程里面。数据库的访问则一般通过网络进行。可以看成下面的结构: + +应用服务线程池 <-> 数据库连接池 <======> 数据库服务器线程(或进程)池 + +需要说明,每个访问数据库的应用服务进程都有自己的线程池和对应的数据库连接池。数据库服务器可能需要处理来自一个或多个服务器的多个应用服务进程内的数据库连接池数据访问请求。 ## 配置数据库连接池 -先说句题外话,老刘没有想到这么简单的一个配置问题竟然多数人都不清楚甚至搞错,这些糊涂的人甚至包括负责连接池编码的程序员。[About Pool Sizing](https://github.com/brettwooldridge/HikariCP/wiki/About-Pool-Sizing)的作者是广泛使用的 HikariCP 的程序员。很不幸,有实现连接池的编码经验,面对很多数据,他给出里错误的结论。不过,他的糟糕的代码风格也预示着他的条理性不好。 +### 配置目标 + +当提到数据库连接池的配置,一个常见也是严重的错误是把连接池和线程池的概念混淆了。前面提到的 HikariCP 的程序员给出的建议就犯了这种错误。 + +如上面系统架构所示,数据库连接池并不控制应用端和数据库端的线程池的大小。每个数据库连接池的配置只是针对自己所在的应用服务进程,限制的是同一个进程内可以访问数据库的并行线程数目。应用服务进程单独管理自己的线程池,除了数据库访问还有处理其他业务逻辑,并行的线程数目基本取决于服务的负载。当应用服务线程需要访问数据库时,其并发度和阻塞数目才收到连接池尺寸的影响。 + +做为应用服务和数据库的桥梁,连接池参数配置的目标是全局优化。具体的优化目的有三个:尽可能满足所有的应用服务并发数据库访问,不让数据库服务器过载,不浪费系统资源。 + +尽可能满足所有的应用服务并发数据库访问的意思很简单:所有需要访问数据库的线程都可以得到需要的数据库连接。如果一个线程用到多个连接,那么需要的连接数目也会成倍增加。这时,需要的连接池最大尺寸应该是最大的并发数据库访问线程数目乘以每个线程需要的连接数目。 + +不让数据库服务器过载是个全局的考虑。因为可能有多个应用服务器的多个连接池会同时发出请求。这个[OLTP performance -- Concurrent Mid-tier connections](https://youtu.be/xNDnVOCdvQ0)录像用一个应用服务线程池进行了模拟。应用服务线程池有 9600 个不断访问数据库的线程,当连接池尺寸为 2048 和 1024 时,数据库处于过载状态,有很多数据库的的等待事件,数据库 CPU 利用率高达 95%。当连接池减少到 96,数据库服务器没有等待事件,CPU 利用率 20%,数据库访问请求等待时间从 33ms 降低到 1ms,数据库 SQL 执行时间从 77ms 降低到 2ms。数据库访问整体响应时间从 100ms 降低到 3ms。这时一个应用服务线程池对一个数据库服务线程池的情况,总共 96 个连接池的数据库处理性能远远超过 1000 个连接池的性能。数据库服务器需要为每个连接分配资源。比如按照 PostgreSQL V11 文档[18.4.3. Resource Limits](https://www.postgresql.org/docs/11/kernel-resources.html),每个连接都需要一个单独进程来处理。每个进程即使空闲,都会消耗不少诸如内存,semapho 等的系统资源。 + +不浪费系统资源是指配置过大的连接池会浪费系统资源,包括内存,网络端口,同步信号等。同时线程池的重启和操作都会响应变慢。不过应用端线程池的开销不是很大,资源的浪费通常不是太大问题。 + +### 配置方法 + +概念清楚,目标明确之后,配置方法就比较容易了。思路有二种:按二端线程(进程)池尺寸估算或按照吞吐量估算。综合考虑二种方法的结果会是比较合理的。 + +方法 1: 找出二端的最大值,其中小的那个值就是连接池上限。应用服务线程池尺寸,比如 Tomcat 最大线程池尺寸缺省值为 200。如果每个线程只用一个数据库连接,那么连接池最大数目应该小于等于 200。如果有些请求用到多于一个连接,则适当增加。如果数据库线程(进程)池的最大尺寸为 151, 参考[MySQL Too many connections](https://dev.mysql.com/doc/refman/5.5/en/too-many-connections.html), 此时连接池最大尺寸应该小于等于 151。取二个值(200, 151)中的那个小的,那么连接池最大尺寸应该小于等于 151。如果还有其他连接池,则还要全局考虑。这个值是连接池的上线。 + +方法 2: 考虑应用服务的负载性质。如果是数量变化很大的 Web 应用服务线程池,那么连接池也可以配置成动态的,配置相应的最小值和最大值。对于想邮件服务这种固定负载的业务应用,可以配置固定尺寸的进程池。 + +仅仅考虑二端线程(进程)池的尺寸会配置过大的连接池,因为这是系统的上限。另一种思路是按照数据库访问的复杂度和响应时间进行估算。这里用到[Little's Law](https://en.wikipedia.org/wiki/Little%27s_law):`并发量 = 每秒请求数量 * 数据库请求响应时间`。 + +如果每秒有 100 个客户请求,每个请求需要 20ms,那么并行量是 `100 * 0.02 = 2`,2 个并发数据库连接就可以了。同理,如果每个请求需要 100ms,那么就需要 10 个并发连接。 + +### 一个无关的计算公式 + +因为是混淆的根源,还是有必要介绍另外一个经常提到但是无关的线程数目计算公式。这个公式来自著名的[Java Concurrency in Practice](http://jcip.net/)。在原著 8.2 节, 第 171 页作者给出了同样著名的线程数目计算公式:`线程数目 = CPU核数 * CPU 利用率 * (1 + 等待时间 / CPU计算时间)`。这个公式考虑了计算密集(计算时间)和 I/O 密集(等待时间)的不同处理模式。可是这个公式可以用于应用服务线程池的尺寸估算,与数据库连接池的估算无关。因为进程池并不能控制线程数目,它控制的是可并发的数据库访问线程数目。这些线程用数据库连接完成网络服务和远程数据库的异步操作,基本没有 CPU 计算时间。套用公式会得出非常大的数字,基本没有实际意义。 + +## Spring 应用的连接池配置 + +如上所述,配置 Spring 连接池首先要考虑到其使用的 HTTP 服务的线程池配置和后端数据库服务器的连接数配置。其次是应用的特点。 + +Spring 缺省使用[HikariCP](https://github.com/brettwooldridge/HikariCP)。HikariCP 有相关的[MySQL 配置](https://github.com/brettwooldridge/HikariCP/wiki/MySQL-Configuration) 和[Hiberate 配置](https://github.com/brettwooldridge/HikariCP/wiki/Hibernate4)的建议。 + +## 一些参考缺省配置 + +[HikariCP](https://github.com/brettwooldridge/HikariCP): DEFAULT_POOL_SIZE = 10 + +[DBCP](https://wiki.apache.org/commons/DBCP): Max pool size : 8 + +[c3p0](https://github.com/swaldman/c3p0): MIN_POOL_SIZE = 3, MAX_POOL_SIZE = 15 From 032d775575cdb8388ce4f9c7bcfb470907b3aafa Mon Sep 17 00:00:00 2001 From: "ying.liu" Date: Fri, 10 May 2019 18:51:58 -0700 Subject: [PATCH 003/231] revise connection pool --- backend/database/database-connection-pool.md | 66 ++++++++++++-------- 1 file changed, 39 insertions(+), 27 deletions(-) diff --git a/backend/database/database-connection-pool.md b/backend/database/database-connection-pool.md index b8bc38f..0e99c36 100644 --- a/backend/database/database-connection-pool.md +++ b/backend/database/database-connection-pool.md @@ -2,8 +2,6 @@ 几乎所有的商业应用都有大量数据库访问,通常这些应用会采用数据库连接池。理解为什么需要连接池,连接池的实现原理和参数设置对于写出正确、高效的程序很有帮助。这些概念可以用于系统运行参数的配置,同时对于理解并发和分布式处理也很有帮助。 -说句题外话,没有想到这么简单的一个数据库连接池配置问题竟然多数人都不清楚甚至搞错。犯糊涂的人甚至包括负责连接池实现的程序员。[About Pool Sizing](https://github.com/brettwooldridge/HikariCP/wiki/About-Pool-Sizing)的作者是广泛使用的 HikariCP 的程序员。很不幸,有实现连接池的编码经验,面对很多数据,他给出里错误的结论和公式。不过不很意外,他的糟糕的代码风格也预示着他的条理性不好。 - ## 为什么需要连接池 任何数据库的访问都需要首先建立数据库连接。这是一个复杂、缓慢的处理。牵涉到通信建立(包括 TCP 的三次握手)、认证、授权、资源的初始化和分配等一系列任务。而且数据库服务器通常和应用服务器是分开的,所有的操作都是分布式网络请求和处理。[建立数据库连接时间](https://stackoverflow.com/questions/2188611/how-long-does-it-take-to-create-a-new-database-connection-to-sql)通常在 100ms 或更长。而通常小数据的 CRUD 数据库操作是 ms 级或更短,加上网络延迟一般 10 到 50 个 ms 就可以完成多数数据库处理结果。在应用启动时预先建立一些数据库连接,应用程序使用已有的连接可以极大提高响应速度。另外,Web 服务应用当客户很多时,有很多线程,连接数目过多以及频繁创建/删除连接也会影响数据库的性能。 @@ -17,74 +15,88 @@ 如同多数分布式基础构件,连接池的原理比较简单,但是牵涉到数据库,操作系统,编程语言,运维以及应用场景的不同特点,具体实现比较复杂。从数据库诞生就有的广泛需求,半个世纪后还有不断改进提高的余地。 -原理上,在应用开始时创建一组数据库的连接。也可以动态创建但是复用已有的连接。这些连接被存储到一个共享的数据结构,称为连接池。每个线程在需要访问数据库时借用(borrow)一个连接,使用完成则释放(release)连接回到连接池供其他线程使用。比较好的线程池构件会有二个参数动态控制线程池的大小:最小数量和最大数量。最小数量指即使负载很轻,也保持一个最小数目的数据库连接以备不时之需。当同时访问数据库的线程数超过最小数量时,则动态创建更多连接。最大数量则是允许的最大数据库连接数量,当最大数目的连接都在使用而有新的线程需要访问数据库时,则新的线程会被阻塞直到有连接被释放回连接池。当负载变低,池里的连接数目超过最小数目而只有低于或等于最小数目的连接被使用时,超过最小数目的连接会被关闭和删除以便节省系统资源。 - -具体的线程池实现需要考虑很多应用细节。 - -- 多余的连接不会立即关闭,而是会等待一段空闲时间(idle time)再关闭。 -- 连接有最长生命时间限制,即使连接池不管,数据库也会自动关闭超过生命时间的连接。在 MySql 里面,连接最长生命时间是 8 个小时。连接池需要定期监控清理无效的连接。 -- 当连接数小于最大数目而需要为新线程创建连接时,新线程应该等待池里第一个可用的连接而不必等待因它而创建的线程。HikariCP 的文档[Welcome to the Jungle](https://github.com/brettwooldridge/HikariCP/blob/dev/documents/Welcome-To-The-Jungle.md) 比较了这种实现的优点:可以避免创建很多不必要的连接并且有更好的性能。 -- 数据库各种异常的处理。[Bad Behavior: Handling Database Down](https://github.com/brettwooldridge/HikariCP/wiki/Bad-Behavior:-Handling-Database-Down) 给出里不同连接池构件实现对于线程阻塞 timeout 的不同处理方式。很多连接池构件不能正确处理。 -- 线程池的健康监控。[HikariCP Dropwizard HealthChecks](https://github.com/brettwooldridge/HikariCP/wiki/Dropwizard-HealthChecks)是一个例子。 -- 线程池的性能监视。[HikariCP Dropwizard Metrics](https://github.com/brettwooldridge/HikariCP/wiki/Dropwizard-Metrics) 给出了监视的性能指标。 -- 线程阻塞的机制以及相关数据结构对连接池的性能有很大影响。[Down the Rabbit Hole](https://github.com/brettwooldridge/HikariCP/wiki/Down-the-Rabbit-Hole)给出了 Java 里的优化方法。坏处是里面有些优化过于琐碎,使得代码晦涩难懂而且需要额外维护工作。 +原理上,在应用开始时创建一组数据库的连接。也可以动态创建但是复用已有的连接。这些连接被存储到一个共享的资源数据结构,称为连接池。这是典型的生产者-消费者并发模型。每个线程在需要访问数据库时借用(borrow)一个连接,使用完成则释放(release)连接回到连接池供其他线程使用。比较好的线程池构件会有二个参数动态控制线程池的大小:最小数量和最大数量。最小数量指即使负载很轻,也保持一个最小数目的数据库连接以备不时之需。当同时访问数据库的线程数超过最小数量时,则动态创建更多连接。最大数量则是允许的最大数据库连接数量,当最大数目的连接都在使用而有新的线程需要访问数据库时,则新的线程会被阻塞直到有连接被释放回连接池。当负载变低,池里的连接数目超过最小数目而只有低于或等于最小数目的连接被使用时,超过最小数目的连接会被关闭和删除以便节省系统资源。当然具体的实现需要考虑很多细节,但是不太影响应用接口,放在文章结尾再讨论。 ## 数据库连接池的系统架构 -连接池的本质是一个共享的数据结构。其核心管理功能是从池中分配一个数据库连接给需要的线程,线程用完后回收连接到池中。由于连接池有限,可以并行进行数据库访问的线程数量最多是连接池的最大尺寸。如果考虑到一个应用线程可能会用到多个数据库连接的可能性,则可以并发访问数据库的线程数目会更少。 +连接池的本质是属于一个操作系统进程(process)的共享的资源。其核心管理功能是从池中分配一个数据库连接给需要的线程,线程用完后回收连接到池中。由于连接池有限,可以并行进行数据库访问的线程数量最多是连接池的最大尺寸。如果考虑到一个应用线程可能会用到多个数据库连接的可能性,则可以并发访问数据库的线程数目会更少。 连接池的使用者是业务应用程序。通常有二种:一种是基于用户/服务请求的 HTTP 服务线程,通常采用线程池。特点是线程数目动态变化很大,数据库的访问模式比较多样,处理时间也有长有短,可能有很大差别。另一种是后台服务,其线程数目比较固定,数据库访问模式和处理时间也比较稳定。 连接池只是给业务应用提供已建立的连接,所有的访问请求都通过连接转发到后台数据库服务器。数据库服务器通常也采用线程(PostfreSQL 用进程)池处理所有的访问请求。 -具体来说,连接池是两个线程池的中间通道。连接池和应用服务线程池在同一个进程里面。数据库的访问则一般通过网络进行。可以看成下面的结构: +具体来说,连接池是两个线程池的中间通道。连接池和应用服务线线程池在同一个进程里面。可以看成下面的结构: -应用服务线程池 <-> 数据库连接池 <======> 数据库服务器线程(或进程)池 +应用服务进程的线程池 == 数据库连接池 ---------------- +| +应用服务进程的线程池 == 数据库连接池 ------ 数据库服务器线程(或进程)池 +| +应用服务进程的线程池 == 数据库连接池 ---------------- -需要说明,每个访问数据库的应用服务进程都有自己的线程池和对应的数据库连接池。数据库服务器可能需要处理来自一个或多个服务器的多个应用服务进程内的数据库连接池数据访问请求。 +上图中,每个访问数据库的应用服务进程都有自己的线程池和对应的数据库连接池。数据库服务器可能需要处理来自一个或多个服务器的多个应用服务进程内的数据库连接池数据访问请求。 ## 配置数据库连接池 ### 配置目标 -当提到数据库连接池的配置,一个常见也是严重的错误是把连接池和线程池的概念混淆了。前面提到的 HikariCP 的程序员给出的建议就犯了这种错误。 +当提到数据库连接池的配置,一个常见也是严重的错误是把连接池和线程池的概念混淆了。如上面系统架构所示,数据库连接池并不控制应用端和数据库端的线程池的大小。而且每个数据库连接池的配置只是针对自己所在的应用服务进程,限制的是同一个进程内可以访问数据库的并行线程数目。应用服务进程单独管理自己的线程池,除了数据库访问还有处理其他业务逻辑,并行的线程数目基本取决于服务的负载。当应用服务线程需要访问数据库时,其并发度和阻塞数目才受到连接池尺寸的影响。 -如上面系统架构所示,数据库连接池并不控制应用端和数据库端的线程池的大小。每个数据库连接池的配置只是针对自己所在的应用服务进程,限制的是同一个进程内可以访问数据库的并行线程数目。应用服务进程单独管理自己的线程池,除了数据库访问还有处理其他业务逻辑,并行的线程数目基本取决于服务的负载。当应用服务线程需要访问数据库时,其并发度和阻塞数目才收到连接池尺寸的影响。 - -做为应用服务和数据库的桥梁,连接池参数配置的目标是全局优化。具体的优化目的有三个:尽可能满足所有的应用服务并发数据库访问,不让数据库服务器过载,不浪费系统资源。 +做为应用服务和数据库的桥梁,连接池参数配置的目标是全局优化。具体的优化目的有三个:尽可能满足应用服务的并发数据库访问,不让数据库服务器过载,不浪费系统资源。 尽可能满足所有的应用服务并发数据库访问的意思很简单:所有需要访问数据库的线程都可以得到需要的数据库连接。如果一个线程用到多个连接,那么需要的连接数目也会成倍增加。这时,需要的连接池最大尺寸应该是最大的并发数据库访问线程数目乘以每个线程需要的连接数目。 -不让数据库服务器过载是个全局的考虑。因为可能有多个应用服务器的多个连接池会同时发出请求。这个[OLTP performance -- Concurrent Mid-tier connections](https://youtu.be/xNDnVOCdvQ0)录像用一个应用服务线程池进行了模拟。应用服务线程池有 9600 个不断访问数据库的线程,当连接池尺寸为 2048 和 1024 时,数据库处于过载状态,有很多数据库的的等待事件,数据库 CPU 利用率高达 95%。当连接池减少到 96,数据库服务器没有等待事件,CPU 利用率 20%,数据库访问请求等待时间从 33ms 降低到 1ms,数据库 SQL 执行时间从 77ms 降低到 2ms。数据库访问整体响应时间从 100ms 降低到 3ms。这时一个应用服务线程池对一个数据库服务线程池的情况,总共 96 个连接池的数据库处理性能远远超过 1000 个连接池的性能。数据库服务器需要为每个连接分配资源。比如按照 PostgreSQL V11 文档[18.4.3. Resource Limits](https://www.postgresql.org/docs/11/kernel-resources.html),每个连接都需要一个单独进程来处理。每个进程即使空闲,都会消耗不少诸如内存,semapho 等的系统资源。 +不让数据库服务器过载是个全局的考虑。因为可能有多个应用服务器的多个连接池会同时发出请求。按照 PostgreSQL V11 文档[18.4.3. Resource Limits](https://www.postgresql.org/docs/11/kernel-resources.html),每个连接都需要一个单独进程来处理。每个进程即使空闲,都会消耗不少诸如内存,semapho 等的系统资源。[Number Of Database Connections](https://wiki.postgresql.org/wiki/Number_Of_Database_Connections#How_to_Find_the_Optimal_Database_Connection_Pool_Size) 这篇文章讨论了 PostgreSQL V9.2 的连接数目。给出的建议公式是 `((core_count * 2) + effective_spindle_count)`,也就是 CPU 核数的二倍加上硬盘轴数。MySQL 采用了不同的服务架构,[MySQL Too many connections](https://dev.mysql.com/doc/refman/5.5/en/too-many-connections.html)给出的缺省连接数目为 151。 + +这个[OLTP performance -- Concurrent Mid-tier connections](https://youtu.be/xNDnVOCdvQ0)录像用一个应用服务线程池进行了模拟。应用服务线程池有 9600 个不断访问数据库的线程,当连接池尺寸为 2048 和 1024 时,数据库处于过载状态,有很多数据库的的等待事件,数据库 CPU 利用率高达 95%。当连接池减少到 96,数据库服务器没有等待事件,CPU 利用率 20%,数据库访问请求等待时间从 33ms 降低到 1ms,数据库 SQL 执行时间从 77ms 降低到 2ms。数据库访问整体响应时间从 100ms 降低到 3ms。这时一个应用服务线程池对一个数据库服务线程池的情况,总共 96 个连接池的数据库处理性能远远超过 1000 个连接池的性能。数据库服务器需要为每个连接分配资源。 -不浪费系统资源是指配置过大的连接池会浪费系统资源,包括内存,网络端口,同步信号等。同时线程池的重启和操作都会响应变慢。不过应用端线程池的开销不是很大,资源的浪费通常不是太大问题。 +不浪费系统资源是指配置过大的连接池会浪费应用服务器的系统资源,包括内存,网络端口,同步信号等。同时线程池的重启和操作都会响应变慢。不过应用端连接池的开销不是很大,资源的浪费通常不是太大问题。 ### 配置方法 概念清楚,目标明确之后,配置方法就比较容易了。思路有二种:按二端线程(进程)池尺寸估算或按照吞吐量估算。综合考虑二种方法的结果会是比较合理的。 -方法 1: 找出二端的最大值,其中小的那个值就是连接池上限。应用服务线程池尺寸,比如 Tomcat 最大线程池尺寸缺省值为 200。如果每个线程只用一个数据库连接,那么连接池最大数目应该小于等于 200。如果有些请求用到多于一个连接,则适当增加。如果数据库线程(进程)池的最大尺寸为 151, 参考[MySQL Too many connections](https://dev.mysql.com/doc/refman/5.5/en/too-many-connections.html), 此时连接池最大尺寸应该小于等于 151。取二个值(200, 151)中的那个小的,那么连接池最大尺寸应该小于等于 151。如果还有其他连接池,则还要全局考虑。这个值是连接池的上线。 +方法 1: 找出二端的最大值,其中小的那个值就是连接池上限。应用服务线程池尺寸,比如 Tomcat 最大线程池尺寸缺省值为 200。如果每个线程只用一个数据库连接,那么连接池最大数目应该小于等于 200。如果有些请求用到多于一个连接,则适当增加。如果数据库线程(进程)池的最大尺寸为 151, 取二个值(200, 151)中的那个小的,那么连接池最大尺寸应该小于等于 151。如果还有其他连接池,则还要全局考虑。这个值是连接池的上线。 -方法 2: 考虑应用服务的负载性质。如果是数量变化很大的 Web 应用服务线程池,那么连接池也可以配置成动态的,配置相应的最小值和最大值。对于想邮件服务这种固定负载的业务应用,可以配置固定尺寸的进程池。 +方法 2: 考虑应用服务的负载性质。如果是数量变化很大的 Web 应用服务线程池,那么连接池也可以配置成动态的,配置相应的最小值和最大值。对于像邮件服务这种固定负载的业务应用,可以配置固定尺寸的进程池。 仅仅考虑二端线程(进程)池的尺寸会配置过大的连接池,因为这是系统的上限。另一种思路是按照数据库访问的复杂度和响应时间进行估算。这里用到[Little's Law](https://en.wikipedia.org/wiki/Little%27s_law):`并发量 = 每秒请求数量 * 数据库请求响应时间`。 如果每秒有 100 个客户请求,每个请求需要 20ms,那么并行量是 `100 * 0.02 = 2`,2 个并发数据库连接就可以了。同理,如果每个请求需要 100ms,那么就需要 10 个并发连接。 +仅仅配置最小和最大连接数目仅仅是开始,根据具体实现不同,还需要配置连接生命周期,响应的超时,以及健康监控等其他参数。具体需要参考连接池的正式文档。 + ### 一个无关的计算公式 因为是混淆的根源,还是有必要介绍另外一个经常提到但是无关的线程数目计算公式。这个公式来自著名的[Java Concurrency in Practice](http://jcip.net/)。在原著 8.2 节, 第 171 页作者给出了同样著名的线程数目计算公式:`线程数目 = CPU核数 * CPU 利用率 * (1 + 等待时间 / CPU计算时间)`。这个公式考虑了计算密集(计算时间)和 I/O 密集(等待时间)的不同处理模式。可是这个公式可以用于应用服务线程池的尺寸估算,与数据库连接池的估算无关。因为进程池并不能控制线程数目,它控制的是可并发的数据库访问线程数目。这些线程用数据库连接完成网络服务和远程数据库的异步操作,基本没有 CPU 计算时间。套用公式会得出非常大的数字,基本没有实际意义。 -## Spring 应用的连接池配置 +## Spring +MySQL 的应用的连接池配置 如上所述,配置 Spring 连接池首先要考虑到其使用的 HTTP 服务的线程池配置和后端数据库服务器的连接数配置。其次是应用的特点。 Spring 缺省使用[HikariCP](https://github.com/brettwooldridge/HikariCP)。HikariCP 有相关的[MySQL 配置](https://github.com/brettwooldridge/HikariCP/wiki/MySQL-Configuration) 和[Hiberate 配置](https://github.com/brettwooldridge/HikariCP/wiki/Hibernate4)的建议。 -## 一些参考缺省配置 +## 其他 + +### 连接池实现细节 + +具体的连接池实现需要考虑很多应用细节。 + +- 多余的连接不会立即关闭,而是会等待一段空闲时间(idle time)再关闭。 +- 连接有最长生命时间限制,即使连接池不管,数据库也会自动关闭超过生命时间的连接。在 MySql 里面,连接最长生命时间是 8 个小时。连接池需要定期监控清理无效的连接。 +- 当连接数小于最大数目而需要为新线程创建连接时,新线程应该等待池里第一个可用的连接而不必等待因它而创建的线程。HikariCP 的文档[Welcome to the Jungle](https://github.com/brettwooldridge/HikariCP/blob/dev/documents/Welcome-To-The-Jungle.md) 比较了这种实现的优点:可以避免创建很多不必要的连接并且有更好的性能。 +- 数据库各种异常的处理。[Bad Behavior: Handling Database Down](https://github.com/brettwooldridge/HikariCP/wiki/Bad-Behavior:-Handling-Database-Down) 给出里不同连接池构件实现对于线程阻塞 timeout 的不同处理方式。很多连接池构件不能正确处理。 +- 线程池的健康监控。[HikariCP Dropwizard HealthChecks](https://github.com/brettwooldridge/HikariCP/wiki/Dropwizard-HealthChecks)是一个例子。 +- 线程池的性能监视。[HikariCP Dropwizard Metrics](https://github.com/brettwooldridge/HikariCP/wiki/Dropwizard-Metrics) 给出了监视的性能指标。 +- 线程阻塞的机制以及相关数据结构对连接池的性能有很大影响。[Down the Rabbit Hole](https://github.com/brettwooldridge/HikariCP/wiki/Down-the-Rabbit-Hole)给出了 Java 里的优化方法。坏处是里面有些优化过于琐碎,使得代码晦涩难懂而且需要额外维护工作。 + +### 一些参考缺省配置 [HikariCP](https://github.com/brettwooldridge/HikariCP): DEFAULT_POOL_SIZE = 10 [DBCP](https://wiki.apache.org/commons/DBCP): Max pool size : 8 [c3p0](https://github.com/swaldman/c3p0): MIN_POOL_SIZE = 3, MAX_POOL_SIZE = 15 + +### 题外话 + +没有想到这么简单的一个数据库连接池配置问题竟然多数人都不清楚甚至搞错。把连接池和线程池搞混的的人甚至包括实施 HikariCP 的程序员。在初始化连接池的时候使用了线程池,按照线程计算公式,创建线程池的任务主要是网络和远程数据库服务请求的延迟,几乎不耗费 CPU 资源,此时线程池可以很大。可是 HikariCP 的程序员还是仅仅用了`Runtime.getRuntime().availableProcessors()`数目的线程用于创建连接池。正确的数目应该是配置的最小连接池数目。参考这个 Issue:[Change the thread pool size to minimumIdle on blocked initialization](https://github.com/brettwooldridge/HikariCP/issues/1375)。 情理之中,他的糟糕的代码风格比较糟糕。 From 5b1aa19e28d0366dc9ddad67fb7440be80f11556 Mon Sep 17 00:00:00 2001 From: "ying.liu" Date: Fri, 10 May 2019 20:13:13 -0700 Subject: [PATCH 004/231] 2nd draft of connection pool --- backend/database/database-connection-pool.md | 26 +++++++++++++++++--- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/backend/database/database-connection-pool.md b/backend/database/database-connection-pool.md index 0e99c36..4a016c9 100644 --- a/backend/database/database-connection-pool.md +++ b/backend/database/database-connection-pool.md @@ -63,17 +63,35 @@ 如果每秒有 100 个客户请求,每个请求需要 20ms,那么并行量是 `100 * 0.02 = 2`,2 个并发数据库连接就可以了。同理,如果每个请求需要 100ms,那么就需要 10 个并发连接。 -仅仅配置最小和最大连接数目仅仅是开始,根据具体实现不同,还需要配置连接生命周期,响应的超时,以及健康监控等其他参数。具体需要参考连接池的正式文档。 +仅仅配置最小和最大连接数目仅仅是开始,根据具体实现不同,还需要配置连接生命周期,连接超时,未释放连接以及健康监控等其他参数。具体需要参考连接池的正式文档。 ### 一个无关的计算公式 因为是混淆的根源,还是有必要介绍另外一个经常提到但是无关的线程数目计算公式。这个公式来自著名的[Java Concurrency in Practice](http://jcip.net/)。在原著 8.2 节, 第 171 页作者给出了同样著名的线程数目计算公式:`线程数目 = CPU核数 * CPU 利用率 * (1 + 等待时间 / CPU计算时间)`。这个公式考虑了计算密集(计算时间)和 I/O 密集(等待时间)的不同处理模式。可是这个公式可以用于应用服务线程池的尺寸估算,与数据库连接池的估算无关。因为进程池并不能控制线程数目,它控制的是可并发的数据库访问线程数目。这些线程用数据库连接完成网络服务和远程数据库的异步操作,基本没有 CPU 计算时间。套用公式会得出非常大的数字,基本没有实际意义。 -## Spring +MySQL 的应用的连接池配置 +## Spring + MySQL 的应用的连接池配置 如上所述,配置 Spring 连接池首先要考虑到其使用的 HTTP 服务的线程池配置和后端数据库服务器的连接数配置。其次是应用的特点。 -Spring 缺省使用[HikariCP](https://github.com/brettwooldridge/HikariCP)。HikariCP 有相关的[MySQL 配置](https://github.com/brettwooldridge/HikariCP/wiki/MySQL-Configuration) 和[Hiberate 配置](https://github.com/brettwooldridge/HikariCP/wiki/Hibernate4)的建议。 +Spring 的 `server.tomcat.max-threads` 参数给出了最大的并行线程数目,缺省值是 200. 由于才有特殊处理,这些线程可以处理的更大的 HTTP 连接数目 `server.tomcat.max-connections`,缺省值是 10000. `spring.task.execution.pool.max-threads`则控制使用`@Async`的最大线程数目, 缺省值没有限制。最好按应用特点配置一个范围。 + +MySQL 数据库用`max_connections`环境变量设置最大连接数,缺省值是 151. 多数建议都是根据内存大小或应用负载来设置这个值。 + +Spring 缺省使用[HikariCP](https://github.com/brettwooldridge/HikariCP)。 + +需要配置的参数如下。 + +- maximumPoolSize: 最大的连接数目。超过这个数目,新的数据库访问线程会被阻塞。缺省值是 10。 +- minimumIdle: 最小的连接数目。缺省值是最大连接数目。 +- maxLifetime:最大的连接生命时间。缺省值是 30 分钟。官方文档建议设置这个值为稍小于数据库的最大连接生命时间。MySQL 的缺省值为 8 小时。可以设置为 7 小时 59 分钟以避免每半个小时重建一次连接。 +- leakDetectionThreshold: 未返回连接报警时间。缺省值是 0,不启用。这个值如果大于 0,如果一个连接被使用的时间超过这个值则会日志报警(warn 级别的 log 信息)。考虑到网络负载情况,可以设置为最大数据库请求时长的 3 倍或 5 倍。 + +HikariCP 建议的[MySQL 配置](https://github.com/brettwooldridge/HikariCP/wiki/MySQL-Configuration): + +- prepStmtCacheSize: 250-500 + prepStmtCacheSqlLimit: 2048 + cachePrepStmts: true + useServerPrepStmts: true ## 其他 @@ -99,4 +117,4 @@ Spring 缺省使用[HikariCP](https://github.com/brettwooldridge/HikariCP)。Hik ### 题外话 -没有想到这么简单的一个数据库连接池配置问题竟然多数人都不清楚甚至搞错。把连接池和线程池搞混的的人甚至包括实施 HikariCP 的程序员。在初始化连接池的时候使用了线程池,按照线程计算公式,创建线程池的任务主要是网络和远程数据库服务请求的延迟,几乎不耗费 CPU 资源,此时线程池可以很大。可是 HikariCP 的程序员还是仅仅用了`Runtime.getRuntime().availableProcessors()`数目的线程用于创建连接池。正确的数目应该是配置的最小连接池数目。参考这个 Issue:[Change the thread pool size to minimumIdle on blocked initialization](https://github.com/brettwooldridge/HikariCP/issues/1375)。 情理之中,他的糟糕的代码风格比较糟糕。 +网上搜了很多,没有想到这么简单的一个数据库连接池配置问题竟然很多人都不清楚。把连接池和线程池搞混的的人很多。甚至实施 HikariCP 的程序员在初始化连接池的时候使用了错误的线程池数目。创建线程池的任务主要是网络和远程数据库服务请求的延迟,几乎不耗费 CPU 资源。按照线程计算公式,此时线程池可以很大。可是 HikariCP 的程序员还是仅仅用了`Runtime.getRuntime().availableProcessors()`数目的线程用于创建连接池。正确的数目应该是配置的最小连接池数目。参考这个 Issue:[Change the thread pool size to minimumIdle on blocked initialization](https://github.com/brettwooldridge/HikariCP/issues/1375)。 这个不难想象,因为他的代码风格比较糟糕。 From b466e700988170e5c41efe746edd62e1276f3eb0 Mon Sep 17 00:00:00 2001 From: "ying.liu" Date: Fri, 10 May 2019 20:22:07 -0700 Subject: [PATCH 005/231] fix format of connection pool --- backend/database/database-connection-pool.md | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/backend/database/database-connection-pool.md b/backend/database/database-connection-pool.md index 4a016c9..9b49f81 100644 --- a/backend/database/database-connection-pool.md +++ b/backend/database/database-connection-pool.md @@ -1,6 +1,6 @@ # 数据库连接池 -几乎所有的商业应用都有大量数据库访问,通常这些应用会采用数据库连接池。理解为什么需要连接池,连接池的实现原理和参数设置对于写出正确、高效的程序很有帮助。这些概念可以用于系统运行参数的配置,同时对于理解并发和分布式处理也很有帮助。 +几乎所有的商业应用都有大量数据库访问,通常这些应用会采用数据库连接池。理解为什么需要连接池,连接池的实现原理,系统架构和性能目标对于写出正确、高效的程序很有帮助。这些概念可用于系统运行参数的配置,同时对于理解并发和分布式处理也很有帮助。 ## 为什么需要连接池 @@ -23,17 +23,13 @@ 连接池的使用者是业务应用程序。通常有二种:一种是基于用户/服务请求的 HTTP 服务线程,通常采用线程池。特点是线程数目动态变化很大,数据库的访问模式比较多样,处理时间也有长有短,可能有很大差别。另一种是后台服务,其线程数目比较固定,数据库访问模式和处理时间也比较稳定。 -连接池只是给业务应用提供已建立的连接,所有的访问请求都通过连接转发到后台数据库服务器。数据库服务器通常也采用线程(PostfreSQL 用进程)池处理所有的访问请求。 +连接池只是给业务应用提供已建立的连接,所有的访问请求都通过连接转发到后台数据库服务器。数据库服务器通常也采用线程(PostgreSQL 每个连对应一个进程)池处理所有的访问请求。 -具体来说,连接池是两个线程池的中间通道。连接池和应用服务线线程池在同一个进程里面。可以看成下面的结构: +具体来说,连接池是两个线程池的中间通道。可以看成下面的结构: -应用服务进程的线程池 == 数据库连接池 ---------------- -| -应用服务进程的线程池 == 数据库连接池 ------ 数据库服务器线程(或进程)池 -| -应用服务进程的线程池 == 数据库连接池 ---------------- +一个或多个进程(应用服务进程的线程池 <-> 数据库连接池) <===============> 一个数据库服务器线程(或进程)池 -上图中,每个访问数据库的应用服务进程都有自己的线程池和对应的数据库连接池。数据库服务器可能需要处理来自一个或多个服务器的多个应用服务进程内的数据库连接池数据访问请求。 +上图中,连接池和应用服务线线程池在同一个进程里面。每个访问数据库的应用服务进程都有自己的线程池和对应的数据库连接池。数据库服务器可能需要处理来自一个或多个服务器的多个应用服务进程内的数据库连接池数据访问请求。 ## 配置数据库连接池 @@ -65,9 +61,9 @@ 仅仅配置最小和最大连接数目仅仅是开始,根据具体实现不同,还需要配置连接生命周期,连接超时,未释放连接以及健康监控等其他参数。具体需要参考连接池的正式文档。 -### 一个无关的计算公式 +### 一个其实无关的相关计算公式 -因为是混淆的根源,还是有必要介绍另外一个经常提到但是无关的线程数目计算公式。这个公式来自著名的[Java Concurrency in Practice](http://jcip.net/)。在原著 8.2 节, 第 171 页作者给出了同样著名的线程数目计算公式:`线程数目 = CPU核数 * CPU 利用率 * (1 + 等待时间 / CPU计算时间)`。这个公式考虑了计算密集(计算时间)和 I/O 密集(等待时间)的不同处理模式。可是这个公式可以用于应用服务线程池的尺寸估算,与数据库连接池的估算无关。因为进程池并不能控制线程数目,它控制的是可并发的数据库访问线程数目。这些线程用数据库连接完成网络服务和远程数据库的异步操作,基本没有 CPU 计算时间。套用公式会得出非常大的数字,基本没有实际意义。 +因为是经常混淆连接池和线程池,这里有必要介绍另外一个经常提到但是无关的线程数目计算公式。这个公式来自著名的[Java Concurrency in Practice](http://jcip.net/)。在原著 8.2 节, 第 171 页作者给出了同样著名的线程数目计算公式:`线程数目 = CPU核数 * CPU 利用率 * (1 + 等待时间 / CPU计算时间)`。这个公式考虑了计算密集(计算时间)和 I/O 密集(等待时间)的不同处理模式。可是这个公式可以用于应用服务线程池的尺寸估算,与数据库连接池的估算无关。因为进程池并不能控制线程数目,它控制的是可并发的数据库访问线程数目。这些线程用数据库连接完成网络服务和远程数据库的异步操作,基本没有 CPU 计算时间。套用公式会得出非常大的数字,基本没有实际意义。 ## Spring + MySQL 的应用的连接池配置 From cd2d59b0e937d1d4a374b28c8c8af4e9a19b3b2e Mon Sep 17 00:00:00 2001 From: "ying.liu" Date: Fri, 10 May 2019 20:57:44 -0700 Subject: [PATCH 006/231] add sample in connection pool --- backend/database/database-connection-pool.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/backend/database/database-connection-pool.md b/backend/database/database-connection-pool.md index 9b49f81..fbbf94e 100644 --- a/backend/database/database-connection-pool.md +++ b/backend/database/database-connection-pool.md @@ -111,6 +111,8 @@ HikariCP 建议的[MySQL 配置](https://github.com/brettwooldridge/HikariCP/wik [c3p0](https://github.com/swaldman/c3p0): MIN_POOL_SIZE = 3, MAX_POOL_SIZE = 15 +[JIRA Tuning database connections](https://confluence.atlassian.com/adminjiraserver070/tuning-database-connections-749382655.html):pool-max-size = 20. 和前三个不同,这是一个应用程序。里面讨论了数据库的连接数目。里面提到一方面数据库可以支持数百连接,另一方面应用服务端连接比较耗费资源,建议在允许的情况下尽可能设成小的数字。 + ### 题外话 网上搜了很多,没有想到这么简单的一个数据库连接池配置问题竟然很多人都不清楚。把连接池和线程池搞混的的人很多。甚至实施 HikariCP 的程序员在初始化连接池的时候使用了错误的线程池数目。创建线程池的任务主要是网络和远程数据库服务请求的延迟,几乎不耗费 CPU 资源。按照线程计算公式,此时线程池可以很大。可是 HikariCP 的程序员还是仅仅用了`Runtime.getRuntime().availableProcessors()`数目的线程用于创建连接池。正确的数目应该是配置的最小连接池数目。参考这个 Issue:[Change the thread pool size to minimumIdle on blocked initialization](https://github.com/brettwooldridge/HikariCP/issues/1375)。 这个不难想象,因为他的代码风格比较糟糕。 From f60abd885d90cbd495845de1a4510dc7d1266af5 Mon Sep 17 00:00:00 2001 From: "ying.liu" Date: Sat, 11 May 2019 09:03:37 -0700 Subject: [PATCH 007/231] revise connection pool --- backend/database/database-connection-pool.md | 34 ++++++++++++-------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/backend/database/database-connection-pool.md b/backend/database/database-connection-pool.md index fbbf94e..eaeab3b 100644 --- a/backend/database/database-connection-pool.md +++ b/backend/database/database-connection-pool.md @@ -15,7 +15,15 @@ 如同多数分布式基础构件,连接池的原理比较简单,但是牵涉到数据库,操作系统,编程语言,运维以及应用场景的不同特点,具体实现比较复杂。从数据库诞生就有的广泛需求,半个世纪后还有不断改进提高的余地。 -原理上,在应用开始时创建一组数据库的连接。也可以动态创建但是复用已有的连接。这些连接被存储到一个共享的资源数据结构,称为连接池。这是典型的生产者-消费者并发模型。每个线程在需要访问数据库时借用(borrow)一个连接,使用完成则释放(release)连接回到连接池供其他线程使用。比较好的线程池构件会有二个参数动态控制线程池的大小:最小数量和最大数量。最小数量指即使负载很轻,也保持一个最小数目的数据库连接以备不时之需。当同时访问数据库的线程数超过最小数量时,则动态创建更多连接。最大数量则是允许的最大数据库连接数量,当最大数目的连接都在使用而有新的线程需要访问数据库时,则新的线程会被阻塞直到有连接被释放回连接池。当负载变低,池里的连接数目超过最小数目而只有低于或等于最小数目的连接被使用时,超过最小数目的连接会被关闭和删除以便节省系统资源。当然具体的实现需要考虑很多细节,但是不太影响应用接口,放在文章结尾再讨论。 +原理上,在应用开始时创建一组数据库的连接。也可以动态创建但是复用已有的连接。这些连接被存储到一个共享的资源数据结构,称为连接池。这是典型的生产者-消费者并发模型。每个线程在需要访问数据库时借用(borrow)一个连接,使用完成则释放(release)连接回到连接池供其他线程使用。比较好的线程池构件会有二个参数动态控制线程池的大小:最小数量和最大数量。最小数量指即使负载很轻,也保持一个最小数目的数据库连接以备不时之需。当同时访问数据库的线程数超过最小数量时,则动态创建更多连接。最大数量则是允许的最大数据库连接数量,当最大数目的连接都在使用而有新的线程需要访问数据库时,则新的线程会被阻塞直到有连接被释放回连接池。当负载变低,池里的连接数目超过最小数目而只有低于或等于最小数目的连接被使用时,超过最小数目的连接会被关闭和删除以便节省系统资源。 + +连接池的实际应用中,最担心的问题就是用了不还。编码逻辑错误或者释放连接放代码没有放到 `finally` 部分都会导致连接池资源枯竭从而造成系统变慢甚至完全阻塞的情况。这种情况类似于内存泄露,因而也叫连接泄露,是常常发生而且难以发现的问题。因而,检测连接泄露并报警是线程池实现的基本需要。 + +连接在被使用时运行在借用它的线程里面,并不是运行在新的线程里面。但是因为每个连接在使用中要实现超时 timeout 机制,官方的 [Java.sql.Connection.setNetworkTimeout API](https://docs.oracle.com/javase/8/docs/api/java/sql/Connection.html)的接口定义是 `setNetworkTimeoutExecutor executor, int milliseconds)`。此出需要指定一个线程池来处理超时的错误报告。也就是每一个连接运行数据库访问时,都会有一个后台线程监控响应超时状态。很多连接池实现会使用 Cached Thread Pool 或 Fixed Thread Pool。Chached Thread Pool 没有线程数目限制,动态创建和回收,适合很多动态的短小请求应用。Fixed Thread Pool 则适合比较固定的连接请求。 + +另外,网络故障和具体数据库实现的限制会使得连接池的连接失效。比如,MySQL 允许一个连接,无论状态正常与否,都不能超过 8 个小时的生命。因此,虽然连接在被使用时运行在调用的线程里面,但是连接池的管理通常需要一个或多个后台线程来管理、维护、和检测连接池的连接状态,保证有指定数目的连接可用。 + +可以看的,虽然数据库连接在执行数据库访问使用调用者的线程,但是连接池的实现通常需要二个或更多的线程池做管理和超时处理。当然连接池的具体实现还要考虑很多细节,但是不直接影响应用接口,放在文章结尾再讨论。 ## 数据库连接池的系统架构 @@ -61,9 +69,9 @@ 仅仅配置最小和最大连接数目仅仅是开始,根据具体实现不同,还需要配置连接生命周期,连接超时,未释放连接以及健康监控等其他参数。具体需要参考连接池的正式文档。 -### 一个其实无关的相关计算公式 +### 一个表面相关,其实无关的计算公式 -因为是经常混淆连接池和线程池,这里有必要介绍另外一个经常提到但是无关的线程数目计算公式。这个公式来自著名的[Java Concurrency in Practice](http://jcip.net/)。在原著 8.2 节, 第 171 页作者给出了同样著名的线程数目计算公式:`线程数目 = CPU核数 * CPU 利用率 * (1 + 等待时间 / CPU计算时间)`。这个公式考虑了计算密集(计算时间)和 I/O 密集(等待时间)的不同处理模式。可是这个公式可以用于应用服务线程池的尺寸估算,与数据库连接池的估算无关。因为进程池并不能控制线程数目,它控制的是可并发的数据库访问线程数目。这些线程用数据库连接完成网络服务和远程数据库的异步操作,基本没有 CPU 计算时间。套用公式会得出非常大的数字,基本没有实际意义。 +因为连接池和线程池经常被混淆,这里有必要介绍另外一个经常提到但是无关的线程数目计算公式。这个公式来自每个 Java 程序员都应该阅读的[Java Concurrency in Practice](http://jcip.net/)。在原著 8.2 节, 第 171 页作者给出了著名的线程数目计算公式:`线程数目 = CPU核数 * CPU 利用率 * (1 + 等待时间 / CPU计算时间)`。这个公式考虑了计算密集(计算时间)和 I/O 密集(等待时间)的不同处理模式。可是这个公式可以用于应用服务线程池或任何线程池的尺寸估算,但是与数据库连接池的大小估算无关。因为进程池并不能控制应用服务的线程数目,它控制的是可并发的数据库访问线程数目。这些线程使用数据库连接完成网络服务和远程数据库的异步操作,此时基本没有使用本机的 CPU 计算时间。套用公式会得出非常大的数字,没有太大实际意义。 ## Spring + MySQL 的应用的连接池配置 @@ -75,31 +83,31 @@ MySQL 数据库用`max_connections`环境变量设置最大连接数,缺省值 Spring 缺省使用[HikariCP](https://github.com/brettwooldridge/HikariCP)。 -需要配置的参数如下。 +需要配置的基本参数如下。 - maximumPoolSize: 最大的连接数目。超过这个数目,新的数据库访问线程会被阻塞。缺省值是 10。 - minimumIdle: 最小的连接数目。缺省值是最大连接数目。 +- leakDetectionThreshold: 未返回连接报警时间。缺省值是 0,不启用。这个值如果大于 0,如果一个连接被使用的时间超过这个值则会日志报警(warn 级别的 log 信息)。考虑到网络负载情况,可以设置为最大数据库请求时长的 3 倍或 5 倍。如果没有这个报警,程序的正确性很难保证。 - maxLifetime:最大的连接生命时间。缺省值是 30 分钟。官方文档建议设置这个值为稍小于数据库的最大连接生命时间。MySQL 的缺省值为 8 小时。可以设置为 7 小时 59 分钟以避免每半个小时重建一次连接。 -- leakDetectionThreshold: 未返回连接报警时间。缺省值是 0,不启用。这个值如果大于 0,如果一个连接被使用的时间超过这个值则会日志报警(warn 级别的 log 信息)。考虑到网络负载情况,可以设置为最大数据库请求时长的 3 倍或 5 倍。 -HikariCP 建议的[MySQL 配置](https://github.com/brettwooldridge/HikariCP/wiki/MySQL-Configuration): +HikariCP 建议的[MySQL 配置](https://github.com/brettwooldridge/HikariCP/wiki/MySQL-Configuration)参数和建议值如下,这些配置有助于提高数据库访问的性能: - prepStmtCacheSize: 250-500 - prepStmtCacheSqlLimit: 2048 - cachePrepStmts: true - useServerPrepStmts: true +- prepStmtCacheSqlLimit: 2048 +- cachePrepStmts: true +- useServerPrepStmts: true ## 其他 -### 连接池实现细节 +### 连接池其他实现细节 具体的连接池实现需要考虑很多应用细节。 - 多余的连接不会立即关闭,而是会等待一段空闲时间(idle time)再关闭。 - 连接有最长生命时间限制,即使连接池不管,数据库也会自动关闭超过生命时间的连接。在 MySql 里面,连接最长生命时间是 8 个小时。连接池需要定期监控清理无效的连接。 -- 当连接数小于最大数目而需要为新线程创建连接时,新线程应该等待池里第一个可用的连接而不必等待因它而创建的线程。HikariCP 的文档[Welcome to the Jungle](https://github.com/brettwooldridge/HikariCP/blob/dev/documents/Welcome-To-The-Jungle.md) 比较了这种实现的优点:可以避免创建很多不必要的连接并且有更好的性能。 +- 连接池需要定期检查数据库的可用状态甚至响应时间,及时报告健康状态。[HikariCP Dropwizard HealthChecks](https://github.com/brettwooldridge/HikariCP/wiki/Dropwizard-HealthChecks)是一个例子。 +- 当需要为新线程访问创建连接时,新线程应该等待池里第一个可用的连接而不必等待因它而创建的线程。HikariCP 的文档[Welcome to the Jungle](https://github.com/brettwooldridge/HikariCP/blob/dev/documents/Welcome-To-The-Jungle.md) 描述了这种实现的优点:可以避免创建很多不必要的连接并且有更好的性能。Hikari 用 5 个连接处理了 50 个突发的数据库短时访问请求,即提高了响应速度,也避免了创建额外的连接。 - 数据库各种异常的处理。[Bad Behavior: Handling Database Down](https://github.com/brettwooldridge/HikariCP/wiki/Bad-Behavior:-Handling-Database-Down) 给出里不同连接池构件实现对于线程阻塞 timeout 的不同处理方式。很多连接池构件不能正确处理。 -- 线程池的健康监控。[HikariCP Dropwizard HealthChecks](https://github.com/brettwooldridge/HikariCP/wiki/Dropwizard-HealthChecks)是一个例子。 - 线程池的性能监视。[HikariCP Dropwizard Metrics](https://github.com/brettwooldridge/HikariCP/wiki/Dropwizard-Metrics) 给出了监视的性能指标。 - 线程阻塞的机制以及相关数据结构对连接池的性能有很大影响。[Down the Rabbit Hole](https://github.com/brettwooldridge/HikariCP/wiki/Down-the-Rabbit-Hole)给出了 Java 里的优化方法。坏处是里面有些优化过于琐碎,使得代码晦涩难懂而且需要额外维护工作。 @@ -115,4 +123,4 @@ HikariCP 建议的[MySQL 配置](https://github.com/brettwooldridge/HikariCP/wik ### 题外话 -网上搜了很多,没有想到这么简单的一个数据库连接池配置问题竟然很多人都不清楚。把连接池和线程池搞混的的人很多。甚至实施 HikariCP 的程序员在初始化连接池的时候使用了错误的线程池数目。创建线程池的任务主要是网络和远程数据库服务请求的延迟,几乎不耗费 CPU 资源。按照线程计算公式,此时线程池可以很大。可是 HikariCP 的程序员还是仅仅用了`Runtime.getRuntime().availableProcessors()`数目的线程用于创建连接池。正确的数目应该是配置的最小连接池数目。参考这个 Issue:[Change the thread pool size to minimumIdle on blocked initialization](https://github.com/brettwooldridge/HikariCP/issues/1375)。 这个不难想象,因为他的代码风格比较糟糕。 +网上搜了很多,没有想到这么简单的一个数据库连接池配置问题竟然没有比较全面、明确的文档。把连接池和线程池搞混的的人很多。甚至实施 HikariCP 的程序员在初始化连接池的时候使用了错误的线程池数目。创建线程池的任务主要是网络和远程数据库服务请求的延迟,几乎不耗费 CPU 资源。按照线程计算公式,此时线程池可以很大。可是 HikariCP 的程序员还是仅仅用了`Runtime.getRuntime().availableProcessors()`数目的线程用于创建连接池。正确的数目应该是配置的最小连接池数目,这样既不浪费,也有最好的性能。参考这个 Issue:[Change the thread pool size to minimumIdle on blocked initialization](https://github.com/brettwooldridge/HikariCP/issues/1375)。 这个不难想象,因为 HikariCP 的代码风格比较糟糕。 From e1664f1c10d10b68ae2f3cd0df5eb2e12c27d233 Mon Sep 17 00:00:00 2001 From: "ying.liu" Date: Sat, 11 May 2019 09:13:41 -0700 Subject: [PATCH 008/231] add more connection pool benefits --- backend/database/database-connection-pool.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/backend/database/database-connection-pool.md b/backend/database/database-connection-pool.md index eaeab3b..2ec5b22 100644 --- a/backend/database/database-connection-pool.md +++ b/backend/database/database-connection-pool.md @@ -10,6 +10,8 @@ - 节省了创建数据库连接的时间,通常这个时间大大超过处理数据访问请求的时间。 - 统一管理数据库请求连接,避免了过多连接或频繁创建/删除连接带来的性能问题。 +- 监控了数据库连接的运行状态和错误报告,减少了应用服务的这部分代码。 +- 可以检查和报告不关闭数据库连接的错误,帮助运维监测数据库访问阻塞和帮助程序员写出正确数据库访问代码。 ## 实现原理 From c3c7f49f20ddb9da80110eea041ba8ab7da01240 Mon Sep 17 00:00:00 2001 From: "ying.liu" Date: Sat, 11 May 2019 09:55:02 -0700 Subject: [PATCH 009/231] revise connection pool --- backend/database/database-connection-pool.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/database/database-connection-pool.md b/backend/database/database-connection-pool.md index 2ec5b22..56c233e 100644 --- a/backend/database/database-connection-pool.md +++ b/backend/database/database-connection-pool.md @@ -19,7 +19,7 @@ 原理上,在应用开始时创建一组数据库的连接。也可以动态创建但是复用已有的连接。这些连接被存储到一个共享的资源数据结构,称为连接池。这是典型的生产者-消费者并发模型。每个线程在需要访问数据库时借用(borrow)一个连接,使用完成则释放(release)连接回到连接池供其他线程使用。比较好的线程池构件会有二个参数动态控制线程池的大小:最小数量和最大数量。最小数量指即使负载很轻,也保持一个最小数目的数据库连接以备不时之需。当同时访问数据库的线程数超过最小数量时,则动态创建更多连接。最大数量则是允许的最大数据库连接数量,当最大数目的连接都在使用而有新的线程需要访问数据库时,则新的线程会被阻塞直到有连接被释放回连接池。当负载变低,池里的连接数目超过最小数目而只有低于或等于最小数目的连接被使用时,超过最小数目的连接会被关闭和删除以便节省系统资源。 -连接池的实际应用中,最担心的问题就是用了不还。编码逻辑错误或者释放连接放代码没有放到 `finally` 部分都会导致连接池资源枯竭从而造成系统变慢甚至完全阻塞的情况。这种情况类似于内存泄露,因而也叫连接泄露,是常常发生而且难以发现的问题。因而,检测连接泄露并报警是线程池实现的基本需要。 +连接池的实际应用中,最担心的问题就是借了不还的这种让其他人无资源可用的人品问题。编码逻辑错误或者释放连接放代码没有放到 `finally` 部分都会导致连接池资源枯竭从而造成系统变慢甚至完全阻塞的情况。这种情况类似于内存泄露,因而也叫连接泄露,是常常发生而且难以发现的问题。因此检测连接泄露并报警是线程池实现的基本需要。 连接在被使用时运行在借用它的线程里面,并不是运行在新的线程里面。但是因为每个连接在使用中要实现超时 timeout 机制,官方的 [Java.sql.Connection.setNetworkTimeout API](https://docs.oracle.com/javase/8/docs/api/java/sql/Connection.html)的接口定义是 `setNetworkTimeoutExecutor executor, int milliseconds)`。此出需要指定一个线程池来处理超时的错误报告。也就是每一个连接运行数据库访问时,都会有一个后台线程监控响应超时状态。很多连接池实现会使用 Cached Thread Pool 或 Fixed Thread Pool。Chached Thread Pool 没有线程数目限制,动态创建和回收,适合很多动态的短小请求应用。Fixed Thread Pool 则适合比较固定的连接请求。 @@ -51,9 +51,9 @@ 尽可能满足所有的应用服务并发数据库访问的意思很简单:所有需要访问数据库的线程都可以得到需要的数据库连接。如果一个线程用到多个连接,那么需要的连接数目也会成倍增加。这时,需要的连接池最大尺寸应该是最大的并发数据库访问线程数目乘以每个线程需要的连接数目。 -不让数据库服务器过载是个全局的考虑。因为可能有多个应用服务器的多个连接池会同时发出请求。按照 PostgreSQL V11 文档[18.4.3. Resource Limits](https://www.postgresql.org/docs/11/kernel-resources.html),每个连接都需要一个单独进程来处理。每个进程即使空闲,都会消耗不少诸如内存,semapho 等的系统资源。[Number Of Database Connections](https://wiki.postgresql.org/wiki/Number_Of_Database_Connections#How_to_Find_the_Optimal_Database_Connection_Pool_Size) 这篇文章讨论了 PostgreSQL V9.2 的连接数目。给出的建议公式是 `((core_count * 2) + effective_spindle_count)`,也就是 CPU 核数的二倍加上硬盘轴数。MySQL 采用了不同的服务架构,[MySQL Too many connections](https://dev.mysql.com/doc/refman/5.5/en/too-many-connections.html)给出的缺省连接数目为 151。 +不让数据库服务器过载是个全局的考虑。因为可能有多个应用服务器的多个连接池会同时发出请求。按照 PostgreSQL V11 文档[18.4.3. Resource Limits](https://www.postgresql.org/docs/11/kernel-resources.html),每个连接都由一个单独进程来处理。每个进程即使空闲,都会消耗不少诸如内存,信号(semaphore), 文件/网络句柄(handler),队列等各种系统资源。这篇文章[Number Of Database Connections](https://wiki.postgresql.org/wiki/Number_Of_Database_Connections#How_to_Find_the_Optimal_Database_Connection_Pool_Size) 讨论了 PostgreSQL V9.2 的连接数目。给出的建议公式是 `((core_count * 2) + effective_spindle_count)`,也就是 CPU 核数的二倍加上硬盘轴数。MySQL 采用了不同的服务架构,[MySQL Too many connections](https://dev.mysql.com/doc/refman/5.5/en/too-many-connections.html)给出的缺省连接数目为 151。这二个系统从具体实现机理、计算办法和建议数值都有很大差别,做为应用程序员应该有基本的理解。 -这个[OLTP performance -- Concurrent Mid-tier connections](https://youtu.be/xNDnVOCdvQ0)录像用一个应用服务线程池进行了模拟。应用服务线程池有 9600 个不断访问数据库的线程,当连接池尺寸为 2048 和 1024 时,数据库处于过载状态,有很多数据库的的等待事件,数据库 CPU 利用率高达 95%。当连接池减少到 96,数据库服务器没有等待事件,CPU 利用率 20%,数据库访问请求等待时间从 33ms 降低到 1ms,数据库 SQL 执行时间从 77ms 降低到 2ms。数据库访问整体响应时间从 100ms 降低到 3ms。这时一个应用服务线程池对一个数据库服务线程池的情况,总共 96 个连接池的数据库处理性能远远超过 1000 个连接池的性能。数据库服务器需要为每个连接分配资源。 +这个[OLTP performance -- Concurrent Mid-tier connections](https://youtu.be/xNDnVOCdvQ0)视频用一个应用服务线程池进行了模拟。应用服务线程池有 9600 个不断访问数据库的线程,当连接池尺寸为 2048 和 1024 时,数据库处于过载状态,有很多数据库的的等待事件,数据库 CPU 利用率高达 95%。当连接池减少到 96,数据库服务器没有等待事件,CPU 利用率 20%,数据库访问请求等待时间从 33ms 降低到 1ms,数据库 SQL 执行时间从 77ms 降低到 2ms。数据库访问整体响应时间从 100ms 降低到 3ms。这时一个应用服务线程池对一个数据库服务线程池的情况,总共 96 个连接池的数据库处理性能远远超过 1000 个连接池的性能。数据库服务器需要为每个连接分配资源。 不浪费系统资源是指配置过大的连接池会浪费应用服务器的系统资源,包括内存,网络端口,同步信号等。同时线程池的重启和操作都会响应变慢。不过应用端连接池的开销不是很大,资源的浪费通常不是太大问题。 @@ -125,4 +125,4 @@ HikariCP 建议的[MySQL 配置](https://github.com/brettwooldridge/HikariCP/wik ### 题外话 -网上搜了很多,没有想到这么简单的一个数据库连接池配置问题竟然没有比较全面、明确的文档。把连接池和线程池搞混的的人很多。甚至实施 HikariCP 的程序员在初始化连接池的时候使用了错误的线程池数目。创建线程池的任务主要是网络和远程数据库服务请求的延迟,几乎不耗费 CPU 资源。按照线程计算公式,此时线程池可以很大。可是 HikariCP 的程序员还是仅仅用了`Runtime.getRuntime().availableProcessors()`数目的线程用于创建连接池。正确的数目应该是配置的最小连接池数目,这样既不浪费,也有最好的性能。参考这个 Issue:[Change the thread pool size to minimumIdle on blocked initialization](https://github.com/brettwooldridge/HikariCP/issues/1375)。 这个不难想象,因为 HikariCP 的代码风格比较糟糕。 +网上搜了很多,没有想到这么简单的一个数据库连接池配置问题竟然没有比较全面、明确的文档。把连接池和线程池搞混的的人很多。甚至实施 HikariCP 的程序员在初始化连接池的时候使用了错误的线程池数目。创建线程池的任务主要是网络和远程数据库服务请求的延迟,几乎不耗费 CPU 资源。按照线程计算公式,此时线程池可以很大。可是 HikariCP 的程序员还是仅仅用了`Runtime.getRuntime().availableProcessors()`数目的线程用于创建连接池。正确的数目应该是配置的最小连接池数目,这样既不浪费,也有最好的性能。参考这个 Issue:[Change the thread pool size to minimumIdle on blocked initialization](https://github.com/brettwooldridge/HikariCP/issues/1375)。 这种错误并不奇怪,因为 HikariCP 的代码风格比较糟糕。很多广泛使用的开源软件其实代码质量并不高,每个人都应该搞清楚概念和问题的本质,多理解其他人的想法但是保持怀疑态度和独立思考能力。 From cb023d9b582bc3b9534062f890e3f8436346865e Mon Sep 17 00:00:00 2001 From: "ying.liu" Date: Sat, 11 May 2019 10:05:55 -0700 Subject: [PATCH 010/231] re-org connection pool --- backend/database/database-connection-pool.md | 28 +++++++++++--------- 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/backend/database/database-connection-pool.md b/backend/database/database-connection-pool.md index 56c233e..5db5726 100644 --- a/backend/database/database-connection-pool.md +++ b/backend/database/database-connection-pool.md @@ -2,7 +2,9 @@ 几乎所有的商业应用都有大量数据库访问,通常这些应用会采用数据库连接池。理解为什么需要连接池,连接池的实现原理,系统架构和性能目标对于写出正确、高效的程序很有帮助。这些概念可用于系统运行参数的配置,同时对于理解并发和分布式处理也很有帮助。 -## 为什么需要连接池 +通用一点来说,一个有经验的工程师面对任何问题都会试着回答三个问题:为什么,是什么,怎么做。了解为什么可以明白问题的真正目的,有助于开放思路和避免无用功。是什么则回答问题的本质概念,是正确答案的保障。怎么做则给出可重复的问题解决思路,使得问题总是以正确、高效的方式得到解决。本篇文章按这个思路来解决数据库连接池如何配置的问题。 + +## 1 为什么需要连接池 任何数据库的访问都需要首先建立数据库连接。这是一个复杂、缓慢的处理。牵涉到通信建立(包括 TCP 的三次握手)、认证、授权、资源的初始化和分配等一系列任务。而且数据库服务器通常和应用服务器是分开的,所有的操作都是分布式网络请求和处理。[建立数据库连接时间](https://stackoverflow.com/questions/2188611/how-long-does-it-take-to-create-a-new-database-connection-to-sql)通常在 100ms 或更长。而通常小数据的 CRUD 数据库操作是 ms 级或更短,加上网络延迟一般 10 到 50 个 ms 就可以完成多数数据库处理结果。在应用启动时预先建立一些数据库连接,应用程序使用已有的连接可以极大提高响应速度。另外,Web 服务应用当客户很多时,有很多线程,连接数目过多以及频繁创建/删除连接也会影响数据库的性能。 @@ -13,7 +15,9 @@ - 监控了数据库连接的运行状态和错误报告,减少了应用服务的这部分代码。 - 可以检查和报告不关闭数据库连接的错误,帮助运维监测数据库访问阻塞和帮助程序员写出正确数据库访问代码。 -## 实现原理 +## 2 数据库连接池是什么 + +### 2.1 实现原理 如同多数分布式基础构件,连接池的原理比较简单,但是牵涉到数据库,操作系统,编程语言,运维以及应用场景的不同特点,具体实现比较复杂。从数据库诞生就有的广泛需求,半个世纪后还有不断改进提高的余地。 @@ -27,7 +31,7 @@ 可以看的,虽然数据库连接在执行数据库访问使用调用者的线程,但是连接池的实现通常需要二个或更多的线程池做管理和超时处理。当然连接池的具体实现还要考虑很多细节,但是不直接影响应用接口,放在文章结尾再讨论。 -## 数据库连接池的系统架构 +### 2.2 数据库连接池的系统架构 连接池的本质是属于一个操作系统进程(process)的共享的资源。其核心管理功能是从池中分配一个数据库连接给需要的线程,线程用完后回收连接到池中。由于连接池有限,可以并行进行数据库访问的线程数量最多是连接池的最大尺寸。如果考虑到一个应用线程可能会用到多个数据库连接的可能性,则可以并发访问数据库的线程数目会更少。 @@ -41,9 +45,9 @@ 上图中,连接池和应用服务线线程池在同一个进程里面。每个访问数据库的应用服务进程都有自己的线程池和对应的数据库连接池。数据库服务器可能需要处理来自一个或多个服务器的多个应用服务进程内的数据库连接池数据访问请求。 -## 配置数据库连接池 +## 3 如何配置数据库连接池 -### 配置目标 +### 3.1 配置目标 当提到数据库连接池的配置,一个常见也是严重的错误是把连接池和线程池的概念混淆了。如上面系统架构所示,数据库连接池并不控制应用端和数据库端的线程池的大小。而且每个数据库连接池的配置只是针对自己所在的应用服务进程,限制的是同一个进程内可以访问数据库的并行线程数目。应用服务进程单独管理自己的线程池,除了数据库访问还有处理其他业务逻辑,并行的线程数目基本取决于服务的负载。当应用服务线程需要访问数据库时,其并发度和阻塞数目才受到连接池尺寸的影响。 @@ -57,7 +61,7 @@ 不浪费系统资源是指配置过大的连接池会浪费应用服务器的系统资源,包括内存,网络端口,同步信号等。同时线程池的重启和操作都会响应变慢。不过应用端连接池的开销不是很大,资源的浪费通常不是太大问题。 -### 配置方法 +### 3.2 配置方法 概念清楚,目标明确之后,配置方法就比较容易了。思路有二种:按二端线程(进程)池尺寸估算或按照吞吐量估算。综合考虑二种方法的结果会是比较合理的。 @@ -71,11 +75,11 @@ 仅仅配置最小和最大连接数目仅仅是开始,根据具体实现不同,还需要配置连接生命周期,连接超时,未释放连接以及健康监控等其他参数。具体需要参考连接池的正式文档。 -### 一个表面相关,其实无关的计算公式 +### 3.3 一个表面相关,其实无关的计算公式 因为连接池和线程池经常被混淆,这里有必要介绍另外一个经常提到但是无关的线程数目计算公式。这个公式来自每个 Java 程序员都应该阅读的[Java Concurrency in Practice](http://jcip.net/)。在原著 8.2 节, 第 171 页作者给出了著名的线程数目计算公式:`线程数目 = CPU核数 * CPU 利用率 * (1 + 等待时间 / CPU计算时间)`。这个公式考虑了计算密集(计算时间)和 I/O 密集(等待时间)的不同处理模式。可是这个公式可以用于应用服务线程池或任何线程池的尺寸估算,但是与数据库连接池的大小估算无关。因为进程池并不能控制应用服务的线程数目,它控制的是可并发的数据库访问线程数目。这些线程使用数据库连接完成网络服务和远程数据库的异步操作,此时基本没有使用本机的 CPU 计算时间。套用公式会得出非常大的数字,没有太大实际意义。 -## Spring + MySQL 的应用的连接池配置 +## 4 Spring + MySQL 的应用的连接池配置 如上所述,配置 Spring 连接池首先要考虑到其使用的 HTTP 服务的线程池配置和后端数据库服务器的连接数配置。其次是应用的特点。 @@ -99,9 +103,9 @@ HikariCP 建议的[MySQL 配置](https://github.com/brettwooldridge/HikariCP/wik - cachePrepStmts: true - useServerPrepStmts: true -## 其他 +## 5 其他 -### 连接池其他实现细节 +### 5.1 连接池其他实现细节 具体的连接池实现需要考虑很多应用细节。 @@ -113,7 +117,7 @@ HikariCP 建议的[MySQL 配置](https://github.com/brettwooldridge/HikariCP/wik - 线程池的性能监视。[HikariCP Dropwizard Metrics](https://github.com/brettwooldridge/HikariCP/wiki/Dropwizard-Metrics) 给出了监视的性能指标。 - 线程阻塞的机制以及相关数据结构对连接池的性能有很大影响。[Down the Rabbit Hole](https://github.com/brettwooldridge/HikariCP/wiki/Down-the-Rabbit-Hole)给出了 Java 里的优化方法。坏处是里面有些优化过于琐碎,使得代码晦涩难懂而且需要额外维护工作。 -### 一些参考缺省配置 +### 5.2 一些参考缺省配置 [HikariCP](https://github.com/brettwooldridge/HikariCP): DEFAULT_POOL_SIZE = 10 @@ -123,6 +127,6 @@ HikariCP 建议的[MySQL 配置](https://github.com/brettwooldridge/HikariCP/wik [JIRA Tuning database connections](https://confluence.atlassian.com/adminjiraserver070/tuning-database-connections-749382655.html):pool-max-size = 20. 和前三个不同,这是一个应用程序。里面讨论了数据库的连接数目。里面提到一方面数据库可以支持数百连接,另一方面应用服务端连接比较耗费资源,建议在允许的情况下尽可能设成小的数字。 -### 题外话 +### 5.3 题外话 网上搜了很多,没有想到这么简单的一个数据库连接池配置问题竟然没有比较全面、明确的文档。把连接池和线程池搞混的的人很多。甚至实施 HikariCP 的程序员在初始化连接池的时候使用了错误的线程池数目。创建线程池的任务主要是网络和远程数据库服务请求的延迟,几乎不耗费 CPU 资源。按照线程计算公式,此时线程池可以很大。可是 HikariCP 的程序员还是仅仅用了`Runtime.getRuntime().availableProcessors()`数目的线程用于创建连接池。正确的数目应该是配置的最小连接池数目,这样既不浪费,也有最好的性能。参考这个 Issue:[Change the thread pool size to minimumIdle on blocked initialization](https://github.com/brettwooldridge/HikariCP/issues/1375)。 这种错误并不奇怪,因为 HikariCP 的代码风格比较糟糕。很多广泛使用的开源软件其实代码质量并不高,每个人都应该搞清楚概念和问题的本质,多理解其他人的想法但是保持怀疑态度和独立思考能力。 From cd58ad92e8413d3d4a7ed0bc7893207003932686 Mon Sep 17 00:00:00 2001 From: "ying.liu" Date: Sat, 11 May 2019 10:13:59 -0700 Subject: [PATCH 011/231] add sections in connection pool config --- backend/database/database-connection-pool.md | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/backend/database/database-connection-pool.md b/backend/database/database-connection-pool.md index 5db5726..46989a1 100644 --- a/backend/database/database-connection-pool.md +++ b/backend/database/database-connection-pool.md @@ -73,7 +73,7 @@ 如果每秒有 100 个客户请求,每个请求需要 20ms,那么并行量是 `100 * 0.02 = 2`,2 个并发数据库连接就可以了。同理,如果每个请求需要 100ms,那么就需要 10 个并发连接。 -仅仅配置最小和最大连接数目仅仅是开始,根据具体实现不同,还需要配置连接生命周期,连接超时,未释放连接以及健康监控等其他参数。具体需要参考连接池的正式文档。 +仅仅配置最小和最大连接数目仅仅是开始,根据具体实现不同,还需要配置连接生命周期,连接超时,未释放连接以及健康监控等其他参数。具体需要参考连接池的使用文档。 ### 3.3 一个表面相关,其实无关的计算公式 @@ -83,10 +83,16 @@ 如上所述,配置 Spring 连接池首先要考虑到其使用的 HTTP 服务的线程池配置和后端数据库服务器的连接数配置。其次是应用的特点。 +### 4.1 应用服务的线程数 + Spring 的 `server.tomcat.max-threads` 参数给出了最大的并行线程数目,缺省值是 200. 由于才有特殊处理,这些线程可以处理的更大的 HTTP 连接数目 `server.tomcat.max-connections`,缺省值是 10000. `spring.task.execution.pool.max-threads`则控制使用`@Async`的最大线程数目, 缺省值没有限制。最好按应用特点配置一个范围。 +### 4.2 数据库方面的连接数 + MySQL 数据库用`max_connections`环境变量设置最大连接数,缺省值是 151. 多数建议都是根据内存大小或应用负载来设置这个值。 +### 4.3 基本参数设置 + Spring 缺省使用[HikariCP](https://github.com/brettwooldridge/HikariCP)。 需要配置的基本参数如下。 @@ -96,6 +102,8 @@ Spring 缺省使用[HikariCP](https://github.com/brettwooldridge/HikariCP)。 - leakDetectionThreshold: 未返回连接报警时间。缺省值是 0,不启用。这个值如果大于 0,如果一个连接被使用的时间超过这个值则会日志报警(warn 级别的 log 信息)。考虑到网络负载情况,可以设置为最大数据库请求时长的 3 倍或 5 倍。如果没有这个报警,程序的正确性很难保证。 - maxLifetime:最大的连接生命时间。缺省值是 30 分钟。官方文档建议设置这个值为稍小于数据库的最大连接生命时间。MySQL 的缺省值为 8 小时。可以设置为 7 小时 59 分钟以避免每半个小时重建一次连接。 +### 4.4 针对数据库的优化设置 + HikariCP 建议的[MySQL 配置](https://github.com/brettwooldridge/HikariCP/wiki/MySQL-Configuration)参数和建议值如下,这些配置有助于提高数据库访问的性能: - prepStmtCacheSize: 250-500 @@ -125,8 +133,8 @@ HikariCP 建议的[MySQL 配置](https://github.com/brettwooldridge/HikariCP/wik [c3p0](https://github.com/swaldman/c3p0): MIN_POOL_SIZE = 3, MAX_POOL_SIZE = 15 -[JIRA Tuning database connections](https://confluence.atlassian.com/adminjiraserver070/tuning-database-connections-749382655.html):pool-max-size = 20. 和前三个不同,这是一个应用程序。里面讨论了数据库的连接数目。里面提到一方面数据库可以支持数百连接,另一方面应用服务端连接比较耗费资源,建议在允许的情况下尽可能设成小的数字。 +[JIRA Tuning database connections](https://confluence.atlassian.com/adminjiraserver070/tuning-database-connections-749382655.html):pool-max-size = 20. 和前三个不同,这是一个数据库应用程序。里面讨论了数据库的连接数目,提到一方面数据库可以支持数百并行连接,另一方面应用服务端的连接还是比较耗费资源,建议在允许的情况下尽可能设成小的数字。 ### 5.3 题外话 -网上搜了很多,没有想到这么简单的一个数据库连接池配置问题竟然没有比较全面、明确的文档。把连接池和线程池搞混的的人很多。甚至实施 HikariCP 的程序员在初始化连接池的时候使用了错误的线程池数目。创建线程池的任务主要是网络和远程数据库服务请求的延迟,几乎不耗费 CPU 资源。按照线程计算公式,此时线程池可以很大。可是 HikariCP 的程序员还是仅仅用了`Runtime.getRuntime().availableProcessors()`数目的线程用于创建连接池。正确的数目应该是配置的最小连接池数目,这样既不浪费,也有最好的性能。参考这个 Issue:[Change the thread pool size to minimumIdle on blocked initialization](https://github.com/brettwooldridge/HikariCP/issues/1375)。 这种错误并不奇怪,因为 HikariCP 的代码风格比较糟糕。很多广泛使用的开源软件其实代码质量并不高,每个人都应该搞清楚概念和问题的本质,多理解其他人的想法但是保持怀疑态度和独立思考能力。 +网上搜了很多,没有想到这么简单的一个数据库连接池配置问题竟然没有比较全面、明确的文档。把连接池和线程池搞混的的人很多。甚至实施 HikariCP 的程序员在初始化连接池的时候使用了错误的线程池数目。创建线程池的开销主要是网络和远程数据库服务请求的延迟,几乎不耗费 CPU 资源。按照线程计算公式,此时线程池可以很大。可是 HikariCP 的程序员还是仅仅用了`Runtime.getRuntime().availableProcessors()`数目的线程用于创建连接池。正确的数目应该是配置的最小连接池数目,这样既不浪费(在连接数小于 CPU 核数时),也有最好的性能(在连接数超过 CPU 核数时)。参考这个 Issue:[Change the thread pool size to minimumIdle on blocked initialization](https://github.com/brettwooldridge/HikariCP/issues/1375)。 这种错误并不奇怪,因为 HikariCP 的代码风格比较糟糕。很多广泛使用的开源软件其实代码质量并不高,每个人都应该搞清楚概念和问题的本质,多理解其他人的想法,但是保持怀疑态度和独立思考能力。 From 39f071f25b0f96184bd6ab894aa4ae5e3ab11c13 Mon Sep 17 00:00:00 2001 From: "ying.liu" Date: Tue, 21 May 2019 09:26:22 +0800 Subject: [PATCH 012/231] add connection pool details --- backend/database/database-connection-pool.md | 29 +++++++++++--------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/backend/database/database-connection-pool.md b/backend/database/database-connection-pool.md index 46989a1..e092aca 100644 --- a/backend/database/database-connection-pool.md +++ b/backend/database/database-connection-pool.md @@ -33,7 +33,7 @@ ### 2.2 数据库连接池的系统架构 -连接池的本质是属于一个操作系统进程(process)的共享的资源。其核心管理功能是从池中分配一个数据库连接给需要的线程,线程用完后回收连接到池中。由于连接池有限,可以并行进行数据库访问的线程数量最多是连接池的最大尺寸。如果考虑到一个应用线程可能会用到多个数据库连接的可能性,则可以并发访问数据库的线程数目会更少。 +连接池的本质是属于一个操作系统进程(process)的信号量(sempphore),用于控制可以并行使用数据库连接的线程数量。其核心管理功能是从池中分配一个数据库连接给需要的线程,线程用完后回收连接到池中。由于连接池有限,可以并行进行数据库访问的线程数量最多是连接池的最大尺寸。如果考虑到一个应用线程可能会用到多个数据库连接的可能性,则可以并发访问数据库的线程数目会更少。 连接池的使用者是业务应用程序。通常有二种:一种是基于用户/服务请求的 HTTP 服务线程,通常采用线程池。特点是线程数目动态变化很大,数据库的访问模式比较多样,处理时间也有长有短,可能有很大差别。另一种是后台服务,其线程数目比较固定,数据库访问模式和处理时间也比较稳定。 @@ -41,7 +41,7 @@ 具体来说,连接池是两个线程池的中间通道。可以看成下面的结构: -一个或多个进程(应用服务进程的线程池 <-> 数据库连接池) <===============> 一个数据库服务器线程(或进程)池 +一个或多个应用服务进程里面(线程池 <-> 数据库连接池) <===============> 一个数据库服务器线程(或进程)池 上图中,连接池和应用服务线线程池在同一个进程里面。每个访问数据库的应用服务进程都有自己的线程池和对应的数据库连接池。数据库服务器可能需要处理来自一个或多个服务器的多个应用服务进程内的数据库连接池数据访问请求。 @@ -51,7 +51,7 @@ 当提到数据库连接池的配置,一个常见也是严重的错误是把连接池和线程池的概念混淆了。如上面系统架构所示,数据库连接池并不控制应用端和数据库端的线程池的大小。而且每个数据库连接池的配置只是针对自己所在的应用服务进程,限制的是同一个进程内可以访问数据库的并行线程数目。应用服务进程单独管理自己的线程池,除了数据库访问还有处理其他业务逻辑,并行的线程数目基本取决于服务的负载。当应用服务线程需要访问数据库时,其并发度和阻塞数目才受到连接池尺寸的影响。 -做为应用服务和数据库的桥梁,连接池参数配置的目标是全局优化。具体的优化目的有三个:尽可能满足应用服务的并发数据库访问,不让数据库服务器过载,不浪费系统资源。 +做为应用服务和数据库的桥梁,连接池参数配置的目标是全局优化。具体的优化目的有四个:尽可能满足应用服务的并发数据库访问,不让数据库服务器过载,能发现用了不还造成的死锁,不浪费系统资源。 尽可能满足所有的应用服务并发数据库访问的意思很简单:所有需要访问数据库的线程都可以得到需要的数据库连接。如果一个线程用到多个连接,那么需要的连接数目也会成倍增加。这时,需要的连接池最大尺寸应该是最大的并发数据库访问线程数目乘以每个线程需要的连接数目。 @@ -59,19 +59,21 @@ 这个[OLTP performance -- Concurrent Mid-tier connections](https://youtu.be/xNDnVOCdvQ0)视频用一个应用服务线程池进行了模拟。应用服务线程池有 9600 个不断访问数据库的线程,当连接池尺寸为 2048 和 1024 时,数据库处于过载状态,有很多数据库的的等待事件,数据库 CPU 利用率高达 95%。当连接池减少到 96,数据库服务器没有等待事件,CPU 利用率 20%,数据库访问请求等待时间从 33ms 降低到 1ms,数据库 SQL 执行时间从 77ms 降低到 2ms。数据库访问整体响应时间从 100ms 降低到 3ms。这时一个应用服务线程池对一个数据库服务线程池的情况,总共 96 个连接池的数据库处理性能远远超过 1000 个连接池的性能。数据库服务器需要为每个连接分配资源。 +能发现用了不还造成的死锁也是选择连接池实现的基本需求。应用程序错误会造成借了不还的情况,反复出现会造成连接池用完应用长期等待甚至死锁的状态。需要有连接借用的超时报错机制,而这个超时时间取决于具体应用。 + 不浪费系统资源是指配置过大的连接池会浪费应用服务器的系统资源,包括内存,网络端口,同步信号等。同时线程池的重启和操作都会响应变慢。不过应用端连接池的开销不是很大,资源的浪费通常不是太大问题。 ### 3.2 配置方法 -概念清楚,目标明确之后,配置方法就比较容易了。思路有二种:按二端线程(进程)池尺寸估算或按照吞吐量估算。综合考虑二种方法的结果会是比较合理的。 +概念清楚,目标明确之后,配置方法就比较容易了。连接池需要考虑二种约束:二端线程(进程)池尺寸约束和应用吞吐量约束。综合考虑二种方法的结果会是比较合理的。 -方法 1: 找出二端的最大值,其中小的那个值就是连接池上限。应用服务线程池尺寸,比如 Tomcat 最大线程池尺寸缺省值为 200。如果每个线程只用一个数据库连接,那么连接池最大数目应该小于等于 200。如果有些请求用到多于一个连接,则适当增加。如果数据库线程(进程)池的最大尺寸为 151, 取二个值(200, 151)中的那个小的,那么连接池最大尺寸应该小于等于 151。如果还有其他连接池,则还要全局考虑。这个值是连接池的上线。 +二端约束: 找出二端的最大值,其中小的那个值就是连接池上限。应用服务线程池尺寸,比如 Tomcat 最大线程池尺寸缺省值为 200。如果每个线程只用一个数据库连接,那么连接池最大数目应该小于等于 200。如果有些请求用到多于一个连接,则适当增加。如果数据库线程(进程)池的最大尺寸为 151, 取二个值(200, 151)中的那个小的,那么连接池最大尺寸应该小于等于 151。如果还有其他连接池,则还要全局考虑。这个值是连接池的上线。 -方法 2: 考虑应用服务的负载性质。如果是数量变化很大的 Web 应用服务线程池,那么连接池也可以配置成动态的,配置相应的最小值和最大值。对于像邮件服务这种固定负载的业务应用,可以配置固定尺寸的进程池。 +应用负载约束: 考虑应用服务的负载性质。应用服务可以分成二类。一类是数量变化很大的 Web 应用服务线程池,那么连接池也可以配置成动态的,配置相应的最小值和最大值。另一类是像邮件服务这种固定负载的业务应用,可以配置固定尺寸的进程池。这二类应用都可以按照数据库访问的复杂度和响应时间进行估算。这里用到[Little's Law](https://en.wikipedia.org/wiki/Little%27s_law):`并发量 = 每秒请求数量 * 数据库请求响应时间`。 -仅仅考虑二端线程(进程)池的尺寸会配置过大的连接池,因为这是系统的上限。另一种思路是按照数据库访问的复杂度和响应时间进行估算。这里用到[Little's Law](https://en.wikipedia.org/wiki/Little%27s_law):`并发量 = 每秒请求数量 * 数据库请求响应时间`。 +如果每秒有 100 个数据库访问请求,每个数据库访问请求需要 20ms,那么并行量是 `100 * 0.02 = 2`,2 个并发数据库连接就可以了。同理,如果每个请求需要 100ms,那么就需要 10 个并发连接。 -如果每秒有 100 个客户请求,每个请求需要 20ms,那么并行量是 `100 * 0.02 = 2`,2 个并发数据库连接就可以了。同理,如果每个请求需要 100ms,那么就需要 10 个并发连接。 +仅仅考虑二端线程(进程)池的尺寸会配置过大的连接池,因为这是系统的上限。由于数据库访问仅仅是应用线程的一部分工作。 原因是在线程数目计算公式里:`线程数目 = CPU核数 * CPU 利用率 * (1 + 等待时间 / CPU计算时间)`, 数据库的等待时间只是线程所有操作的等待时间的一部分。 仅仅配置最小和最大连接数目仅仅是开始,根据具体实现不同,还需要配置连接生命周期,连接超时,未释放连接以及健康监控等其他参数。具体需要参考连接池的使用文档。 @@ -104,12 +106,12 @@ Spring 缺省使用[HikariCP](https://github.com/brettwooldridge/HikariCP)。 ### 4.4 针对数据库的优化设置 -HikariCP 建议的[MySQL 配置](https://github.com/brettwooldridge/HikariCP/wiki/MySQL-Configuration)参数和建议值如下,这些配置有助于提高数据库访问的性能: +HikariCP [建议的 MySQL 配置](https://github.com/brettwooldridge/HikariCP/wiki/MySQL-Configuration)参数和建议值如下,这些配置有助于提高数据库访问的性能.这些参数的缺省值在[MySQL JDBC 文档](https://dev.mysql.com/doc/connector-j/5.1/en/connector-j-reference-configuration-properties.html) -- prepStmtCacheSize: 250-500 -- prepStmtCacheSqlLimit: 2048 -- cachePrepStmts: true -- useServerPrepStmts: true +- prepStmtCacheSize: 250-500. Default: 25. +- prepStmtCacheSqlLimit: 2048. Default: 256. +- cachePrepStmts: true. Default: false. +- useServerPrepStmts: true. Default: false. ## 5 其他 @@ -117,6 +119,7 @@ HikariCP 建议的[MySQL 配置](https://github.com/brettwooldridge/HikariCP/wik 具体的连接池实现需要考虑很多应用细节。 +- 数据库连接的使用还牵涉到事物处理,Spring 的同步数据库访问采用 ThreadLocal 保存事物处理相关状态。所以连接池执行数据库访问时必须在调用者的线程,不能运行在新的线程。Spring 异步数据库访问则可以跨线程。 - 多余的连接不会立即关闭,而是会等待一段空闲时间(idle time)再关闭。 - 连接有最长生命时间限制,即使连接池不管,数据库也会自动关闭超过生命时间的连接。在 MySql 里面,连接最长生命时间是 8 个小时。连接池需要定期监控清理无效的连接。 - 连接池需要定期检查数据库的可用状态甚至响应时间,及时报告健康状态。[HikariCP Dropwizard HealthChecks](https://github.com/brettwooldridge/HikariCP/wiki/Dropwizard-HealthChecks)是一个例子。 From 83f9eee6ea3b75596286437c39da6cea259846ab Mon Sep 17 00:00:00 2001 From: "ying.liu" Date: Tue, 21 May 2019 16:17:29 +0800 Subject: [PATCH 013/231] add citing to java semaphore --- backend/database/database-connection-pool.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/database/database-connection-pool.md b/backend/database/database-connection-pool.md index e092aca..3d6ed40 100644 --- a/backend/database/database-connection-pool.md +++ b/backend/database/database-connection-pool.md @@ -33,7 +33,7 @@ ### 2.2 数据库连接池的系统架构 -连接池的本质是属于一个操作系统进程(process)的信号量(sempphore),用于控制可以并行使用数据库连接的线程数量。其核心管理功能是从池中分配一个数据库连接给需要的线程,线程用完后回收连接到池中。由于连接池有限,可以并行进行数据库访问的线程数量最多是连接池的最大尺寸。如果考虑到一个应用线程可能会用到多个数据库连接的可能性,则可以并发访问数据库的线程数目会更少。 +连接池的本质是属于一个操作系统进程(process)的计数信号量(counting sempphore),用于控制可以并行使用数据库连接的线程数量。在 Java SDK 有一个[Semaphore Class](https://docs.oracle.com/javase/8/docs/api/java/util/concurrent/Semaphore.html)可以用来管理各种有限数量的资源。连接池的核心管理功能是从池中分配一个数据库连接给需要的线程,线程用完后回收连接到池中。由于连接池有限,可以并行进行数据库访问的线程数量最多是连接池的最大尺寸。如果考虑到一个应用线程可能会用到多个数据库连接的可能性,则可以并发访问数据库的线程数目会更少。 连接池的使用者是业务应用程序。通常有二种:一种是基于用户/服务请求的 HTTP 服务线程,通常采用线程池。特点是线程数目动态变化很大,数据库的访问模式比较多样,处理时间也有长有短,可能有很大差别。另一种是后台服务,其线程数目比较固定,数据库访问模式和处理时间也比较稳定。 From ef96e87991bcc0ccb83bd888bc20aa4c0692cce7 Mon Sep 17 00:00:00 2001 From: Quan Liu Date: Wed, 22 May 2019 14:12:40 +0800 Subject: [PATCH 014/231] update bug report & issue workflow --- dev-process/how-to/how-to-report-bug.md | 87 +++++++++++++++++-------- dev-process/process/issue-workflow.md | 10 ++- 2 files changed, 67 insertions(+), 30 deletions(-) diff --git a/dev-process/how-to/how-to-report-bug.md b/dev-process/how-to/how-to-report-bug.md index 400a1d3..0116681 100644 --- a/dev-process/how-to/how-to-report-bug.md +++ b/dev-process/how-to/how-to-report-bug.md @@ -1,28 +1,59 @@ -# 项目的bug管理 -bug管理文档,主要介绍如何提交bug,以及这些bug如何管理、关闭、跟踪等 - -# 发现bug与bug提交 -- 相关人员在使用产品、测试产品的过程中,发现bug之后,提交到测试同事,由测试同事统一管理。需要提供测试场景描述、问题描述等,最好能提供截图供测试人员复现bug。 -- 测试人员收到其他同事的bug,需自己再复现bug,并进行一定的分析 -- 测试人员在分析完bug之后,依据分析结果,在相关项目的github repo上,提交issue,issue需将场景、输入、结果、bug描述、截图等提供出来,供开发人员分析和解决 - -# bug的解决 -- 模块开发人员需每日查看repo上的issue,并依据相关描述进行排查 -- 如果Bug属于当前repo,则在解决的时候建立新分之解决,并提交PR,提交PR时,将PR与issue关联上,并在PR的comment里面描述问题产生原因,以及解决方法 -- 如果Bug不属于当前repo,则在根据分析,将issue提交到其他repo,并将当前issue地址也关联上,以便追踪 - -# issue的关闭 -基本原则:```issue只能够创建者和测试人员关闭,其他人员不允许关闭``` -- Bug解决完之后,部署到测试环境,由测试人员验证,待通过之后,则可关闭相关issue。 - -# Bug的管理 -Bug的管理,根据不同人员略有不同,主要角色分三种:开发人员、测试人员、产品人员 -- 开发人员 - - 开发人员不需汇总管理, 只依据github的issue来管理bug - - 需每日跟进自己相关的issue -- 测试人员 - - 测试人员需有一个bug汇总,需跟进所有repo的bug,对于bug汇总的管理,新建一个bug-repo - - 测试人员在跟进bug的解决时,需要到每个repo里面去添加issue,并跟进 -- 产品人员 - - 产品人员在做版本规划的时候,需要有需求汇总,bug汇总,每次发版本,需要明确完成了哪些新功能,解决了哪些bug - - 产品人员在跟进bug时,主要以测试人员的汇总为主,不需要进入到每个repo去查看issue的解决情况,如需了解具体bug的解决情况,可单独查看issue或找相关开发人员沟通 +# 项目的 bug 管理 + +bug 管理文档,主要介绍如何提交 bug,以及这些 bug 如何管理、关闭、跟踪等 + +## bug 基本信息定义 + +bug 总共有以下状态: + +- 新建 + - 下一步可流转状态:接受/处理、已拒绝、挂起、延期 +- 接受/处理 + - 下一步可流转状态:转需求、已解决、已拒绝、挂起、延期 +- 转需求 + - 下一步可流转状态:新建 +- 已解决 + - 下一步可流转状态:重新打开、已关闭 +- 重新打开 + - 下一步可流转状态:接受/处理 +- 已拒绝 + - 下一步可流转状态:重新打开、已关闭 +- 已关闭 + - 下一步可流转状态:重新打开 +- 挂起 + - 下一步可流转状态:接受/处理 +- 延期 + - 下一步可流转状态:接受/处理 + +bug 严重程度说明: + +- 致命: 导致整个系统功能不可用、资金流转异常等 +- 严重: 主要功能不可用,或者导致某个分支流 block 住 +- 一般: 不影响其他功能的功能性 bug +- 提示:文案错误或者提示内容不友好 +- 建议:非需求内的交互体验,不影响功能使用,但是可以提高交互友好型性或者效率 + +## 发现 bug 与 bug 提交 + +- 相关人员在使用产品、测试产品的过程中,发现 bug 之后,提交到测试同事,由测试同事统一管理。需要提供测试场景描述、问题描述等,最好能提供截图供测试人员复现 bug +- 测试人员收到其他同事的 bug,需自己再复现 bug,并进行一定的分析 +- 测试人员在分析完 bug 之后,依据分析结果,在 tapd 上建立相应的缺陷,并指定模块负责人为缺陷处理人 +- 填写 bug 时,需要填写的信息为:处理人、优先级、严重程度、发现版本、测试阶段、重现概率 + +## bug 的解决 + +- 开发人员需每次查看自己负责模块(迭代)的缺陷列表,并根据其优先级,逐一解决或者流转状态。无法定为到具体原因的,由小组组长定为之后再分配到人 +- 如果 bug 涉及到了需求的改动,请及时通知产品进行商量和需求文档的改动 +- 解决 bug 之后,提 PR 的时候,请将 bug 的 ID 附在 PR 的 commit message 内 +- bug 处理完之后,及时流转其状态,以便检查 + +## issue 的关闭 + +基本原则:开发人员,处理、流转 bug 的状态,bug 的最终关闭,由测试人员在测试通过后进行关闭 + +- Bug 解决完之后,部署到测试环境,由测试人员验证,待通过之后,则可关闭相关 issue + +## bug 报告 + +- 在模块开发、上线前,测试小组会发出模块测试报告 +- 定期的,会做线上 bug 统计,并发出报告 diff --git a/dev-process/process/issue-workflow.md b/dev-process/process/issue-workflow.md index 46d8a1e..f5769e1 100644 --- a/dev-process/process/issue-workflow.md +++ b/dev-process/process/issue-workflow.md @@ -2,10 +2,16 @@ 这里主要介绍 Issue 的使用流程。所有牵扯到系统的改动,必须添加 issue 进行跟踪。 +开发有几个基本原则需要遵守: + +- 所有需求,需要在 tapd 上规划清楚,并且需要制定连接到 pm 的原型设计界面 +- 所有 bug(这里指的是线上 bug),均需要我们在 tapd 上建立缺陷,进行跟踪。功能开发阶段、联调阶段发现的 bug,不计入其中 +- 所有 PR 上,需要制定带上功能 ID,bugId(这两个 ID 是 tapd 上的 ID),方便进行 Review 和跟踪 + ## Issue 管理 -1. Issue 分为两种类型;feature 和 bug -2. 测哪部分,或者给哪部分提新需求,那么就在哪部分开 issue。例如给 tmc 提需求,就开在 tmc 里面,如果给 App 提 issue,就给 app 提 issue +1. Issue 分为两种类型;feature(需求) 和 bug(缺陷) +2. 所有的需求 3. 开发人员在开始处理 issue 时,在 issue 处增加评论:“正在处理”(可选)。处理完、且自己验证完之后,把 issue 关闭,并回复:“已处理”。 4. Issue 的分析和转移:如果开发人员(如前端),分析了 issue 之后,发现时后端的问题,那么在与`后端相关人员`确认之后,在后端开一个新的 issue,并且把后端 issue 的链接采用评论的方式添加到前端 issue 中,如:“确认为后端 Issue:[issueLink]'” 5. 日清:每天下班前,认领当天的 issue(认领那些确认是自己,或者由自己来处理的 issue,不清楚的,不认领),项目负责人需要在下班前把没有人认领的 issue,分配到具体的人(请尽量按照责任和能力划分) From 2ca275094879cd7ddd2179a21d633e7a57ae9699 Mon Sep 17 00:00:00 2001 From: Quan Liu Date: Wed, 22 May 2019 14:44:05 +0800 Subject: [PATCH 015/231] delete issue-workflow --- dev-process/process/issue-workflow.md | 36 --------------------------- 1 file changed, 36 deletions(-) delete mode 100644 dev-process/process/issue-workflow.md diff --git a/dev-process/process/issue-workflow.md b/dev-process/process/issue-workflow.md deleted file mode 100644 index f5769e1..0000000 --- a/dev-process/process/issue-workflow.md +++ /dev/null @@ -1,36 +0,0 @@ -# Issue Workflow - -这里主要介绍 Issue 的使用流程。所有牵扯到系统的改动,必须添加 issue 进行跟踪。 - -开发有几个基本原则需要遵守: - -- 所有需求,需要在 tapd 上规划清楚,并且需要制定连接到 pm 的原型设计界面 -- 所有 bug(这里指的是线上 bug),均需要我们在 tapd 上建立缺陷,进行跟踪。功能开发阶段、联调阶段发现的 bug,不计入其中 -- 所有 PR 上,需要制定带上功能 ID,bugId(这两个 ID 是 tapd 上的 ID),方便进行 Review 和跟踪 - -## Issue 管理 - -1. Issue 分为两种类型;feature(需求) 和 bug(缺陷) -2. 所有的需求 -3. 开发人员在开始处理 issue 时,在 issue 处增加评论:“正在处理”(可选)。处理完、且自己验证完之后,把 issue 关闭,并回复:“已处理”。 -4. Issue 的分析和转移:如果开发人员(如前端),分析了 issue 之后,发现时后端的问题,那么在与`后端相关人员`确认之后,在后端开一个新的 issue,并且把后端 issue 的链接采用评论的方式添加到前端 issue 中,如:“确认为后端 Issue:[issueLink]'” -5. 日清:每天下班前,认领当天的 issue(认领那些确认是自己,或者由自己来处理的 issue,不清楚的,不认领),项目负责人需要在下班前把没有人认领的 issue,分配到具体的人(请尽量按照责任和能力划分) - -## Issue 等级划分 - -**_不能私自更改优先级,需和 issue 相关的同事商量之后才能够更改_** - -- 紧急:放下手中一切事物,立马去解决,一年中应该不会出现几次,且出现这种 issue,需要立即当面和项目负责人商量 -- 高:严重影响用户使用,需要尽快解决 -- 中:影响用户使用,根据优先级,逐步解决 -- 低:几乎不影响用户使用,可以延后处理 -- 新需求:针对新需求 - -## isuue 编写的格式 - -**_按照设置的 issue 模版来写_** - -其他注意事项 - -- 以书面语气进行 issue 的描述,不要带入个人感情,如:不允许用“!!!” -- 在保证明确、清晰的描述 issue 的前提下,可以适当删减模版中的内容 From 1486a4331f1d501787cdca551576a97a25095b8b Mon Sep 17 00:00:00 2001 From: Chunxiang Liu Date: Thu, 23 May 2019 08:55:58 +0800 Subject: [PATCH 016/231] time contract --- backend-and-frontend/common-contract.md | 1 + frontend/best-practices/angular-best-practices.md | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/backend-and-frontend/common-contract.md b/backend-and-frontend/common-contract.md index 4adc6dc..38e5c76 100644 --- a/backend-and-frontend/common-contract.md +++ b/backend-and-frontend/common-contract.md @@ -6,6 +6,7 @@ - 系统自己生成的时间:前后端交互过程中统一转为 [UTC 时间](https://zh.wikipedia.org/wiki/%E5%8D%8F%E8%B0%83%E4%B8%96%E7%95%8C%E6%97%B6),格式上采用 [ISO 8601](https://zh.wikipedia.org/wiki/ISO_8601) 规定的 yyyy-MM-dd'T'HH:mm:ss.SSS'Z' 标准格式。 - 第三方传入的时间:前后端交互过程中不对日期格式做转换处理。 +- 需要在接口文档中注明是标准格式的字符串还是其他格式,如果是其他格式,则注明具体格式(例如:yyyy-MM-dd)。 ## 大数字传输规范 diff --git a/frontend/best-practices/angular-best-practices.md b/frontend/best-practices/angular-best-practices.md index 4f2f339..30843db 100644 --- a/frontend/best-practices/angular-best-practices.md +++ b/frontend/best-practices/angular-best-practices.md @@ -369,12 +369,15 @@ this.service ## Templates 风格指南 - **Template**保持足够简单,尽量避免计算以及表达式,如若需要,可将其移到**Component**里面使用计算属性表示: + ```ts export class DemoComponent { /// 此处略去其他代码 - + get isVisible() { return this.list.length > 0 && balabala…… } } ``` + +- 对于后端返回的时间格式,不要滥用 date pipe 来对时间进行格式转换,只有在标准[ISO 8601]格式下才能使用 date pipe, 如果是其他格式的字符串(例如: yyyy-MM-dd HH:mm), 直接使用 date pipe 的话在 IE 和 Safari 上会存在兼容性问题。 \ No newline at end of file From 760fbd7112b494e373589ca099ad6f182e02aacd Mon Sep 17 00:00:00 2001 From: "ying.liu" Date: Thu, 23 May 2019 10:50:47 +0800 Subject: [PATCH 017/231] revise db conn --- backend/database/database-connection-pool.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/database/database-connection-pool.md b/backend/database/database-connection-pool.md index 3d6ed40..ef8ae84 100644 --- a/backend/database/database-connection-pool.md +++ b/backend/database/database-connection-pool.md @@ -25,7 +25,7 @@ 连接池的实际应用中,最担心的问题就是借了不还的这种让其他人无资源可用的人品问题。编码逻辑错误或者释放连接放代码没有放到 `finally` 部分都会导致连接池资源枯竭从而造成系统变慢甚至完全阻塞的情况。这种情况类似于内存泄露,因而也叫连接泄露,是常常发生而且难以发现的问题。因此检测连接泄露并报警是线程池实现的基本需要。 -连接在被使用时运行在借用它的线程里面,并不是运行在新的线程里面。但是因为每个连接在使用中要实现超时 timeout 机制,官方的 [Java.sql.Connection.setNetworkTimeout API](https://docs.oracle.com/javase/8/docs/api/java/sql/Connection.html)的接口定义是 `setNetworkTimeoutExecutor executor, int milliseconds)`。此出需要指定一个线程池来处理超时的错误报告。也就是每一个连接运行数据库访问时,都会有一个后台线程监控响应超时状态。很多连接池实现会使用 Cached Thread Pool 或 Fixed Thread Pool。Chached Thread Pool 没有线程数目限制,动态创建和回收,适合很多动态的短小请求应用。Fixed Thread Pool 则适合比较固定的连接请求。 +连接在被使用时运行在借用它的线程里面,并不是运行在新的线程里面。但是因为每个连接在使用中要实现超时 timeout 机制,官方的 [Java.sql.Connection.setNetworkTimeout API](https://docs.oracle.com/javase/8/docs/api/java/sql/Connection.html)的接口定义是 `setNetworkTimeoutExecutor executor, int milliseconds)`。此处需要指定一个线程池来处理超时的错误报告。也就是每一个连接运行数据库访问时,都会有一个后台线程监控响应超时状态。很多连接池实现会使用 Cached Thread Pool 或 Fixed Thread Pool。Chached Thread Pool 没有线程数目限制,动态创建和回收,适合很多动态的短小请求应用。Fixed Thread Pool 则适合比较固定的连接请求。 另外,网络故障和具体数据库实现的限制会使得连接池的连接失效。比如,MySQL 允许一个连接,无论状态正常与否,都不能超过 8 个小时的生命。因此,虽然连接在被使用时运行在调用的线程里面,但是连接池的管理通常需要一个或多个后台线程来管理、维护、和检测连接池的连接状态,保证有指定数目的连接可用。 @@ -59,7 +59,7 @@ 这个[OLTP performance -- Concurrent Mid-tier connections](https://youtu.be/xNDnVOCdvQ0)视频用一个应用服务线程池进行了模拟。应用服务线程池有 9600 个不断访问数据库的线程,当连接池尺寸为 2048 和 1024 时,数据库处于过载状态,有很多数据库的的等待事件,数据库 CPU 利用率高达 95%。当连接池减少到 96,数据库服务器没有等待事件,CPU 利用率 20%,数据库访问请求等待时间从 33ms 降低到 1ms,数据库 SQL 执行时间从 77ms 降低到 2ms。数据库访问整体响应时间从 100ms 降低到 3ms。这时一个应用服务线程池对一个数据库服务线程池的情况,总共 96 个连接池的数据库处理性能远远超过 1000 个连接池的性能。数据库服务器需要为每个连接分配资源。 -能发现用了不还造成的死锁也是选择连接池实现的基本需求。应用程序错误会造成借了不还的情况,反复出现会造成连接池用完应用长期等待甚至死锁的状态。需要有连接借用的超时报错机制,而这个超时时间取决于具体应用。 +能发现用了不还造成的阻塞也是选择连接池实现的基本需求。应用程序错误会造成借了不还的情况,反复出现会造成连接池用完应用长期等待甚至死锁的状态。需要有连接借用的超时报错机制,而这个超时时间取决于具体应用。 不浪费系统资源是指配置过大的连接池会浪费应用服务器的系统资源,包括内存,网络端口,同步信号等。同时线程池的重启和操作都会响应变慢。不过应用端连接池的开销不是很大,资源的浪费通常不是太大问题。 @@ -69,7 +69,7 @@ 二端约束: 找出二端的最大值,其中小的那个值就是连接池上限。应用服务线程池尺寸,比如 Tomcat 最大线程池尺寸缺省值为 200。如果每个线程只用一个数据库连接,那么连接池最大数目应该小于等于 200。如果有些请求用到多于一个连接,则适当增加。如果数据库线程(进程)池的最大尺寸为 151, 取二个值(200, 151)中的那个小的,那么连接池最大尺寸应该小于等于 151。如果还有其他连接池,则还要全局考虑。这个值是连接池的上线。 -应用负载约束: 考虑应用服务的负载性质。应用服务可以分成二类。一类是数量变化很大的 Web 应用服务线程池,那么连接池也可以配置成动态的,配置相应的最小值和最大值。另一类是像邮件服务这种固定负载的业务应用,可以配置固定尺寸的进程池。这二类应用都可以按照数据库访问的复杂度和响应时间进行估算。这里用到[Little's Law](https://en.wikipedia.org/wiki/Little%27s_law):`并发量 = 每秒请求数量 * 数据库请求响应时间`。 +应用负载约束: 考虑应用服务的负载性质。应用服务可以分成二类。一类是数量变化很大的 Web 应用服务线程池,那么连接池也可以配置成动态的,配置相应的最小值和最大值。另一类是像邮件服务这种固定负载的业务应用,可以配置固定尺寸的进程池。这二类应用都可以按照数据库访问的复杂度和响应时间进行估算。这里用到[Little's Law](https://en.wikipedia.org/wiki/Little%27s_law):`并发量 = 每秒请求数量 * 数据库请求响应时间`。注意:这里的请求响应时间包括网络时间+数据库访问时间。很多时候网络时间大于数据库访问时间。如果一个应用线程有多个数据库访问请求,尤其是有事物处理的时候,这个数据库请求响应时间其实是持有连接的时间,公式变为:`并发量(连接数): 每秒请求数 (QPS)* 数据库连接持有时间`。 如果每秒有 100 个数据库访问请求,每个数据库访问请求需要 20ms,那么并行量是 `100 * 0.02 = 2`,2 个并发数据库连接就可以了。同理,如果每个请求需要 100ms,那么就需要 10 个并发连接。 @@ -79,7 +79,7 @@ ### 3.3 一个表面相关,其实无关的计算公式 -因为连接池和线程池经常被混淆,这里有必要介绍另外一个经常提到但是无关的线程数目计算公式。这个公式来自每个 Java 程序员都应该阅读的[Java Concurrency in Practice](http://jcip.net/)。在原著 8.2 节, 第 171 页作者给出了著名的线程数目计算公式:`线程数目 = CPU核数 * CPU 利用率 * (1 + 等待时间 / CPU计算时间)`。这个公式考虑了计算密集(计算时间)和 I/O 密集(等待时间)的不同处理模式。可是这个公式可以用于应用服务线程池或任何线程池的尺寸估算,但是与数据库连接池的大小估算无关。因为进程池并不能控制应用服务的线程数目,它控制的是可并发的数据库访问线程数目。这些线程使用数据库连接完成网络服务和远程数据库的异步操作,此时基本没有使用本机的 CPU 计算时间。套用公式会得出非常大的数字,没有太大实际意义。 +因为连接池和线程池经常被混淆,这里有必要介绍另外一个经常提到但是无关的线程数目计算公式。这个公式来自每个 Java 程序员都应该阅读的[Java Concurrency in Practice](http://jcip.net/)。在原著 8.2 节, 第 171 页作者给出了著名的线程数目计算公式:`线程数目 = CPU核数 * CPU 利用率 * (1 + 等待时间 / CPU计算时间)`。这个公式考虑了计算密集(计算时间)和 I/O 密集(等待时间)的不同处理模式。[计算进程的 CPU 使用率](https://stackoverflow.com/questions/1420426/how-to-calculate-the-cpu-usage-of-a-process-by-pid-in-linux-from-c)给出了具体的技术方式和 Script。可是这个公式可以用于应用服务线程池或任何线程池的尺寸估算,但是与数据库连接池的大小估算无关。因为进程池并不能控制应用服务的线程数目,它控制的是可并发的数据库访问线程数目。这些线程使用数据库连接完成网络服务和远程数据库的异步操作,此时基本没有使用本机的 CPU 计算时间。套用公式会得出非常大的数字,没有实际意义。 ## 4 Spring + MySQL 的应用的连接池配置 From 12168d19e2bdc01161b633bdd8dc957bb80f2f2d Mon Sep 17 00:00:00 2001 From: "ying.liu" Date: Sat, 25 May 2019 15:45:15 +0800 Subject: [PATCH 018/231] change postgresql pool description --- backend/database/database-connection-pool.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/backend/database/database-connection-pool.md b/backend/database/database-connection-pool.md index ef8ae84..7a8c1a1 100644 --- a/backend/database/database-connection-pool.md +++ b/backend/database/database-connection-pool.md @@ -55,7 +55,7 @@ 尽可能满足所有的应用服务并发数据库访问的意思很简单:所有需要访问数据库的线程都可以得到需要的数据库连接。如果一个线程用到多个连接,那么需要的连接数目也会成倍增加。这时,需要的连接池最大尺寸应该是最大的并发数据库访问线程数目乘以每个线程需要的连接数目。 -不让数据库服务器过载是个全局的考虑。因为可能有多个应用服务器的多个连接池会同时发出请求。按照 PostgreSQL V11 文档[18.4.3. Resource Limits](https://www.postgresql.org/docs/11/kernel-resources.html),每个连接都由一个单独进程来处理。每个进程即使空闲,都会消耗不少诸如内存,信号(semaphore), 文件/网络句柄(handler),队列等各种系统资源。这篇文章[Number Of Database Connections](https://wiki.postgresql.org/wiki/Number_Of_Database_Connections#How_to_Find_the_Optimal_Database_Connection_Pool_Size) 讨论了 PostgreSQL V9.2 的连接数目。给出的建议公式是 `((core_count * 2) + effective_spindle_count)`,也就是 CPU 核数的二倍加上硬盘轴数。MySQL 采用了不同的服务架构,[MySQL Too many connections](https://dev.mysql.com/doc/refman/5.5/en/too-many-connections.html)给出的缺省连接数目为 151。这二个系统从具体实现机理、计算办法和建议数值都有很大差别,做为应用程序员应该有基本的理解。 +不让数据库服务器过载是个全局的考虑。因为可能有多个应用服务器的多个连接池会同时发出请求。按照 PostgreSQL V11 文档[18.4.3. Resource Limits](https://www.postgresql.org/docs/11/kernel-resources.html),每个连接都由一个单独进程来处理。每个进程即使空闲,都会消耗不少诸如内存,信号(semaphore), 文件/网络句柄(handler),队列等各种系统资源。这篇文章[Number Of Database Connections](https://wiki.postgresql.org/wiki/Number_Of_Database_Connections#How_to_Find_the_Optimal_Database_Connection_Pool_Size) 讨论了 PostgreSQL V9.2 的并发连接数目。给出的建议公式是 `((core_count * 2) + effective_spindle_count)`,也就是 CPU 核数的二倍加上硬盘轴数。值得注意的是,这个并发连接数目并非数据库这面的连接池尺寸。实际上 PostgreSQL 内部并没有连接池,只有允许的最大连接数目 [max_connections](https://www.postgresql.org/docs/current/runtime-config-connection.html#GUC-MAX-CONNECTIONS),缺省值为 100。MySQL 采用了不同的服务架构,[MySQL Too many connections](https://dev.mysql.com/doc/refman/5.5/en/too-many-connections.html)给出的缺省连接数目为 151。这二个系统从具体实现机理、计算办法和建议数值都有很大差别,做为应用程序员应该有基本的理解。 这个[OLTP performance -- Concurrent Mid-tier connections](https://youtu.be/xNDnVOCdvQ0)视频用一个应用服务线程池进行了模拟。应用服务线程池有 9600 个不断访问数据库的线程,当连接池尺寸为 2048 和 1024 时,数据库处于过载状态,有很多数据库的的等待事件,数据库 CPU 利用率高达 95%。当连接池减少到 96,数据库服务器没有等待事件,CPU 利用率 20%,数据库访问请求等待时间从 33ms 降低到 1ms,数据库 SQL 执行时间从 77ms 降低到 2ms。数据库访问整体响应时间从 100ms 降低到 3ms。这时一个应用服务线程池对一个数据库服务线程池的情况,总共 96 个连接池的数据库处理性能远远超过 1000 个连接池的性能。数据库服务器需要为每个连接分配资源。 @@ -106,12 +106,12 @@ Spring 缺省使用[HikariCP](https://github.com/brettwooldridge/HikariCP)。 ### 4.4 针对数据库的优化设置 -HikariCP [建议的 MySQL 配置](https://github.com/brettwooldridge/HikariCP/wiki/MySQL-Configuration)参数和建议值如下,这些配置有助于提高数据库访问的性能.这些参数的缺省值在[MySQL JDBC 文档](https://dev.mysql.com/doc/connector-j/5.1/en/connector-j-reference-configuration-properties.html) +HikariCP [建议的 MySQL 配置](https://github.com/brettwooldridge/HikariCP/wiki/MySQL-Configuration)参数和建议值如下,这些配置有助于提高数据库访问的性能. 注意,这个配置是对数据源(dataSource)配置,不是连接池的配置。这些参数的说明在[MySQL JDBC 文档](https://dev.mysql.com/doc/connector-j/5.1/en/connector-j-reference-configuration-properties.html) -- prepStmtCacheSize: 250-500. Default: 25. -- prepStmtCacheSqlLimit: 2048. Default: 256. -- cachePrepStmts: true. Default: false. -- useServerPrepStmts: true. Default: false. +- `dataSource.prepStmtCacheSize`: 250-500. Default: 25. +- `dataSource.prepStmtCacheSqlLimit`: 2048. Default: 256. +- `dataSource.cachePrepStmts`: true. Default: false. +- `dataSource.useServerPrepStmts`: true. Default: false. ## 5 其他 From 3a62fa8e194d30be47ae5570f091297d578680ce Mon Sep 17 00:00:00 2001 From: "ying.liu" Date: Sat, 25 May 2019 17:32:17 +0800 Subject: [PATCH 019/231] revise connection pool and add java code --- backend/README.md | 6 +- backend/code/java-best-practices.md | 38 +++++ backend/code/java-code-guideline.md | 134 ++-------------- ...24\347\246\273\347\272\247\345\210\253.md" | 144 +++++++++++++----- ...23\350\277\236\346\216\245\346\261\240.md" | 0 5 files changed, 161 insertions(+), 161 deletions(-) create mode 100644 backend/code/java-best-practices.md rename backend/database/database-connection-pool.md => "backend/database/\346\225\260\346\215\256\345\272\223\350\277\236\346\216\245\346\261\240.md" (100%) diff --git a/backend/README.md b/backend/README.md index 83b74cb..a5e6ba0 100644 --- a/backend/README.md +++ b/backend/README.md @@ -14,10 +14,11 @@ ## 编码规范 +- [Java 代码最佳实践](./code/java-best-practices.md): Java 代码惯例用法 +- [Java 高级代码规范](./code/java-code-guideline.md): 关于复杂场景的 java 编码规约 - [如何处理异常](./code/how-to-handle-exception.md): 关于异常处理的方式 -- [Java 编码指南](./code/java-code-guideline.md): 关于 java 编码的规约 - [后端如何写日志](./code/如何写日志.md): 关于后端记日志的详细说明 -- [Java异步编程规范](./code/java异步编程规范.md): 关于Java中异步编程的详细说明 +- [Java 异步编程规范](./code/java异步编程规范.md): 关于 Java 中异步编程的详细说明 - [Java 命名规范](./code/Java命名规范.md): 关于后端 Java 代码的命名规范 - [Java 后台项目如何分层](./code/Java后台服务分层规范.md): 关于 Java 后台服务项目如何分层的一些标准 @@ -25,3 +26,4 @@ - [数据库设计规范-mysql](./database/数据库设计规范-mysql.md): 关于数据库设计的规范 - [数据库事务与隔离级别](./database/数据库事务与隔离级别.md) +- [数据库连接池](./database/数据库连接池.md) diff --git a/backend/code/java-best-practices.md b/backend/code/java-best-practices.md new file mode 100644 index 0000000..aea662d --- /dev/null +++ b/backend/code/java-best-practices.md @@ -0,0 +1,38 @@ +# Java 代码最佳实践 + +## try with resource + +尽量使用 Java 7 和 Java 9 带来的新语法减缓代码。注意,当有多个资源时,各个资源用`;`分开,都应该都做 try 后面的括号里面。 + +```java +// use the folloiwng in Java 7 and Java 8 +try (InputStream stream = new MyInputStream(...)){ + // ... use stream +} catch(IOException e) { + // handle exception +} + +// use the following in Java 9 and later +InputStream stream = new MyInputStream(...) +try (stream) { + // ... use stream +} catch(IOException e) { + // handle exception +} + +// NOT the following +InputStream stream = new MyInputStream(...); +try { + // ... use stream +} catch(IOException e) { + // handle exception +} finally { + try { + if(stream != null) { + stream.close(); + } + } catch(IOException e) { + // handle yet another possible exception + } +} +``` diff --git a/backend/code/java-code-guideline.md b/backend/code/java-code-guideline.md index a32f202..298204f 100644 --- a/backend/code/java-code-guideline.md +++ b/backend/code/java-code-guideline.md @@ -1,4 +1,4 @@ -# java-code-guideline +# Java 高级代码规范 ## 1. Spring 依赖注入 @@ -124,8 +124,8 @@ HTTP/1.1 200 - **结论**: - 过长代码要抽成方法 - - 每个函数不超过10条语句 - - 一个函数的所有语句都在单一抽象层(SLA原则) + - 每个函数不超过 10 条语句 + - 一个函数的所有语句都在单一抽象层(SLA 原则) ## 8. 异常的处理 @@ -136,124 +136,14 @@ HTTP/1.1 200 - 使用异常类型而非异常信息来分辨异常来自的不同 Domain 类 - **注意**:以上两个例子即使和你的业务契合,也不一定满足和你的业务要求 -## 9. 数据库事物处理(Transaction) - -读数据错误与丢失更新 - -- [事务隔离级中](https://juejin.im/post/5b90cbf4e51d450e84776d27),脏读、不可重复读、幻读三个问题都是由事务 A 对数据进行修改、增加,事务 B 总是在做读操作造成的。 - -如果两事务都在对数据进行修改则会导致另外的问题:丢失更新。为什么出现丢失更新: - -- 多个 session 对数据库同一张表的同一行数据进行修改,时间线上有所重复,可能会出现各种写覆盖的情况。 -- 示例可见:[并发事务的丢失更新及其处理方式](https://blog.csdn.net/u014590757/article/details/79612858) - -解决方案: - -- 根据具体的业务场景,尽量缩小事物范围并采用正确的[事物隔离级别](https://dev.mysql.com/doc/refman/8.0/en/innodb-transaction-isolation-levels.html)。 -- 使 用数据库行级锁(如乐观锁、S 锁)。完全避免对行级数据的脏操作,但是使得对该行数据的访问串行化,对于比较大的表对象而言,这样的设置往往不是我们想要的结果。 -- 缩小事务管辖的范围。控制事务所辖代码执行时间的长度,不能将很耗时的操作(如外部服务调用)与数据修改置于同一个事务中。此方案只是尽量减少两个事务中的写操作互相影响的可能,无法完全避免。 -- 使用 ORM save 方法实现数据持久化的情况下,开启 Dynamic update,使得保存更改时影响的字段仅限于被改动了字段。此方案通过控制更新字段的范围,尽量减少脏操作可能,但也无法完全避免。 - -## 10. 关于 Hibernate Dynamic update - -主要缺陷 - -- 语义错位。本意是直接修改部分属性,现在变成取整个 Object,改部分属性,存整个 Object。中间不可控因素太多。 -- 每次根据改动了的字段,动态生成 SQL 语句,性能上相比全更操作有所降低 -- 需要从数据库拿到整个 Object 所有数据才能修改,大多数时候不必要, -- 当两个 session 同时对同一字段进行更新操作,极端情况下会因为 ORM 缓存出现莫名其妙的情况,示例见:[Stackexchange Q: What's the overhead of updating all columns, even the ones that haven't changed](https://dba.stackexchange.com/questions/176582/whats-the-overhead-of-updating-all-columns-even-the-ones-that-havent-changed) - -## 11. 如何更新数据库字段 - -- 拒绝使用 Spring Data JPA 的 save 方法 - - 默认配置且未使用锁的情况下,save 方法会更新实体类的所有字段,一方面增加了开销,另一方面歪曲了更新特定字段的语义,多线程并发访问更新下的情况下易出现问题。 - - 配置动态更新且未使用锁的情况下,save 方法会监测改动了的字段并进行更新,但是可能会出现 11 点中提到的古怪情形。 - - 总的来看,使用 ORM save 方法进行实体类更新陷入了 “You wanted a banana but you got a gorilla holding the banana” 的怪圈,导致做的事情不精确、或者有其它的风险。[参考文章](https://www.johndcook.com/blog/2011/07/19/you-wanted-banana/) -- 使用自定义 SQL 进行字段更新 - - 使用 JPA 提供的 @Query/@Modifying 书写 JPQL 进行精确控制的字段更新操作。 - -## 12. 处理 Hibernate 懒加载 - -什么是懒加载 - -> An object that doesn't contain all of the data you need but knows how to get it. -\- Martin Fowler defines in [Patterns of Enterprise Application Architecture](https://martinfowler.com/books/eaa.html) - -懒加载在我们项目中带来的问题: - -- 使用 Spring Data JPA 进行包含列表子对象的对象的列表查询时,若最后使用的结果集不仅限于该对象本身,而还包含其子对象中的内容,会出现 N + 1 问题 -- 使用 Spring Data JPA 查询数据时,若是从非 Controller 环境(如消息队列消费者等异步线程环境),访问对象下面的列表子对象会出现 session closed 异常 - -对付 N + 1 问题: - -- 列表查询改用 Spring Jdbc Template 直接书写原生 SQL 语句执行查询,最大程度上提高效率 - -对付非事务环境下访问懒加载数据 session closed 问题: - -1. 设置 Hibernate 属性(v4.1.6 版本后可用):hibernate.enable_lazy_load_no_trans=true -2. 使用 @Fetch(FetchMode.JOIN) 注解 -3. 使用 @LazyCollection(LazyCollectionOption.FALSE) 注解 -4. 其它请补充 - -## 13. 注释 - -- 类注释 - - 类级别的注释必须的,注释的内容是该类的职责描述,也可以包含一些使用说明,示例等。 - - 类的作者,添加修改时间之类的注释是不需要的,因为有源代码可以查到这些信息。 - -- 方法注释 - - 方法的注释应该描述该方法做什么。 - - 方法的命名应该清晰易懂,合理地命名比注释更重要,如果方法名能够足够表达清楚就不需要注释。 - -## 14. 使用 AspectJ - -建议AOP用aspectJ: - -```xml - -``` - -相较于 Java JDK 代理、Cglib 登,AspectJ 不但 runtime 性能提高一个数量级,而且支持 class,method(public or private) 和同一个类的方法调用。可以把@Transaction写到最相关的地方。坏处是配置和build可能稍稍有些麻烦。 - -## 15. 减少乐观锁使用 - -不建议用乐观锁。所有的事物都明明白白的写出事物处理控制语句。如果更新不需要检查条件(比如更改地址),则直接更新,后面的提交可能覆盖前面的版本。因为我们不用 respository.save(), 通常只有最后提交的部分属性更新,多数业务场景都可以用。 - -如果更新有一定条件,比如取消订单需要订单的状态是可取消状态,则更新时需要先用select for update检查更新的条件符合再更新,不符合条件返回相应的业务错误代码。乐观锁适用于读的版本是最新的数据版本。 - -需要使用乐观锁的场景有: - -- 待补充 - -## 16. 事务的使用 - -1. 所有查询放在事务之外,多条查询考虑用 readOnly 模式,建议用READ COMMITTED事物级别。但是外层事务 readOnly 事务会覆盖内层事务,使内层非只读事务表现出只读特性,我们的处理方式:(待补充) -2. 远程调用与事务,事物过程里面不许有远程调用。 -3. 在处理中应该先完成一个表的所有操作再处理下一个表的操作。相关的表进行的操作相邻。先业务表再history/audit之类的辅助表操作。 -4. 在事物里面处理多个表时,程序各处一定要按照同样的顺序。最好时按照聚合群的概念,从根部的表开始,广度优先,每层指定表的顺序。 -5. 多个表的操作最好封装到一个函数/方法里面。 -6. 序列号生成使用下面的事物模式: - -```java - @Transactional(propagation = Propagation.REQUIRES_NEW, isolation = Isolation.SERIALIZABLE) -``` - -## 17. 减少外键使用 - -插入操作会需要S lock所有的外键。所以像History或审计之类的表不要和主要业务表建立外键,可以建个索引用于快速查询就是了,这样也实现了表之间的解耦。 - -## 18. 锁的使用 - -尽可能避免表级别的锁。如果很多需要串行处理的操作,可以建立一个辅助的只有一行的semaphore(信号)表,事物开始时先修改这个表,然后进行其他业务处理。 - -## 19. java中使用swagger +## 9. java 中使用 swagger 针对`io.springfox:springfox-swagger2`的使用,我们要注意: -1. @ApiModel注解value属性值不能写中文,会导致swagger导出json时会报错。建议直接不写参数。 -2. 任何swagger注解的属性值都不要有单引号,json不认识单引号,swagger导出json会报错。比如@ApiModelProperty注解example属性值我们有时候希望给复杂类型(比如"['111','222']")。遇到这种情况,我们不写example。 +1. @ApiModel 注解 value 属性值不能写中文,会导致 swagger 导出 json 时会报错。建议直接不写参数。 +2. 任何 swagger 注解的属性值都不要有单引号,json 不认识单引号,swagger 导出 json 会报错。比如@ApiModelProperty 注解 example 属性值我们有时候希望给复杂类型(比如"['111','222']")。遇到这种情况,我们不写 example。 -## 20. 对同一服务下的接口文档进行分类 +## 10. 对同一服务下的接口文档进行分类 如 [swagger-usage-guideline](https://github.com/cntehang/public-dev-docs/blob/master/backend/swagger-usage-guideline.md) 中说明的那样,我们使用 swagger 用来作为前后端交流接口约定的工具。 @@ -279,14 +169,14 @@ HTTP/1.1 200 } ``` -## 21. 更多地使用官方工具包中定义好的 API,以一种更易读的方式对空、null 进行判断 +## 11. 更多地使用官方工具包中定义好的 API,以一种更易读的方式对空、null 进行判断 - 判断集合是否非空使用 org.apache.commons.collections4.CollectionUtils.isNotEmpty - 判断字符串非空使用 org.apache.commons.lang3.StringUtils.isNotEmpty,若还需非空格则使用 org.apache.commons.lang3.StringUtils.isNotBlank - 判断对象为非 null 使用 java.util.Objects.nonnull - 判断 Boolean 类型是否为 true 使用 org.apache.commons.lang3.BooleanUtils.isTrue -## 22. 日志记录应当涵盖所有代码分支 +## 12. 日志记录应当涵盖所有代码分支 日志记录是为追踪业务流程,排查系统 BUG 服务的,所以日志记录应该涵盖代码执行的所有分支。如: @@ -294,7 +184,7 @@ HTTP/1.1 200 - if-else 语句的 if 代码块和 else 代码块 - throw exception 代码前 -## 23. 所有集成测试用例使用统一的外部服务模拟器 +## 13. 所有集成测试用例使用统一的外部服务模拟器 集成测试中需要对当前服务调用到的外部服务进行模拟(使用 WireMock 等工具),如下代码定义了一个运行在 10098 端口的模拟服务器: @@ -308,7 +198,7 @@ HTTP/1.1 200 如果为每个 IntegrationTest 类创建一个模拟服务器,一方面会降低集成测试运行的效率(考虑模拟服务器开闭消耗的时间),另一方面会给包含异步方法调用的集成测试带来访问不到目标地址的风险(进行访问拦截的模拟服务器此时可能已经关闭)。故推荐为所有的集成测试启动一个唯一的外部服务模拟器,统一加载需要拦截的 URL,在所有集成测试运行之始,在所有集成测试运行结束后关闭。 -## 24. 统一服务的错误处理 +## 14. 统一服务的错误处理 这里的统一是指形式上的统一,如同本文第三点中所述 API 返回体数据结构定义的那样,错误信息也应该在这样的数据结构中返回。 要想做到这一点,我们需要一个统一的异常拦截器对程序中抛出的异常进行包装,以兼容定义好的数据结构。要想实现这一点,可使用自定义 ExceptionResolver(Spring 3.2 以下),或者使用 ControllerAdvice(Spring 3.2 及以上)。一个可能的最佳异常处理器形式如下: @@ -348,4 +238,4 @@ public class BestExceptionHandler { 相较于自定义 ExceptionResolver 实现,使用 ControllerAdvice 的优点在于屏蔽了 Respon/Request 等对象,以及丑陋的写输出流的操作。需要注意的是,这里限制了返回的所有错误形式都是我们约定好的 API 响应数据结构。 -目前我们项目中混用了 ExceptionResolver 和 ControllerAdvice,实际上选用一种即可。 \ No newline at end of file +目前我们项目中混用了 ExceptionResolver 和 ControllerAdvice,实际上选用一种即可。 diff --git "a/backend/database/\346\225\260\346\215\256\345\272\223\344\272\213\345\212\241\344\270\216\351\232\224\347\246\273\347\272\247\345\210\253.md" "b/backend/database/\346\225\260\346\215\256\345\272\223\344\272\213\345\212\241\344\270\216\351\232\224\347\246\273\347\272\247\345\210\253.md" index f7ef3aa..4028ce6 100644 --- "a/backend/database/\346\225\260\346\215\256\345\272\223\344\272\213\345\212\241\344\270\216\351\232\224\347\246\273\347\272\247\345\210\253.md" +++ "b/backend/database/\346\225\260\346\215\256\345\272\223\344\272\213\345\212\241\344\270\216\351\232\224\347\246\273\347\272\247\345\210\253.md" @@ -1,85 +1,155 @@ # 数据库事务与隔离级别 -## 事务ACID特性 +## 数据库事物处理(Transaction) -### 原子性(Atomicity) +读数据错误与丢失更新 -原子性是指事务是一个不可再分割的工作单元,事务中的操作要么都发生,要么都不发生。 +- [事务隔离级中](https://juejin.im/post/5b90cbf4e51d450e84776d27),脏读、不可重复读、幻读三个问题都是由事务 A 对数据进行修改、增加,事务 B 总是在做读操作造成的。 -### 一致性(Consistency) +如果两事务都在对数据进行修改则会导致另外的问题:丢失更新。为什么出现丢失更新: -一致性是指在事务开始之前和事务结束以后,数据库的完整性约束没有被破坏。这是说数据库事务不能破坏关系数据的完整性以及业务逻辑上的一致性。 +- 多个 session 对数据库同一张表的同一行数据进行修改,时间线上有所重复,可能会出现各种写覆盖的情况。 +- 示例可见:[并发事务的丢失更新及其处理方式](https://blog.csdn.net/u014590757/article/details/79612858) -如A给B转账,不论转账的事务操作是否成功,其两者的存款总额不变。 +解决方案: -### 隔离性(Isolation) +- 根据具体的业务场景,尽量缩小事物范围并采用正确的[事物隔离级别](https://dev.mysql.com/doc/refman/8.0/en/innodb-transaction-isolation-levels.html)。 +- 使 用数据库行级锁(如乐观锁、S 锁)。完全避免对行级数据的脏操作,但是使得对该行数据的访问串行化,对于比较大的表对象而言,这样的设置往往不是我们想要的结果。 +- 缩小事务管辖的范围。控制事务所辖代码执行时间的长度,不能将很耗时的操作(如外部服务调用)与数据修改置于同一个事务中。此方案只是尽量减少两个事务中的写操作互相影响的可能,无法完全避免。 +- 使用 ORM save 方法实现数据持久化的情况下,开启 Dynamic update,使得保存更改时影响的字段仅限于被改动了字段。此方案通过控制更新字段的范围,尽量减少脏操作可能,但也无法完全避免。 -多个事务并发访问时,事务之间是隔离的,一个事务不应该影响其它事务运行效果。 +## 关于 Hibernate Dynamic update -在并发环境中,当不同的事务同时操纵相同的数据时,每个事务都有各自的完整数据空间。由并发事务所做的修改必须与任何其他并发事务所做的修改隔离。事务查看数据更新时,数据所处的状态要么是另一事务修改它之前的状态,要么是另一事务修改它之后的状态,事务不会查看到中间状态的数据。 +主要缺陷 -事务最复杂问题都是由事务隔离性引起的。完全的隔离性是不现实的,完全的隔离性要求数据库同一时间只执行一条事务,这样会严重影响性能。 +- 语义错位。本意是直接修改部分属性,现在变成取整个 Object,改部分属性,存整个 Object。中间不可控因素太多。 +- 每次根据改动了的字段,动态生成 SQL 语句,性能上相比全更操作有所降低 +- 需要从数据库拿到整个 Object 所有数据才能修改,大多数时候不必要, +- 当两个 session 同时对同一字段进行更新操作,极端情况下会因为 ORM 缓存出现莫名其妙的情况,示例见:[Stackexchange Q: What's the overhead of updating all columns, even the ones that haven't changed](https://dba.stackexchange.com/questions/176582/whats-the-overhead-of-updating-all-columns-even-the-ones-that-havent-changed) -关于隔离性中的事务隔离等级,下面会说明。 +## 如何更新数据库字段 -### 持久性(Durability) +- 拒绝使用 Spring Data JPA 的 save 方法 + - 默认配置且未使用锁的情况下,save 方法会更新实体类的所有字段,一方面增加了开销,另一方面歪曲了更新特定字段的语义,多线程并发访问更新下的情况下易出现问题。 + - 配置动态更新且未使用锁的情况下,save 方法会监测改动了的字段并进行更新,但是可能会出现 11 点中提到的古怪情形。 + - 总的来看,使用 ORM save 方法进行实体类更新陷入了 “You wanted a banana but you got a gorilla holding the banana” 的怪圈,导致做的事情不精确、或者有其它的风险。[参考文章](https://www.johndcook.com/blog/2011/07/19/you-wanted-banana/) +- 使用自定义 SQL 进行字段更新 + - 使用 JPA 提供的 @Query/@Modifying 书写 JPQL 进行精确控制的字段更新操作。 -持久性,意味着在事务完成以后,该事务所对数据库所作的更改便持久的保存在数据库之中,并不会被回滚。 +## 处理 Hibernate 懒加载 -## 读现象 +什么是懒加载 -事务不隔离会带来的问题: +> An object that doesn't contain all of the data you need but knows how to get it. +> \- Martin Fowler defines in [Patterns of Enterprise Application Architecture](https://martinfowler.com/books/eaa.html) -* 更新丢失(Lost updates): 针对并发写数据 +懒加载在我们项目中带来的问题: - 两事务同时更新,A失败回滚覆盖B事务的更新,或事务A执行更新操作,在事务A结束前事务B也更新,则事务A的更新结果被事务B的覆盖。 +- 使用 Spring Data JPA 进行包含列表子对象的对象的列表查询时,若最后使用的结果集不仅限于该对象本身,而还包含其子对象中的内容,会出现 N + 1 问题 +- 使用 Spring Data JPA 查询数据时,若是从非 Controller 环境(如消息队列消费者等异步线程环境),访问对象下面的列表子对象会出现 session closed 异常 -* 脏读(Dirty reads): 针对未提交数据 +对付 N + 1 问题: - 事务A对数据进行了更新,但还没有提交,事务B可以读取到事务A没有提交的更新结果,这样造成的问题就是,如果事务A回滚,那么,事务B在此之前所读取的数据就是一笔脏数据。 +- 列表查询改用 Spring Jdbc Template 直接书写原生 SQL 语句执行查询,最大程度上提高效率 -* 不可重复读(Non-repeatable reads): 针对其他提交前后,读取数据本身的对比 +对付非事务环境下访问懒加载数据 session closed 问题: - 不可重复读取是指同一个事务在整个事务过程中对同一笔数据进行读取,每次读取结果都不同。如果事务A在事务B的更新操作之前读取一次数据,在事务B的更新操作之后再读取同一笔数据一次,两次结果是不同的。 +1. 设置 Hibernate 属性(v4.1.6 版本后可用):hibernate.enable_lazy_load_no_trans=true +2. 使用 @Fetch(FetchMode.JOIN) 注解 +3. 使用 @LazyCollection(LazyCollectionOption.FALSE) 注解 +4. 其它请补充 -* 幻读(Phantom reads): 针对其他提交前后,读取数据条数的对比 +## 使用 AspectJ - 幻读是指同样一笔查询在整个事务过程中多次执行后,查询所得的结果集是不一样的。幻读针对的是多笔记录。 +建议 AOP 用 aspectJ: -### 不可重复读与幻读的区别 +```xml + +``` -不可重复读的重点是修改,指的是同样条件读取过的数据,再次读取出来发现值不一样。 -幻读的重点是数据条数的变化(新增或删除),指的是同样的条件,两次读出来的记录数不一样 +相较于 Java JDK 代理、Cglib 等,AspectJ 不但 runtime 性能提高一个数量级,而且支持 class,method(public or private) 和同一个类的方法调用。可以把@Transaction 写到最相关的地方。坏处是配置和 build 可能稍稍有些麻烦。 + +## 不建议用乐观锁 + +不建议用乐观锁。所有的事物都明明白白的写出事物处理控制语句。如果更新不需要检查条件(比如更改地址),则直接更新,后面的提交可能覆盖前面的版本。 + +如果更新有一定条件,比如取消订单需要订单的状态是可取消状态,则更新时需要先用 select for update 检查更新的条件符合再更新,不符合条件返回相应的业务错误代码。乐观锁适用于读的版本是最新的数据版本。 + +## 事务的使用 + +1. 所有查询放在事务之外,多条查询考虑用 readOnly 模式,建议用 READ COMMITTED 事物级别。但是外层事务 readOnly 事务会覆盖内层事务,使内层非只读事务表现出只读特性,我们的处理方式:(待补充) +2. 远程调用与事务,事物过程里面不许有远程调用。 +3. 在处理中应该先完成一个表的所有操作再处理下一个表的操作。相关的表进行的操作相邻。先业务表再 history/audit 之类的辅助表操作。 +4. 在事物里面处理多个表时,程序各处一定要按照同样的顺序。最好时按照聚合群的概念,从根部的表开始,广度优先,每层指定表的顺序。 +5. 多个表的操作最好封装到一个函数/方法里面。 +6. 序列号生成使用下面的事物模式: + +```java + @Transactional(propagation = Propagation.REQUIRES_NEW, isolation = Isolation.SERIALIZABLE) +``` + +## 减少外键使用 + +插入操作会需要 S lock 所有的外键。所以像 History 或审计之类的表不要和主要业务表建立外键,可以建个索引用于快速查询就是了,这样也实现了表之间的解耦。 + +## 锁的使用 + +尽可能避免表级别的锁。如果很多需要串行处理的操作,可以建立一个辅助的只有一行的 semaphore(信号)表,事物开始时先修改这个表,然后进行其他业务处理。 + +## 读现象 + +事务不隔离会带来的问题: + +- 更新丢失(Lost updates): 针对并发写数据 + + 两事务同时更新,A 失败回滚覆盖 B 事务的更新,或事务 A 执行更新操作,在事务 A 结束前事务 B 也更新,则事务 A 的更新结果被事务 B 的覆盖。 + +- 脏读(Dirty reads): 针对未提交数据 + + 事务 A 对数据进行了更新,但还没有提交,事务 B 可以读取到事务 A 没有提交的更新结果,这样造成的问题就是,如果事务 A 回滚,那么,事务 B 在此之前所读取的数据就是一笔脏数据。 + +- 不可重复读(Non-repeatable reads): 针对其他提交前后,读取数据本身的对比 + + 不可重复读取是指同一个事务在整个事务过程中对同一笔数据进行读取,每次读取结果都不同。如果事务 A 在事务 B 的更新操作之前读取一次数据,在事务 B 的更新操作之后再读取同一笔数据一次,两次结果是不同的。 + +- 幻读(Phantom reads): 针对其他提交前后,读取数据条数的对比 + + 幻读是指同样一笔查询在整个事务过程中多次执行后,查询所得的结果集是不一样的。幻读针对的是多笔记录。 ## 事务隔离级别 数据库事务有四种隔离级别,由低到高分别为: -* 读未提交(Read uncommitted,RU) +- 读未提交(Read uncommitted,RU) + + 最低的隔离级别,指的是一个事务可以读其他事务未提交的数据。 - 最低的隔离级别,指的是一个事务可以读其他事务未提交的数据。 +- 读提交(Read committed,RC) -* 读提交(Read committed,RC) + 一个事务要等另一个事务提交后才能读取数据。 - 一个事务要等另一个事务提交后才能读取数据。 +- 可重复读(Repeatable Read,RR) -* 可重复读(Repeatable Read,RR) + 在开始读取数据(事务开启)时,不再允许修改操作。 - 在开始读取数据(事务开启)时,不再允许修改操作。 +- 串行化(Serializable,S) -* 串行化(Serializable,S) + 最高的事务隔离级别,事务串行化顺序执行。 - 最高的事务隔离级别,事务串行化顺序执行。 +### 不可重复读与幻读的区别 + +不可重复读的重点是修改,指的是同样条件读取过的数据,再次读取出来发现值不一样。 +幻读的重点是数据条数的变化(新增或删除),指的是同样的条件,两次读出来的记录数不一样 ### 隔离级别与读现象 | | 更新丢失 | 脏读 | 不可重复读 | 幻读 | -|--------------|----------|------|------------|------| +| ------------ | -------- | ---- | ---------- | ---- | | 读未提交(RU) | 避免 | | | | | 读提交(RC) | 避免 | 避免 | | | | 可重复读(RR) | 避免 | 避免 | 避免 | | | 串行化(S) | 避免 | 避免 | 避免 | 避免 | -#### 参考文档 +## 参考文档 -* [Isolation (database systems)](https://en.wikipedia.org/wiki/Isolation_%28database_systems%29#Read_phenomena) \ No newline at end of file +- [Isolation (database systems)](https://en.wikipedia.org/wiki/Isolation_%28database_systems%29#Read_phenomena) diff --git a/backend/database/database-connection-pool.md "b/backend/database/\346\225\260\346\215\256\345\272\223\350\277\236\346\216\245\346\261\240.md" similarity index 100% rename from backend/database/database-connection-pool.md rename to "backend/database/\346\225\260\346\215\256\345\272\223\350\277\236\346\216\245\346\261\240.md" From 8f2839e46982f239d8e6778420c81a0864b4e5c9 Mon Sep 17 00:00:00 2001 From: davis Date: Wed, 29 May 2019 10:34:51 +0800 Subject: [PATCH 020/231] add time zone practices --- backend/code/java-best-practices.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/backend/code/java-best-practices.md b/backend/code/java-best-practices.md index aea662d..c6dad22 100644 --- a/backend/code/java-best-practices.md +++ b/backend/code/java-best-practices.md @@ -36,3 +36,23 @@ try { } } ``` + +## 时区概念 + +程序中对时间处理,是根据服务器本地时间来的,所以对时间处理(转换,比较),必须要有时区的概念 + +反例: +```java +public static boolean isDateTimeGreaterThanNowOfBeijing(String dateTimeStr) { + DateTime dateTime = DateTime.parse(dateTimeStr, DATE_TIME_PATTERN); // 转换时未指定时区,下面的比对会错误 + DateTime now = DateTime.now(DateTimeZone.forID(ZONE_SHANGHAI)); + return dateTime.getMillis() > now.getMillis(); +} +``` + +正例: +```java +public static DateTime getCstNow() { + return new DateTime(DateTimeZone.forID(ZONE_SHANGHAI)); // 指定时区 +} +``` \ No newline at end of file From b9fd2d26a2accb1a0db9daaecd391738cb119648 Mon Sep 17 00:00:00 2001 From: davis Date: Wed, 29 May 2019 10:44:38 +0800 Subject: [PATCH 021/231] add api response data type practices --- backend/code/java-best-practices.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/backend/code/java-best-practices.md b/backend/code/java-best-practices.md index c6dad22..93ba7ce 100644 --- a/backend/code/java-best-practices.md +++ b/backend/code/java-best-practices.md @@ -55,4 +55,13 @@ public static boolean isDateTimeGreaterThanNowOfBeijing(String dateTimeStr) { public static DateTime getCstNow() { return new DateTime(DateTimeZone.forID(ZONE_SHANGHAI)); // 指定时区 } -``` \ No newline at end of file +``` + +## 接口对外数据类型 + +在返回给客户端的接口中,有些数据类型需要特殊处理: + +1. double/Double -> String:防止出现double转string时把不必要的数字也带上 +2. float/Float -> String:防止出现float转string时把不必要的数字也带上 +3. BigDecimal -> String:BigDecimal一般用于表示金额,这个需要严肃处理,指定具体的格式化形式,防止默认的转换与预期的要求不符 +4. DateTime/其他时间类型 -> String:时间的格式各异,必须要转为String返回 \ No newline at end of file From a310981f2c55587b0f2a7a6c3a106519a9c7b669 Mon Sep 17 00:00:00 2001 From: Quan Liu Date: Thu, 30 May 2019 14:29:39 +0800 Subject: [PATCH 022/231] update --- backend/code/how-to-handle-exception.md | 1 + backend/code/java-best-practices.md | 10 +++--- backend/code/java-code-guideline.md | 2 -- ...25\345\206\231\346\227\245\345\277\227.md" | 32 +++++++++---------- 4 files changed, 23 insertions(+), 22 deletions(-) diff --git a/backend/code/how-to-handle-exception.md b/backend/code/how-to-handle-exception.md index ba40ddf..bd51572 100644 --- a/backend/code/how-to-handle-exception.md +++ b/backend/code/how-to-handle-exception.md @@ -1,2 +1,3 @@ # 服务当中,如何处理异常 + - 不允许把产生的异常直接抛出到前端,必须转换成前端可处理的信息抛出 diff --git a/backend/code/java-best-practices.md b/backend/code/java-best-practices.md index 93ba7ce..5fa953b 100644 --- a/backend/code/java-best-practices.md +++ b/backend/code/java-best-practices.md @@ -42,6 +42,7 @@ try { 程序中对时间处理,是根据服务器本地时间来的,所以对时间处理(转换,比较),必须要有时区的概念 反例: + ```java public static boolean isDateTimeGreaterThanNowOfBeijing(String dateTimeStr) { DateTime dateTime = DateTime.parse(dateTimeStr, DATE_TIME_PATTERN); // 转换时未指定时区,下面的比对会错误 @@ -51,6 +52,7 @@ public static boolean isDateTimeGreaterThanNowOfBeijing(String dateTimeStr) { ``` 正例: + ```java public static DateTime getCstNow() { return new DateTime(DateTimeZone.forID(ZONE_SHANGHAI)); // 指定时区 @@ -61,7 +63,7 @@ public static DateTime getCstNow() { 在返回给客户端的接口中,有些数据类型需要特殊处理: -1. double/Double -> String:防止出现double转string时把不必要的数字也带上 -2. float/Float -> String:防止出现float转string时把不必要的数字也带上 -3. BigDecimal -> String:BigDecimal一般用于表示金额,这个需要严肃处理,指定具体的格式化形式,防止默认的转换与预期的要求不符 -4. DateTime/其他时间类型 -> String:时间的格式各异,必须要转为String返回 \ No newline at end of file +1. double/Double -> String:防止出现 double 转 string 时把不必要的数字也带上 +2. float/Float -> String:防止出现 float 转 string 时把不必要的数字也带上 +3. BigDecimal -> String:BigDecimal 一般用于表示金额,这个需要严肃处理,指定具体的格式化形式,防止默认的转换与预期的要求不符 +4. DateTime/其他时间类型 -> String:时间的格式各异,必须要转为 String 返回 diff --git a/backend/code/java-code-guideline.md b/backend/code/java-code-guideline.md index 298204f..0b557ff 100644 --- a/backend/code/java-code-guideline.md +++ b/backend/code/java-code-guideline.md @@ -173,8 +173,6 @@ HTTP/1.1 200 - 判断集合是否非空使用 org.apache.commons.collections4.CollectionUtils.isNotEmpty - 判断字符串非空使用 org.apache.commons.lang3.StringUtils.isNotEmpty,若还需非空格则使用 org.apache.commons.lang3.StringUtils.isNotBlank -- 判断对象为非 null 使用 java.util.Objects.nonnull -- 判断 Boolean 类型是否为 true 使用 org.apache.commons.lang3.BooleanUtils.isTrue ## 12. 日志记录应当涵盖所有代码分支 diff --git "a/backend/code/\345\246\202\344\275\225\345\206\231\346\227\245\345\277\227.md" "b/backend/code/\345\246\202\344\275\225\345\206\231\346\227\245\345\277\227.md" index e3ae082..eda142e 100644 --- "a/backend/code/\345\246\202\344\275\225\345\206\231\346\227\245\345\277\227.md" +++ "b/backend/code/\345\246\202\344\275\225\345\206\231\346\227\245\345\277\227.md" @@ -30,7 +30,7 @@ Info 表示一个重要的系统事件。可以给系统运维人员提供重要 Debug 是调试的主要级别。这个级别的信息应该给出完整的执行路径和重要的执行结果。打印的信息不应该太详细(比如有十个以上的属性),也不应该用在重复十次以上的循环内部。 -Trace 给出详细的程序运行状态。Trace 可以用在循环的内部或用于打印完整的详细信息。当输出详细信息是,通常也先有一个 Debug 级别的摘要信息。比如,Debug 信息给出数组的尺寸,而 Trace 级别给出具体的数组数据(所有元素或一部分元素)。 +Trace 给出详细的程序运行状态。Trace 可以用在循环的内部或用于打印完整的详细信息。当输出详细信息时,通常也先有一个 Debug 级别的摘要信息。比如,Debug 信息给出数组的尺寸,而 Trace 级别给出具体的数组数据(所有元素或一部分元素)。 ## 日志格式 @@ -46,7 +46,7 @@ LOG.info('userId:1234 clicked on buttonId:save on pageId:sign-up') 第二种格式给出了具体的变量名称和对应状态值,是推荐的日志格式。即参数名和参数值之间用':'分隔。 -Info和Debug级别的日志应成对出现,通常在函数出入口进行记录, 推荐格式如下: +Info 和 Debug 级别的日志应成对出现,通常在函数出入口进行记录, 推荐格式如下: ```java // "Enter. "作为推荐的函数进入点的日志格式标准,后面可以加上关键参数的信息 @@ -76,30 +76,30 @@ LOG.debug("Exit. return value:{}", returnValue); 2. LOG.trace("Enter. request:{}", params); ``` -第一种方法在关闭日志以后以会有函数调用toJson,会对性能造成影响,避免使用; -第二种方法在真正记录日志时才会调用params的toString()方法,推荐使用。 +第一种方法在关闭日志以后以会有函数调用 toJson,会对性能造成影响,避免使用; +第二种方法在真正记录日志时才会调用 params 的 toString()方法,推荐使用。 -## Tmc后台日志示例 +## Tmc 后台日志示例 -### Tmc后台日志级别确定: +### Tmc 后台日志级别确定: -- 顶层的重要业务(改变业务状态)都需Info级别的进出口日志。大订单和各产品订单的操作都属于顶层业务,产品订单的具体处理步骤都是Debug或Trace级别。 -- 对于查询业务一般为Debug或Trace级别,对于特别关注的查询业务,比如机票查询请求,也可以记录为Info级别。 -- 只有顶层才有Info, 其他子层级不应该有Info级别。 -- 定时任务也视为顶层操作,需要Info级别. +- 顶层的重要业务(改变业务状态)都需 Info 级别的进出口日志。大订单和各产品订单的操作都属于顶层业务,产品订单的具体处理步骤都是 Debug 或 Trace 级别。 +- 对于查询业务一般为 Debug 或 Trace 级别,对于特别关注的查询业务,比如机票查询请求,也可以记录为 Info 级别。 +- 只有顶层才有 Info, 其他子层级不应该有 Info 级别。 +- 定时任务也视为顶层操作,需要 Info 级别. ### 后台订单取消操作分析: -后台取消确认操作,从用户角度看,这是一个比较大的业务操作,系统运维人员需要知道这个操作的起止时间和状态,所以在Applicaton顶层方法里需要一个Info级别的信息,记录操作的开始和结束状态。开始的状态只有一个任务id,所以开始的日志记录应该包括任务id,日志信息应为"Enter taskId=1234"。 +后台取消确认操作,从用户角度看,这是一个比较大的业务操作,系统运维人员需要知道这个操作的起止时间和状态,所以在 Applicaton 顶层方法里需要一个 Info 级别的信息,记录操作的开始和结束状态。开始的状态只有一个任务 id,所以开始的日志记录应该包括任务 id,日志信息应为"Enter taskId=1234"。 -取消操作顶层的所有出口(包括throw Exception)需要Info级别以上的Log。 +取消操作顶层的所有出口(包括 throw Exception)需要 Info 级别以上的 Log。 出口有两种状态,成功和失败。 -对于成功的状态,日志级别为Info, 信息为"Exit",如果操作需要返回数据,这里应记录概要数据。比如"Exit. data:1234"。(对于登录接口的密码错误是正常业务状态,也应属于成功。) +对于成功的状态,日志级别为 Debug, 信息为"Exit",如果操作需要返回数据,这里应记录概要数据。比如"Exit. data:1234"。(对于登录接口的密码错误是正常业务状态,也应属于成功。) -对于失败的状态,在抛出异常的地方要记录日志,根据错误程度级别分别为Error或Warning。 其中Error表示严重系统或应用错误,需要运维或程序员尽快修复,Warning为不正常的系统状态(比如网络超时错误)或不应该发生的应用状态(比如订单状态为不可取消状态),需要运维或程序员关注。 +对于失败的状态,在抛出异常的地方要记录日志,根据错误程度级别分别为 Error 或 Warning。 其中 Error 表示严重系统或应用错误,需要运维或程序员尽快修复,Warning 为不正常的系统状态(比如网络超时错误)或不应该发生的应用状态(比如订单状态为不可取消状态),需要运维或程序员关注。 -对于取消订单而言,未找到对应的待取消订单或订单状态不能取消,属于不应该发生的事情,但不影响系统正常运行,应属于Warining级别,需要程序员关注,找出发生的原因。 +对于取消订单而言,未找到对应的待取消订单或订单状态不能取消,属于不应该发生的事情,但不影响系统正常运行,应属于 Warining 级别,需要程序员关注,找出发生的原因。 -取消订单包括很多子步骤,所有这些子步骤的日志信息应该为Debug级别, +取消订单包括很多子步骤,所有这些子步骤的日志信息应该为 Debug 级别, From 5d12972657686002f0cae93d14c921e4f01e965b Mon Sep 17 00:00:00 2001 From: liyingchun <> Date: Fri, 31 May 2019 12:43:47 +0800 Subject: [PATCH 023/231] add ionic4 upgrade docs --- .../ionic4/ion-virtual-scroll.md | 34 ++++ ...51\242\230cordova-hot-code-push-plugin.md" | 189 ++++++++++++++++++ .../ionic4/ionic4-upgrade-issues.md | 92 +++++++++ 3 files changed, 315 insertions(+) create mode 100644 frontend/best-practices/ionic-project/ionic4/ion-virtual-scroll.md create mode 100644 "frontend/best-practices/ionic-project/ionic4/ionic4 \344\275\277\347\224\250\347\203\255\346\233\264\346\226\260\346\217\222\344\273\266\351\227\256\351\242\230cordova-hot-code-push-plugin.md" create mode 100644 frontend/best-practices/ionic-project/ionic4/ionic4-upgrade-issues.md diff --git a/frontend/best-practices/ionic-project/ionic4/ion-virtual-scroll.md b/frontend/best-practices/ionic-project/ionic4/ion-virtual-scroll.md new file mode 100644 index 0000000..32be042 --- /dev/null +++ b/frontend/best-practices/ionic-project/ionic4/ion-virtual-scroll.md @@ -0,0 +1,34 @@ +# 数据量比较大时,使用ion-virtual-scroll + +> 该组件在数据量很大时的性能优势极为明显,接收的是一个数组,即使数据量很大,每次也只显示一部分(大概是稍大于屏幕显示范围的数量),如果数据量较小或者有分页时,不推荐使用。该组件本身也是有问题的,快速滑动时会有留白,但是为了应对大量数据的明显卡顿问题可以暂时忽略轻微的留白。 + +使用注意事项: + +- 注意要给approxItemHeight,最好与每个item的实际渲染高度相差不大,可以加速与计算virtualstroll的滚动高度。此处要特别注意,如果预估的高度与实际高度相差特别大,在低性能设备上会卡顿的很明显, 如果很相近,则会很顺畅丝滑 + +- trackBy ,更改数据源(筛选,重新查询)后同一个元素可以重用,一般返回对应数据的唯一标识 + +- 如果有头部信息,需要注意headerFn的性能问题,该方法里最好只做简单操作,否则会很卡 + +- headerFn和trackBy方法里都不能直接访问this,所以可以让headerFn和trackBy返回一个匿名函数,在匿名函数中使用this的引用 + + 代码如下: + + ```typescript + /** + * 虚拟item 头部信息 + */ + virturalItemHeaderFn() { + const thisReference = this + return function(_: Airport, recordIndex: number, __: Airport[]) { + return !thisReference.isSearching + ? thisReference.keysMap.get(recordIndex) + : null + } + } + ``` + + +## 参考资料 + +- [Ionic 4 | Implement Infinite Scroll List with Virtual Scroll List in Ionic 4 Application](https://www.freakyjolly.com/ionic-4-implement-infinite-scroll-list-with-virtual-scroll-list-in-ionic-4-application/) diff --git "a/frontend/best-practices/ionic-project/ionic4/ionic4 \344\275\277\347\224\250\347\203\255\346\233\264\346\226\260\346\217\222\344\273\266\351\227\256\351\242\230cordova-hot-code-push-plugin.md" "b/frontend/best-practices/ionic-project/ionic4/ionic4 \344\275\277\347\224\250\347\203\255\346\233\264\346\226\260\346\217\222\344\273\266\351\227\256\351\242\230cordova-hot-code-push-plugin.md" new file mode 100644 index 0000000..09d98c4 --- /dev/null +++ "b/frontend/best-practices/ionic-project/ionic4/ionic4 \344\275\277\347\224\250\347\203\255\346\233\264\346\226\260\346\217\222\344\273\266\351\227\256\351\242\230cordova-hot-code-push-plugin.md" @@ -0,0 +1,189 @@ +# ionic4 使用热更新插件问题 + +> 依赖cordova-hot-code-push-plugin 热更新,在升级到ionic4之后都会遇到白屏的问题,经过调试代码,发现是由于cordova-plugin-ionic-webview升级导致的 + +cordova-plugin-ionic-webview升级到2.x以上会有如下影响 + +- iOS 最低兼容之10+ +- android最低兼容 4.4+ +- GCDWebServer 不支持带转义的字符,比如 **cordova-hot-code-push-plugin** 的存储位置是 **Application Support**, 在URL转义后变成 **Application%20Support** , 此时GCDWebServer无法找到该路径,所以会白屏 +- 1.0时GCDWebServer的默认localserver 根目录是APP的根目录,而2.x之后改成了包中www目录,热更新后新的目录在 **Application Support**对应的www中,与localserver的根目录不符,所以此时也无法找到 + + + +针对以上基础改动,我们可以采取如下措施 + + + +## 1. iOS 打包target 改为10+ + +根据苹果官网的统计: + +iOS 10 以上的版本占据了95%的市场份额,相对来说兼容10以下的性价比太低 + + + +## 2. android 兼容 至4.4+ + +根据安卓官方统计: + +4.4以下的版本份额不足4%,所以低版本的也可以不给予考虑 + +## 3. 改动 cordova-hot-code-push-plugin的存储位置 + +将 **cordova-hot-code-push-plugin** 存储位置改为非 **Application Support** 的目录,比如cacheDirectory。此处只需要改动iOS对应的代码,如下: + +在 **HCPFilesStructure.m**的 **pluginRootFolder**方法中做如下改动 + +```objective-c + NSURL *supportDir = [fileManager applicationSupportDirectory]; +``` + +改为 + +```objective-c + NSURL *supportDir = [fileManager applicationCacheDirectory]; +``` + + + +## 4. 在热更新插件启动完毕和热更新文件下载完毕后重设localserver根目录,并重启localserver + +#### 安卓改动: + +只需要改动 **redirectToLocalStorageIndexPage**方法,使用到了java的反射机制 + +```java + private void redirectToLocalStorageIndexPage() { + final String indexPage = getStartingPage(); + + // remove query and fragment parameters from the index page path + // TODO: cleanup this fragment + String strippedIndexPage = indexPage; + if (strippedIndexPage.contains("#") || strippedIndexPage.contains("?")) { + int idx = strippedIndexPage.lastIndexOf("?"); + if (idx >= 0) { + strippedIndexPage = strippedIndexPage.substring(0, idx); + } else { + idx = strippedIndexPage.lastIndexOf("#"); + strippedIndexPage = strippedIndexPage.substring(0, idx); + } + } + + // make sure, that index page exists + String external = Paths.get(fileStructure.getWwwFolder(), strippedIndexPage); + if (!new File(external).exists()) { + Log.d("CHCP", "External starting page not found. Aborting page change."); + return; + } + try { + Log.d("CHCP", "begin restart app"); + String basePath = fileStructure.getWwwFolder(); + // 尝试重置本地服务器根目录为当前热更新后的外置存储路径 + Class[] cArg = new Class[1]; + cArg[0] = String.class; + // 此处重置loacalserver的根目录 + webView.getEngine().getClass().getDeclaredMethod("setServerBasePath", cArg).invoke(webView.getEngine(), + basePath); + } catch (NoSuchMethodException e) { + // TODO Auto-generated catch block + e.printStackTrace(); + } catch (IllegalAccessException e) { + // TODO Auto-generated catch block + e.printStackTrace(); + } catch (InvocationTargetException e) { + // TODO Auto-generated catch block + e.printStackTrace(); + } catch (SecurityException e) { + // TODO Auto-generated catch block + e.printStackTrace(); + } catch (Exception e) { + e.printStackTrace(); + + } + + Log.d("CHCP", "Loading external page: " + external); + } +``` + + + +#### iOS改动: + +> iOS的改动有两处,一处是热更新拆件加载完毕后和热更新文件下载完成后都需要重定向localserver的根目录 +> +> 为了与其他插件耦合,采用了objective-c的 + +添加如下方法: + +```objective-c +/** + * 切换本地服务根目录到外存储目录 + */ +-(void) switchServerBaseToExternalPath{ + + NSString * basePath = [_filesStructure.wwwFolder.absoluteString stringByReplacingOccurrencesOfString:@"file://" withString:@""]; + // 先要确保webVieEngine能响应以下两个方法 + if([self.webViewEngine respondsToSelector:@selector(setServerPath:)] && [self.webViewEngine respondsToSelector:@selector(basePath)]){ + // 先判断之前的本地服务根目录是否与将要切换的路径相同,如果不相同则切换,否则不切换 + NSString * preBasePath = [self.webViewEngine performSelector:@selector(basePath)]; + if( ![preBasePath isEqualToString:basePath] && [[NSFileManager defaultManager] fileExistsAtPath:basePath]){ + + dispatch_async(dispatch_get_main_queue(), ^{ + // 此处需要在主线程中调用,否则会出现意想不到的错误 + [self.webViewEngine performSelector:@selector(setServerPath:) withObject:basePath]; + }); + + } + NSLog(@"reset the base server success, start reload app"); + }else{ + // 如果不能响应,则不需要再调用切换了,保持APP在未更新状态 + NSLog(@"cannot reset the base server, keep current page"); + } +} +``` + +并在改动**resetIndexPageToExternalStorage**的代码如下: + +```objective-c +/** + * Redirect user to the index page that is located on the external storage. + */ +- (void)resetIndexPageToExternalStorage { + NSString *indexPageStripped = [self indexPageFromConfigXml]; + + NSRange r = [indexPageStripped rangeOfCharacterFromSet:[NSCharacterSet characterSetWithCharactersInString:@"?#"] options:0]; + if (r.location != NSNotFound) { + indexPageStripped = [indexPageStripped substringWithRange:NSMakeRange(0, r.location)]; + } + + NSURL *indexPageExternalURL = [self appendWwwFolderPathToPath:indexPageStripped]; + if (![[NSFileManager defaultManager] fileExistsAtPath:indexPageExternalURL.path]) { + return; + } + + // rewrite starting page www folder path: should load from external storage + if ([self.viewController isKindOfClass:[CDVViewController class]]) { + // 在此处重置localserver + [self switchServerBaseToExternalPath]; + } else { + NSLog(@"HotCodePushError: Can't make starting page to be from external storage. Main controller should be of type CDVViewController."); + } +} +``` + + + +fork了一份 **cordova-hot-code-push-plugin **的代码,并做了相应的改动,如果想用可以直接fork一下然后自己打npm的包,地址: + + + +当然, 我也带了一个npm的包,名字叫做 **teh-hot-code-push-plugin** + +安装方法: + +```bash +cordova plugin add teh-hot-code-push-plugin +``` + +其他与原来 插件没啥区别。亲测可用,如有问题,随时可以提issues或者评论。 \ No newline at end of file diff --git a/frontend/best-practices/ionic-project/ionic4/ionic4-upgrade-issues.md b/frontend/best-practices/ionic-project/ionic4/ionic4-upgrade-issues.md new file mode 100644 index 0000000..7ed66e7 --- /dev/null +++ b/frontend/best-practices/ionic-project/ionic4/ionic4-upgrade-issues.md @@ -0,0 +1,92 @@ +# Issues of migrating from ionic3 to ionic4 + +## 1. 在package删除了插件后再plugins文件中并没有同步删除 + +> 需要手动删除,否则会一直拷贝到iOS或Android项目中,导致编译出错 + + 删除某个插件后,再build,如果失败了,报错某个插件未安装或未找到,直接使用 + ```cordova platform rm ios/android``` 移除掉platform然后再重新添加就好了,目前还没有更好的处理方式 + +## 2. 本地跑Android机器build一直失败 + +> 执行cordova build android 出现输出如下,编译不成功。 +> +> ANDROID_HOME=/Users/huangenai/Library/Android/sdk +> JAVA_HOME=/Library/Java/JavaVirtualMachines/jdk1.8.0_191.jdk/Contents/Home +> +> /Applications/Android Studio.app/Contents/gradle/gradle-4.6/bin/gradle: Command failed with exit code EACCES + + 解决办法: 是因为项目文件目录的权限问题,给够权限就行 + +## 3. header ,title,segment,searchbar 在安卓和ios设备上样式差异较大,且兼容较差 + +> 统一使用iOS样式,设置mode=”ios",这样可以统一头部展示,title显示在正中间,返回按钮也统一了。 + +## 4. iOS10.3 footer无法显示 + +在全局样式 ***variables.scss*** 文件中更改 ion-content 的 ***display***,代码如下: + +``` css + ion-content { + display: flex; + } +``` + +参考资料: + + + +## 5. header无法透明 + +> 在有些页面需要header透明,content可在header下滚动,但是返回按钮可以显示。 官方文档中给出的方案是设置***ion-header*** 的 ***translucent*** 为true,同事设置 ***ion-content*** 的 ***fullscreen*** 为true + +事实证明此方案目前不起作用,虽然此时header处已经透明了,ion-content也是屏幕高度了,只是ion-content的position是relative, 仍会根据header来设置y轴位置,因此需要将ion-header的position改为 fixed + +具体步骤如下: + +- 设置ion-content 的 fullscreen为true +- 设置ion-toolbar 的css样式: + + ```css + --background: transparent; + --ion-color-base: transparent !important; + --border-style: none; + ``` + +参考资料: + + +## 6. 系统兼容问题 + +> ionic4 目前支持: +> +> | Platform | Supported Versions | +> | ----------- | ------------------ | +> | **Android** | 4.4+ | +> | **iOS** | 10+ | + +参考资料: + + +## 7. header中多个toolbar,页面切换时不跟随页面滑动 + +> 这是ionic的bug,需要升级ionic/core 和ionic/angular + +升级明星如下: + +``` ts +npm i @ionic/core@latest @ionic/angular@latest +``` + +参考资料: + + + + + +## 8. 界面切换时列表卡顿 + +> 如果下一个界面是一个列表,切换期间加载数据渲染列表会导致卡顿问题 +此时可以在生命周期钩子 ngOnInit中拉取数据,但是当 生命周期钩子 ionViewDidEnter 被调用时再显示列表 + +如果有loading,甚至可以在 ionViewDidEnter 拉取数据 From e4850f959d027c2071d788b664f9e7596de63c21 Mon Sep 17 00:00:00 2001 From: "ying.liu" Date: Mon, 3 Jun 2019 08:13:28 +0800 Subject: [PATCH 024/231] fix typo --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index 67829c7..f35a8db 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,6 @@ 所有文件的创建和更新需要创建 PR 和通过 Review。然后通知所有相关人员按新规则执行。不影响软件开发活动的简单的笔误更正和内容改善可以直接提交。 文档统一采用 Markdown 格式。建议采用 VS Code 并用 [markdownlint](https://marketplace.visualstudio.com/items?itemName=DavidAnson.vscode-markdownlint)来保证 markdown 文件风格的一致性。 -v ## 文档说明 From a1389d406f4aaeff9c416b66d09e943e08d3b61b Mon Sep 17 00:00:00 2001 From: "ying.liu" Date: Mon, 3 Jun 2019 08:14:29 +0800 Subject: [PATCH 025/231] revise readme --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f35a8db..694800b 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ ## 文档说明 - [程序员工作指南](./程序员工作指南.md):我们的开发理念 +- [dev-process](./dev-process/README.md):开发流程的描述 +- [frontend](./frontend/README.md):前端的技术文档 - [backend](./backend/README.md): 后端的技术文档 - [backend-and-frontend](./backend-and-frontend/README.md):前后端接口相关的技术文档 -- [dev-process](./dev-process/README.md):开发流程规则 -- [frontend](./frontend/README.md):前端的技术文档 From d22987dab95d7d3c810b8b58887837cb197cebfd Mon Sep 17 00:00:00 2001 From: Field Date: Mon, 3 Jun 2019 10:08:35 +0800 Subject: [PATCH 026/231] =?UTF-8?q?retry=E3=80=81retryWhen=E3=80=81catchEr?= =?UTF-8?q?ror=E3=80=81repeat=E3=80=81repeatWhen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...43\200\201repeat\343\200\201repeatWhen.md" | 263 ++++++++++++++++++ 1 file changed, 263 insertions(+) create mode 100644 "frontend/best-practices/rxjs/retry\343\200\201retryWhen\343\200\201catchError\343\200\201repeat\343\200\201repeatWhen.md" diff --git "a/frontend/best-practices/rxjs/retry\343\200\201retryWhen\343\200\201catchError\343\200\201repeat\343\200\201repeatWhen.md" "b/frontend/best-practices/rxjs/retry\343\200\201retryWhen\343\200\201catchError\343\200\201repeat\343\200\201repeatWhen.md" new file mode 100644 index 0000000..06b366d --- /dev/null +++ "b/frontend/best-practices/rxjs/retry\343\200\201retryWhen\343\200\201catchError\343\200\201repeat\343\200\201repeatWhen.md" @@ -0,0 +1,263 @@ +# RxJS重试之retry、retryWhen、catchError、repeat、repeatWhen + +最近处理业务时,在符合某种条件的情况下需要进行一次请求重发。RxJS 能够实现重试的操作符有 3 种: + +- retry +- retryWhen +- catchError + +`retry`、`retryWhen`、`catchError` 都属于 Error Handling 的操作符,通过**捕获异常**来实现重试。此外还有两个操作符 `repeat` ,`repeatWhen` 能够执行重复操作,下面对这 5 个操作符进行一波实战。 + +## 准备工作 + +简单实现一个请求函数,模拟调用接口。 [点击查看Angular HttpClient 的实现](https://github.com/angular/angular/blob/master/packages/http/src/backends/xhr_backend.ts) + +```ts +import { Observable, Observer } from "rxjs"; + +export const SUCCESS = 200 +export const quest = (param: { code: number }) => { + return new Observable((observer: Observer) => { + log('questing') + setTimeout(() => { + if (param.code === SUCCESS) { + observer.next('ok') + observer.complete() + } else { + observer.error('error') + } + }, 2000) + }) +} +``` + +以及 log 函数 + +```ts +export const log = function (...args: any[]) { + console.log.apply(console, args) +} +export const logCompleted = () => { + log('completed') +} +``` + +## 操作符 + +### repeat + +```ts +public repeat(count: number): Observable +``` + +重复 `count` 次由源 Observable 所发出的项的流。[官方文档](https://cn.rx.js.org/class/es6/Observable.js~Observable.html#instance-method-repeat) + +```ts +const param = {code:200} +quest(param) + .pipe( + repeat(2) + ) + .subscribe(log, log,logCompleted) + +// 执行结果: +// questing +// ok +// questing +// ok +// completed +``` + +值得注意一下的是,当全部的 repeat 执行完之后 Observable 变为 completed,尽管在 `quest` 中 `observer.complete()` 是紧跟着 `observer.next('ok')` 之后的。 + +如果 `count` 为0则产生一个空的 Observable (立即完成的 observable)。 + +### repeatWhen + +```ts +public repeatWhen(notifier: function(notifications: Observable): Observable): Observable +``` + +根据 `notifier` 返回的 Observable 来决定是否重复。 [官方文档](https://cn.rx.js.org/class/es6/Observable.js~Observable.html#instance-method-repeatWhen) + +```ts +// 模拟出 repeat 效果 +const param = {code:200} +quest(param) + .pipe( + repeatWhen(result$ => result$.pipe( //result$ + scan(count=>{ + return count + 1 + },0), + takeWhile(count => { + return count < 2 + }) + + )) + ) + .subscribe(log,log,logCompleted) +// 执行结果: +// questing +// ok +// questing +// ok +// completed +``` + +`repeatWhen` 必然会执行 1 次。看下 notifications 出现错误时的输出结果。 + +```ts +quest(param) + .pipe( + repeatWhen(result$ => result$.pipe( + tap( _=>{ + throw 'repeatWhen error' + }) + )) + ) + .subscribe(log,log,logCompleted) +// questing +// ok +// repeatWhen error +``` + +### retry + +```ts +retry(count: number): Observable +``` + +返回一个 Observable, 该 Observable 是源 Observable 不包含错误异常的镜像。 如果源 Observable 发生错误, 这个方法不会传播错误而是会不断的重新订阅源 Observable 直到达到最大重试次数 (由 `count` 参数指定)。[官方文档](https://cn.rx.js.org/class/es6/Observable.js~Observable.html#instance-method-retry) + +```ts +const param = {code:100} +quest(param) + .pipe( + retry(2) + ) + .subscribe(log, log,logCompleted) +// questing +// questing +// questing +// error +``` + +### retryWhen + +```ts +public retryWhen(notifier: function(errors: Observable): Observable): Observable +``` + +根据 `notifier` 返回的 Observable 来决定是否重试,用法和 `repeatWhen` 类似。[官方文档](https://cn.rx.js.org/class/es6/Observable.js~Observable.html#instance-method-retryWhen) + +```ts +let param = {code: 200} +let flag = false +quest(param) + .pipe( + tap(_ => { + if (!flag) throw 'reload' + }), + retryWhen(err$ => err$.pipe( + takeWhile(err => { + if(err === 'reload'){ + return true + }else + throw err + }), + tap(_ => { + flag = true + }), + )) + ) + .subscribe(log, log, logCompleted) +// questing +// questing +// ok +// completed +``` + +要注意一下的是,一定要清楚上游有哪些 Error ,符合条件的进行 `retry` 不符合条件的要继续 `throw`。 + +### catchError/catch + +```ts +public catch(selector: function): Observable +``` + +捕获 observable 中的错误,可以通过返回一个新的 observable 或者抛出错误对象来处理。[官方文档](https://cn.rx.js.org/class/es6/Observable.js~Observable.html#instance-method-catch) + +`selector` 函数接受两个参数: + +- `err` ,错误对象, +- `caught` ,源 Observable,当你想“重试”的时候返回它即可。 + +```ts +let param = {code: 200} +let flag = false +quest(param) + .pipe( + tap(_ => { + if (!flag) throw 'reload' + }), + catchError((err,catch$) => { + if(err === 'reload'){ + flag = true + return catch$ + }else + throw err + }) + ) + .subscribe(log, log, logCompleted) +// questing +// questing +// ok +// completed +``` + +效果和 `retryWhen` 一样,代码更简单,不需要考虑重试次数时可以考虑。 + +## retry 源码 + +`retry`、`retryWhen`、`catchError` 可以对错误重试,想要通过它们实现非错误重试,需要伪造一个 Error 以及额外判断。在非错误的情况下,可以进行重试吗?几经验证,没行得通 `this._unsubscribeAndRecycle()` 在 `_next` 函数中使用后,不能正常工作,具体原因未查明。又或者这个方案本身就存在问题,留待以后研究。 + +```ts +import { Subscriber } from 'rxjs/Subscriber'; + +export function retry(count = -1) { + // 这里改了下 + // return this.lift(new RetryOperator(count, this)); + return function (source) { + return source.lift(new RetryOperator(count, source)); + } +} +class RetryOperator { + constructor(public count, public source) { } + call(subscriber, source) { + return source.subscribe(new RetrySubscriber(subscriber, this.count, this.source)); + } +} +class RetrySubscriber extends Subscriber { + constructor(destination, public count, public source) { + super(destination); + } + error(err) { + if (!this.isStopped) { + const { source, count } = this; + if (count === 0) { + return super.error(err); + } + else if (count > -1) { + this.count = count - 1; + } + source.subscribe(this._unsubscribeAndRecycle()); + } + } +} +``` + +## 参考链接 + +[演示地址](https://stackblitz.com/edit/rxjs-y4ofkh) + +[创建操作符 | operator-creation](https://cloud.tencent.com/developer/section/1489395) \ No newline at end of file From 9371429ce7de6347bbbfa4f64ec7bc5a97b088c8 Mon Sep 17 00:00:00 2001 From: "ying.liu" Date: Mon, 3 Jun 2019 11:17:16 +0800 Subject: [PATCH 027/231] revise db transaction --- ...24\347\246\273\347\272\247\345\210\253.md" | 184 +++++++++--------- 1 file changed, 91 insertions(+), 93 deletions(-) diff --git "a/backend/database/\346\225\260\346\215\256\345\272\223\344\272\213\345\212\241\344\270\216\351\232\224\347\246\273\347\272\247\345\210\253.md" "b/backend/database/\346\225\260\346\215\256\345\272\223\344\272\213\345\212\241\344\270\216\351\232\224\347\246\273\347\272\247\345\210\253.md" index 4028ce6..4bd106d 100644 --- "a/backend/database/\346\225\260\346\215\256\345\272\223\344\272\213\345\212\241\344\270\216\351\232\224\347\246\273\347\272\247\345\210\253.md" +++ "b/backend/database/\346\225\260\346\215\256\345\272\223\344\272\213\345\212\241\344\270\216\351\232\224\347\246\273\347\272\247\345\210\253.md" @@ -1,155 +1,153 @@ # 数据库事务与隔离级别 -## 数据库事物处理(Transaction) +本文讨论了数据库事物处理的一些基本概念和使用原则。 -读数据错误与丢失更新 +## 事物处理的基本原则 -- [事务隔离级中](https://juejin.im/post/5b90cbf4e51d450e84776d27),脏读、不可重复读、幻读三个问题都是由事务 A 对数据进行修改、增加,事务 B 总是在做读操作造成的。 +除了缺省的,所有的数据库访问都清晰地写出事物处理隔离级别。 -如果两事务都在对数据进行修改则会导致另外的问题:丢失更新。为什么出现丢失更新: +只读取所需要的数据,不读多余的。 -- 多个 session 对数据库同一张表的同一行数据进行修改,时间线上有所重复,可能会出现各种写覆盖的情况。 -- 示例可见:[并发事务的丢失更新及其处理方式](https://blog.csdn.net/u014590757/article/details/79612858) +只更新改变的数据,不写多余的。 -解决方案: +如果更新不需要检查条件(比如更改地址),则直接更新,后面的提交会覆盖前面的版本。 -- 根据具体的业务场景,尽量缩小事物范围并采用正确的[事物隔离级别](https://dev.mysql.com/doc/refman/8.0/en/innodb-transaction-isolation-levels.html)。 -- 使 用数据库行级锁(如乐观锁、S 锁)。完全避免对行级数据的脏操作,但是使得对该行数据的访问串行化,对于比较大的表对象而言,这样的设置往往不是我们想要的结果。 -- 缩小事务管辖的范围。控制事务所辖代码执行时间的长度,不能将很耗时的操作(如外部服务调用)与数据修改置于同一个事务中。此方案只是尽量减少两个事务中的写操作互相影响的可能,无法完全避免。 -- 使用 ORM save 方法实现数据持久化的情况下,开启 Dynamic update,使得保存更改时影响的字段仅限于被改动了字段。此方案通过控制更新字段的范围,尽量减少脏操作可能,但也无法完全避免。 +如果更新有一定条件,比如取消订单需要订单的状态是可取消状态,则更新时需要先用 select for update 检查更新的条件符合再更新,不符合条件返回相应的业务错误代码。 -## 关于 Hibernate Dynamic update +尽量缩小事物范围并采用正确的事物隔离级别。 -主要缺陷 +尽量缩小占用数据库连接的时间。用的时候拿,用完立刻归还。 -- 语义错位。本意是直接修改部分属性,现在变成取整个 Object,改部分属性,存整个 Object。中间不可控因素太多。 -- 每次根据改动了的字段,动态生成 SQL 语句,性能上相比全更操作有所降低 -- 需要从数据库拿到整个 Object 所有数据才能修改,大多数时候不必要, -- 当两个 session 同时对同一字段进行更新操作,极端情况下会因为 ORM 缓存出现莫名其妙的情况,示例见:[Stackexchange Q: What's the overhead of updating all columns, even the ones that haven't changed](https://dba.stackexchange.com/questions/176582/whats-the-overhead-of-updating-all-columns-even-the-ones-that-havent-changed) +## 数据库事物处理(Transaction) -## 如何更新数据库字段 +为保证数据一致性,数据库支持不同的[事务隔离级别](https://dev.mysql.com/doc/refman/8.0/en/innodb-transaction-isolation-levels.html)。数据不一致可以分为二大类:读写不一致和更新丢失。 -- 拒绝使用 Spring Data JPA 的 save 方法 - - 默认配置且未使用锁的情况下,save 方法会更新实体类的所有字段,一方面增加了开销,另一方面歪曲了更新特定字段的语义,多线程并发访问更新下的情况下易出现问题。 - - 配置动态更新且未使用锁的情况下,save 方法会监测改动了的字段并进行更新,但是可能会出现 11 点中提到的古怪情形。 - - 总的来看,使用 ORM save 方法进行实体类更新陷入了 “You wanted a banana but you got a gorilla holding the banana” 的怪圈,导致做的事情不精确、或者有其它的风险。[参考文章](https://www.johndcook.com/blog/2011/07/19/you-wanted-banana/) -- 使用自定义 SQL 进行字段更新 - - 使用 JPA 提供的 @Query/@Modifying 书写 JPQL 进行精确控制的字段更新操作。 +### 事务隔离级别 -## 处理 Hibernate 懒加载 +数据库事务有四种隔离级别,由低到高分别为: -什么是懒加载 +- 读未提交(Read uncommitted,RU) -> An object that doesn't contain all of the data you need but knows how to get it. -> \- Martin Fowler defines in [Patterns of Enterprise Application Architecture](https://martinfowler.com/books/eaa.html) + 最低的隔离级别,指的是一个事务可以读其他事务未提交的数据。 -懒加载在我们项目中带来的问题: +- 读提交(Read committed,RC) -- 使用 Spring Data JPA 进行包含列表子对象的对象的列表查询时,若最后使用的结果集不仅限于该对象本身,而还包含其子对象中的内容,会出现 N + 1 问题 -- 使用 Spring Data JPA 查询数据时,若是从非 Controller 环境(如消息队列消费者等异步线程环境),访问对象下面的列表子对象会出现 session closed 异常 + 一个事务要等另一个事务提交后才能读取数据。 -对付 N + 1 问题: +- 可重复读(Repeatable Read,RR) -- 列表查询改用 Spring Jdbc Template 直接书写原生 SQL 语句执行查询,最大程度上提高效率 + 在开始读取数据(事务开启)时,不再允许修改操作。 -对付非事务环境下访问懒加载数据 session closed 问题: +- 串行化(Serializable,S) -1. 设置 Hibernate 属性(v4.1.6 版本后可用):hibernate.enable_lazy_load_no_trans=true -2. 使用 @Fetch(FetchMode.JOIN) 注解 -3. 使用 @LazyCollection(LazyCollectionOption.FALSE) 注解 -4. 其它请补充 + 最高的事务隔离级别,事务串行化顺序执行。 -## 使用 AspectJ +### 读写不一致 -建议 AOP 用 aspectJ: +脏读、不可重复读和幻读[三个问题](https://juejin.im/post/5b90cbf4e51d450e84776d27)都是源于事务 A 对数据进行修改时同时有另一个事务 B 在做读操作造成的。 -```xml - -``` +- 脏读(Dirty reads): 针对未提交数据 -相较于 Java JDK 代理、Cglib 等,AspectJ 不但 runtime 性能提高一个数量级,而且支持 class,method(public or private) 和同一个类的方法调用。可以把@Transaction 写到最相关的地方。坏处是配置和 build 可能稍稍有些麻烦。 + 事务 A 对数据进行了更新,但还没有提交,事务 B 可以读取到事务 A 没有提交的更新结果,这样造成的问题就是,如果事务 A 回滚,那么,事务 B 在此之前所读取的数据就是一笔脏数据。 -## 不建议用乐观锁 +- 不可重复读(Non-repeatable reads): 针对其他提交前后,读取数据本身的对比 -不建议用乐观锁。所有的事物都明明白白的写出事物处理控制语句。如果更新不需要检查条件(比如更改地址),则直接更新,后面的提交可能覆盖前面的版本。 + 不可重复读取是指同一个事务在整个事务过程中对同一笔数据进行读取,每次读取结果都不同。如果事务 A 在事务 B 的更新操作之前读取一次数据,在事务 B 的更新操作之后再读取同一笔数据一次,两次结果是不同的。 -如果更新有一定条件,比如取消订单需要订单的状态是可取消状态,则更新时需要先用 select for update 检查更新的条件符合再更新,不符合条件返回相应的业务错误代码。乐观锁适用于读的版本是最新的数据版本。 +- 幻读(Phantom reads): 针对其他提交前后,读取数据条数的对比 -## 事务的使用 + 幻读是指同样一笔查询在整个事务过程中多次执行后,查询所得的结果集是不一样的。幻读针对的是多笔记录。 -1. 所有查询放在事务之外,多条查询考虑用 readOnly 模式,建议用 READ COMMITTED 事物级别。但是外层事务 readOnly 事务会覆盖内层事务,使内层非只读事务表现出只读特性,我们的处理方式:(待补充) -2. 远程调用与事务,事物过程里面不许有远程调用。 -3. 在处理中应该先完成一个表的所有操作再处理下一个表的操作。相关的表进行的操作相邻。先业务表再 history/audit 之类的辅助表操作。 -4. 在事物里面处理多个表时,程序各处一定要按照同样的顺序。最好时按照聚合群的概念,从根部的表开始,广度优先,每层指定表的顺序。 -5. 多个表的操作最好封装到一个函数/方法里面。 -6. 序列号生成使用下面的事物模式: +| | 更新丢失 | 脏读 | 不可重复读 | 幻读 | +| -------------- | -------- | ---- | ---------- | ---- | +| 读未提交(RU) | 避免 | | | | +| 读提交(RC) | 避免 | 避免 | | | +| 可重复读(RR) | 避免 | 避免 | 避免 | | +| 串行化(S) | 避免 | 避免 | 避免 | 避免 | -```java - @Transactional(propagation = Propagation.REQUIRES_NEW, isolation = Isolation.SERIALIZABLE) -``` +不可重复读的重点是修改,指的是同样条件读取过的数据,再次读取出来发现值不一样。 +幻读的重点是数据条数的变化(新增或删除),指的是同样的条件,两次读出来的记录数不一样。 -## 减少外键使用 +### 更新丢失 -插入操作会需要 S lock 所有的外键。所以像 History 或审计之类的表不要和主要业务表建立外键,可以建个索引用于快速查询就是了,这样也实现了表之间的解耦。 +[更新丢失(Lost updates)](https://blog.csdn.net/u014590757/article/details/79612858)针对并发写数据.多个 session 对数据库同一张表的同一行数据进行修改,时间线上有所重复,可能会出现各种写覆盖的情况。具体说就是后面的写操作会覆盖前面的写操作。 -## 锁的使用 +## 不建议用乐观锁 -尽可能避免表级别的锁。如果很多需要串行处理的操作,可以建立一个辅助的只有一行的 semaphore(信号)表,事物开始时先修改这个表,然后进行其他业务处理。 +乐观锁就是给一个数据加一个版本,每次写的时候先检查是否开始读出的数据是最新版本,如果是,则更新数据和版本。如果数据在读之后,写之前已经修改有了更新的版本,则报错。乐观锁适用于并发写数据不常见而且可以自动或方便的人工修复的场景。如果并发写经常发生,就不符合乐观的假设了。如果不能自动或方便的人工修复,由于没有事物等待机制,处理事物失败的成本会很高。 -## 读现象 +所以通常不建议用乐观锁。 -事务不隔离会带来的问题: +## Spring JPA -- 更新丢失(Lost updates): 针对并发写数据 +### 关于 Hibernate Dynamic update - 两事务同时更新,A 失败回滚覆盖 B 事务的更新,或事务 A 执行更新操作,在事务 A 结束前事务 B 也更新,则事务 A 的更新结果被事务 B 的覆盖。 +使用 ORM save 方法实现数据持久化的情况下,开启 Dynamic update,使得保存更改时影响的字段仅限于被改动了字段。此方案通过控制更新字段的范围,尽量减少脏操作可能,但也无法完全避免。主要缺陷 -- 脏读(Dirty reads): 针对未提交数据 +- 语义错位。本意是直接修改部分属性,现在变成取整个 Object,改部分属性,存整个 Object。中间不可控因素太多。 +- 每次根据改动了的字段,动态生成 SQL 语句,性能上相比全更操作有所降低。 +- 需要从数据库拿到整个 Object 所有数据才能修改,大多数时候不必要。 +- 当两个 session 同时对同一字段进行更新操作,会出现各种数据一致性错误。示例见:[Stackexchange Q: What's the overhead of updating all columns, even the ones that haven't changed](https://dba.stackexchange.com/questions/176582)。 - 事务 A 对数据进行了更新,但还没有提交,事务 B 可以读取到事务 A 没有提交的更新结果,这样造成的问题就是,如果事务 A 回滚,那么,事务 B 在此之前所读取的数据就是一笔脏数据。 +### 如何更新数据库字段 -- 不可重复读(Non-repeatable reads): 针对其他提交前后,读取数据本身的对比 +- 只有在创建新的数据时使用 Spring Data JPA 的 save 方法。 +- 不要在更新数据时使用 Spring Data JPA 的 save 方法。 + - 默认配置且未使用锁的情况下,save 方法会更新实体类的所有字段,一方面增加了开销,另一方面歪曲了更新特定字段的语义,多线程并发访问更新下的情况下易出现问题。 + - 配置动态更新且未使用锁的情况下,save 方法会监测改动了的字段并进行更新,但是由于脏读的可能性,更新的数据可能出错。 + - 总的来看,使用 ORM save 方法进行实体类更新陷入了 “You wanted a banana but you got a gorilla holding the banana” 的怪圈,导致做的事情不精确、或者有其它的风险。[参考文章](https://www.johndcook.com/blog/2011/07/19/you-wanted-banana/) +- 使用自定义 SQL 进行字段更新 + - 使用 JPA 提供的 @Query/@Modifying 书写 JPQL 进行精确控制的字段更新操作。 - 不可重复读取是指同一个事务在整个事务过程中对同一笔数据进行读取,每次读取结果都不同。如果事务 A 在事务 B 的更新操作之前读取一次数据,在事务 B 的更新操作之后再读取同一笔数据一次,两次结果是不同的。 +### 处理 Hibernate 懒加载 -- 幻读(Phantom reads): 针对其他提交前后,读取数据条数的对比 +当一个对象有很多关联的数据时,关联的数据只有在使用时才被加载就叫懒加载。 - 幻读是指同样一笔查询在整个事务过程中多次执行后,查询所得的结果集是不一样的。幻读针对的是多笔记录。 +懒加载在我们项目中带来的问题: -## 事务隔离级别 +- 使用 Spring Data JPA 进行包含列表子对象的对象的列表查询时,若最后使用的结果集不仅限于该对象本身,而还包含其子对象中的内容,会出现 N + 1 问题。 +- 使用 Spring Data JPA 查询数据时,若是从非 Controller 环境(如消息队列消费者等异步线程环境),访问对象下面的列表子对象会出现 session closed 异常。因为此时没有 session,没有懒加载机制。 -数据库事务有四种隔离级别,由低到高分别为: +对付 N + 1 问题: -- 读未提交(Read uncommitted,RU) +- 列表查询改用 Spring Jdbc Template 直接书写原生 SQL 语句执行查询,最大程度上提高效率 - 最低的隔离级别,指的是一个事务可以读其他事务未提交的数据。 +对付非事务环境下访问懒加载数据 session closed 问题: -- 读提交(Read committed,RC) +1. 设置 Hibernate 属性(v4.1.6 版本后可用):hibernate.enable_lazy_load_no_trans=true +2. 使用 @Fetch(FetchMode.JOIN) 注解 +3. 使用 @LazyCollection(LazyCollectionOption.FALSE) 注解 +4. Spring boot 的 Open Session In View (OSIV) 虽然可用,但是由于 session 时间过长,一直占用数据库连接,不建议使用。 - 一个事务要等另一个事务提交后才能读取数据。 +### 使用 AspectJ -- 可重复读(Repeatable Read,RR) +建议 AOP 用 aspectJ: - 在开始读取数据(事务开启)时,不再允许修改操作。 +```xml + +``` -- 串行化(Serializable,S) +相较于 Java JDK 代理、Cglib 等,AspectJ 不但 runtime 性能提高一个数量级,而且支持 class,method(public or private) 和同一个类的方法调用。可以把@Transaction 写到最相关的地方。坏处是配置和 build 可能稍稍有些麻烦。 - 最高的事务隔离级别,事务串行化顺序执行。 +## 事务的使用建议 -### 不可重复读与幻读的区别 +- 减少外键使用 -不可重复读的重点是修改,指的是同样条件读取过的数据,再次读取出来发现值不一样。 -幻读的重点是数据条数的变化(新增或删除),指的是同样的条件,两次读出来的记录数不一样 +插入操作会需要 S lock 所有的外键。所以像 History 或审计之类的表不要和主要业务表建立外键,可以建个索引用于快速查询就是了,这样也实现了表之间的解耦。 -### 隔离级别与读现象 +- 锁的使用 -| | 更新丢失 | 脏读 | 不可重复读 | 幻读 | -| ------------ | -------- | ---- | ---------- | ---- | -| 读未提交(RU) | 避免 | | | | -| 读提交(RC) | 避免 | 避免 | | | -| 可重复读(RR) | 避免 | 避免 | 避免 | | -| 串行化(S) | 避免 | 避免 | 避免 | 避免 | +尽可能避免表级别的锁。如果很多需要串行处理的操作,可以建立一个辅助的只有一行的 semaphore(信号)表,事物开始时先修改这个表,然后进行其他业务处理。 -## 参考文档 +- 最佳实践 -- [Isolation (database systems)](https://en.wikipedia.org/wiki/Isolation_%28database_systems%29#Read_phenomena) +1. 所有查询放在事务之外,多条查询考虑用 readOnly 模式,建议用 READ COMMITTED 事物级别。但是外层事务 readOnly 事务会覆盖内层事务,使内层非只读事务表现出只读特性,我们的处理方式:(待补充) +2. 远程调用与事务,事物过程里面不许有远程调用。 +3. 在处理中应该先完成一个表的所有操作再处理下一个表的操作。相关的表进行的操作相邻。先业务表再 history/audit 之类的辅助表操作。 +4. 在事物里面处理多个表时,程序各处一定要按照同样的顺序。最好时按照聚合群的概念,从根部的表开始,广度优先,每层指定表的顺序。 +5. 多个表的操作最好封装到一个函数/方法里面。 +6. 序列号生成使用下面的事物模式: + +```java + @Transactional(propagation = Propagation.REQUIRES_NEW, isolation = Isolation.SERIALIZABLE) +``` From e75551aee8fa580c87aa5b30f63929713113887d Mon Sep 17 00:00:00 2001 From: "ying.liu" Date: Mon, 3 Jun 2019 11:37:19 +0800 Subject: [PATCH 028/231] organize basic rules --- ...24\347\246\273\347\272\247\345\210\253.md" | 54 +++++++------------ 1 file changed, 20 insertions(+), 34 deletions(-) diff --git "a/backend/database/\346\225\260\346\215\256\345\272\223\344\272\213\345\212\241\344\270\216\351\232\224\347\246\273\347\272\247\345\210\253.md" "b/backend/database/\346\225\260\346\215\256\345\272\223\344\272\213\345\212\241\344\270\216\351\232\224\347\246\273\347\272\247\345\210\253.md" index 4bd106d..18e010d 100644 --- "a/backend/database/\346\225\260\346\215\256\345\272\223\344\272\213\345\212\241\344\270\216\351\232\224\347\246\273\347\272\247\345\210\253.md" +++ "b/backend/database/\346\225\260\346\215\256\345\272\223\344\272\213\345\212\241\344\270\216\351\232\224\347\246\273\347\272\247\345\210\253.md" @@ -4,43 +4,20 @@ ## 事物处理的基本原则 -除了缺省的,所有的数据库访问都清晰地写出事物处理隔离级别。 +看上去纷繁复杂,其实掌握了下面这些基本原则,花时间搞清楚自己在干什么,就完全可以避免数据不一致并保证高并发。 -只读取所需要的数据,不读多余的。 - -只更新改变的数据,不写多余的。 - -如果更新不需要检查条件(比如更改地址),则直接更新,后面的提交会覆盖前面的版本。 - -如果更新有一定条件,比如取消订单需要订单的状态是可取消状态,则更新时需要先用 select for update 检查更新的条件符合再更新,不符合条件返回相应的业务错误代码。 - -尽量缩小事物范围并采用正确的事物隔离级别。 - -尽量缩小占用数据库连接的时间。用的时候拿,用完立刻归还。 +- 除了缺省的,所有的数据库访问都清晰地写出事物隔离级别。 +- 只读取所需要的数据,不读多余的。不要懒加载,早加载所有用到的数据,不加载多余的。 +- 只更新改变的数据,不写多余的。处理创建或整体修改,不要整体 save, 只 update 改变的数据. +- 不需要事物,就不要用。比如,如果更新不需要检查条件(比如更改地址),则直接更新,后面的提交覆盖前面的版本。 +- 如果更新有一定条件,比如取消订单需要订单的状态是可取消状态,则更新时需要先用 select for update 检查更新的条件,符合条件再更新,不符合条件返回相应的业务错误代码。 +- 尽量缩小事物范围,尽可能晚开始事物,尽可能早提交或回滚。 +- 并采用正确的事物隔离级别。拿不准就正确性第一,用高一级的事物隔离。 +- 尽量缩小占用数据库连接的时间。用的时候拿,用完立刻归还。 ## 数据库事物处理(Transaction) -为保证数据一致性,数据库支持不同的[事务隔离级别](https://dev.mysql.com/doc/refman/8.0/en/innodb-transaction-isolation-levels.html)。数据不一致可以分为二大类:读写不一致和更新丢失。 - -### 事务隔离级别 - -数据库事务有四种隔离级别,由低到高分别为: - -- 读未提交(Read uncommitted,RU) - - 最低的隔离级别,指的是一个事务可以读其他事务未提交的数据。 - -- 读提交(Read committed,RC) - - 一个事务要等另一个事务提交后才能读取数据。 - -- 可重复读(Repeatable Read,RR) - - 在开始读取数据(事务开启)时,不再允许修改操作。 - -- 串行化(Serializable,S) - - 最高的事务隔离级别,事务串行化顺序执行。 +数据库的数据不一致可以分为二大类:读写不一致和更新丢失。数据库依靠事务处理来保证数据一致性和并发性。乐观锁是一种轻量的事物处理机制,但是使用场合非常有限。 ### 读写不一致 @@ -72,7 +49,16 @@ [更新丢失(Lost updates)](https://blog.csdn.net/u014590757/article/details/79612858)针对并发写数据.多个 session 对数据库同一张表的同一行数据进行修改,时间线上有所重复,可能会出现各种写覆盖的情况。具体说就是后面的写操作会覆盖前面的写操作。 -## 不建议用乐观锁 +### 事务隔离级别 + +为保证数据一致性,数据库支持不同的[事务隔离级别](https://dev.mysql.com/doc/refman/8.0/en/innodb-transaction-isolation-levels.html)。数据库事务有四种隔离级别,由低到高分别为: + +- 读未提交(Read uncommitted,RU): 最低的隔离级别,指的是一个事务可以读其他事务未提交的数据。 +- 读提交(Read committed,RC): 一个事务要等另一个事务提交后才能读取数据。 +- 可重复读(Repeatable Read,RR): 在开始读取数据(事务开启)时,不再允许修改操作。 +- 串行化(Serializable,S): 最高的事务隔离级别,事务串行化顺序执行。 + +### 不建议用乐观锁 乐观锁就是给一个数据加一个版本,每次写的时候先检查是否开始读出的数据是最新版本,如果是,则更新数据和版本。如果数据在读之后,写之前已经修改有了更新的版本,则报错。乐观锁适用于并发写数据不常见而且可以自动或方便的人工修复的场景。如果并发写经常发生,就不符合乐观的假设了。如果不能自动或方便的人工修复,由于没有事物等待机制,处理事物失败的成本会很高。 From fc38be883eead903e0cb64bb7495e0af161ad7ea Mon Sep 17 00:00:00 2001 From: "ying.liu" Date: Mon, 3 Jun 2019 16:08:16 +0800 Subject: [PATCH 029/231] add culture --- README.md | 13 +++++---- culture.md | 84 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 5 deletions(-) create mode 100644 culture.md diff --git a/README.md b/README.md index 694800b..c9d154f 100644 --- a/README.md +++ b/README.md @@ -2,19 +2,22 @@ --- -备注: **这里只存放可以公开共享的、目前有效的技术文档,不要存放任何会议记录或具体项目相关的文档。比如包含有内部项目截屏、计划或人员信息的文档要存在内部私有库。** +备注: **这里只存放可以公开共享的、目前有效的文档,不要存放任何会议记录或具体项目相关的文档。包含有内部项目截屏、计划或人员信息的文档要存在内部私有库。** 持续的、高质效的软件开发能力是现代企业的核心竞争力。多年实践让我们意识到以下几点事实: -- 思想就是软件 Mind is software (老刘) -- 切忌随波逐流 Only dead fish go with the flow (西谚) -- 做人如果没有梦想,跟咸鱼有什么分别 Salted fish has no dream (星爷) -- 管理的本质是激发善意和潜能 The essence of management is to inspire goodwill and potential(德鲁克) +- [企业文化](./culture.md)不是业务的一个方面,它就是业务。Culture isn't just one aspect of the game, it is the game. (郭士纳,Louis V. Gerstner, IBM 前总裁) +- 思想就是软件。 Mind is software. (老刘) +- 切忌随波逐流。 Only dead fish go with the flow.(西谚) +- 做人如果没有梦想,跟咸鱼有什么分别。 Salted fish has no dream. (星爷) +- 管理的本质是激发善意和潜能。The essence of management is to inspire goodwill and potential.(德鲁克) 用思想创造软件进而改变世界的程序员责任重大,任劳任怨。可是环顾四周,多数软件团队的研发能力相对计算机的巨大潜力和广泛的业务需求有巨大鸿沟,开发效率低,软件的质量令人忧伤。稍感安慰的是半个多世纪的编程历史积累了一些最佳实践(best practices)。遵守基于这些最佳实践的规则能大大改善程序员的工作效率。 ## 出发点 +首先,企业文化是根本。所有的流程和做事方法都来自[我们的企业文化](./culture.md)。 + 软件开发有二个根本性原则:正确的业务逻辑与可维护性。 软件开发管理活动,如自动测试要求,代码标准,代码审核,文档标准,github 工作流,需求工作流程,设计工作流程,开发环境配置等等都是基于这二个原则来展开。我们明白这些流程、标准、模版不是限制,任何规则都可以定制,目的是让整个开发过程自动化和标准化,从而让开发人员把精力放在最关键的创造性工作中。 diff --git a/culture.md b/culture.md new file mode 100644 index 0000000..df901ca --- /dev/null +++ b/culture.md @@ -0,0 +1,84 @@ +# 企业文化 + +## 1 导言 + +企业是团队创造价值的载体。每个成员都是和有着相同价值观的人一起做有意义的事情。企业的法律根本是所有股份的价值。团队的所有正式成员都应该是股东。我们非常认同彼得· 德鲁克的观点:“管理的本质是激发善意和潜能” 。 + +企业文化就是企业的价值观,就是企业推崇的行为和技能。这不是口号,是实际执行准则和做事原则。哪些行为被鼓励,哪些人被提升奖励,哪些人被惩罚这些看得见摸得着的行为是企业文化。 +我们奉行的企业文化有如下原则: + +- 信任 +- 基于共识的决策 +- 平衡务实 +- 持续学习 +- 坦诚相待 + +## 2 原则 + +### 2.1 信任 + +我们竭尽所能招聘和挽留优秀的伙伴。所有的同事都拥有值得信任的人品和能力。体系在下面几点: + +- 自我驱动:努力、认真。以非常专业的标准要求自己。 +- 正直:有职业道德。正直就是做符合价值观的事,即使无人知道。 +- 全局观:考虑全局而不是小团队的得失 + +在实际工作中,信任体现在下面几点: + +- 自己决定进度 +- 每个人的意见都被倾听和理解 +- 不计考勤 +- 自我管理 + +### 2.2 基于共识的决策 + +基于共识决策的目的是让每个人发挥最大潜力。对团队而言就是可以多维度看问题。我们深知个人是靠不住的。每个人都有自己的知识领域和由于偏见和习惯带来的认知盲区。基于共识的决策体现在下面的做法: + +- 公开所有信息,员工可以参与所有事物。 +- 根据经验和历史,每个人有不同的决策权重。 +- 每个人都有充分的表达和被理解的权利,理解其他人的观点。 +- 确保每个人对决定的优缺点有深刻理解。 + +### 2.3 平衡务实 + +我们追求平衡务实带来的持续高效。平衡意味着生活工作有调理,身体健康。注重工作的实效,不搞面子工程。靠能力和健康提高效率。 + +- 标准要求:40 小时工作 + 20 小时的自我学习 + 每周 4 次(10 小时)锻炼身体。 +- 不要求加班 +- 下面二种工作模式或混合方式都好 + - 996:早 9 晚 9, 每周 6 天 + - 965:早 9 晚 6,每周 5 天 + +一旦成为正式员工,我们的评价体现是和自己做比较:自己比过去更好。 + +### 2.4 持续学习 + +目的是能更高效完成更重要的工作。每个人都成为特定技术+业务领域专家。养成学习习惯,懂得元学习(学习的方法和心态)。公司提供书籍和交流平台。 + +评价学习的结果: + +- 能做更有挑战的工作 +- 写出最佳实践文档 +- 做技术分享 +- 每季或每半年写一篇博客 + +### 2.5 坦诚相待 + +对自己能 + +- 承认错误 +- 理解不同意见 +- 认识自己的认知误区和特质 + +对他人 + +- 有勇气说你认为对的,哪怕有所争议。 +- 在认识生活的真相后依然热爱生活:做主动的改变者而不是抱怨者 + +评价标准就是大家认为你坦白直率。 + +## 3 总结 + +所有企业都有崇高的目标,我们相信实现这些目标的过程和目标高度统一:追求一个美好的目标的过程本身应该就是美好的。人生醒着的时候大部分时间是上班工作,为什么不让这些时间过的美好而又充满挑战的乐趣呢? + +更深层的原因是软件公司顶级员工的效率是普通员工的[10 倍或更高](https://www.joelonsoftware.com/2005/07/25/hitting-the-high-notes/)。 软件行业的成功秘诀就是 优秀人才 ==》十倍生产力 ==〉市场垄断者。软件行业因为网络效应而产生垄断效益。而吸引和挽留优秀人才的最有效办法就是企业文化。 From 02f4fd46871b2edf691bb4e4533c4545dfe9caca Mon Sep 17 00:00:00 2001 From: "ying.liu" Date: Thu, 6 Jun 2019 13:45:35 +0800 Subject: [PATCH 030/231] add redis --- ...5\220\246\350\246\201\347\224\250redis.md" | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 "backend/database/\346\230\257\345\220\246\350\246\201\347\224\250redis.md" diff --git "a/backend/database/\346\230\257\345\220\246\350\246\201\347\224\250redis.md" "b/backend/database/\346\230\257\345\220\246\350\246\201\347\224\250redis.md" new file mode 100644 index 0000000..2abf4ab --- /dev/null +++ "b/backend/database/\346\230\257\345\220\246\350\246\201\347\224\250redis.md" @@ -0,0 +1,27 @@ +# 是否要用 Redis + +团队采用 Redis 缓存了用户 session 和一些其他基础数据和一些查询结果。这是个大的系统结构设计,需要仔细权衡收益和成本。 + +## 问题 + +考虑到在 MySQL 之外再采购部署一套 Redis 会产生如下的问题: + +- 额外的费用:相同的内存配置,和 MySQL 数据库服务价钱差不多。 +- 多了一个系统失效节点:本来只有 MySQL,现在如果 Redis 失效,则整个业务系统也无法运行。 +- 额外的 API:需要学习使用多一套 API。 +- 数据同步难题:如果将 MySQL 查询结果放到 Redis,数据的同步是个难题。 +- 另外的部署:需要增加一套账户和配置参数。 + +## 答案 + +具体到我们的具体应用,上述问题的答案如下: + +- 额外的费用:即使和数据库费用差不多,但是在 MySQL 实现那些功能更花钱而且不稳定。 +- 多了一个系统失效节点:阿里云有分布式可靠措施,Redis 也比较成熟,可以信赖。 +- 额外的 API:团队使用 Redis 的程序员普遍认为其 API 简单已用。而且类似 Hash, Set, List 这样的数据结构在我们应用里有许多应用场景。 +- 数据同步难题:避免 MySQL 和 Redis 需要同步的使用。 +- 另外的部署:这是一次性的开支,可以基本忽略。 + +## 结论 + +可以把 Redis 做为我们的一个基础服务设施使用。根据使用场景,可以把使用中的陷阱和最佳实践写到文档。 From 0b26142995c38f02291071c6899922e912d08a3a Mon Sep 17 00:00:00 2001 From: "ying.liu" Date: Thu, 6 Jun 2019 13:47:19 +0800 Subject: [PATCH 031/231] revise backend readme --- backend/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/README.md b/backend/README.md index a5e6ba0..6004873 100644 --- a/backend/README.md +++ b/backend/README.md @@ -27,3 +27,4 @@ - [数据库设计规范-mysql](./database/数据库设计规范-mysql.md): 关于数据库设计的规范 - [数据库事务与隔离级别](./database/数据库事务与隔离级别.md) - [数据库连接池](./database/数据库连接池.md) +- [是否使用 Redis](./database/是否使用redis.md) From 89d15d6455dabceb69fe62f3d41918136f7c14b4 Mon Sep 17 00:00:00 2001 From: "ying.liu" Date: Thu, 6 Jun 2019 13:50:55 +0800 Subject: [PATCH 032/231] revise redis --- .../\346\230\257\345\220\246\350\246\201\347\224\250redis.md" | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git "a/backend/database/\346\230\257\345\220\246\350\246\201\347\224\250redis.md" "b/backend/database/\346\230\257\345\220\246\350\246\201\347\224\250redis.md" index 2abf4ab..9377114 100644 --- "a/backend/database/\346\230\257\345\220\246\350\246\201\347\224\250redis.md" +++ "b/backend/database/\346\230\257\345\220\246\350\246\201\347\224\250redis.md" @@ -24,4 +24,6 @@ ## 结论 -可以把 Redis 做为我们的一个基础服务设施使用。根据使用场景,可以把使用中的陷阱和最佳实践写到文档。 +在我们的应用里面有许多地方 Redis 带来很大的方便,尤其是很多只读基础数据和不需要长期保存的数据(比如 Session token, 短信验证码等), Redis 的 API 非常简单好用而且性能高,系统稳定性非常好。相同的功能如果自己实现成本更高而且稳定性不好。 + +所以可以把 Redis 做为我们的一个基础服务设施使用。根据使用场景,可以把使用中的陷阱和最佳实践写到文档。 From 8195f198d9be671e11ba9996e6e36875810edd84 Mon Sep 17 00:00:00 2001 From: umasuo Date: Thu, 6 Jun 2019 13:56:27 +0800 Subject: [PATCH 033/231] =?UTF-8?q?Update=20=E6=98=AF=E5=90=A6=E8=A6=81?= =?UTF-8?q?=E7=94=A8redis.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 改了个错别字 --- .../\346\230\257\345\220\246\350\246\201\347\224\250redis.md" | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git "a/backend/database/\346\230\257\345\220\246\350\246\201\347\224\250redis.md" "b/backend/database/\346\230\257\345\220\246\350\246\201\347\224\250redis.md" index 9377114..e2fade2 100644 --- "a/backend/database/\346\230\257\345\220\246\350\246\201\347\224\250redis.md" +++ "b/backend/database/\346\230\257\345\220\246\350\246\201\347\224\250redis.md" @@ -18,8 +18,8 @@ - 额外的费用:即使和数据库费用差不多,但是在 MySQL 实现那些功能更花钱而且不稳定。 - 多了一个系统失效节点:阿里云有分布式可靠措施,Redis 也比较成熟,可以信赖。 -- 额外的 API:团队使用 Redis 的程序员普遍认为其 API 简单已用。而且类似 Hash, Set, List 这样的数据结构在我们应用里有许多应用场景。 -- 数据同步难题:避免 MySQL 和 Redis 需要同步的使用。 +- 额外的 API:团队使用 Redis 的程序员普遍认为其 API 简单易用。而且类似 Hash, Set, List 这样的数据结构在我们应用里有许多应用场景。 +- 数据同步难题:尽量避免 MySQL 和 Redis 需要同步的使用。 - 另外的部署:这是一次性的开支,可以基本忽略。 ## 结论 From 2c1fbe2e1688a7139d152813fedfc887235a06cc Mon Sep 17 00:00:00 2001 From: umasuo Date: Thu, 6 Jun 2019 13:57:36 +0800 Subject: [PATCH 034/231] =?UTF-8?q?Update=20=E6=98=AF=E5=90=A6=E8=A6=81?= =?UTF-8?q?=E7=94=A8redis.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../\346\230\257\345\220\246\350\246\201\347\224\250redis.md" | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git "a/backend/database/\346\230\257\345\220\246\350\246\201\347\224\250redis.md" "b/backend/database/\346\230\257\345\220\246\350\246\201\347\224\250redis.md" index e2fade2..3c79abf 100644 --- "a/backend/database/\346\230\257\345\220\246\350\246\201\347\224\250redis.md" +++ "b/backend/database/\346\230\257\345\220\246\350\246\201\347\224\250redis.md" @@ -1,6 +1,6 @@ # 是否要用 Redis -团队采用 Redis 缓存了用户 session 和一些其他基础数据和一些查询结果。这是个大的系统结构设计,需要仔细权衡收益和成本。 +团队采用 Redis 缓存了用户 Session 和一些其他基础数据和一些查询结果。这是个大的系统结构设计,需要仔细权衡收益和成本。 ## 问题 @@ -24,6 +24,6 @@ ## 结论 -在我们的应用里面有许多地方 Redis 带来很大的方便,尤其是很多只读基础数据和不需要长期保存的数据(比如 Session token, 短信验证码等), Redis 的 API 非常简单好用而且性能高,系统稳定性非常好。相同的功能如果自己实现成本更高而且稳定性不好。 +在我们的应用里面有许多地方 Redis 带来很大的方便,尤其是很多只读基础数据和不需要长期保存的数据(比如 Session Token, 短信验证码等), Redis 的 API 非常简单好用而且性能高,系统稳定性非常好。相同的功能如果自己实现成本更高而且稳定性不好。 所以可以把 Redis 做为我们的一个基础服务设施使用。根据使用场景,可以把使用中的陷阱和最佳实践写到文档。 From e3092c26add9f6f92bae3a3aa2b6836ba2a43e70 Mon Sep 17 00:00:00 2001 From: liulang Date: Mon, 10 Jun 2019 09:12:16 +0800 Subject: [PATCH 035/231] =?UTF-8?q?=E8=A7=84=E8=8C=83=E5=89=8D=E5=90=8E?= =?UTF-8?q?=E7=AB=AF=E5=AF=B9=E5=A7=93=E5=90=8D=E7=9B=B8=E5=85=B3=E5=AD=97?= =?UTF-8?q?=E6=AE=B5=E7=9A=84=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend-and-frontend/common-contract.md | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/backend-and-frontend/common-contract.md b/backend-and-frontend/common-contract.md index 38e5c76..a05357d 100644 --- a/backend-and-frontend/common-contract.md +++ b/backend-and-frontend/common-contract.md @@ -1,6 +1,6 @@ # 前后端协作规约 -## 日期传输规范 +## 1 日期传输规范 对于系统中的两类日期时间,分别规定如下: @@ -8,10 +8,25 @@ - 第三方传入的时间:前后端交互过程中不对日期格式做转换处理。 - 需要在接口文档中注明是标准格式的字符串还是其他格式,如果是其他格式,则注明具体格式(例如:yyyy-MM-dd)。 -## 大数字传输规范 +## 2 大数字传输规范 对于 Java 中的 long 型等大数字,当超过一定范围,JavaScript 无法精确展示,故后端给前端的 Long 型数字时需转换为 String 字符串返回。 -## 列表传输规范 +## 3 列表传输规范 -对于列表形式的数据,如多个 ID,前后端应以列表/数组传输,不要使用逗号分隔的字符串。 \ No newline at end of file +对于列表形式的数据,如多个 ID,前后端应以列表/数组传输,不要使用逗号分隔的字符串。 + +## 4 姓名字段规范 + +为了准确表达客户姓名,涉及到客户姓名展示的地方(不含历史快照部分),后端接口都会返回四个字段: + +- 中文姓 surnameCn, 名givenNameCn +- 英文姓 surnameEn, 名givenNameEn + +下述规则供前端参考 + +- 在未做多语言国际化的情况下,前端展示姓名时需要根据如下原则: + - 若有中文姓名则显示 surnameCn + givenNameCn,否则展示 givenNameEn + “ ” + surnameEn +- 若做了多语言国际化,则根据系统语言环境按如下规则显示: + - 若为中文语言环境,则优先展示 surnameCn + givenNameCn + - 若为英文语言环境,则优先展示 givenNameCn + " " + surnameCn From ef6d86625d3e6b95161c4c4f7a53a437c089e9fc Mon Sep 17 00:00:00 2001 From: liulang Date: Mon, 10 Jun 2019 09:31:20 +0800 Subject: [PATCH 036/231] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E8=A7=84=E8=8C=83?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E7=9A=84=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/code/java-code-guideline.md | 30 +++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/backend/code/java-code-guideline.md b/backend/code/java-code-guideline.md index 0b557ff..965b5a1 100644 --- a/backend/code/java-code-guideline.md +++ b/backend/code/java-code-guideline.md @@ -237,3 +237,33 @@ public class BestExceptionHandler { 相较于自定义 ExceptionResolver 实现,使用 ControllerAdvice 的优点在于屏蔽了 Respon/Request 等对象,以及丑陋的写输出流的操作。需要注意的是,这里限制了返回的所有错误形式都是我们约定好的 API 响应数据结构。 目前我们项目中混用了 ExceptionResolver 和 ControllerAdvice,实际上选用一种即可。 + +## 15. Spring Boot 配置 + +针对 Spring Boot 一些容易重复安放的配置项,规定如下: + +- app name、config server 信息、active profile 全部放在 bootstrap.yml 里面 +- 其它配置项放在 application.yml 和 application-{profile}.yml 中 + +具体来说,需要注意的点有: + +- 项目内部的 application.yml 中去掉 app name, active profile(一般都需要去掉) +- 项目内部的 bootstrap.yml 中维护好 app name, active profile, confing server 配置等配置项 +- 如果以后不想拉配置中心的 dev 配置(即不连接开发库,而连接本机数据库、redis 等)进行开发,可以将 bootstrap.yml 中关于配置中心的配置注释掉如下: + +```yml +spring: + application: + name: tmc-services + profiles: + active: dev +# cloud: +# config: +# name: tmcservices +# label: master +# uri: https://dev-config-service.teyixing.com +``` + +- jar 包旁的 bootstrap.yml 应指定 active-profile 和配置中心的相关配置 +- 远程配置 git 仓库中的 app name 相关属性可以去掉,最后以项目内部 bootstrap.yml 配置文件中的为准 +- 为了让集成测试不读取真实的 application.yml 及 bootstrap.yml,需要在集成测试 resources 目录下配置好 application.yml (内含集成测试使用的内存数据库等配置)并新建一个空的 bootstrap.yml 文件 From fa7283e712520312547ffa5e3182657a376f8e65 Mon Sep 17 00:00:00 2001 From: liulang Date: Mon, 10 Jun 2019 09:37:14 +0800 Subject: [PATCH 037/231] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E8=A7=84=E8=8C=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/code/java-code-guideline.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/code/java-code-guideline.md b/backend/code/java-code-guideline.md index 965b5a1..c88a59e 100644 --- a/backend/code/java-code-guideline.md +++ b/backend/code/java-code-guideline.md @@ -249,7 +249,7 @@ public class BestExceptionHandler { - 项目内部的 application.yml 中去掉 app name, active profile(一般都需要去掉) - 项目内部的 bootstrap.yml 中维护好 app name, active profile, confing server 配置等配置项 -- 如果以后不想拉配置中心的 dev 配置(即不连接开发库,而连接本机数据库、redis 等)进行开发,可以将 bootstrap.yml 中关于配置中心的配置注释掉如下: +- 如果以后不想拉配置中心的 dev 配置(即使用项目内部的 application-dev.yml)进行开发,可以将 bootstrap.yml 中关于配置中心的配置注释掉如下: ```yml spring: From 73b3a0d5f54cd429bbac4ab91fc49fa83c0abf93 Mon Sep 17 00:00:00 2001 From: "ying.liu" Date: Tue, 18 Jun 2019 08:08:28 +0800 Subject: [PATCH 038/231] add backend architecture --- backend/README.md | 2 +- backend/backend-architecture.md | 104 ++++++++++++++++++ backend/resources/akka-flowgraph.png | Bin 0 -> 16476 bytes backend/resources/jet-workflow-example.png | Bin 0 -> 50902 bytes backend/resources/jet-workflow.png | Bin 0 -> 64746 bytes .../resources/workflow-vs-microservice.png | Bin 0 -> 50238 bytes 6 files changed, 105 insertions(+), 1 deletion(-) create mode 100644 backend/backend-architecture.md create mode 100644 backend/resources/akka-flowgraph.png create mode 100644 backend/resources/jet-workflow-example.png create mode 100644 backend/resources/jet-workflow.png create mode 100644 backend/resources/workflow-vs-microservice.png diff --git a/backend/README.md b/backend/README.md index 6004873..2b939f1 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,6 +1,6 @@ # 后端技术规范和最佳实践 -这里是有关后端项目的代码规范。 +这里是有关后端项目的技术与代码规范。旧的系统采用 Java, JPA/Hibernate。 新系统的架构在[backend architecture](./backend-architecture.md)有描述. ## 开发流程 diff --git a/backend/backend-architecture.md b/backend/backend-architecture.md new file mode 100644 index 0000000..5fe3011 --- /dev/null +++ b/backend/backend-architecture.md @@ -0,0 +1,104 @@ +# 后端技术架构 + +任何技术系统都是为其业务服务。技术是达成业务目标的手段。虽然每个业务系统都强调正确性、可靠性、可维护性、开发效率和灵活性,但是真正明白自己业务系统特点并能做出合适的选择还需要对业务系统和相关技术的有深刻、本质性的理解。 + +## 业务系统特点 + +我们的差旅管理业务有不同形态的客户群体,不同形态的产品,复杂多变的业务流程。产品、客户、业务功能需要灵活的组合并且经常变。即使同一个处理流程,不同的客户也需要各种定制功能。 + +传统的做法是编写一套处理流程,包含所有业务处理任务,每个客户群体,甚至每个客户通过参数定制。再加上产品维度,这种做法会让主流程非常复杂,参数配置会非常多。结果就是不堪重负,难以维护,运行缓慢。 + +## 技术系统需求 + +一个理想的系统的架构就是以尽可能靠近物理世界的运作方式。这样一个系统比较高效而且(表面上)容易理解。现实生活中业务系统在时空上都是以一种总体异步,局部同步的多个体并行方式在工作。特定时空的业务内容包含业务处理和数据二个维度。 + +不同于传统做法,把系统功能分成可以组合的细颗粒任务,类似于数学的分形(fractal),然后以一种一致的方式对不同客户/产品进行任意组合。结果就是一种基于工作流(workflow)的工作方式。 + +从数据的角度,这篇 2004 年[星巴克不用二阶段提交](https://www.enterpriseintegrationpatterns.com/ramblings/18_starbucks.html)的博客揭示了业务数据的本质:异步、若序列、灵活错误处理(放弃/重试/补偿)、最终一致性。 + +近些年的分布式系统趋势回应了类似我们的业务系统需求特点。大趋势是采用异步、分布式并发的工作方式。事物处理也大都是采用最终一致性的工作方式,放弃强一致性换来高并发和高可靠。 + +## 技术选型 + +因为对技术要求比较高,灵活的 Workflow 的工作方式并不多见。[Jet Tech 的 Workflow 博客](https://medium.com/jettech/microservices-to-workflows-expressing-business-flows-using-an-f-dsl-d2e74e6d6d5e) 是一个比较出名的例子。 + +![Jet Workflow](./resources/jet-workflow.png) + +Workflow 的一个基本要求就是把数据和处理数据的函数分开。传统的面向对象编程(封装和继承)在这种场合是一种反模式的模型。函数式编程模型,尤其是响应式流处理(reactive streams)和 workflow 有着天然的契合。流行的基于 JVM 的异步流处理框架有 RxJava, Spring Reactor, Vert.x, Ratpack 和 Akka。 + +除 Akka, 其他框架/类库都比较新而且工作在较低的 Reactive Streams 模型上。使用者需要在 `Flux`, `Mono`, `Subscription` 这种原始概念上逐层建立自己的高层次业务抽象模型。RxJava 有比较完善的开源生态系统。可是采用 Java 语言,在错误处理和运行监控都比较复杂。一种流行的说法是采用 RxJava 的需要六年以上 Java 工作经验。 + +Akka 就是为了异步分布式处理量身定做的一个工具库(toolkit)。借鉴了 Erlang OTS 的 Actor model, Akka Actor 发布于 2009 年 7 月, Akka Streams 2015 年 4 月发布。做为 Rective Streams 的标准制定参与者和异步分布式处理的长期实践者,Akka Streams 的开发者明白应用开发需要高层次的抽象工具。2014 年 9 月发布的[Akk Stream 预览版](https://akka.io/blog/news/2014/09/12/akka-streams-0.7-released) 就封装了底层接口并支持流程领域特定语言(Flow DSL)来定义灵活组合流程(flow)的 流程图(flow graph)。 + +![flow graph](./resources/akka-flowgraph.png) + +借鉴 [Jet Workflow](https://medium.com/jettech/microservices-to-workflows-expressing-business-flows-using-an-f-dsl-d2e74e6d6d5e), 一个好的工作流工具库需要满足下面几个条件: + +- 强类型:太多的复杂数据类型,需要编译时验证和编程时即时帮助。 +- 可读性:清楚定义流程步骤和分支,容易理解和维护。 +- 显示错误处理:支持基本的丢弃、重试和补偿错误处理方式。 +- 可扩展:以一种标准方式支持灵活的业务处理组合与扩展。 + +相比前微服务,workflow 是更高层次的抽象。如下图: + +![workflow vs mcirosevice](./resources/workflow-vs-microservice.png) + +下面是一个 Jet Tech 的流程处理例子。 + +![Jet workflow example](./resources/jet-workflow-example.png) + +Jet Tech 在 2018 年用 FSharp 开发了一套自己的内部工作流工具库。而 Akka Streams 是一个成熟的开源软件,如果不想从头开发,基本上是目前的唯一选择。 + +## 数据库访问 + +目前流行的数据库访问模式有三种:ORM、SQL 和 Type-safe SQL。对应的实现有 Hibernate, JdbcTempalte/MyBatis, 和 jOOQ/QueryDSL。数据库访问应该满足下面条件: + +- 显示的数据库访问:清清楚楚知道连接的范围,事务处理的范围,数据是否缓存等。 +- 类型安全:可以编译检查数据类型,也方便重构。 +- 高效:只查询所要的数据和修改要修改的数据,一次数据库访问完成。 +- 简单的关联数据查询:方便的访问有关系的表。 +- 方便的数据映射:程序数据和数据库表数据的方便转换。这个函数式语言有先天优势。 +- 支持原始 SQL: 总有场合需要这个功能。 +- 手工或自动元数据生成。 + +在 Java 语言里面,只有 jOOQ/QueryDSL 满足上面多数条件。如果采用函数式语言如 Scala,则打开了另一扇门 Functioinal Relational Mapping (FRM)。由于关系型数据库的基本操作(关系运算和关系演算)是函数演算的一个子集,FRM 有着天然的契合度。Scala 的 [Slick FRM 库](https://slick.lightbend.com/) 也满足上面的要求。 + +## 可选技术栈 + +Akka 同时支持 Java 和 Scala。考虑到从 Web API 服务到数据库访问的整个流程处理,我们有四种选择。 + +- Java: Spring WebFlux + Akka Streams/Actor + jOOQ (方案一) +- Java: Spring DI + Akka HTTP + Akka Streams/Actor + jOOQ (方案三) +- Java: Spring DI + Play framework + Akka Streams/Actor + jOOQ (方案二) +- Scala: Play framework + Akka Stream/Actor + Slick (方案四) + +前三个方案都是基于 Java 和 Spring DI,主要是为了方便现有团队的技术升级。方案一和方案二都已经开发出了概念原型,包括 jOOQ 的异步访问库。方案三花了一天时间还没有调通。方案四是有悠久历史的 Scala 的标准配置,没有什么悬念。 + +方案一有二个变种,在 Controller 层返回 Akka Steam 的 `Source` 或 Spring actor 的 `Flux/Mono`。前者按官方文档是支持的,可是一直没有调通,虽然都是同一个标准,有双方接口支持,可是其实并不匹配。后一个变种调通了,可是代码非常难看。 + +方案二比较可行灵活。不过需要自己实现 Web Api 层的路由,参数转换/验证,错误处理框架和 pluting 框架,有一些工作量。在整个链条处理上,处处感觉到 Java 的坑,既有 Java 非函数语言本身的特点,也有 Akka 把 Java 做为二等公民的不给力支持。学习资料比较少。 + +方案三官方的库很久没有更新,做起来需要对二个框架都需要比较深的了解,每次版本升级都有坑要填,常见还是会弃用 Spring 框架。 + +方案四是个标配方案,优点非常明显: + +- 成熟: 各个技术板块都有 10 年左右的实践经验,是所有方案里面最可靠的。 +- 原生支持:Scala 是 Akka 的原生开发语言,文档也很丰富。 +- 技术优势明显:相比其其它混合框架和非原生编程语言模式,方案四是自包含生态,没有集成问题。 +- Slick 的 FRM 数据库访问是目前最好的模式。技术层面远超其它 ORM 或 typesafe SQL。 + +可是最大的问题是 Scala 语言。Scala 是一个多范式语言,同时支持面向对象和函数式编程。这成为其最大卖点和最大缺点。短期来看,多范式是一个负资产。具体体现如下: + +- 学习曲线比较陡:二个范式的学习和贯通通常需要三到六个月成为熟手,精通则需要一年以上。 +- 有面向对象的经验是学习障碍:函数式的思维很多地方和面向对象是相反的,改掉旧习惯来学习相反的新习惯比没有旧习惯来学习更难。 +- 多范式容易让让人走偏:本来函数式是很适合要解决的问题,可是随手可用的面向对象功能随时让人走偏。 + +## 技术栈选择 + +所有的业务运行问题都是人的问题。我们百里挑一组建的团队学习和研发能力是方案选择的最关键考量。 + +- 我们团队学习能力强。 +- 并行转型方案 + - 少数人原型尝试,总结。 + - 多数人仍用现有模式开发,六个月到一年完成转型。 +- 保底方案:现有 Spring 架构加 jOOQ 替换 Jpa/Hibernatge。 diff --git a/backend/resources/akka-flowgraph.png b/backend/resources/akka-flowgraph.png new file mode 100644 index 0000000000000000000000000000000000000000..a464fda413f3612895ac4bf8e8e7e97d28c01d14 GIT binary patch literal 16476 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 literal 0 HcmV?d00001 diff --git a/backend/resources/jet-workflow-example.png b/backend/resources/jet-workflow-example.png new file mode 100644 index 0000000000000000000000000000000000000000..d62d5a414fb0dec82c927da56e094fe967f80080 GIT binary patch literal 50902 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