Dunwu Blog

大道至简,知易行难

Spring Bean 作用域

Spring Bean 作用域

来源 说明
singleton 默认 Spring Bean 作用域,一个 BeanFactory 有且仅有一个实例
prototype 原型作用域,每次依赖查找和依赖注入生成新 Bean 对象
request 将 Spring Bean 存储在 ServletRequest 上下文中
session 将 Spring Bean 存储在 HttpSession 中
application 将 Spring Bean 存储在 ServletContext 中

“singleton” Bean 作用域

“prototype” Bean 作用域

Spring 容器没有办法管理 prototype Bean 的完整生命周期,也没有办法记录实例的存在。销毁回调方法将不会执行,可以利用 BeanPostProcessor 进行清扫工作。

“request” Bean 作用域

  • 配置
    • XML - <bean class="..." scope = “request" />
    • Java 注解 - @RequestScope@Scope(WebApplicationContext.SCOPE_REQUEST)
  • 实现
    • API - RequestScope

“session” Bean 作用域

  • 配置
    • XML - <bean class="..." scope = “session" />
    • Java 注解 - @SessionScope@Scope(WebApplicationContext.SCOPE_SESSION)
  • 实现
    • API - SessionScope

“application” Bean 作用域

  • 配置
    • XML - <bean class="..." scope = “application" />
    • Java 注解 - @ApplicationScope@Scope(WebApplicationContext.SCOPE_APPLICATION)
  • 实现
    • API - ServletContextScope

自定义 Bean 作用域

  • 实现 Scope

    • org.springframework.beans.factory.config.Scope
  • 注册 Scope

    • API - org.springframework.beans.factory.config.ConfigurableBeanFactory#registerScope
  • 配置

    1
    2
    3
    4
    5
    6
    7
    8
    <bean class="org.springframework.beans.factory.config.CustomScopeConfigurer">
    <property name="scopes">
    <map>
    <entry key="...">
    </entry>
    </map>
    </property>
    </bean>

问题

Spring 內建的 Bean 作用域有几种?

singleton、prototype、request、session、application 以及 websocket

singleton Bean 是否在一个应用是唯一的?

否。singleton bean 仅在当前 Spring IoC 容器(BeanFactory)中是单例对象。

application Bean 是否可以被其他方案替代?

可以的,实际上,“application” Bean 与“singleton” Bean 没有本质区别

参考资料

Spring IoC 依赖来源

依赖查找的来源

查找来源

来源 配置元数据
Spring BeanDefinition <bean id ="user" class="xxx.xxx.User">
@Bean public User user() {...}
BeanDefinitionBuilder
单例对象 API 实现

Spring 內建 BeanDefintion

Bean 名称 Bean 实例 使用场景
org.springframework.context.annotation.internalConfigurationAnnotationProcessor ConfigurationClassPostProcessor 对象 处理 Spring 配置类
org.springframework.context.annotation.internalAutowiredAnnotationProcessor AutowiredAnnotationBeanPostProcessor 对象 处理 @Autowired 以及 @Value 注解
org.springframework.context.annotation.internalCommonAnnotationProcessor CommonAnnotationBeanPostProcessor 对象 (条件激活)处理 JSR-250 注解,如 @PostConstruct 等
org.springframework.context.event.internalEventListenerProcessor EventListenerMethodProcessor 对象 处理标注 @EventListener 的 Spring 事件监听方法

Spring 內建单例对象

Bean 名称 Bean 实例 使用场景
environment Environment 对象 外部化配置以及 Profiles
systemProperties java.util.Properties 对象 Java 系统属性
systemEnvironment java.util.Map 对象 操作系统环境变量
messageSource MessageSource 对象 国际化文案
lifecycleProcessor LifecycleProcessor 对象 Lifecycle Bean 处理器
applicationEventMulticaster ApplicationEventMulticaster 对象 Spring 事件广播器

依赖注入的来源

来源 配置元数据
Spring BeanDefinition <bean id ="user" class="xxx.xxx.User">
@Bean public User user() {...}
BeanDefinitionBuilder
单例对象 API 实现
非 Spring 容器管理对象

Spring 容器管理和游离对象

来源 Spring Bean 对象 生命周期管理 配置元信息 使用场景
Spring BeanDefinition 依赖查找、依赖注入
单体对象 依赖查找、依赖注入
Resolvable Dependency 依赖注入

Spring BeanDefinition 作为依赖来源

  • 元数据:BeanDefinition
  • 注册:BeanDefinitionRegistry#registerBeanDefinition
  • 类型:延迟和非延迟
  • 顺序:Bean 生命周期顺序按照注册顺序

单例对象作为依赖来源

  • 要素
    • 来源:外部普通 Java 对象(不一定是 POJO)
    • 注册:SingletonBeanRegistry#registerSingleton
  • 限制
    • 无生命周期管理
    • 无法实现延迟初始化 Bean

非 Spring 对象容器管理对象作为依赖来源

  • 要素
    • 注册:ConfigurableListableBeanFactory#registerResolvableDependency
  • 限制
    • 无生命周期管理
    • 无法实现延迟初始化 Bean
    • 无法通过依赖查找

外部化配置作为依赖来源

  • 要素
    • 类型:非常规 Spring 对象依赖来源
  • 限制
    • 无生命周期管理
    • 无法实现延迟初始化 Bean
    • 无法通过依赖查找

问题

注入和查找的依赖来源是否相同?

否,依赖查找的来源仅限于 Spring BeanDefinition 以及单例对象,而依赖注入的来源还包括 Resolvable Dependency 以及 @Value 所标注的外部化配置

单例对象能在 IoC 容器启动后注册吗?

可以的,单例对象的注册与 BeanDefinition 不同,BeanDefinition 会被 ConfigurableListableBeanFactory#freezeConfiguration() 方法影响,从而冻结注册,单例对象则没有这个限制。

Spring 依赖注入的来源有哪些?

  • Spring BeanDefinition
  • 单例对象
  • Resolvable Dependency
  • @Value 外部化配置

参考资料

安全漏洞防护

XSS

概念

跨站脚本(Cross-site scripting,通常简称为XSS) 是一种网站应用程序的安全漏洞攻击,是代码注入的一种。它允许恶意用户将代码注入到网页上,其他用户在观看网页时就会受到影响。这类攻击通常包含了 HTML 以及用户端脚本语言。

XSS 攻击示例:

假如有下面一个 textbox

1
<input type="text" name="address1" value="value1from" />

value1from 是来自用户的输入,如果用户不是输入 value1from,而是输入 "/><script>alert(document.cookie)</script><!- 那么就会变成:

1
2
3
4
5
<input type="text" name="address1" value="" />
<script>
alert(document.cookie)
</script>
<!- ">

嵌入的 JavaScript 代码将会被执行。攻击的威力,取决于用户输入了什么样的脚本。

攻击手段和目的

常用的 XSS 攻击手段和目的有:

  • 盗用 cookie,获取敏感信息。
  • 利用植入 Flash,通过 crossdomain 权限设置进一步获取更高权限;或者利用 Java 等得到类似的操作。
  • 利用 iframeframeXMLHttpRequest 或上述 Flash 等方式,以(被攻击)用户的身份执行一些管理动作,或执行一些一般的如发微博、加好友、发私信等操作。
  • 利用可被攻击的域受到其他域信任的特点,以受信任来源的身份请求一些平时不允许的操作,如进行不当的投票活动。
  • 在访问量极大的一些页面上的 XSS 可以攻击一些小型网站,实现 DDoS 攻击的效果。

应对手段

  • 过滤特殊字符 - 将用户所提供的内容进行过滤,从而避免 HTML 和 Jascript 代码的运行。如 > 转义为 &gt< 转义为 &lt 等,就可以防止大部分攻击。为了避免对不必要的内容错误转移,如 3<5 中的 < 需要进行文本匹配后再转移,如:<img src= 这样的上下文中的 < 才转义。
  • 设置 Cookie 为 HttpOnly - 设置了 HttpOnly 的 Cookie 可以防止 JavaScript 脚本调用,就无法通过 document.cookie 获取用户 Cookie 信息。

:point_right: 参考阅读:

CSRF

概念

**跨站请求伪造(Cross-site request forgery,CSRF)**,也被称为 one-click attack 或者 session riding,通常缩写为 CSRF 或者 XSRF。它是一种挟持用户在当前已登录的 Web 应用程序上执行非本意的操作的攻击方法。和跨站脚本(XSS)相比,XSS 利用的是用户对指定网站的信任,CSRF 利用的是网站对用户网页浏览器的信任。

攻击手段和目的

可以如此理解 CSRF:攻击者盗用了你的身份,以你的名义发送恶意请求。

CSRF 能做的事太多:

  • 以你名义发送邮件,发消息
  • 用你的账号购买商品
  • 用你的名义完成虚拟货币转账
  • 泄露个人隐私

应对手段

  • 表单 Token - CSRF 是一个伪造用户请求的操作,所以需要构造用户请求的所有参数才可以。表单 Token 通过在请求参数中添加随机数的办法来阻止攻击者获得所有请求参数。
  • 验证码 - 请求提交时,需要用户输入验证码,以避免用户在不知情的情况下被攻击者伪造请求。
  • Referer check - HTTP 请求头的 Referer 域中记录着请求资源,可通过检查请求来源,验证其是否合法。

:point_right: 参考阅读:

SQL 注入

概念

**SQL 注入攻击(SQL injection)**,是发生于应用程序之数据层的安全漏洞。简而言之,是在输入的字符串之中注入 SQL 指令,在设计不良的程序当中忽略了检查,那么这些注入进去的指令就会被数据库服务器误认为是正常的 SQL 指令而运行,因此遭到破坏或是入侵。

攻击示例:

考虑以下简单的登录表单:

1
2
3
4
5
<form action="/login" method="POST">
<p>Username: <input type="text" name="username" /></p>
<p>Password: <input type="password" name="password" /></p>
<p><input type="submit" value="登陆" /></p>
</form>

我们的处理里面的 SQL 可能是这样的:

1
2
3
username:=r.Form.Get("username")
password:=r.Form.Get("password")
sql:="SELECT * FROM user WHERE username='"+username+"' AND password='"+password+"'"

如果用户的输入的用户名如下,密码任意

1
myuser' or 'foo' = 'foo' --

那么我们的 SQL 变成了如下所示:

1
SELECT * FROM user WHERE username='myuser' or 'foo' = 'foo' --'' AND password='xxx'

在 SQL 里面 -- 是注释标记,所以查询语句会在此中断。这就让攻击者在不知道任何合法用户名和密码的情况下成功登录了。

对于 MSSQL 还有更加危险的一种 SQL 注入,就是控制系统,下面这个可怕的例子将演示如何在某些版本的 MSSQL 数据库上执行系统命令。

1
2
sql:="SELECT * FROM products WHERE name LIKE '%"+prod+"%'"
Db.Exec(sql)

如果攻击提交 a%' exec master..xp_cmdshell 'net user test testpass /ADD' -- 作为变量 prod 的值,那么 sql 将会变成

1
sql:="SELECT * FROM products WHERE name LIKE '%a%' exec master..xp_cmdshell 'net user test testpass /ADD'--%'"

MSSQL 服务器会执行这条 SQL 语句,包括它后面那个用于向系统添加新用户的命令。如果这个程序是以 sa 运行而 MSSQLSERVER 服务又有足够的权限的话,攻击者就可以获得一个系统帐号来访问主机了。

虽然以上的例子是针对某一特定的数据库系统的,但是这并不代表不能对其它数据库系统实施类似的攻击。针对这种安全漏洞,只要使用不同方法,各种数据库都有可能遭殃。

攻击手段和目的

  • 数据表中的数据外泄,例如个人机密数据,账户数据,密码等。
  • 数据结构被黑客探知,得以做进一步攻击(例如 SELECT * FROM sys.tables)。
  • 数据库服务器被攻击,系统管理员账户被窜改(例如 ALTER LOGIN sa WITH PASSWORD='xxxxxx')。
  • 获取系统较高权限后,有可能得以在网页加入恶意链接、恶意代码以及 XSS 等。
  • 经由数据库服务器提供的操作系统支持,让黑客得以修改或控制操作系统(例如 xp_cmdshell “net stop iisadmin”可停止服务器的 IIS 服务)。
  • 破坏硬盘数据,瘫痪全系统(例如 xp_cmdshell “FORMAT C:”)。

应对手段

  • 使用参数化查询 - 建议使用数据库提供的参数化查询接口,参数化的语句使用参数而不是将用户输入变量嵌入到 SQL 语句中,即不要直接拼接 SQL 语句。例如使用 database/sql 里面的查询函数 PrepareQuery ,或者 Exec(query string, args ...interface{})
  • 单引号转换 - 在组合 SQL 字符串时,先针对所传入的参数进行字符替换(将单引号字符替换为连续 2 个单引号字符)。

:point_right: 参考阅读:

DoS

**拒绝服务攻击(denial-of-service attack, DoS)亦称洪水攻击**,是一种网络攻击手法,其目的在于使目标电脑的网络或系统资源耗尽,使服务暂时中断或停止,导致其正常用户无法访问。

当黑客使用网络上两个或以上被攻陷的电脑作为“僵尸”向特定的目标发动“拒绝服务”式攻击时,称为分布式拒绝服务攻击(distributed denial-of-service attack,缩写:DDoS attack、DDoS)。

攻击方式

  • 带宽消耗型攻击
  • 资源消耗型攻击

应对手段

  • 防火墙 - 允许或拒绝特定通讯协议,端口或 IP 地址。当攻击从少数不正常的 IP 地址发出时,可以简单的使用拒绝规则阻止一切从攻击源 IP 发出的通信。
  • 路由器、交换机 - 具有速度限制和访问控制能力。
  • 流量清洗 - 通过采用抗 DoS 软件处理,将正常流量和恶意流量区分开。

:point_right: 参考阅读:

认证设计

认证和授权

什么是认证

认证 (Authentication) 是根据凭据验明访问者身份的流程。即验证“你是你所说的那个人”的过程。

身份认证,通常通过用户名/邮箱/手机号以及密码匹配来完成,也可以通过手机/邮箱验证码或者生物特征(如:指纹、虹膜)等其他因素。在某些应用系统中,为了追求更高的安全性,往往会要求多种认证因素叠加使用,这就是我们经常说的多因素认证。

常见的认证方式

  • 用户名、密码认证
  • 手机和短信验证码认证
  • 邮箱和邮件验证码认证
  • 人脸识别、指纹识别等生物因素认证
  • 令牌认证
  • OTP 认证
  • Radius 网络认证

什么是授权

授权 (Authorization) 是指向经过身份认证的访问者授予执行某项操作的权限(如访问资源,执行文件/数据读写权限等)。 简言之,授权是验证“你被允许做你想做的事”的过程。

虽然授权通常在身份验证后立即发生(例如,登录计算机系统时),但这并不意味着授权以身份验证为前提:匿名代理可以被授权执行有限的操作集。

由于 Http 是一种无状态的协议,服务器单从网络连接上无从知道客户身份。会话跟踪是 Web 程序中常用的技术,用来跟踪用户的整个会话。常用会话跟踪技术是 Cookie 与 Session。

Cookie 实际上是存储在客户端上的文本信息,并保留了各种跟踪的信息。

Cookie 保存在客户端浏览器中,而 Session 保存在服务器上。如果说 Cookie 机制是通过检查客户身上的“通行证”来确定客户身份的话,那么 Session 机制就是通过检查服务器上的“客户明细表”来确认客户身份。

单点登录

SSO(Single Sign On),即单点登录。所谓单点登录,就是同平台的诸多应用登陆一次,下一次就免登陆的功能。

SSO 需要解决多个异构系统之间的问题:

  • Session 共享问题
  • 跨域问题

Session 共享问题

分布式 Session 的几种实现策略:

  • 粘性 Session - 缺点:当服务器节点宕机时,将丢失该服务器节点上的所有 Session
  • 应用服务器间的 Session 复制共享 - 缺点:占用过多内存同步过程占用网络带宽以及服务器处理器时间
  • 基于缓存的 Session 共享 ✅ (推荐方案) - 不过需要程序自身控制 Session 读写,可以考虑基于 spring-session + redis 这种成熟的方案来处理。

Cookie 不能跨域!比如:浏览器不会把 www.google.com 的 cookie 传给 www.baidu.com。

这就存在一个问题:由于域名不同,用户在系统 A 登录后,浏览器记录系统 A 的 Cookie,但是访问系统 B 的时候不会携带这个 Cookie。

针对 Cookie 不能跨域 的问题,有几种解决方案:

  • 服务端生成 Cookie 后,返回给客户端,客户端解析 Cookie ,提取 Token (比如 JWT),此后每次请求都携带这个 Token。
  • 多个域名共享 Cookie,在返回 Cookie 给客户端的时候,在 Cookie 中设置 domain 白名单。
  • 将 Token 保存在 SessionStroage 中(不依赖 Cookie 就没有跨域的问题了)。

CAS

CAS 是实现 SSO 的主流方式。

CAS 分为两部分,CAS Server 和 CAS Client

  • CAS Server - 负责用户的认证工作,就像是把第一次登录用户的一个标识存在这里,以便此用户在其他系统登录时验证其需不需要再次登录。
  • CAS Client - 业务应用,需要接入 CAS Server。当用户访问我们的应用时,首先需要重定向到 CAS Server 端进行验证,要是原来登陆过,就免去登录,重定向到下游系统,否则进行用户名密码登陆操作。

术语:

  • Ticket Granting Ticket (TGT) - 可以认为是 CAS Server 根据用户名、密码生成的一张票,存在 Server 端。
  • Ticket Granting Cookie (TGC) - 其实就是一个 Cookie,存放用户身份信息,由 Server 发给 Client 端。
  • Service Ticket (ST) - 由 TGT 生成的一次性票据,用于验证,只能用一次。

CAS 工作流程:

img

  1. 用户访问 CAS Client A(业务系统),第一次访问,重定向到认证服务中心(CAS Server)。CAS Server 发现当前请求中没有 Cookie,再重定向到 CAS Server 的登录页面。重定向请求的 URL 中包含访问地址,以便认证成功后直接跳转到访问页面。
  2. 用户在登录页面输入用户名、密码等认证信息,认证成功后,CAS Server 生成 TGT,再用 TGT 生成一个 ST。然后返回 ST 和 TGC(Cookie)给浏览器。
  3. 浏览器携带 ST 再度访问之前想访问的 CAS Client A 页面。
  4. CAS Client A 收到 ST 后,向 CAS Server 验证 ST 的有效性。验证通过则允许用户访问页面。
  5. 此时,如果登录另一个 CAS Client B,会先重定向到 CAS Server,CAS Server 可以判断这个 CAS Client B 是第一次访问,但是本地有 TGC,所以无需再次登录。用 TGC 创建一个 ST,返回给浏览器。
  6. 重复类似 3、4 步骤。

img

以上了归纳总结如下:

  1. 访问服务 - 用户访问 SSO Client 资源。
  2. 定向认证 - SSO Client 重定向用户请求到 SSO Server。
  3. 用户认证 - 用户身份认证。
  4. 发放票据 - SSO Server 会产生一个 Service Ticket (ST) 并返回给浏览器。
  5. 验证票据 - 浏览器每次访问 SSO Client 时,携带 ST,SSO Client 向 SSO Server 验证票据。只有验证通过,才允许访问。
  6. 传输用户信息 - SSO Server 验证票据通过后,传输用户认证结果信息给 SSO Client。

JWT

JSON Web Token (JWT,RFC 7519 (opens new window)),是为了在网络应用环境间传递声明而执行的一种基于 JSON 的开放标准((RFC 7519)。该 token 被设计为紧凑且安全的,特别适用于分布式站点的单点登录(SSO)场景。JWT 的声明一般被用来在身份提供者和服务提供者间传递被认证的用户身份信息,以便于从资源服务器获取资源,也可以增加一些额外的其它业务逻辑所必须的声明信息,该 token 也可直接被用于认证,也可被加密。

详细内容可以参考这篇文章:什么是 JWT (opens new window)

Oauth2.0

OAuth 是一个关于授权(Authorization)的开放网络标准,在全世界得到广泛应用,目前的版本是 2.0 版。

简单来说,OAuth 是一种授权机制。资源的所有者告诉系统,同意授权第三方应用进入系统,访问这些资源。系统从而产生一个短期的进入令牌(token),用来代替密码,供第三方应用使用。

客户端必须得到用户的授权(authorization grant),才能获得令牌(access token)。

OAuth 2.0 定义了四种授权方式。

  • 授权码模式(authorization code)
  • 简化模式(implicit)
  • 密码模式(resource owner password credentials)
  • 客户端模式(client credentials)

授权码模式

授权码(authorization code)方式,指的是第三方应用先申请一个授权码,然后再用该授权码获取令牌。

这种方式是最常用的流程,安全性也最高,它适用于那些有后端的 Web 应用。授权码通过前端传送,令牌则是储存在后端,而且所有与资源服务器的通信都在后端完成。这样的前后端分离,可以避免令牌泄漏。

隐藏模式

有些 Web 应用是纯前端应用,没有后端。这时就不能用上面的方式了,必须将令牌储存在前端。RFC 6749 就规定了第二种方式,允许直接向前端颁发令牌。这种方式没有授权码这个中间步骤,所以称为(授权码)”隐藏式”(implicit)。

密码模式

如果你高度信任某个应用,RFC 6749 也允许用户把用户名和密码,直接告诉该应用。该应用就使用你的密码,申请令牌,这种方式称为”密码式”(password)。

客户端凭证模式

适用于没有前端的命令行应用,即在命令行下请求令牌。

令牌的更新

如果用户访问的时候,客户端的”访问令牌”已经过期,则需要使用”更新令牌”申请一个新的访问令牌。

客户端发出更新令牌的 HTTP 请求,包含以下参数:

  • granttype:表示使用的授权模式,此处的值固定为”refreshtoken”,必选项。
  • refresh_token:表示早前收到的更新令牌,必选项。
  • scope:表示申请的授权范围,不可以超出上一次申请的范围,如果省略该参数,则表示与上一次一致

OIDC

ID Token

ID Token 相当于用户的身份凭证,开发者的前端访问后端接口时可以携带 ID Token,开发者服务器可以校验用户的 ID Token 以确定用户身份,验证通过后返回相关资源。

ID Token 本质上是一个 JWT Token,包含了该用户身份信息相关的 key/value 键值对,例如:

1
2
3
4
5
6
7
8
9
10
{
"iss": "https://server.example.com",
"sub": "24400320", // subject 的缩写,为用户 ID
"aud": "s6BhdRkqt3",
"nonce": "n-0S6_WzA2Mj",
"exp": 1311281970,
"iat": 1311280970,
"auth_time": 1311280969,
"acr": "urn:mace:incommon:iap:silver"
}

ID Token 本质上是一个 JWT Token 意味着:

用户的身份信息直接被编码进了 id_token,你不需要额外请求其他的资源来获取用户信息;

id_token 可以验证其没有被篡改过,详情请见如何验证 ID Token。

Access Token

Access Token 用于基于 Token 的认证模式,允许应用访问一个资源 API。用户认证授权成功后,认证系统会签发 Access Token 给应用。应用需要携带 Access Token 访问资源 API,资源服务 API 会通过拦截器查验 Access Token 中的 scope 字段是否包含特定的权限项目,从而决定是否返回资源。

如果你的用户通过社交账号登录,例如微信登录,微信作为身份提供商会颁发自己的 Access Token,你的应用可以利用 Access Token 调用微信相关的 API。这些 Access Token 是由社交账号服务方控制的,格式也是任意的。

Refresh Token

AccessToken 和 IdToken 是 JSON Web Token (opens new window),有效时间通常较短。通常用户在获取资源的时候需要携带 AccessToken,当 AccessToken 过期后,用户需要获取一个新的 AccessToken。

Refresh Token 用于获取新的 AccessToken。这样可以缩短 AccessToken 的过期时间保证安全,同时又不会因为频繁过期重新要求用户登录。

用户在初次认证时,Refresh Token 会和 AccessToken、IdToken 一起返回。你的应用必须安全地存储 Refresh Token,它的重要性和密码是一样的,因为 Refresh Token 能够一直让用户保持登录。

参考资料

授权设计

授权模式

最简单的授权形式可能是根据是否已对发出请求的实体进行身份验证来授予或拒绝访问权限。 如果请求者可证明自己是所自称的身份,则可访问受保护的资源或功能。

常见的授权模式有以下几种:

  • ACL:ACL 即 通过访问控制列表。ACL 进行的授权涉及到维护明确的特定实体列表,这些实体有权或无权访问资源或功能。 ACL 提供对身份验证即授权的精细控制,但管理工作会随着实体数量的增加而变得困难。
  • RBAC:RBAC 即 基于角色的权限控制(Role-Based Access Control)。RBAC 应该是最常见的授权模式。 使用 RBAC 时,会对角色进行定义,以说明实体可执行的活动类型。 应用程序开发人员向角色而非单个实体授予访问权限。 然后,管理员可再将角色分配给不同的实体,从而控制哪些实体有权访问哪些资源和功能。在高级 RBAC 实现中,可将角色映射到权限集合,其中权限描述了可执行的细化操作或活动。 然后,会将角色配置为权限组合。 通过将授予给为实体分配的各种角色的权限进行相交,计算实体的总体权限集。
  • ABAC:ABAC 即 基于属性的访问控制 是一种更精细的访问控制机制。在此方法中,规则应用于实体、所访问的资源和当前环境。 这些规则用于确定对资源和功能的访问级别。 例如,可能只允许拥有管理员身份的用户在工作日上午 9 点至下午 5 点期间访问使用元数据标记“仅限工作时间的管理员”标识的文件。 在这种情况下,通过检查用户的属性(状态为管理员)、资源属性(文件上的元数据标记)以及环境属性(当前时间)来确定访问权限。
    • ABAC 的优点:可通过规则和条件评估实现更精细的动态访问控制,而无需创建大量特定的角色和 RBAC 分配。

RBAC

RBAC(Role-Based Access Control)即:基于角色的权限控制。通过角色关联用户,角色关联权限的方式间接赋予用户权限。

每个用户关联一个或多个角色,每个角色关联一个或多个权限,从而可以实现了非常灵活的权限管理。角色可以根据实际业务需求灵活创建,这样就省去了每新增一个用户就要关联一遍所有权限的麻烦。简单来说 RBAC 就是:用户关联角色,角色关联权限。

img

角色继承(Hierarchical Role) 就是指角色可以继承于其他角色,在拥有其他角色权限的同时,自己还可以关联额外的权限。这种设计可以给角色分组和分层,一定程度简化了权限管理工作。

img

职责分离(Separation of Duty)

为了避免用户拥有过多权限而产生利益冲突,例如一个篮球运动员同时拥有裁判的权限(看一眼就给你判犯规狠不狠?),另一种职责分离扩展版的 RBAC 被提出。

职责分离有两种模式:

静态职责分离(Static Separation of Duty):用户无法同时被赋予有冲突的角色。

img

动态职责分离(Dynamic Separation of Duty):用户在一次会话(Session)中不能同时激活自身所拥有的、互相有冲突的角色,只能选择其一。

img

讲了这么多 RBAC,都还只是在用户和权限之间进行设计,并没有涉及到用户和对象之间的权限判断,而在实际业务系统中限制用户能够使用的对象是很常见的需求。

RBAC0 模型

最简单的用户、角色、权限模型。这里面又包含了 2 种:

  1. 用户和角色是多对一关系,即:一个用户只充当一种角色,一种角色可以有多个用户担当。
  2. 用户和角色是多对多关系,即:一个用户可同时充当多种角色,一种角色可以有多个用户担当。

那么,什么时候该使用多对一的权限体系,什么时候又该使用多对多的权限体系呢?

如果系统功能比较单一,使用人员较少,岗位权限相对清晰且确保不会出现兼岗的情况,此时可以考虑用多对一的权限体系。其余情况尽量使用多对多的权限体系,保证系统的可扩展性。如:张三既是行政,也负责财务工作,那张三就同时拥有行政和财务两个角色的权限。

RBAC1 模型

相对于 RBAC0 模型,增加了子角色,引入了继承概念,即子角色可以继承父角色的所有权限。

img

使用场景:如某个业务部门,有经理、主管、专员。主管的权限不能大于经理,专员的权限不能大于主管,如果采用 RBAC0 模型做权限系统,极可能出现分配权限失误,最终出现主管拥有经理都没有的权限的情况。

而 RBAC1 模型就很好解决了这个问题,创建完经理角色并配置好权限后,主管角色的权限继承经理角色的权限,并且支持在经理权限上删减主管权限。

RBAC2 模型

基于 RBAC0 模型,增加了对角色的一些限制:角色互斥、基数约束、先决条件角色等。

  • 角色互斥:同一用户不能分配到一组互斥角色集合中的多个角色,互斥角色是指权限互相制约的两个角色。案例:财务系统中一个用户不能同时被指派给会计角色和审计员角色。
  • 基数约束:一个角色被分配的用户数量受限,它指的是有多少用户能拥有这个角色。例如:一个角色专门为公司 CEO 创建的,那这个角色的数量是有限的。
  • 先决条件角色:指要想获得较高的权限,要首先拥有低一级的权限。例如:先有副总经理权限,才能有总经理权限。
  • 运行时互斥:例如,允许一个用户具有两个角色的成员资格,但在运行中不可同时激活这两个角色。

RBAC3 模型

称为统一模型,它包含了 RBAC1 和 RBAC2,利用传递性,也把 RBAC0 包括在内,综合了 RBAC0、RBAC1 和 RBAC2 的所有特点,这里就不在多描述了。

img

什么是权限

说了这么久用户-角色-权限,可能小伙伴们都了解了什么是用户、什么是角色。但是有的小伙伴会好奇,那权限又是个什么玩意呢?

权限是资源的集合,这里的资源指的是软件中所有的内容,包括模块、菜单、页面、字段、操作功能(增删改查)等等。具体的权限配置上,目前形式多种多样,按照我个人的理解,可以将权限分为:页面权限、操作权限和数据权限(这种分类法,主要是结合自己在工作中的实际情况理解总结而来,若有不足之处,也请大家指出)。

页面权限:所有系统都是由一个个的页面组成,页面再组成模块,用户是否能看到这个页面的菜单、是否能进入这个页面就称为页面权限。

如下图:

img

客户列表、客户黑名单、客户审批页面组成了客户管理这个模块。对于普通用户,不能进行审批操作,即无客户审批页面权限,在他的账号登录后侧边导航栏只显示客户列表、客户黑名单两个菜单。

操作权限:用户凡是在操作系统中的任何动作、交互都是操作权限,如增删改查等。

数据权限:一般业务管理系统,都有数据私密性的要求:哪些人可以看到哪些数据,不可以看到哪些数据。

简单举个例子:某系统中有销售部门,销售专员负责推销商品,销售主管负责管理销售专员日常工作,经理负责组织管理销售主管作业。

如下图:

img

按照实际理解,‘销售专员张三’登录时,只能看到自己负责的数据;销售主管 2 登录时,能看到他所领导的所有业务员负责的数据,但看不到其他团队业务员负责的数据。

换另外一句话就是:我的客户只有我和我的直属上级以及直属上级的领导能看到,这就是我理解的数据权限。

要实现数据权限有多种方式:

  1. 可以利用 RBAC1 模型,通过角色分级来实现。
  2. 在‘用户-角色-权限’的基础上,增加用户与组织的关联关系,用组织决定用户的数据权限。

具体如何做呢?

① 组织层级划分:

img

② 数据可视权限规则制定:上级组织只能看到下级组织员工负责的数据,而不能看到其他平级组织及其下级组织的员工数据等。

通过以上两点,系统就可以在用户登录时,自动判断要给用户展示哪些数据了。

用户组的使用

当平台用户基数增大,角色类型增多时,如果直接给用户配角色,管理员的工作量就会很大。这时候我们可以引入一个概念“用户组”,就是将相同属性的用户归类到一起。

例如:加入用户组的概念后,可以将部门看做一个用户组,再给这个部门直接赋予角色(1 万员工部门可能就几十个),使部门拥有部门权限,这样这个部门的所有用户都有了部门权限,而不需要为每一个用户再单独指定角色,极大的减少了分配权限的工作量。

同时,也可以为特定的用户指定角色,这样用户除了拥有所属用户组的所有权限外,还拥有自身特定的权限。

用户组的优点,除了减少工作量,还有更便于理解、增加多级管理关系等。如:我们在进行组织机构配置的时候,除了加入部门,还可以加入科室、岗位等层级,来为用户组内部成员的权限进行等级上的区分。

关于用户组的详细疑难解答,请查看https://wen.woshipm.com/question/detail/88fues.html。在这里也十分感谢为我解答疑惑的朋友们!

实例分析一、如何设计 RBAC 权限系统

首先,我们思考一下一个简单的权限系统应该具备哪些内容?

答案显而易见,RBAC 模型:用户-角色-权限。所以最基本的我们应该具备用户、角色、权限这三个内容。

接下来,我们思考,究竟如何将三者关联起来。回顾前文,角色作为枢纽,关联用户、权限。所以在 RBAC 模型下,我们应该:创建一个角色,并为这个角色赋予相应权限,最后将角色赋予用户

将这个问题抽象为流程,如下图:

img

现在,基本的流程逻辑已经抽象出来了,接下来,分析该如何设计呢?

  • 第一步,需要角色管理列表,在角色管理列表能快速创建一个角色,且创建角色的同时能为角色配置权限,并且支持创建成功的角色列表能随时进行权限配置的的修改;
  • 第二步,需要用户管理列表,在用户管理列表能快速添加一个用户,且添加用户时有让用户关联角色的功能。

简单来说权限系统设计就包含以上两步,接下来为大家进行实例分析。

实例分析二、

① 创建角色列表

img

在角色列表快速创建一个角色:点击创建角色,支持创建角色时配置权限。

img

② 创建用户列表

img

在用户列表快速创建一个用户:支持用户关联角色的功能。

img

上述案例是基于最简单的 RBAC0 模型创建,适用于大部分常规的权限管理系统。

下面再分析一下 RBAC1 中角色分级具体如何设计。

  1. 在 RBAC0 的基础上,加上角色等级这个字段。
  2. 权限分配规则制定:低等级角色只能在高等级角色权限基础上进行删减权限。

具体界面呈现如下图:

img

以上就是简单的 RBAC 系统设计,若需更复杂的,还请读者根据上面的分析自行揣摩思考,尽管样式不同,但万变不离其宗,理解清楚 RBAC 模型后,结合自己的业务就可以设计出一套符合自己平台需求的角色权限系统,具体的就不再多阐述了。

OAuth2.0

OAuth2.0 简介

OAuth 是一个授权标准协议。OAuth 在全世界得到广泛应用,目前的版本是 2.0 版。

简单来说,OAuth 是一种授权机制。资源的所有者告诉系统,同意授权第三方应用进入系统,访问这些资源。系统从而产生一个短期的进入令牌(token),用来代替密码,供第三方应用使用。

客户端必须得到用户的授权(authorization grant),才能获得令牌(access token)。

根据 OAuth 2.0 协议规范,主要有四个主体

  • 授权服务器:负责颁发 Access Token。
  • 资源所有者:你的应用的用户是资源的所有者,授权其他人访问他的资源。
  • 调用方:调用方请求获取 Access Token,经过用户授权后,授权服务器为其颁发 Access Token。调用方可以携带 Access Token 到资源服务器访问用户的资源。
  • 资源服务器:接受 Access Token,然后验证它的被赋予的权限项目,最后返回资源。

其他重要概念:

  • 一次 OAuth 2.0 授权是指用户授权调用方相关的权限。
  • Code 授权码是由授权服务器颁发的,用于调用方使用 Code 换取 Token。
  • Access Token 由授权服务器颁发,持有 Access Token 说明完成了用户授权。
  • Refresh Token 是一个可选的 Token,用于在 Access Token 过期后获取一个新的 Access Token。

OAuth 2.0 授权模式

OAuth 2.0 定义了四种授权方式。

  • 授权码模式(authorization code)
  • 简化模式(implicit)
  • 密码模式(resource owner password credentials)
  • 客户端模式(client credentials)

授权码模式

授权码(authorization code)方式,指的是第三方应用先申请一个授权码,然后再用该授权码获取令牌。

这种方式是最常用的流程,安全性也最高,它适用于那些有后端的 Web 应用。授权码通过前端传送,令牌则是储存在后端,而且所有与资源服务器的通信都在后端完成。这样的前后端分离,可以避免令牌泄漏。

隐藏模式

有些 Web 应用是纯前端应用,没有后端。这时就不能用上面的方式了,必须将令牌储存在前端。RFC 6749 就规定了第二种方式,允许直接向前端颁发令牌。这种方式没有授权码这个中间步骤,所以称为(授权码)”隐藏式”(implicit)。

密码模式

如果你高度信任某个应用,RFC 6749 也允许用户把用户名和密码,直接告诉该应用。该应用就使用你的密码,申请令牌,这种方式称为”密码式”(password)。

客户端凭证模式

适用于没有前端的命令行应用,即在命令行下请求令牌。

令牌

如果用户访问的时候,客户端的”访问令牌”已经过期,则需要使用”更新令牌”申请一个新的访问令牌。

客户端发出更新令牌的 HTTP 请求,包含以下参数:

  • granttype:表示使用的授权模式,此处的值固定为”refreshtoken”,必选项。
  • refresh_token:表示早前收到的更新令牌,必选项。
  • scope:表示申请的授权范围,不可以超出上一次申请的范围,如果省略该参数,则表示与上一次一致

参考资料

Spring 之事务

Spring 针对 Java Transaction API (JTA)、JDBC、Hibernate 和 Java Persistence API(JPA) 等事务 API,实现了一致的编程模型,而 Spring 的声明式事务功能更是提供了极其方便的事务配置方式,配合 Spring Boot 的自动配置,大多数 Spring Boot 项目只需要在方法上标记 @Transactional 注解,即可一键开启方法的事务性配置。

理解事务

在软件开发领域,全有或全无的操作被称为事务(transaction)。事务允许你将几个操作组合成一个要么全部发生要么全部不发生的工作单元。传统上 Java EE 开发对事务管理有两种选择:全局事务本地事务,两者都有很大的局限性。

事务的特性

事务应该具有 4 个属性:原子性、一致性、隔离性、持久性。这四个属性通常称为 ACID

  • 原子性(Atomic):一个事务是一个不可分割的工作单位,事务中包括的诸操作要么都做,要么都不做。
  • 一致性(Consistent):事务必须是使数据库从一个一致性状态变到另一个一致性状态。一致性与原子性是密切相关的。
  • 隔离性(Isolated):一个事务的执行不能被其他事务干扰。即一个事务内部的操作及使用的数据对并发的其他事务是隔离的,并发执行的各个事务之间不能互相干扰。
  • 持久性(Durable):持久性也称永久性(permanence),指一个事务一旦提交,它对数据库中数据的改变就应该是永久性的。接下来的其他操作或故障不应该对其有任何影响。

全局事务

全局事务允许您使用多个事务资源,通常是关系数据库和消息队列。应用服务器通过 JTA 管理全局事务,这是一个繁琐的 API(部分原因在于其异常模型)。此外,JTA UserTransaction 通常需要来自 JNDI,这意味着您还需要使用 JNDI 才能使用 JTA。全局事务的使用限制了应用程序代码的任何潜在重用,因为 JTA 通常仅在应用程序服务器环境中可用。

以前,使用全局事务的首选方式是通过 EJB CMT(容器管理事务)。 CMT 是一种声明式事务管理(不同于程序化事务管理)。 EJB CMT 消除了对与事务相关的 JNDI 查找的需要,尽管使用 EJB 本身就需要使用 JNDI。它消除了大部分(但不是全部)编写 Java 代码来控制事务的需要。其明显的缺点是 CMT 与 JTA 和应用程序服务器环境相关联。此外,它仅在选择在 EJB 中实现业务逻辑(或至少在事务性 EJB 外观之后)时才可用。一般来说,EJB 的负面影响是如此之大,以至于这不是一个有吸引力的提议,尤其是在面对声明式事务管理的引人注目的替代方案时。

本地事务

本地事务是指定资源的,例如与 JDBC 连接关联的事务。本地事务可能更容易使用,但有一个明显的缺点:它们不能跨多个事务资源工作。例如,使用 JDBC 连接管理事务的代码不能在全局 JTA 事务中运行。因为应用服务器不参与事务管理,它不能确保跨多个资源的正确性(值得注意的是,大多数应用程序使用单个事务资源。)。另一个缺点是本地事务对编程模型具有侵入性。

Spring 对事务的支持

Spring 通过回调机制将实际的事务实现从事务性的代码中抽象出来。Spring 解决了全局和本地事务的缺点。它允许开发人员在任何环境中使用一致的编程模型。您只需编写一次代码,它就可以从不同环境中的不同事务管理策略中受益。Spring 提供了对编码式和声明式事务管理的支持,大多数情况下都推荐使用声明式事务管理。

  • 编码式事务允许用户在代码中精确定义事务的边界
  • 声明式事务(基于 AOP)有助于用户将操作与事务规则进行解耦

通过程序化事务管理,开发人员可以使用 Spring 事务抽象,它可以在任何底层事务基础上运行。使用首选的声明性模型,开发人员通常编写很少或根本不编写与事务管理相关的代码,因此不依赖 Spring 事务 API 或任何其他事务 API。

Spring 事务的优点

Spring 框架为事务管理提供了一致的抽象,具有以下好处:

  • 跨不同事务 API 的一致编程模型,例如 Java Transaction API (JTA)、JDBC、Hibernate 和 Java Persistence API (JPA)。
  • 支持声明式事务管理。
  • 用于编程事务管理的 API 比复杂事务 API(如 JTA)更简单。
  • 与 Spring 的数据访问抽象完美集成。

核心 API

TransactionManager

Spring 事务抽象的关键是事务策略的概念。事务策略由 TransactionManager 定义,特别是用于命令式事务管理的 org.springframework.transaction.PlatformTransactionManager 接口和用于响应式事务管理的 org.springframework.transaction.ReactiveTransactionManager 接口。

PlatformTransactionManager

以下清单显示了 PlatformTransactionManager API 的定义:

1
2
3
4
5
6
7
8
public interface PlatformTransactionManager extends TransactionManager {

TransactionStatus getTransaction(TransactionDefinition definition) throws TransactionException;

void commit(TransactionStatus status) throws TransactionException;

void rollback(TransactionStatus status) throws TransactionException;
}

PlatformTransactionManager 是一个 SPI 接口,所以使用者可以以编程方式使用它。因为 PlatformTransactionManager 是一个接口,所以可以根据需要轻松地 MOCK 或存根。它不依赖于查找策略,例如 JNDI。 PlatformTransactionManager 实现的定义与 Spring IoC 容器中的任何其他对象(或 bean)一样。仅此一项优势就使 Spring 事务成为有价值的抽象,即使您使用 JTA 也是如此。与直接使用 JTA 相比,您可以更轻松地测试事务代码。

同样,为了与 Spring 的理念保持一致,任何 PlatformTransactionManager 接口的方法可以抛出的 TransactionException 都是未经检查的(也就是说,它扩展了 java.lang.RuntimeException 类)。事务架构故障几乎总是致命的。极少数情况下,应用程序可以从事务失败中恢复,开发人员可以选择捕获和处理 TransactionException。重点是开发人员并非被迫这样做。

getTransaction(..) 方法根据 TransactionDefinition 参数返回一个 TransactionStatus 对象。如果当前调用堆栈中存在匹配的事务,则返回的 TransactionStatus 可能表示新事务或可以表示现有事务。后一种情况的含义是,与 Java EE 事务上下文一样,TransactionStatus 与执行线程相关联。

从以上可以看出,具体的事务管理机制对 Spring 来说是透明的,它并不关心那些,那些是对应各个平台需要关心的,所以 Spring 事务管理的一个优点就是为不同的事务 API 提供一致的编程模型,如 JTA、JDBC、Hibernate、JPA。下面分别介绍各个平台框架实现事务管理的机制。

JDBC 事务

如果应用程序中直接使用 JDBC 来进行持久化,DataSourceTransactionManager 会为你处理事务边界。为了使用 DataSourceTransactionManager,你需要使用如下的 XML 将其装配到应用程序的上下文定义中:

1
2
3
<bean id="transactionManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager">
<property name="dataSource" ref="dataSource" />
</bean>

实际上,DataSourceTransactionManager 是通过调用 java.sql.Connection 来管理事务,而后者是通过 DataSource 获取到的。通过调用连接的 commit() 方法来提交事务,同样,事务失败则通过调用 rollback() 方法进行回滚。

Hibernate 事务

如果应用程序的持久化是通过 Hibernate 实现的,那么你需要使用 HibernateTransactionManager。对于 Hibernate3,需要在 Spring 上下文定义中添加如下的 bean 声明:

1
2
3
<bean id="transactionManager" class="org.springframework.orm.hibernate3.HibernateTransactionManager">
<property name="sessionFactory" ref="sessionFactory" />
</bean>

sessionFactory 属性需要装配一个 Hibernate 的 session 工厂,HibernateTransactionManager 的实现细节是它将事务管理的职责委托给 org.hibernate.Transaction 对象,而后者是从 Hibernate Session 中获取到的。当事务成功完成时,HibernateTransactionManager 将会调用 Transaction 对象的 commit() 方法,反之,将会调用 rollback() 方法。

Java 持久化 API 事务(JPA)

Hibernate 多年来一直是事实上的 Java 持久化标准,但是现在 Java 持久化 API 作为真正的 Java 持久化标准进入大家的视野。如果你计划使用 JPA 的话,那你需要使用 Spring 的 JpaTransactionManager 来处理事务。你需要在 Spring 中这样配置 JpaTransactionManager

1
2
3
<bean id="transactionManager" class="org.springframework.orm.jpa.JpaTransactionManager">
<property name="sessionFactory" ref="sessionFactory" />
</bean>

JpaTransactionManager 只需要装配一个 JPA 实体管理工厂(javax.persistence.EntityManagerFactory 接口的任意实现)。JpaTransactionManager 将与由工厂所产生的 JPA EntityManager 合作来构建事务。

Java 原生 API 事务(JTA)

如果你没有使用以上所述的事务管理,或者是跨越了多个事务管理源(比如两个或者是多个不同的数据源),你就需要使用JtaTransactionManager

1
2
3
<bean id="transactionManager" class="org.springframework.transaction.jta.JtaTransactionManager">
<property name="transactionManagerName" value="java:/TransactionManager" />
</bean>

JtaTransactionManager 将事务管理的责任委托给 javax.transaction.UserTransactionjavax.transaction.TransactionManager 对象,其中事务成功完成通过 UserTransaction.commit() 方法提交,事务失败通过 UserTransaction.rollback() 方法回滚。

ReactiveTransactionManager

Spring 还为使用响应式类型或 Kotlin 协程的响应式应用程序提供了事务管理抽象。以下清单显示了 org.springframework.transaction.ReactiveTransactionManager 定义的事务策略:

1
2
3
4
5
6
7
8
public interface ReactiveTransactionManager extends TransactionManager {

Mono<ReactiveTransaction> getReactiveTransaction(TransactionDefinition definition) throws TransactionException;

Mono<Void> commit(ReactiveTransaction status) throws TransactionException;

Mono<Void> rollback(ReactiveTransaction status) throws TransactionException;
}

响应式事务管理器主要是一个 SPI,所以使用者可以以编程方式使用它。因为 ReactiveTransactionManager 是一个接口,所以可以根据需要轻松地 MOCK 或存根。

TransactionDefinition

PlatformTransactionManager 通过 getTransaction(TransactionDefinition definition) 方法来得到事务,这个方法里面的参数是 TransactionDefinition 类,这个类就定义了一些基本的事务属性。事务属性可以理解成事务的一些基本配置,描述了事务策略如何应用到方法上。

TransactionDefinition 接口内容如下:

1
2
3
4
5
6
public interface TransactionDefinition {
int getPropagationBehavior(); // 返回事务的传播行为
int getIsolationLevel(); // 返回事务的隔离级别,事务管理器根据它来控制另外一个事务可以看到本事务内的哪些数据
int getTimeout(); // 返回事务必须在多少秒内完成
boolean isReadOnly(); // 事务是否只读,事务管理器能够根据这个返回值进行优化,确保事务是只读的
}

我们可以发现 TransactionDefinition 正好用来定义事务属性,下面详细介绍一下各个事务属性。

传播行为

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

传播行为 含义
PROPAGATION_REQUIRED 表示当前方法必须运行在事务中。如果当前事务存在,方法将会在该事务中运行。否则,会启动一个新的事务
PROPAGATION_SUPPORTS 表示当前方法不需要事务上下文,但是如果存在当前事务的话,那么该方法会在这个事务中运行
PROPAGATION_MANDATORY 表示该方法必须在事务中运行,如果当前事务不存在,则会抛出一个异常
PROPAGATION_REQUIRED_NEW 表示当前方法必须运行在它自己的事务中。一个新的事务将被启动。如果存在当前事务,在该方法执行期间,当前事务会被挂起。如果使用 JTATransactionManager 的话,则需要访问 TransactionManager
PROPAGATION_NOT_SUPPORTED 表示该方法不应该运行在事务中。如果存在当前事务,在该方法运行期间,当前事务将被挂起。如果使用 JTATransactionManager 的话,则需要访问 TransactionManager
PROPAGATION_NEVER 表示当前方法不应该运行在事务上下文中。如果当前正有一个事务在运行,则会抛出异常
PROPAGATION_NESTED 表示如果当前已经存在一个事务,那么该方法将会在嵌套事务中运行。嵌套的事务可以独立于当前事务进行单独地提交或回滚。如果当前事务不存在,那么其行为与 PROPAGATION_REQUIRED 一样。注意各厂商对这种传播行为的支持是有所差异的。可以参考资源管理器的文档来确认它们是否支持嵌套事务

注:以下具体讲解传播行为的内容参考自 Spring 事务机制详解

  1. PROPAGATION_REQUIRED 如果存在一个事务,则支持当前事务。如果没有事务则开启一个新的事务。
1
2
3
4
5
6
// 事务属性 PROPAGATION_REQUIRED
methodA {
……
methodB();
……
}
1
2
3
4
// 事务属性 PROPAGATION_REQUIRED
methodB {
……
}

使用 spring 声明式事务,spring 使用 AOP 来支持声明式事务,会根据事务属性,自动在方法调用之前决定是否开启一个事务,并在方法执行之后决定事务提交或回滚事务。

单独调用 methodB 方法:

1
2
3
main {
metodB();
}

相当于

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Main {
Connection con=null;
try{
con = getConnection();
con.setAutoCommit(false);

//方法调用
methodB();

//提交事务
con.commit();
} Catch(RuntimeException ex) {
//回滚事务
con.rollback();
} finally {
//释放资源
closeCon();
}
}

Spring 保证在 methodB 方法中所有的调用都获得到一个相同的连接。在调用 methodB 时,没有一个存在的事务,所以获得一个新的连接,开启了一个新的事务。
单独调用 MethodA 时,在 MethodA 内又会调用 MethodB.

执行效果相当于:

1
2
3
4
5
6
7
8
9
10
11
12
main{
Connection con = null;
try{
con = getConnection();
methodA();
con.commit();
} catch(RuntimeException ex) {
con.rollback();
} finally {
closeCon();
}
}

调用 MethodA 时,环境中没有事务,所以开启一个新的事务.当在 MethodA 中调用 MethodB 时,环境中已经有了一个事务,所以 methodB 就加入当前事务。

  1. PROPAGATION_SUPPORTS 如果存在一个事务,支持当前事务。如果没有事务,则非事务的执行。但是对于事务同步的事务管理器,PROPAGATION_SUPPORTS 与不使用事务有少许不同。
1
2
3
4
5
6
7
8
9
//事务属性 PROPAGATION_REQUIRED
methodA(){
methodB();
}

//事务属性 PROPAGATION_SUPPORTS
methodB(){
……
}

单纯的调用 methodB 时,methodB 方法是非事务的执行的。当调用 methdA 时,methodB 则加入了 methodA 的事务中,事务地执行。

  1. PROPAGATION_MANDATORY 如果已经存在一个事务,支持当前事务。如果没有一个活动的事务,则抛出异常。
1
2
3
4
5
6
7
8
9
//事务属性 PROPAGATION_REQUIRED
methodA(){
methodB();
}

//事务属性 PROPAGATION_MANDATORY
methodB(){
……
}

当单独调用 methodB 时,因为当前没有一个活动的事务,则会抛出异常 throw new IllegalTransactionStateException(“Transaction propagation ‘mandatory’ but no existing transaction found”);当调用 methodA 时,methodB 则加入到 methodA 的事务中,事务地执行。

  1. PROPAGATION_REQUIRES_NEW 总是开启一个新的事务。如果一个事务已经存在,则将这个存在的事务挂起。
1
2
3
4
5
6
7
8
9
10
11
//事务属性 PROPAGATION_REQUIRED
methodA(){
doSomeThingA();
methodB();
doSomeThingB();
}

//事务属性 PROPAGATION_REQUIRES_NEW
methodB(){
……
}

调用 A 方法:

1
2
3
main(){
methodA();
}

相当于

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
main(){
TransactionManager tm = null;
try{
//获得一个JTA事务管理器
tm = getTransactionManager();
tm.begin();//开启一个新的事务
Transaction ts1 = tm.getTransaction();
doSomeThing();
tm.suspend();//挂起当前事务
try{
tm.begin();//重新开启第二个事务
Transaction ts2 = tm.getTransaction();
methodB();
ts2.commit();//提交第二个事务
} Catch(RunTimeException ex) {
ts2.rollback();//回滚第二个事务
} finally {
//释放资源
}
//methodB执行完后,恢复第一个事务
tm.resume(ts1);
doSomeThingB();
ts1.commit();//提交第一个事务
} catch(RunTimeException ex) {
ts1.rollback();//回滚第一个事务
} finally {
//释放资源
}
}

在这里,我把 ts1 称为外层事务,ts2 称为内层事务。从上面的代码可以看出,ts2 与 ts1 是两个独立的事务,互不相干。Ts2 是否成功并不依赖于 ts1。如果 methodA 方法在调用 methodB 方法后的 doSomeThingB 方法失败了,而 methodB 方法所做的结果依然被提交。而除了 methodB 之外的其它代码导致的结果却被回滚了。使用 PROPAGATION_REQUIRES_NEW,需要使用 JtaTransactionManager 作为事务管理器。

  1. PROPAGATION_NOT_SUPPORTED 总是非事务地执行,并挂起任何存在的事务。使用 PROPAGATION_NOT_SUPPORTED,也需要使用 JtaTransactionManager 作为事务管理器。(代码示例同上,可同理推出)
  2. PROPAGATION_NEVER 总是非事务地执行,如果存在一个活动事务,则抛出异常。
  3. PROPAGATION_NESTED 如果一个活动的事务存在,则运行在一个嵌套的事务中. 如果没有活动事务, 则按 TransactionDefinition.PROPAGATION_REQUIRED 属性执行。这是一个嵌套事务,使用 JDBC 3.0 驱动时,仅仅支持 DataSourceTransactionManager 作为事务管理器。需要 JDBC 驱动的 java.sql.Savepoint 类。有一些 JTA 的事务管理器实现可能也提供了同样的功能。使用 PROPAGATION_NESTED,还需要把 PlatformTransactionManager 的 nestedTransactionAllowed 属性设为 true;而 nestedTransactionAllowed 属性值默认为 false。
1
2
3
4
5
6
7
8
9
10
11
//事务属性 PROPAGATION_REQUIRED
methodA(){
doSomeThingA();
methodB();
doSomeThingB();
}

//事务属性 PROPAGATION_NESTED
methodB(){
……
}

如果单独调用 methodB 方法,则按 REQUIRED 属性执行。如果调用 methodA 方法,相当于下面的效果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
main(){
Connection con = null;
Savepoint savepoint = null;
try{
con = getConnection();
con.setAutoCommit(false);
doSomeThingA();
savepoint = con2.setSavepoint();
try{
methodB();
} catch(RuntimeException ex) {
con.rollback(savepoint);
} finally {
//释放资源
}
doSomeThingB();
con.commit();
} catch(RuntimeException ex) {
con.rollback();
} finally {
//释放资源
}
}

当 methodB 方法调用之前,调用 setSavepoint 方法,保存当前的状态到 savepoint。如果 methodB 方法调用失败,则恢复到之前保存的状态。但是需要注意的是,这时的事务并没有进行提交,如果后续的代码(doSomeThingB()方法)调用失败,则回滚包括 methodB 方法的所有操作。

嵌套事务一个非常重要的概念就是内层事务依赖于外层事务。外层事务失败时,会回滚内层事务所做的动作。而内层事务操作失败并不会引起外层事务的回滚。

PROPAGATION_NESTED 与 PROPAGATION_REQUIRES_NEW 的区别:它们非常类似,都像一个嵌套事务,如果不存在一个活动的事务,都会开启一个新的事务。使用 PROPAGATION_REQUIRES_NEW 时,内层事务与外层事务就像两个独立的事务一样,一旦内层事务进行了提交后,外层事务不能对其进行回滚。两个事务互不影响。两个事务不是一个真正的嵌套事务。同时它需要 JTA 事务管理器的支持。

使用 PROPAGATION_NESTED 时,外层事务的回滚可以引起内层事务的回滚。而内层事务的异常并不会导致外层事务的回滚,它是一个真正的嵌套事务。DataSourceTransactionManager 使用 savepoint 支持 PROPAGATION_NESTED 时,需要 JDBC 3.0 以上驱动及 1.4 以上的 JDK 版本支持。其它的 JTA TrasactionManager 实现可能有不同的支持方式。

PROPAGATION_REQUIRES_NEW 启动一个新的, 不依赖于环境的 “内部” 事务. 这个事务将被完全 commited 或 rolled back 而不依赖于外部事务, 它拥有自己的隔离范围, 自己的锁, 等等. 当内部事务开始执行时, 外部事务将被挂起, 内务事务结束时, 外部事务将继续执行。

另一方面, PROPAGATION_NESTED 开始一个 “嵌套的” 事务, 它是已经存在事务的一个真正的子事务. 潜套事务开始执行时, 它将取得一个 savepoint. 如果这个嵌套事务失败, 我们将回滚到此 savepoint. 潜套事务是外部事务的一部分, 只有外部事务结束后它才会被提交。

由此可见, PROPAGATION_REQUIRES_NEW 和 PROPAGATION_NESTED 的最大区别在于, PROPAGATION_REQUIRES_NEW 完全是一个新的事务, 而 PROPAGATION_NESTED 则是外部事务的子事务, 如果外部事务 commit, 嵌套事务也会被 commit, 这个规则同样适用于 roll back.

PROPAGATION_REQUIRED 应该是我们首先的事务传播行为。它能够满足我们大多数的事务需求。

隔离级别

事务的第二个维度就是隔离级别(isolation level)。隔离级别定义了一个事务可能受其他并发事务影响的程度。

  1. 并发事务引起的问题

在典型的应用程序中,多个事务并发运行,经常会操作相同的数据来完成各自的任务。并发虽然是必须的,但可能会导致一下的问题。

  • 脏读(Dirty reads)——脏读发生在一个事务读取了另一个事务改写但尚未提交的数据时。如果改写在稍后被回滚了,那么第一个事务获取的数据就是无效的。
  • 不可重复读(Nonrepeatable read)——不可重复读发生在一个事务执行相同的查询两次或两次以上,但是每次都得到不同的数据时。这通常是因为另一个并发事务在两次查询期间进行了更新。
  • 幻读(Phantom read)——幻读与不可重复读类似。它发生在一个事务(T1)读取了几行数据,接着另一个并发事务(T2)插入了一些数据时。在随后的查询中,第一个事务(T1)就会发现多了一些原本不存在的记录。

不可重复读与幻读的区别

不可重复读的重点是修改:
同样的条件, 你读取过的数据, 再次读取出来发现值不一样了
例如:在事务 1 中,Mary 读取了自己的工资为 1000,操作并没有完成

1
2
con1 = getConnection();
select salary from employee empId ="Mary";

在事务 2 中,这时财务人员修改了 Mary 的工资为 2000,并提交了事务.

1
2
3
con2 = getConnection();
update employee set salary = 2000;
con2.commit();

在事务 1 中,Mary 再次读取自己的工资时,工资变为了 2000

1
2
//con1
select salary from employee empId ="Mary";

在一个事务中前后两次读取的结果并不一致,导致了不可重复读。

幻读的重点在于新增或者删除:
同样的条件, 第 1 次和第 2 次读出来的记录数不一样
例如:目前工资为 1000 的员工有 10 人。事务 1,读取所有工资为 1000 的员工。

1
2
con1 = getConnection();
Select * from employee where salary =1000;

共读取 10 条记录

这时另一个事务向 employee 表插入了一条员工记录,工资也为 1000

1
2
3
con2 = getConnection();
Insert into employee(empId,salary) values("Lili",1000);
con2.commit();

事务 1 再次读取所有工资为 1000 的员工

1
2
//con1
select * from employee where salary =1000;

共读取到了 11 条记录,这就产生了幻像读。

从总的结果来看, 似乎不可重复读和幻读都表现为两次读取的结果不一致。但如果你从控制的角度来看, 两者的区别就比较大。
对于前者, 只需要锁住满足条件的记录。
对于后者, 要锁住满足条件及其相近的记录。

  1. 隔离级别
隔离级别 含义
ISOLATION_DEFAULT 使用后端数据库默认的隔离级别
ISOLATION_READ_UNCOMMITTED 最低的隔离级别,允许读取尚未提交的数据变更,可能会导致脏读、幻读或不可重复读
ISOLATION_READ_COMMITTED 允许读取并发事务已经提交的数据,可以阻止脏读,但是幻读或不可重复读仍有可能发生
ISOLATION_REPEATABLE_READ 对同一字段的多次读取结果都是一致的,除非数据是被本身事务自己所修改,可以阻止脏读和不可重复读,但幻读仍有可能发生
ISOLATION_SERIALIZABLE 最高的隔离级别,完全服从 ACID 的隔离级别,确保阻止脏读、不可重复读以及幻读,也是最慢的事务隔离级别,因为它通常是通过完全锁定事务相关的数据库表来实现的

只读

事务的第三个特性是它是否为只读事务。如果事务只对后端的数据库进行该操作,数据库可以利用事务的只读特性来进行一些特定的优化。通过将事务设置为只读,你就可以给数据库一个机会,让它应用它认为合适的优化措施。

事务超时

为了使应用程序很好地运行,事务不能运行太长的时间。因为事务可能涉及对后端数据库的锁定,所以长时间的事务会不必要的占用数据库资源。事务超时就是事务的一个定时器,在特定时间内事务如果没有执行完毕,那么就会自动回滚,而不是一直等待其结束。

回滚规则

事务五边形的最后一个方面是一组规则,这些规则定义了哪些异常会导致事务回滚而哪些不会。默认情况下,事务只有遇到运行期异常时才会回滚,而在遇到检查型异常时不会回滚(这一行为与 EJB 的回滚行为是一致的)
但是你可以声明事务在遇到特定的检查型异常时像遇到运行期异常那样回滚。同样,你还可以声明事务遇到特定的异常不回滚,即使这些异常是运行期异常。

TransactionStatus

TransactionStatus 接口为事务代码提供了一种简单的方式来控制事务执行和查询事务状态。这些概念应该很熟悉,因为它们对所有事务 API 都是通用的。以下清单显示了 TransactionStatus 接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public interface TransactionStatus extends TransactionExecution, SavepointManager, Flushable {

@Override
boolean isNewTransaction();

boolean hasSavepoint();

@Override
void setRollbackOnly();

@Override
boolean isRollbackOnly();

void flush();

@Override
boolean isCompleted();
}

可以发现这个接口描述的是一些处理事务提供简单的控制事务执行和查询事务状态的方法,在回滚或提交的时候需要应用对应的事务状态。

TransactionTemplate

Spring 提供了对编程式事务和声明式事务的支持。编程式事务允许用户在代码中精确定义事务的边界,而声明式事务(基于 AOP)有助于用户将操作与事务规则进行解耦。TransactionTemplate 就是用于支持编程式事务的核心 API。

采用 TransactionTemplate 和采用其他 Spring 模板,如 JdbcTempalte 和 HibernateTemplate 是一样的方法。它使用回调方法,把应用程序从处理取得和释放资源中解脱出来。如同其他模板,TransactionTemplate 是线程安全的。代码片段:

1
2
3
4
5
6
7
8
TransactionTemplate tt = new TransactionTemplate(); // 新建一个TransactionTemplate
Object result = tt.execute(
new TransactionCallback(){
public Object doTransaction(TransactionStatus status){
updateOperation();
return resultOfUpdateOperation();
}
}); // 执行execute方法进行事务管理

使用 TransactionCallback()可以返回一个值。如果使用 TransactionCallbackWithoutResult 则没有返回值。

声明式事务管理

大多数 Spring 用户选择声明式事务管理。此选项对应用程序代码的影响最小,因此最符合非侵入式轻量级容器的理想。

Spring 框架的声明式事务管理是通过 Spring AOP 实现的。然而,由于事务方面代码随 Spring 发行版一起提供并且可以以样板方式使用,因此通常不必理解 AOP 概念即可有效地使用此代码。

Spring 框架的声明式事务管理类似于 EJB CMT,因为您可以指定事务行为(或缺少它)到单个方法级别。如有必要,您可以在事务上下文中进行 setRollbackOnly() 调用。两种类型的事务管理之间的区别是:

  • 与绑定到 JTA 的 EJB CMT 不同,Spring 框架的声明式事务管理适用于任何环境。通过调整配置文件,它可以使用 JDBC、JPA 或 Hibernate 处理 JTA 事务或本地事务。
  • 您可以将 Spring 声明式事务管理应用于任何类,而不仅仅是诸如 EJB 之类的特殊类。
  • Spring 提供声明性回滚规则,这是一个没有 EJB 等效功能的特性。提供了对回滚规则的编程和声明性支持。
  • Spring 允许您使用 AOP 自定义事务行为。例如,您可以在事务回滚的情况下插入自定义行为。您还可以添加任意 advice 以及事务性 advice。使用 EJB CMT,您无法影响容器的事务管理,除非使用 setRollbackOnly()
  • Spring 不像高端应用服务器那样支持跨远程调用传播事务上下文。如果您需要此功能,我们建议您使用 EJB。但是,在使用这种特性之前要仔细考虑,因为通常情况下,不希望事务跨越远程调用。

回滚规则的概念很重要。它们让您指定哪些异常(和 throwable)应该导致自动回滚。您可以在配置中以声明方式指定它,而不是在 Java 代码中。因此,尽管您仍然可以在 TransactionStatus 对象上调用 setRollbackOnly() 来回滚当前事务,但通常您可以指定 MyApplicationException 必须始终导致回滚的规则。此选项的显着优势是业务对象不依赖于事务基础架构。例如,它们通常不需要导入 Spring 事务 API 或其他 Spring API。

尽管 EJB 容器默认行为会在系统异常(通常是运行时异常)上自动回滚事务,但 EJB CMT 不会在应用程序异常(即除 java.rmi.RemoteException 之外的检查异常)上自动回滚事务。虽然声明式事务管理的 Spring 默认行为遵循 EJB 约定(回滚仅在未经检查的异常上自动),但自定义此行为通常很有用。

Spring 声明式事务管理的实现

关于 Spring 框架的声明式事务支持,最重要的概念是这种支持是通过 AOP 代理启用的,并且事务 advice 是由元数据驱动的(目前是基于 XML 或基于注释的)。 AOP 与事务元数据的结合产生了一个 AOP 代理,它使用 TransactionInterceptor 和适当的 TransactionManager 实现来驱动围绕方法调用的事务。

Spring 的 TransactionInterceptor 为命令式和响应式编程模型提供事务管理。拦截器通过检查方法返回类型来检测所需的事务管理风格。返回响应式类型的方法,例如 Publisher 或 Kotlin Flow(或它们的子类型)有资格进行响应式事务管理。包括 void 在内的所有其他返回类型都使用代码路径进行命令式事务管理。

事务管理风格会影响需要哪个事务管理器。命令式事务需要 PlatformTransactionManager,而响应式事务使用 ReactiveTransactionManager 实现。

@Transactional 通常与 PlatformTransactionManager 管理的线程绑定事务一起使用,将事务公开给当前执行线程中的所有数据访问操作。注意:这不会传播到方法中新启动的线程。

ReactiveTransactionManager 管理的反应式事务使用 Reactor 上下文而不是线程本地属性。因此,所有参与的数据访问操作都需要在同一个反应式管道中的同一个 Reactor 上下文中执行。

下图显示了在事务代理上调用方法的概念视图:

声明式事务示例

考虑以下接口及其伴随的实现。此示例使用 Foo 和 Bar 类作为占位符,以便您可以专注于事务使用,而无需关注特定的域模型。就本示例而言,DefaultFooService 类在每个已实现方法的主体中抛出 UnsupportedOperationException 实例这一事实很好。该行为使您可以看到正在创建的事务,然后回滚以响应 UnsupportedOperationException 实例。

以下清单显示了 FooService 接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// the service interface that we want to make transactional

package x.y.service;

public interface FooService {

Foo getFoo(String fooName);

Foo getFoo(String fooName, String barName);

void insertFoo(Foo foo);

void updateFoo(Foo foo);

}

以下示例显示了上述接口的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package x.y.service;

public class DefaultFooService implements FooService {

@Override
public Foo getFoo(String fooName) {
// ...
}

@Override
public Foo getFoo(String fooName, String barName) {
// ...
}

@Override
public void insertFoo(Foo foo) {
// ...
}

@Override
public void updateFoo(Foo foo) {
// ...
}
}

假设 FooService 接口的前两个方法 getFoo(String) 和 getFoo(String, String) 必须在具有只读语义的事务上下文中运行,并且其他方法 insertFoo(Foo) 和 updateFoo(Foo ),必须在具有读写语义的事务上下文中运行。以下配置将在接下来的几段中详细说明:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
<!-- from the file 'context.xml' -->
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:aop="http://www.springframework.org/schema/aop"
xmlns:tx="http://www.springframework.org/schema/tx"
xsi:schemaLocation="
http://www.springframework.org/schema/beans
https://www.springframework.org/schema/beans/spring-beans.xsd
http://www.springframework.org/schema/tx
https://www.springframework.org/schema/tx/spring-tx.xsd
http://www.springframework.org/schema/aop
https://www.springframework.org/schema/aop/spring-aop.xsd">

<!-- this is the service object that we want to make transactional -->
<bean id="fooService" class="x.y.service.DefaultFooService"/>

<!-- the transactional advice (what 'happens'; see the <aop:advisor/> bean below) -->
<tx:advice id="txAdvice" transaction-manager="txManager">
<!-- the transactional semantics... -->
<tx:attributes>
<!-- all methods starting with 'get' are read-only -->
<tx:method name="get*" read-only="true"/>
<!-- other methods use the default transaction settings (see below) -->
<tx:method name="*"/>
</tx:attributes>
</tx:advice>

<!-- ensure that the above transactional advice runs for any execution
of an operation defined by the FooService interface -->
<aop:config>
<aop:pointcut id="fooServiceOperation" expression="execution(* x.y.service.FooService.*(..))"/>
<aop:advisor advice-ref="txAdvice" pointcut-ref="fooServiceOperation"/>
</aop:config>

<!-- don't forget the DataSource -->
<bean id="dataSource" class="org.apache.commons.dbcp.BasicDataSource" destroy-method="close">
<property name="driverClassName" value="oracle.jdbc.driver.OracleDriver"/>
<property name="url" value="jdbc:oracle:thin:@rj-t42:1521:elvis"/>
<property name="username" value="scott"/>
<property name="password" value="tiger"/>
</bean>

<!-- similarly, don't forget the TransactionManager -->
<bean id="txManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager">
<property name="dataSource" ref="dataSource"/>
</bean>

<!-- other <bean/> definitions here -->

</beans>

检查前面的配置。它假定您要使服务对象 fooService bean 具有事务性。要应用的事务语义封装在 <tx:advice/> 定义中。<tx:advice/> 定义读作“所有以 get 开头的方法都将在只读事务的上下文中运行,所有其他方法都将以默认事务语义运行”。<tx:advice/> 标签的 transaction-manager 属性设置为将驱动事务的 TransactionManager bean 的名称(在本例中为 txManager bean)。

如果要连接的 TransactionManager 的 bean 名称具有名称 transactionManager,则可以省略事务 advice (tx:advice/) 中的 transaction-manager 属性。如果要连接的 TransactionManager bean 有任何其他名称,则必须显式使用 transaction-manager 属性,如前面的示例所示。

<aop:config/> 定义确保由 txAdvice bean 定义的事务性建议在程序中的适当位置运行。首先,您定义一个切入点,该切入点与 FooService 接口 (fooServiceOperation) 中定义的任何操作的执行相匹配。然后,您使用一个 adviser 将切入点与 txAdvice 相关联。结果表明,在执行 fooServiceOperation 时,会运行 txAdvice 定义的建议。

一个常见的要求是使整个服务层具有事务性。最好的方法是更改切入点表达式以匹配服务层中的任何操作。以下示例显示了如何执行此操作:

1
2
3
4
<aop:config>
<aop:pointcut id="fooServiceMethods" expression="execution(* x.y.service.*.*(..))"/>
<aop:advisor advice-ref="txAdvice" pointcut-ref="fooServiceMethods"/>
</aop:config>

前面显示的配置用于围绕从 fooService bean 定义创建的对象创建事务代理。代理配置了事务 advice,以便在代理上调用适当的方法时,根据与该方法关联的事务配置,启动、暂停、标记为只读等事务。考虑以下测试驱动前面显示的配置的程序:

1
2
3
4
5
6
7
8
public final class Boot {

public static void main(final String[] args) throws Exception {
ApplicationContext ctx = new ClassPathXmlApplicationContext("context.xml");
FooService fooService = ctx.getBean(FooService.class);
fooService.insertFoo(new Foo());
}
}

回滚一个声明性事务

Spring 框架中,触发事务回滚的推荐方式是在事务上下文的代码中抛出异常。Spring 事务框架会捕获任何未处理的异常,并确定是否将事务标记为回滚。

在其默认配置中,Spring 事务框架只会将存在运行时且未经检查异常的事务标记为回滚。也就是说,当抛出的异常是 RuntimeException 的实例或子类时。 (默认情况下,错误实例也会导致回滚)。从事务方法抛出的检查异常不会导致默认配置中的回滚。

您可以通过指定回滚规则,明确指定哪些异常类型将导致事务回滚。

回滚规则约定在抛出指定异常时是否应回滚事务,并且规则基于模式。模式可以是完全限定的类名或异常类型的完全限定类名的子字符串(必须是 Throwable 的子类),目前不支持通配符。例如,javax.servlet.ServletExceptionServletException 的值将匹配 javax.servlet.ServletException 及其子类。

回滚规则可以通过 rollback-forno-rollback-for 属性在 XML 中配置,这允许将模式指定为字符串。使用 @Transactional 时,可以通过 rollbackFor / noRollbackForrollbackForClassName / noRollbackForClassName 属性配置回滚规则,它们允许将模式分别指定为类引用或字符串。当异常类型被指定为类引用时,其完全限定名称将用作模式。因此,@Transactional(rollbackFor = example.CustomException.class) 等价于 @Transactional(rollbackForClassName = 'example.CustomException')

以下 XML 片段演示了如何通过 rollback-for 属性提供异常模式来为已检查的、特定的 Exception 类型配置回滚:

1
2
3
4
5
6
<tx:advice id="txAdvice" transaction-manager="txManager">
<tx:attributes>
<tx:method name="get*" read-only="true" rollback-for="NoProductInStockException"/>
<tx:method name="*"/>
</tx:attributes>
</tx:advice>

如果您不希望在抛出异常时回滚事务,您还可以指定“不回滚”规则。下面的例子告诉 Spring 事务框架,即使在面对未处理的 InstrumentNotFoundException 时也要提交伴随事务。

1
2
3
4
5
6
<tx:advice id="txAdvice">
<tx:attributes>
<tx:method name="updateStock" no-rollback-for="InstrumentNotFoundException"/>
<tx:method name="*"/>
</tx:attributes>
</tx:advice>

当 Spring Framework 事务框架捕获到异常,并检查配置的回滚规则以确定是否将事务标记为回滚时,由最重要的匹配规则决定。因此,在以下配置的情况下,除 InstrumentNotFoundException 之外的任何异常都会导致伴随事务的回滚。

1
2
3
4
5
<tx:advice id="txAdvice">
<tx:attributes>
<tx:method name="*" rollback-for="Throwable" no-rollback-for="InstrumentNotFoundException"/>
</tx:attributes>
</tx:advice>

您还可以以编程方式指示所需的回滚。虽然很简单,但这个过程非常具有侵入性,并且将您的代码与 Spring Framework 的事务基础设施紧密耦合。以下示例显示如何以编程方式指示所需的回滚。

1
2
3
4
5
6
7
8
public void resolvePosition() {
try {
// some business logic...
} catch (NoProductInStockException ex) {
// trigger rollback programmatically
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
}
}

如果可能的话,强烈建议您使用声明性方法进行回滚。如果您绝对需要,可以使用程序化回滚,但它的使用与实现干净的基于 POJO 的架构背道而驰。

为不同的 Bean 配置不同的事务语义

考虑您有许多服务层对象的场景,并且您希望对每个对象应用完全不同的事务配置。您可以通过定义具有不同 <aop:advisor/> 元素和不同 advice-ref 属性值的切点来实现这一点。

作为一个比较点,首先假设您的所有服务层类都定义在根 x.y.service 包中。 要使作为该包(或子包)中定义的类的实例并且名称以 Service 结尾的所有 bean 都具有默认的事务配置,您可以编写以下内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:aop="http://www.springframework.org/schema/aop"
xmlns:tx="http://www.springframework.org/schema/tx"
xsi:schemaLocation="
http://www.springframework.org/schema/beans
https://www.springframework.org/schema/beans/spring-beans.xsd
http://www.springframework.org/schema/tx
https://www.springframework.org/schema/tx/spring-tx.xsd
http://www.springframework.org/schema/aop
https://www.springframework.org/schema/aop/spring-aop.xsd">

<aop:config>

<aop:pointcut id="serviceOperation"
expression="execution(* x.y.service..*Service.*(..))"/>

<aop:advisor pointcut-ref="serviceOperation" advice-ref="txAdvice"/>

</aop:config>

<!-- these two beans will be transactional... -->
<bean id="fooService" class="x.y.service.DefaultFooService"/>
<bean id="barService" class="x.y.service.extras.SimpleBarService"/>

<!-- ... and these two beans won't -->
<bean id="anotherService" class="org.xyz.SomeService"/> <!-- (not in the right package) -->
<bean id="barManager" class="x.y.service.SimpleBarManager"/> <!-- (doesn't end in 'Service') -->

<tx:advice id="txAdvice">
<tx:attributes>
<tx:method name="get*" read-only="true"/>
<tx:method name="*"/>
</tx:attributes>
</tx:advice>

<!-- other transaction infrastructure beans such as a TransactionManager omitted... -->

</beans>

以下示例显示了如何使用完全不同的事务设置配置两个不同的 bean

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:aop="http://www.springframework.org/schema/aop"
xmlns:tx="http://www.springframework.org/schema/tx"
xsi:schemaLocation="
http://www.springframework.org/schema/beans
https://www.springframework.org/schema/beans/spring-beans.xsd
http://www.springframework.org/schema/tx
https://www.springframework.org/schema/tx/spring-tx.xsd
http://www.springframework.org/schema/aop
https://www.springframework.org/schema/aop/spring-aop.xsd">

<aop:config>

<aop:pointcut id="defaultServiceOperation"
expression="execution(* x.y.service.*Service.*(..))"/>

<aop:pointcut id="noTxServiceOperation"
expression="execution(* x.y.service.ddl.DefaultDdlManager.*(..))"/>

<aop:advisor pointcut-ref="defaultServiceOperation" advice-ref="defaultTxAdvice"/>

<aop:advisor pointcut-ref="noTxServiceOperation" advice-ref="noTxAdvice"/>

</aop:config>

<!-- this bean will be transactional (see the 'defaultServiceOperation' pointcut) -->
<bean id="fooService" class="x.y.service.DefaultFooService"/>

<!-- this bean will also be transactional, but with totally different transactional settings -->
<bean id="anotherFooService" class="x.y.service.ddl.DefaultDdlManager"/>

<tx:advice id="defaultTxAdvice">
<tx:attributes>
<tx:method name="get*" read-only="true"/>
<tx:method name="*"/>
</tx:attributes>
</tx:advice>

<tx:advice id="noTxAdvice">
<tx:attributes>
<tx:method name="*" propagation="NEVER"/>
</tx:attributes>
</tx:advice>

<!-- other transaction infrastructure beans such as a TransactionManager omitted... -->

</beans>

<tx:advice/> 配置

<tx:advice/> 的默认配置为:

  • 传播设置是 REQUIRED

  • 隔离级别为 DEFAULT

  • 事务是 read-write

  • 事务超时默认为底层事务系统的默认超时,如果不支持超时,则为无。

  • 任何 RuntimeException 都会触发回滚,而任何已检查的 Exception 都不会

<tx:advice/> 配置属性

属性 是否必要 默认值 描述
name Yes 与事务属性关联的方法名称。支持通配符,如:get*handle*on*Event
propagation No REQUIRED 事务传播行为
isolation No DEFAULT 事务隔离级别。仅适用于 REQUIREDREQUIRES_NEW 的传播设置。
timeout No -1 事务超时时间(单位:秒)。仅适用于 REQUIREDREQUIRES_NEW 的传播设置。
read-only No false read-write 或 read-only 事务。
rollback-for No 触发回滚的 Exception 实例列表(通过逗号分隔)。
no-rollback-for No 不触发回滚的 Exception 实例列表(通过逗号分隔)。

使用 @Transactional 注解

除了基于 XML 的声明式事务配置方法之外,您还可以使用基于注解的方法。

下面是一个使用 @Transactional 注解的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Transactional
public class DefaultFooService implements FooService {

@Override
public Foo getFoo(String fooName) {
// ...
}

@Override
public Foo getFoo(String fooName, String barName) {
// ...
}

@Override
public void insertFoo(Foo foo) {
// ...
}

@Override
public void updateFoo(Foo foo) {
// ...
}
}

如上所述在类级别使用,@Transactional 注解表明声明类(及其子类)的所有方法都使用默认事务配置。 或者,可以单独为每个方法指定注解。请注意,类级别的注解不适用于类层次结构中的祖先类; 在这种情况下,继承的方法需要在本地重新声明才能参与子类级别的注解。

当上面的 POJO 类在 Spring 上下文中定义为 bean 时,您可以通过 @Configuration 类中的 @EnableTransactionManagement 注解使 bean 实例具有事务性。

在 XML 配置中, <tx:annotation-driven/> 标签提供了类似的便利:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
<!-- from the file 'context.xml' -->
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:aop="http://www.springframework.org/schema/aop"
xmlns:tx="http://www.springframework.org/schema/tx"
xsi:schemaLocation="
http://www.springframework.org/schema/beans
https://www.springframework.org/schema/beans/spring-beans.xsd
http://www.springframework.org/schema/tx
https://www.springframework.org/schema/tx/spring-tx.xsd
http://www.springframework.org/schema/aop
https://www.springframework.org/schema/aop/spring-aop.xsd">

<!-- this is the service object that we want to make transactional -->
<bean id="fooService" class="x.y.service.DefaultFooService"/>

<!-- enable the configuration of transactional behavior based on annotations -->
<!-- a TransactionManager is still required -->
<tx:annotation-driven transaction-manager="txManager"/>

<bean id="txManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager">
<!-- (this dependency is defined somewhere else) -->
<property name="dataSource" ref="dataSource"/>
</bean>

<!-- other <bean/> definitions here -->

</beans>

@Transactional 配置

Property Type Description
value String Optional qualifier that specifies the transaction manager to be used.
transactionManager String Alias for value.
label Array of String labels to add an expressive description to the transaction. Labels may be evaluated by transaction managers to associate implementation-specific behavior with the actual transaction.
propagation enum: Propagation Optional propagation setting.
isolation enum: Isolation Optional isolation level. Applies only to propagation values of REQUIRED or REQUIRES_NEW.
timeout int (in seconds of granularity) Optional transaction timeout. Applies only to propagation values of REQUIRED or REQUIRES_NEW.
timeoutString String (in seconds of granularity) Alternative for specifying the timeout in seconds as a String value — for example, as a placeholder.
readOnly boolean Read-write versus read-only transaction. Only applicable to values of REQUIRED or REQUIRES_NEW.
rollbackFor Array of Class objects, which must be derived from Throwable. Optional array of exception types that must cause rollback.
rollbackForClassName Array of exception name patterns. Optional array of exception name patterns that must cause rollback.
noRollbackFor Array of Class objects, which must be derived from Throwable. Optional array of exception types that must not cause rollback.
noRollbackForClassName Array of exception name patterns. Optional array of exception name patterns that must not cause rollback.

多事务管理器场景下使用 @Transactional

某些情况下,应用程序中可能需要接入多个数据源,相应的,也需要多个独立的事务管理器。使用者可以使用 @Transactional 注释的 value 或 transactionManager 属性来选择性地指定要使用的 TransactionManager 的标识。这可以是 bean 名称或事务管理器 bean 的限定符值。

1
2
3
4
5
6
7
8
9
10
11
public class TransactionalService {

@Transactional("order")
public void setSomething(String name) { ... }

@Transactional("account")
public void doSomething() { ... }

@Transactional("reactive-account")
public Mono<Void> doSomethingReactive() { ... }
}

下面展示如何定义 TransactionManager

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<tx:annotation-driven/>

<bean id="transactionManager1" class="org.springframework.jdbc.datasource.DataSourceTransactionManager">
...
<qualifier value="order"/>
</bean>

<bean id="transactionManager2" class="org.springframework.jdbc.datasource.DataSourceTransactionManager">
...
<qualifier value="account"/>
</bean>

<bean id="transactionManager3" class="org.springframework.data.r2dbc.connectionfactory.R2dbcTransactionManager">
...
<qualifier value="reactive-account"/>
</bean>

在这种情况下,TransactionalService 上的各个方法在单独的事务管理器下运行,由 order、account 和 reactive-account 限定符区分。 如果没有找到明确指定的 TransactionManager bean,则仍使用默认的 <tx:annotation-driven> 目标 bean 名称。

自定义组合注解

如果您发现在许多不同的方法上重复使用 @Transactional 相同的属性,可以使用 Spring 的元注解自定义组合注解。

1
2
3
4
5
6
7
8
9
10
11
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Transactional(transactionManager = "order", label = "causal-consistency")
public @interface OrderTx {
}

@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Transactional(transactionManager = "account", label = "retryable")
public @interface AccountTx {
}

使用示例:

1
2
3
4
5
6
7
8
9
10
11
12
public class TransactionalService {

@OrderTx
public void setSomething(String name) {
// ...
}

@AccountTx
public void doSomething() {
// ...
}
}

在上面的示例中,我们使用语法来定义事务管理器限定符和事务标签,但我们也可以包括传播行为、回滚规则、超时和其他特性。

事务传播

在 Spring 管理的事务中,请注意物理事务和逻辑事务之间的差异,以及传播设置如何应用于这种差异。

PROPAGATION_REQUIRED 强制执行物理事务,如果尚不存在事务,则在当前范围的本地执行或参与更大范围定义的现有“外部”事务。 这是同一线程内的常见调用堆栈安排中的一个很好的默认设置(例如,委托给多个存储库方法的服务外观,其中所有底层资源都必须参与服务级事务)。

当传播设置为 PROPAGATION_REQUIRED 时,将为应用该设置的每个方法创建一个逻辑事务范围。每个这样的逻辑事务范围可以单独确定仅回滚状态,外部事务范围在逻辑上独立于内部事务范围。在标准 PROPAGATION_REQUIRED 行为的情况下,所有这些范围都映射到同一个物理事务。因此,在内部事务范围内设置的仅回滚标记确实会影响外部事务实际提交的机会。

但是,在内部事务范围设置了仅回滚标记的情况下,外部事务尚未决定回滚本身,因此回滚(由内部事务范围静默触发)是意外的。此时会引发相应的 UnexpectedRollbackException。这是预期的行为,因此事务的调用者永远不会被误导以为执行了提交,而实际上并没有执行。因此,如果内部事务(外部调用者不知道)默默地将事务标记为仅回滚,外部调用者仍会调用提交。外部调用者需要接收 UnexpectedRollbackException 以清楚地指示执行了回滚。

PROPAGATION_REQUIRES_NEW 与 PROPAGATION_REQUIRED 相比,始终为每个受影响的事务范围使用独立的物理事务,从不参与外部范围的现有事务。 在这种安排下,底层资源事务是不同的,因此可以独立提交或回滚,外部事务不受内部事务回滚状态的影响,内部事务的锁在完成后立即释放。 这样一个独立的内部事务也可以声明自己的隔离级别、超时和只读设置,而不是继承外部事务的特性。

JDBC 异常抽象

Spring 会将数据操作的异常转换为 DataAccessException

Spring 是怎么认识那些错误码的

通过 SQLErrorCodeSQLExceptionTranslator 解析错误码

ErrorCode 定义(sql-error-codes.xml 文件)

Spring 事务最佳实践

img

Spring 事务未生效

使用 @Transactional 注解开启声明式事务时, 最容易忽略的问题是,很可能事务并没有生效。

@Transactional 生效原则:

@Transactional 方法必须是 public

原则一:除非特殊配置(比如使用 AspectJ 静态织入实现 AOP),否则只有定义在 public 方法上的 @Transactional 才能生效。原因是,Spring 默认通过动态代理的方式实现 AOP,对目标方法进行增强,private 方法无法代理到,Spring 自然也无法动态增强事务处理逻辑。

【示例】错误使用 @Transactional 案例一

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Transactional
void createUserPrivate(UserEntity entity) {
userRepository.save(entity);
if (entity.getName().contains("test")) { throw new RuntimeException("invalid username!"); }
}

//私有方法
public int createUserWrong1(String name) {
try {
this.createUserPrivate(new UserEntity(name));
} catch (Exception ex) {
log.error("create user failed because {}", ex.getMessage());
}
return userRepository.findByName(name).size();
}

当传入名为 test 的用户实体,会抛出异常,但 @Transactional 未生效,不会触发回滚。

必须通过 Spring 注入的 Bean 进行调用

原则二:必须通过代理过的类从外部调用目标方法才能生效

【示例】错误使用 @Transactional 案例二

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//自调用
public int createUserWrong2(String name) {
try {
this.createUserPublic(new UserEntity(name));
} catch (Exception ex) {
log.error("create user failed because {}", ex.getMessage());
}
return userRepository.findByName(name).size();
}

//可以传播出异常
@Transactional
public void createUserPublic(UserEntity entity) {
userRepository.save(entity);
if (entity.getName().contains("test")) { throw new RuntimeException("invalid username!"); }
}

当传入名为 test 的用户实体,会抛出异常,但 @Transactional 未生效,不会触发回滚。

说明:Spring 通过 AOP 技术对方法进行字节码增强,要调用增强过的方法必然是调用代理后的对象。

事务虽然生效但未回滚

通过 AOP 实现事务处理可以理解为,使用 try…catch… 来包裹标记了 @Transactional 注解的方法,当方法出现了异常并且满足一定条件的时候,在 catch 里面我们可以设置事务回滚,没有异常则直接提交事务。

“一定条件”,主要包括两点:

第一,只有异常传播出了标记了 @Transactional 注解的方法,事务才能回滚。在 Spring 的 TransactionAspectSupport 里有个 invokeWithinTransaction 方法,里面就是处理事务的逻辑。

第二,默认情况下,出现 RuntimeException(非受检异常)或 Error 的时候,Spring 才会回滚事务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
@Service
@Slf4j
public class UserService {

@Autowired
private UserRepository userRepository;

//异常无法传播出方法,导致事务无法回滚
@Transactional
public void createUserWrong1(String name) {
try {
userRepository.save(new UserEntity(name));
throw new RuntimeException("error");
} catch (Exception ex) {
log.error("create user failed", ex);
}
}

//即使出了受检异常也无法让事务回滚
@Transactional
public void createUserWrong2(String name) throws IOException {
userRepository.save(new UserEntity(name));
otherTask();
}

//因为文件不存在,一定会抛出一个IOException
private void otherTask() throws IOException {
Files.readAllLines(Paths.get("file-that-not-exist"));
}

}

在 createUserWrong1 方法中会抛出一个 RuntimeException,但由于方法内 catch 了所有异常,异常无法从方法传播出去,事务自然无法回滚。

在 createUserWrong2 方法中,注册用户的同时会有一次 otherTask 文件读取操作,如果文件读取失败,我们希望用户注册的数据库操作回滚。虽然这里没有捕获异常,但因为 otherTask 方法抛出的是受检异常,createUserWrong2 传播出去的也是受检异常,事务同样不会回滚。

【解决方案一】如果你希望自己捕获异常进行处理的话,也没关系,可以手动设置 TransactionAspectSupport.currentTransactionStatus().setRollbackOnly(); 让当前事务处于回滚状态

1
2
3
4
5
6
7
8
9
10
@Transactional
public void createUserRight1(String name) {
try {
userRepository.save(new UserEntity(name));
throw new RuntimeException("error");
} catch (Exception ex) {
log.error("create user failed", ex);
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
}
}

【解决方案二】在注解中声明 @Transactional(rollbackFor = Exception.class),期望遇到所有的 Exception 都回滚事务(来突破默认不回滚受检异常的限制):

1
2
3
4
5
@Transactional(rollbackFor = Exception.class)
public void createUserRight2(String name) throws IOException {
userRepository.save(new UserEntity(name));
otherTask();
}

细化事务传播方式

如果方法涉及多次数据库操作,并希望将它们作为独立的事务进行提交或回滚,那么
我们需要考虑进一步细化配置事务传播方式,也就是 @Transactional 注解的 Propagation 属性。

1
2
3
4
5
6
7
8
9
/**
* {@link Propagation#REQUIRES_NEW} 表示执行到这个方法时需要开启新的事务,并挂起当前事务
*/
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void createSubUserWithExceptionRight(UserEntity entity) {
log.info("createSubUserWithExceptionRight start");
userRepository.save(entity);
throw new RuntimeException("invalid status");
}

参考资料

数据库连接池

数据库连接池负责分配、管理和释放数据库连接,它允许应用程序重复使用一个现有的数据库连接,而不是再重新建立一个;释放空闲时间超过最大空闲时间的数据库连接来避免因为没有释放数据库连接而引起的数据库连接遗漏。这项技术能明显提高对数据库操作的性能。——摘自百度百科

什么是数据库连接池

数据库连接是一种关键的有限的昂贵的资源,这一点在多用户的网页应用程序中体现得尤为突出。 一个数据库连接对象均对应一个物理数据库连接,每次操作都打开一个物理连接,使用完都关闭连接,这样造成系统的 性能低下。 数据库连接池的解决方案是在应用程序启动时建立足够的数据库连接,并讲这些连接组成一个连接池(简单说:在一个“池”里放了好多半成品的数据库联接对象),由应用程序动态地对池中的连接进行申请、使用和释放。对于多于连接池中连接数的并发请求,应该在请求队列中排队等待。并且应用程序可以根据池中连接的使用率,动态增加或减少池中的连接数。 连接池技术尽可能多地重用了消耗内存地资源,大大节省了内存,提高了服务器地服务效率,能够支持更多的客户服务。通过使用连接池,将大大提高程序运行效率,同时,我们可以通过其自身的管理机制来监视数据库连接的数量、使用情况等。

为什么需要数据库连接池

不使用数据库连接池

不使用数据库连接池的步骤

  1. TCP 建立连接的三次握手
  2. MySQL 认证的三次握手
  3. 真正的 SQL 执行
  4. MySQL 的关闭
  5. TCP 的四次握手关闭

不使用数据库连接池的特性:

  • 优点:实现简单
  • 缺点
    • 网络 IO 较多
    • 数据库的负载较高
    • 响应时间较长及 QPS 较低
    • 应用频繁的创建连接和关闭连接,导致临时对象较多,GC 频繁
    • 在关闭连接后,会出现大量 TIME_WAIT 的 TCP 状态(在 2 个 MSL 之后关闭)

使用数据库连接池

使用数据库连接池的步骤:只有第一次访问的时候,需要建立连接。 但是之后的访问,均会复用之前创建的连接,直接执行 SQL 语句。

使用数据库连接池的优点

  • 减少了网络开销
  • 系统的性能会有一个实质的提升
  • 没有了 TIME_WAIT 状态

数据库连接池如何工作

数据库连接池工作的核心在于以下几点:

  1. 创建连接池:与线程池等池化对象类似,数据库连接池会在进程启动之初,根据配置初始化,并在池中创建了几个连接对象,以便使用时能从连接池中获取。连接池中的连接不能随意创建和关闭,以避免创建、关闭所带来的系统开销。

  2. 使用、管理连接池中:连接池管理策略是连接池机制的核心,连接池内连接的分配和释放对系统的性能有很大的影响。合理的策略可以保证数据库连接的有效复用,避免频繁的建立、释放连接所带来的系统资源开销。通常,数据库连接池的管理策略如下:

    1. 当请求数据库连接时,首先查看连接池中是否有空闲连接。
    2. 如果存在空闲连接,则将连接分配给客户使用。
    3. 如果没有空闲连接,则查看当前所开的连接数是否已经达到最大连接数。若未达到,就重新创建一个连接,并分配给请求的客户;如果达到,就按设定的最大等待时间进行等待,若超出最大等待时间,则抛出异常给客户。
    4. 当客户释放数据库连接时,先判断该连接的引用次数是否超过了规定值。如果超过,就从连接池中删除该连接;否则保留为其他客户服务。
  3. 关闭连接池:当应用程序退出时,关闭连接池中所有的连接,释放连接池相关的资源,该过程正好与创建相反。

数据库连接池的核心参数

使用数据库连接池,需要为其配置一些参数,以控制其工作。

通常,数据库连接池都会包含以下核心参数:

  • 最小连接数:是连接池一直保持的数据库连接,所以如果应用程序对数据库连接的使用量不大,将会有大量的数据库连接资源被浪费.
  • 最大连接数:是连接池能申请的最大连接数,如果数据库连接请求超过次数,后面的数据库连接请求将被加入到等待队列中,这会影响以后的数据库操作
  • 最大空闲时间
  • 获取连接超时时间
  • 超时重试连接次数

数据库连接池的问题

并发问题:为了保证连接管理服务具有最大的通用性,必须考虑多线程环境,即并发问题。

事务处理:我们知道,事务具有原子性,此时要求对数据库的操作符合“ALL-OR-NOTHING”原则,即对于一组 SQL 语句要么全做,要么全不做。我们知道当 2 个线程共用一个连接 Connection 对象,而且各自都有自己的事务要处理时候,对于连接池是一个很头疼的问题,因为即使 Connection 类提供了相应的事务支持,可是我们仍然不能确定那个数据库操作是对应那个事务的,这是由于我们有2个线程都在进行事务操作而引起的。为此我们可以使用每一个事务独占一个连接来实现,虽然这种方法有点浪费连接池资源但是可以大大降低事务管理的复杂性。

连接池的分配与释放:连接池的分配与释放,对系统的性能有很大的影响。合理的分配与释放,可以提高连接的复用度,从而降低建立新连接的开销,同时还可以加快用户的访问速度。 对于连接的管理可使用一个 List。即把已经创建的连接都放入 List 中去统一管理。每当用户请求一个连接时,系统检查这个 List 中有没有可以分配的连接。如果有就把那个最合适的连接分配给他;如果没有就抛出一个异常给用户。

连接池的配置与维护:连接池中到底应该放置多少连接,才能使系统的性能最佳?系统可采取设置最小连接数(minConnection)和最大连接数(maxConnection)等参数来控制连接池中的连接。比方说,最小连接数是系统启动时连接池所创建的连接数。如果创建过多,则系统启动就慢,但创建后系统的响应速度会很快;如果创建过少,则系统启动的很快,响应起来却慢。这样,可以在开发时,设置较小的最小连接数,开发起来会快,而在系统实际使用时设置较大的,因为这样对访问客户来说速度会快些。最大连接数是连接池中允许连接的最大数目,具体设置多少,要看系统的访问量,可通过软件需求上得到。 如何确保连接池中的最小连接数呢?有动态和静态两种策略。动态即每隔一定时间就对连接池进行检测,如果发现连接数量小于最小连接数,则补充相应数量的新连接,以保证连接池的正常运转。静态是发现空闲连接不够时再去检查。

数据库连接池技术选型

常见的数据库连接池:

  • HikariCP:HiKariCP 号称是跑的最快的连接池,并且是 SpringBoot 框架的默认连接池。
  • Druid:Druid 是阿里巴巴开源的数据库连接池。Druid 内置强大的监控功能,监控特性不影响性能。功能强大,能防 SQL 注入,内置 Loging 能诊断 Hack 应用行为。
  • DBCP: 由 Apache 开发的一个 Java 数据库连接池。commons-dbcp2 基于 commons-pool2 来实现底层的对象池机制。单线程,性能较差,适用于小型系统。官方自 2021 年后没有再更新。
  • C3P0:开源的 JDBC 连接池,实现了数据源和 JNDI 绑定,支持 JDBC3 规范和 JDBC2 的标准扩展。单线程,性能较差,适用于小型系统。官方自 2019 年后再没有更新。
  • Tomcat-jdbc:Tomcat 在 7.0 以前使用 DBCP 做为连接池组件,从 7.0 后新增了 Tomcat jdbc pool 模块,基于 Tomcat JULI,使用 Tomcat 日志框架,完全兼容 dbcp,通过异步方式获取连接,支持高并发应用环境,超级简单核心文件只有 8 个,支持 JMX,支持 XA Connection。

来自 Druid 的竞品对比(https://github.com/alibaba/druid/wiki/Druid%E8%BF%9E%E6%8E%A5%E6%B1%A0%E4%BB%8B%E7%BB%8D):

功能类别 功能 Druid HikariCP DBCP Tomcat-jdbc C3P0
性能 PSCache
LRU
SLB 负载均衡支持
稳定性 ExceptionSorter
扩展 扩展 Filter JdbcIntercepter
监控 监控方式 jmx/log/http jmx/metrics jmx jmx jmx
支持 SQL 级监控
Spring/Web 关联监控
诊断支持 LogFilter
连接泄露诊断 logAbandoned
安全 SQL 防注入
支持配置加密

从数据库连接池最重要的性能角度来看:HikariCP 应该性能最好;Druid 也不错,并且有更多、更久的生产实践,更为可靠;而其他常见的数据库连接池性能远远不如。

从功能角度来看:Druid 功能最全面,除基本的数据库连接池能力以外,还支持 sql 级监控、扩展、SQL 防注入以及监控等功能。

综合来看:HikariCP 是 Spring Boot 首选数据库连接池,对于 Spring Boot 项目来说,无疑适配性最好。而非 Spring Boot 项目,可以优先考虑 Druid,在国内有大规模应用,中文社区支持良好。

HikariCP

HiKariCP 号称是跑的最快的连接池,并且是 SpringBoot 框架的默认连接池。

HiKariCP 为了提升性能,做了很多细节上的优化,例如:

  • 使用 FastList 替代 ArrayList,通过初始化的默认值,减少了越界检查的操作
  • 优化并精简了字节码,通过使用 Javassist,减少了动态代理的性能损耗,比如使用 invokestatic 指令代替 invokevirtual 指令
  • 实现了无锁的 ConcurrentBag,减少了并发场景下的锁竞争

HikariCP 关键配置:

  • maximum-pool-size:池中最大连接数(包括空闲和正在使用的连接)。默认值是 10,这个一般预估应用的最大连接数,后期根据监测得到一个最大值的一个平均值。要知道,最大连接并不是越多越好,一个 connection 会占用系统的带宽和存储。但是 当连接池没有空闲连接并且已经到达最大值,新来的连接池请求(HikariPool#getConnection)会被阻塞直到connectionTimeout(毫秒),超时后便抛出 SQLException。
  • minimum-idle:池中最小空闲连接数量。默认值 10,小于池中最大连接数,一般根据系统大部分情况下的数据库连接情况取一个平均值。Hikari 会尽可能、尽快地将空闲连接数维持在这个数量上。如果为了获得最佳性能和对峰值需求的响应能力,我们也不妨让他和最大连接数保持一致,使得 HikariCP 成为一个固定大小的数据库连接池。
  • connection-timeout:连接超时时间。默认值为 30s,可以接收的最小超时时间为 250ms。但是连接池请求也可以自定义超时时间(com.zaxxer.hikari.pool.HikariPool#getConnection(long))。
  • idle-timeout:空闲连接存活最大时间,默认 600000(十分钟)
  • max-lifetime:连接池中连接的最大生命周期。当连接一致处于闲置状态时,超过 8 小时数据库会主动断开连接。为了防止大量的同一时间处于空闲连接因为数据库方的闲置超时策略断开连接(可以理解为连接雪崩),一般将这个值设置的比数据库的“闲置超时时间”小几秒,以便这些连接断开后,HikariCP 能迅速的创建新一轮的连接。
  • pool-name:连接池的名字。一般会出现在日志和 JMX 控制台中。默认值:auto-genenrated。建议取一个合适的名字,便于监控。
  • auto-commit:是否自动提交池中返回的连接。默认值为 true。一般是有必要自动提交上一个连接中的事物的。如果为 false,那么就需要应用层手动提交事物。

参考配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
# 连接池名称
spring.datasource.hikari.pool-name = SpringTutorialHikariPool
# 最大连接数,小于等于 0 会被重置为默认值 10;大于零小于 1 会被重置为 minimum-idle 的值
spring.datasource.hikari.maximum-pool-size = 10
# 最小空闲连接,默认值10,小于 0 或大于 maximum-pool-size,都会重置为 maximum-pool-size
spring.datasource.hikari.minimum-idle = 10
# 连接超时时间(单位:毫秒),小于 250 毫秒,会被重置为默认值 30 秒
spring.datasource.hikari.connection-timeout = 60000
# 空闲连接超时时间,默认值 600000(10分钟),大于等于 max-lifetime 且 max-lifetime>0,会被重置为0;不等于 0 且小于 10 秒,会被重置为 10 秒
# 只有空闲连接数大于最大连接数且空闲时间超过该值,才会被释放
spring.datasource.hikari.idle-timeout = 600000
# 连接最大存活时间,不等于 0 且小于 30 秒,会被重置为默认值 30 分钟。该值应该比数据库所设置的超时时间短
spring.datasource.hikari.max-lifetime = 1800000

Druid

Druid 是阿里巴巴开源的数据库连接池。Druid 连接池为监控而生,内置强大的监控功能,监控特性不影响性能。功能强大,能防 SQL 注入,内置 Loging 能诊断 Hack 应用行为。

Druid 关键配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# 数据库访问配置
# 主数据源,默认的
spring.datasource.type=com.alibaba.druid.pool.DruidDataSource
spring.datasource.driver-class-name=com.mysql.jdbc.Driver
spring.datasource.url=jdbc:mysql://localhost:3306/druid
spring.datasource.username=root
spring.datasource.password=root

# 下面为连接池的补充设置,应用到上面所有数据源中
# 初始化大小,最小,最大
spring.datasource.initialSize=5
spring.datasource.minIdle=5
spring.datasource.maxActive=20
# 配置获取连接等待超时的时间
spring.datasource.maxWait=60000
# 配置间隔多久才进行一次检测,检测需要关闭的空闲连接,单位是毫秒
spring.datasource.timeBetweenEvictionRunsMillis=60000
# 配置一个连接在池中最小生存的时间,单位是毫秒
spring.datasource.minEvictableIdleTimeMillis=300000
spring.datasource.validationQuery=SELECT 1 FROM DUAL
spring.datasource.testWhileIdle=true
spring.datasource.testOnBorrow=false
spring.datasource.testOnReturn=false
# 打开PSCache,并且指定每个连接上PSCache的大小
spring.datasource.poolPreparedStatements=true
spring.datasource.maxPoolPreparedStatementPerConnectionSize=20
# 配置监控统计拦截的filters,去掉后监控界面sql无法统计,'wall'用于防火墙
spring.datasource.filters=stat,wall,log4j
# 通过connectProperties属性来打开mergeSql功能;慢SQL记录
spring.datasource.connectionProperties=druid.stat.mergeSql=true;druid.stat.slowSqlMillis=5000
# 合并多个DruidDataSource的监控数据
#spring.datasource.useGlobalDataSourceStat=true

参考资料

《MySQL 实战 45 讲》笔记

基础架构:一条 SQL 查询语句是如何执行的?

  1. 连接器:连接器负责跟客户端建立连接、获取权限、维持和管理连接。
  2. 查询缓存:命中缓存,则直接返回结果。弊大于利,因为失效非常频繁——任何更新都会清空查询缓存。
  3. 分析器
    • 词法分析:解析 SQL 关键字
    • 语法分析:生成一颗对应的语法解析树
  4. 优化器
    • 根据语法树生成多种执行计划
    • 索引选择:根据策略选择最优方式
  5. 执行器
    • 校验读写权限
    • 根据执行计划,调用存储引擎的 API 来执行查询
  6. 存储引擎:存储数据,提供读写接口

日志系统:一条 SQL 更新语句是如何执行的?

更新流程和查询的流程大致相同,不同之处在于:更新流程还涉及两个重要的日志模块:

  • redo log(重做日志)
  • binlog(归档日志)

redo log

如果每一次的更新操作都需要写进磁盘,然后磁盘也要找到对应的那条记录,然后再更新,整个过程 IO 成本、查找成本都很高。为了解决这个问题,MySQL 采用了 WAL 技术(全程是 Write-Ahead Logging),它的关键点就是先写日志,再写磁盘。

具体来说,当有一条记录需要更新的时候,InnoDB 引擎就会先把记录写到 redo log 里,并更新内存,这个时候更新就算完成了。同时,InnoDB 引擎会在适当的时候,将这个操作记录更新到磁盘里面,而这个更新往往是在系统比较空闲的时候做。

InnoDB 的 redo log 是固定大小的,比如可以配置为一组 4 个文件,每个文件的大小是 1GB,那么总共就可以记录 4GB 的操作。从头开始写,写到末尾就又回到开头循环写,如下面这个图所示。

write pos 是当前记录的位置,一边写一边后移,写到第 3 号文件末尾后就回到 0 号文件开头。checkpoint 是当前要擦除的位置,也是往后推移并且循环的,擦除记录前要把记录更新到数据文件。

write pos 和 checkpoint 之间的是还空着的部分,可以用来记录新的操作。如果 write pos 追上 checkpoint,表示“粉板”满了,这时候不能再执行新的更新,得停下来先擦掉一些记录,把 checkpoint 推进一下。

有了 redo log,InnoDB 就可以保证即使数据库发生异常重启,之前提交的记录都不会丢失,这个能力称为crash-safe

binlog

redo log 是 InnoDB 引擎特有的日志,而 Server 层也有自己的日志,称为 binlog(归档日志)。

redo log 和 binlog 的差异:

  1. redo log 是 InnoDB 引擎特有的;binlog 是 MySQL 的 Server 层实现的,所有引擎都可以使用。
  2. redo log 是物理日志,记录的是“在某个数据页上做了什么修改”;binlog 是逻辑日志,记录的是这个语句的原始逻辑,比如“给 ID=2 这一行的 c 字段加 1 ”。
  3. redo log 是循环写的,空间固定会用完;binlog 是追加写入的。“追加写”是指 binlog 文件写到一定大小后会切换到下一个,并不会覆盖以前的日志。

再来看一下:update 语句时的内部流程

  1. 执行器先找引擎取 ID=2 这一行。ID 是主键,引擎直接用树搜索找到这一行。如果 ID=2 这一行所在的数据页本来就在内存中,就直接返回给执行器;否则,需要先从磁盘读入内存,然后再返回。
  2. 执行器拿到引擎给的行数据,把这个值加上 1,比如原来是 N,现在就是 N+1,得到新的一行数据,再调用引擎接口写入这行新数据。
  3. 引擎将这行新数据更新到内存中,同时将这个更新操作记录到 redo log 里面,此时 redo log 处于 prepare 状态。然后告知执行器执行完成了,随时可以提交事务。
  4. 执行器生成这个操作的 binlog,并把 binlog 写入磁盘。
  5. 执行器调用引擎的提交事务接口,引擎把刚刚写入的 redo log 改成提交(commit)状态,更新完成。

两阶段提交

为什么日志需要“两阶段提交”

由于 redo log 和 binlog 是两个独立的逻辑,如果不用两阶段提交,要么就是先写完 redo log 再写 binlog,或者采用反过来的顺序。

  1. 先写 redo log 后写 binlog。假设在 redo log 写完,binlog 还没有写完的时候,MySQL 进程异常重启。由于我们前面说过的,redo log 写完之后,系统即使崩溃,仍然能够把数据恢复回来,所以恢复后这一行 c 的值是 1。
    • 但是由于 binlog 没写完就 crash 了,这时候 binlog 里面就没有记录这个语句。因此,之后备份日志的时候,存起来的 binlog 里面就没有这条语句。
    • 然后你会发现,如果需要用这个 binlog 来恢复临时库的话,由于这个语句的 binlog 丢失,这个临时库就会少了这一次更新,恢复出来的这一行 c 的值就是 0,与原库的值不同。
  2. 先写 binlog 后写 redo log。如果在 binlog 写完之后 crash,由于 redo log 还没写,崩溃恢复以后这个事务无效,所以这一行 c 的值是 0。但是 binlog 里面已经记录了“把 c 从 0 改成 1”这个日志。所以,在之后用 binlog 来恢复的时候就多了一个事务出来,恢复出来的这一行 c 的值就是 1,与原库的值不同。

可以看到,如果不使用“两阶段提交”,那么数据库的状态就有可能和用它的日志恢复出来的库的状态不一致。

事务隔离:为什么你改了我还看不见?

深入浅出索引

索引数据结构

哈希索引

适用:只能用于等值查询

哈希索引的限制

  • 无法用于排序:因为哈希索引数据不是按照索引值顺序存储的。
  • 不支持部分索引匹配查找:因为哈希索引时使用索引列的全部内容来进行哈希计算的。
  • 不能用索引中的值来避免读取行:因为哈希索引只包含哈希值和行指针,不存储字段。
  • 只支持等值比较查询(包括 =、IN()、<=>);不支持任何范围查询
  • 哈希索引非常快,除非有很多哈希冲突:
    • 出现哈希冲突时,必须遍历链表中所有行指针,逐行比较匹配
    • 如果哈希冲突多的话,维护索引的代价会很高

哈希索引的应用

Mysql 中,只有 Memory 存储引擎显示支持哈希索引。

有序数组索引

有序数组索引在等值查询和范围查询场景中的性能都非常优秀。

如果仅仅看查询效率,有序数组就是最好的数据结构了。但是,更新数据的时候,往中间插入一个记录就必须得挪动后面所有的记录,成本太高。所以,有序数组索引只适用于静态存储引擎

B+ 树索引

在 InnoDB 中,表都是根据主键顺序以索引的形式存放的,这种存储方式的表称为索引组织表。又因为 InnoDB 使用了 B+ 树索引模型,所以数据都是存储在 B+ 树中的。

每一个索引在 InnoDB 里面对应一棵 B+ 树。

根据叶子节点的内容,索引类型分为主键索引(聚簇索引)和非主键索引(非聚簇索引)。

  • 聚簇索引:叶子节点存储行数据。
    • 可以把相关数据保存在一起
    • 数据访问更快
    • 使用覆盖索引扫描的查询可以直接使用叶节点中的主键值
  • 非聚簇索引:叶子节点存储主键。访问需要两次索引查找。
    • 第一次获得对应主键值
    • 第二次去聚簇索引中查找对应行,即回表

B+ 树为了维护索引有序性,在插入新值的时候需要做动态调整。

  • 插入位置如果不是末尾,需要挪动后面的数据,空出位置。
  • 更糟的情况是,如果待插入位置所在的数据页已经满了,根据 B+ 树的算法,这时候需要申请一个新的数据页,然后挪动部分数据过去。这个过程称为页分裂。
    • 这种情况下,性能自然会受影响。
    • 除了性能外,页分裂操作还影响数据页的利用率。原本放在一个页的数据,现在分到两个页中,整体空间利用率降低大约 50%。
  • 如果相邻的两个页由于删除了数据,利用率很低之后,会将数据页做合并。合并的过程,可以认为是分裂过程的逆过程。

由于每个非主键索引的叶子节点上都是主键的值。显然,主键长度越小,普通索引的叶子节点就越小,普通索引占用的空间也就越小。所以,从性能和存储空间方面考量,自增主键往往是更合理的选择。

什么场景适合用业务字段直接做主键的呢?

  • 只有一个索引;
  • 该索引必须是唯一索引。

为什么不用二叉树?

要考虑尽量减少磁盘扫描。

索引策略

  • 索引基本原则
    • 索引不是越多越好,不要为所有列都创建索引
    • 要尽量避免冗余和重复索引
    • 要考虑删除未使用的索引
    • 尽量的扩展索引,不要新建索引
    • 频繁作为 WHERE 过滤条件的列应该考虑添加索引
  • 独立索引
    • 索引列不能是表达式的一部分,也不能是函数的参数
    • 对索引字段做函数操作,可能会破坏索引值的有序性,因此优化器就决定放弃走树搜索功能。
  • 前缀索引和索引选择性
    • 索引的选择性是指:不重复的索引值和数据表记录总数的比值。
    • 选择性越高,查询效率越高使用前缀索引,定义好长度,就可以做到既节省空间,又不用额外增加太多的查询成本。
    • order by 无法使用前缀索引,无法把前缀索引用作覆盖索引
  • 最左前缀匹配原则
    • 将选择性高的列或基数大的列优先排在多列索引最前列
    • 匹配联合索引最左前缀的时候,如果遇到了范围查询,比如(<)(>)和 between 等,就会停止匹配。
  • 覆盖索引:索引上的信息足够满足查询请求,不需要回表查询数据。
  • 使用索引扫描来排序:ORDER BY 的字段作为索引,这样命中索引的查询结果,不需要额外排序
  • = 和 in 可以乱序:不需要考虑 =、IN 等的顺序,Mysql 会自动优化这些条件的顺序,以匹配尽可能多的索引列。

全局锁和表锁 :给表加个字段怎么有这么多阻碍?

根据加锁的范围,MySQL 里面的锁大致可以分成全局锁、表级锁和行锁三类

全局锁就是对整个数据库实例加锁。MySQL 提供了一个加全局读锁的方法,命令是 Flush tables with read lock (FTWRL)。当需要让整个库处于只读状态的时候,可以使用这个命令,之后其他线程的以下语句会被阻塞:数据更新语句(数据的增删改)、数据定义语句(包括建表、修改表结构等)和更新类事务的提交语句。

全局锁的典型使用场景是,做全库逻辑备份。也就是把整库每个表都 select 出来存成文本。

MySQL 里面表级别的锁有两种:一种是表锁,一种是元数据锁(meta data lock,MDL)。

表锁的语法是 lock tables … read/write。与 FTWRL 类似,可以用 unlock tables 主动释放锁,也可以在客户端断开的时候自动释放。需要注意,lock tables 语法除了会限制别的线程的读写外,也限定了本线程接下来的操作对象。

另一类表级的锁是 MDL(metadata lock)。MDL 不需要显式使用,在访问一个表的时候会被自动加上。MDL 的作用是,保证读写的正确性。MySQL 5.5 版本中引入了 MDL,当对一个表做增删改查操作的时候,加 MDL 读锁;当要对表做结构变更操作的时候,加 MDL 写锁。

  • 读锁之间不互斥,因此你可以有多个线程同时对一张表增删改查。
  • 读写锁之间、写锁之间是互斥的,用来保证变更表结构操作的安全性。因此,如果有两个线程要同时给一个表加字段,其中一个要等另一个执行完才能开始执行。

给一个表加字段,或者修改字段,或者加索引,需要扫描全表的数据。

行锁功过:怎么减少行锁对性能的影响?

MySQL 的行锁是在引擎层由各个引擎自己实现的。但并不是所有的引擎都支持行锁,比如 MyISAM 引擎就不支持行锁。不支持行锁意味着并发控制只能使用表锁,对于这种引擎的表,同一张表上任何时刻只能有一个更新在执行,这就会影响到业务并发度。InnoDB 是支持行锁的,这也是 MyISAM 被 InnoDB 替代的重要原因之一。

如果事务中需要锁多个行,要把最可能造成锁冲突、最可能影响并发度的锁尽量往后放。

死锁和死锁检测

当并发系统中不同线程出现循环资源依赖,涉及的线程都在等待别的线程释放资源时,就会导致这几个线程都进入无限等待的状态,称为死锁

当出现死锁以后,有两种策略:

  • 进入等待,直到超时。这个超时时间可以通过参数 innodb_lock_wait_timeout 来设置。
    • 在 InnoDB 中,innodb_lock_wait_timeout 的默认值是 50s,意味着如果此策略,当出现死锁以后,第一个被锁住的线程要过 50s 才会超时退出,然后其他线程才有可能继续执行。对于在线服务来说,这个等待时间往往是无法接受的。
    • 但是,我们又不可能直接把这个时间设置成一个很小的值,比如 1s。这样当出现死锁的时候,确实很快就可以解开,但如果不是死锁,而是简单的锁等待呢?所以,超时时间设置太短的话,会出现很多误伤。
  • 发起死锁检测,发现死锁后,主动回滚死锁链条中的某一个事务,让其他事务得以继续执行。将参数 innodb_deadlock_detect 设置为 on,表示开启这个逻辑。
    • 主动死锁检测在发生死锁的时候,是能够快速发现并进行处理的,但是它也是有额外负担的。每当一个事务被锁的时候,就要看看它所依赖的线程有没有被别人锁住,如此循环,最后判断是否出现了循环等待,也就是死锁。
    • 极端情况下,如果所有事务都要更新同一行:每个新来的被堵住的线程,都要判断会不会由于自己的加入导致了死锁,这是一个时间复杂度是 O(n) 的操作。假设有 1000 个并发线程要同时更新同一行,那么死锁检测操作就是 100 万这个量级的。虽然最终检测的结果是没有死锁,但是这期间要消耗大量的 CPU 资源。因此,你就会看到 CPU 利用率很高,但是每秒却执行不了几个事务。

减少死锁的主要方向,就是控制访问相同资源的并发事务量。

事务到底是隔离的还是不隔离的

“快照”在 MVCC 里是怎么工作的?

InnoDB 里面每个事务有一个唯一的事务 ID,叫作 transaction id。它是在事务开始的时候向 InnoDB 的事务系统申请的,是按申请顺序严格递增的。

而每行数据也都是有多个版本的。每次事务更新数据的时候,都会生成一个新的数据版本,并且把 transaction id 赋值给这个数据版本的事务 ID,记为 row trx_id。同时,旧的数据版本要保留,并且在新的数据版本中,能够有信息可以直接拿到它。

图中虚线框里是同一行数据的 4 个版本,当前最新版本是 V4,k 的值是 22,它是被 transaction id 为 25 的事务更新的,因此它的 row trx_id 也是 25。

图中的三个虚线箭头,就是 undo log;而 V1、V2、V3 并不是物理上真实存在的,而是每次需要的时候根据当前版本和 undo log 计算出来的。比如,需要 V2 的时候,就是通过 V4 依次执行 U3、U2 算出来。

按照可重复读的定义,一个事务启动的时候,能够看到所有已经提交的事务结果。但是之后,这个事务执行期间,其他事务的更新对它不可见。

因此,一个事务只需要在启动的时候声明说,“以我启动的时刻为准,如果一个数据版本是在我启动之前生成的,就认;如果是我启动以后才生成的,我就不认,我必须要找到它的上一个版本”。

当然,如果“上一个版本”也不可见,那就得继续往前找。还有,如果是这个事务自己更新的数据,它自己还是要认的。

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

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

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

这样,对于当前事务的启动瞬间来说,一个数据版本的 row trx_id,有以下几种可能:

  1. 如果落在绿色部分,表示这个版本是已提交的事务或者是当前事务自己生成的,这个数据是可见的;
  2. 如果落在红色部分,表示这个版本是由将来启动的事务生成的,是肯定不可见的;
  3. 如果落在黄色部分,那就包括两种情况
    a. 若 row trx_id 在数组中,表示这个版本是由还没提交的事务生成的,不可见;
    b. 若 row trx_id 不在数组中,表示这个版本是已经提交了的事务生成的,可见。

InnoDB 利用了“所有数据都有多个版本”的这个特性,实现了“秒级创建快照”的能力。

更新逻辑

更新数据都是先读后写的,而这个读,只能读当前的值,称为“当前读”(current read)。

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

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

而读提交的逻辑和可重复读的逻辑类似,它们最主要的区别是:

  • 在可重复读隔离级别下,只需要在事务开始的时候创建一致性视图,之后事务里的其他查询都共用这个一致性视图;
  • 在读提交隔离级别下,每一个语句执行前都会重新算出一个新的视图。

普通索引和唯一索引,应该怎么选择?

普通索引和唯一索引的查询性能相差微乎其微

当需要更新一个数据页时,如果数据页在内存中就直接更新,而如果这个数据页还没有在内存中的话,在不影响数据一致性的前提下,InooDB 会将这些更新操作缓存在 change buffer 中,这样就不需要从磁盘中读入这个数据页了。在下次查询需要访问这个数据页的时候,将数据页读入内存,然后执行 change buffer 中与这个页有关的操作。通过这种方式就能保证这个数据逻辑的正确性。

虽然名字叫作 change buffer,实际上它是可以持久化的数据。也就是说,change buffer 在内存中有拷贝,也会被写入到磁盘上。将 change buffer 中的操作应用到原数据页,得到最新结果的过程称为 merge。除了访问这个数据页会触发 merge 外,系统有后台线程会定期 merge。在数据库正常关闭(shutdown)的过程中,也会执行 merge 操作。

显然,如果能够将更新操作先记录在 change buffer,减少读磁盘,语句的执行速度会得到明显的提升。而且,数据读入内存是需要占用 buffer pool 的,所以这种方式还能够避免占用内存,提高内存利用率。

change buffer 的使用场景

change buffer 只限于用在普通索引的场景下,而不适用于唯一索引。

因为 merge 的时候是真正进行数据更新的时刻,而 change buffer 的主要目的就是将记录的变更动作缓存下来,所以在一个数据页做 merge 之前,change buffer 记录的变更越多(也就是这个页面上要更新的次数越多),收益就越大。

  • 对于写多读少的业务来说,页面在写完以后马上被访问到的概率比较小,此时 change buffer 的使用效果最好。
  • 如果一个业务的更新模式是写入之后马上会做查询,那么即使满足了条件,将更新先记录在 change buffer,但之后由于马上要访问这个数据页,会立即触发 merge 过程。这样随机访问 IO 的次数不会减少,反而增加了 change buffer 的维护代价。

索引选择和实践

如果所有的更新后面,都马上伴随着对这个记录的查询,那么你应该关闭 change buffer。而在其他情况下,change buffer 都能提升更新性能。

在实际使用中,你会发现,普通索引和 change buffer 的配合使用,对于数据量大的表的更新优化还是很明显的。

特别地,在使用机械硬盘时,change buffer 这个机制的收效是非常显著的。所以,当你有一个类似“历史数据”的库,并且出于成本考虑用的是机械硬盘时,那你应该特别关注这些表里的索引,尽量使用普通索引,然后把 change buffer 尽量开大,以确保这个“历史数据”表的数据写入速度。

change buffer 和 redo log

图 - 带 change buffer 的更新过程

图 - 带 change buffer 的读过程

redo log 主要节省的是随机写磁盘的 IO 消耗(转成顺序写),而 change buffer 主要节省的则是随机读磁盘的 IO 消耗。

由于唯一索引用不上 change buffer 的优化机制,因此如果业务可以接受,从性能角度出发我建议你优先考虑非唯一索引。

MySQL 为什么有时候会选错索引

选择索引是优化器的工作。

优化器选择索引的目的,是找到一个最优的执行方案,并用最小的代价去执行语句。在数据库里面,扫描行数是影响执行代价的因素之一。扫描的行数越少,意味着访问磁盘数据的次数越少,消耗的 CPU 资源越少。但是,扫描行数并不是唯一的判断标准,优化器还会结合是否使用临时表、是否排序等因素进行综合判断。

MySQL 在真正开始执行语句之前,并不能精确地知道满足这个条件的记录有多少条,而只能根据统计信息来估算记录数。

这个统计信息就是索引的“区分度”。显然,一个索引上不同的值越多,这个索引的区分度就越好。而一个索引上不同的值的个数,我们称之为“基数”(cardinality)。也就是说,这个基数越大,索引的区分度越好。

如果发现 explain 的结果预估的 rows 值跟实际情况差距比较大,可以采用 analyze table t 命令来重新统计索引信息

对于其他优化器误判的情况,你可以在应用端用 force index 来强行指定索引,也可以通过修改语句来引导优化器,还可以通过增加或者删除索引来绕过这个问题。

怎么给字符串字段加索引?

使用前缀索引,定义好长度,就可以做到既节省空间,又不用额外增加太多的查询成本。

在建立索引时关注的是区分度,区分度越高越好。因为区分度越高,意味着重复的键值越少。因此,我们可以通过统计索引上有多少个不同的值来判断要使用多长的前缀。

可以通过下面的方式来测试不同前缀长度的区分度:

1
2
3
4
5
6
7
select count(distinct email) as L from SUser;
select
count(distinct left(email,4))as L4,
count(distinct left(email,5))as L5,
count(distinct left(email,6))as L6,
count(distinct left(email,7))as L7,
from SUser;

使用前缀索引很可能会损失区分度,所以你需要预先设定一个可以接受的损失比例,比如 5%。然后,在返回的 L4~L7 中,找出不小于 L * 95% 的值,假设这里 L6、L7 都满足,你就可以选择前缀长度为 6。

需要注意:使用前缀索引就用不上覆盖索引对查询性能的优化了,必须回表才能拿到该索引字段的完整信息。

如果前缀的区分度不够好的情况时如何处理?

第一种方式是使用倒序存储

1
select field_list from t where id_card = reverse('input_id_card_string');

第二种方式是使用 hash 字段

可以在表上再创建一个整数字段,来保存身份证的校验码,同时在这个字段上创建索引。

1
alter table t add id_card_crc int unsigned, add index(id_card_crc);

然后每次插入新记录的时候,都同时用 crc32() 这个函数得到校验码填到这个新字段。由于校验码可能存在冲突,也就是说两个不同的身份证号通过 crc32() 函数得到的结果可能是相同的,所以你的查询语句 where 部分要判断 id_card 的值是否精确相同。

1
select field_list from t where id_card_crc=crc32('input_id_card_string') and id_card='input_id_card_string'

这两种方式的对比:

  • 它们的相同点是,都不支持范围查询。倒序存储的字段上创建的索引是按照倒序字符串的方式排序的,已经没有办法利用索引方式查出身份证号码在 [ID_X, ID_Y] 的所有市民了。同样地,hash 字段的方式也只能支持等值查询。
  • 它们的区别
    • 从占用的额外空间来看,倒序存储方式在主键索引上,不会消耗额外的存储空间,而 hash 字段方法需要增加一个字段。当然,倒序存储方式使用 4 个字节的前缀长度应该是不够的,如果再长一点,这个消耗跟额外这个 hash 字段也差不多抵消了。
    • 在 CPU 消耗方面,倒序方式每次写和读的时候,都需要额外调用一次 reverse 函数,而 hash 字段的方式需要额外调用一次 crc32() 函数。如果只从这两个函数的计算复杂度来看的话,reverse 函数额外消耗的 CPU 资源会更小些。
    • 从查询效率上看,使用 hash 字段方式的查询性能相对更稳定一些。因为 crc32 算出来的值虽然有冲突的概率,但是概率非常小,可以认为每次查询的平均扫描行数接近 1。而倒序存储方式毕竟还是用的前缀索引的方式,也就是说还是会增加扫描行数。

小结:

  1. 直接创建完整索引,这样可能比较占用空间;
  2. 创建前缀索引,节省空间,但会增加查询扫描次数,并且不能使用覆盖索引;
  3. 倒序存储,再创建前缀索引,用于绕过字符串本身前缀的区分度不够的问题;
  4. 创建 hash 字段索引,查询性能稳定,有额外的存储和计算消耗,跟第三种方式一样,都不支持范围扫描。

为什么我的 MySQL 会“抖”一下?

利用 WAL 技术,数据库将随机写转换成了顺序写,大大提升了数据库的性能。

但是,由此也带来了内存脏页的问题。脏页会被后台线程自动 flush,也会由于数据页淘汰而触发 flush,而刷脏页的过程由于会占用资源,可能会让你的更新和查询语句的响应时间长一些。

为什么表数据删掉一半,表文件大小不变?

表数据既可以存在共享表空间里,也可以是单独的文件。这个行为是由参数 innodb_file_per_table 控制的:

  1. 这个参数设置为 OFF 表示的是,表的数据放在系统共享表空间,也就是跟数据字典放在一起;
  2. 这个参数设置为 ON 表示的是,每个 InnoDB 表数据存储在一个以 .ibd 为后缀的文件中。

我建议你不论使用 MySQL 的哪个版本,都将这个值设置为 ON。因为,一个表单独存储为一个文件更容易管理,而且在你不需要这个表的时候,通过 drop table 命令,系统就会直接删除这个文件。而如果是放在共享表空间中,即使表删掉了,空间也是不会回收的。

要删掉 R4 这个记录,InnoDB 引擎只会把 R4 这个记录标记为删除。如果之后要再插入一个 ID 在 300 和 600 之间的记录时,可能会复用这个位置。但是,磁盘文件的大小并不会缩小。

如果删掉了一个数据页上的所有记录,则整个数据页就可以被复用了。

如果把整个表的数据删除,则所有的数据页都会被标记为可复用。但是磁盘上,文件不会变小。

delete 命令其实只是把记录的位置,或者数据页标记为了“可复用”,但磁盘文件的大小是不会变的。也就是说,通过 delete 命令是不能回收表空间的。这些可以复用,而没有被使用的空间,看起来就像是“空洞”。

如果数据是按照索引递增顺序插入的,那么索引是紧凑的。但如果数据是随机插入的,就可能造成索引的数据页分裂。页分裂完成后,就可能产生空洞。另外,更新索引上的值,可以理解为删除一个旧的值,再插入一个新值。不难理解,这也是会造成空洞的。

也就是说,经过大量增删改的表,都是可能是存在空洞的。

重建表

那么,如何收缩表空间,去除空洞呢?

可以使用 alter table A engine=InnoDB 命令来重建表。MySQL 会自动完成转存数据、交换表名、删除旧表的操作。

显然,花时间最多的步骤是往临时表插入数据的过程,如果在这个过程中,有新的数据要写入到表 A 的话,就会造成数据丢失。因此,在整个 DDL 过程中,表 A 中不能有更新。也就是说,这个 DDL 不是 Online 的。

MySQL 5.6 版本开始引入的 Online DDL,对这个操作流程做了优化。

  1. 建立一个临时文件,扫描表 A 主键的所有数据页;
  2. 用数据页中表 A 的记录生成 B+ 树,存储到临时文件中;
  3. 生成临时文件的过程中,将所有对 A 的操作记录在一个日志文件(row log)中,对应的是图中 state2 的状态;
  4. 临时文件生成后,将日志文件中的操作应用到临时文件,得到一个逻辑数据上与表 A 相同的数据文件,对应的就是图中 state3 的状态;
  5. 用临时文件替换表 A 的数据文件。

对于一个大表来说,Online DDL 最耗时的过程就是拷贝数据到临时表的过程,这个步骤的执行期间可以接受增删改操作。所以,相对于整个 DDL 过程来说,锁的时间非常短。对业务来说,就可以认为是 Online 的。

需要补充说明的是,上述的这些重建方法都会扫描原表数据和构建临时文件。对于很大的表来说,这个操作是很消耗 IO 和 CPU 资源的。因此,如果是线上服务,你要很小心地控制操作时间。如果想要比较安全的操作的话,我推荐你使用 GitHub 开源的 gh-ost 来做。

optimize table、analyze table 和 alter table 这三种方式重建表的区别:

  • 从 MySQL 5.6 版本开始,alter table t engine = InnoDB(也就是 recreate)默认的就是上面图 4 的流程了;
  • analyze table t 其实不是重建表,只是对表的索引信息做重新统计,没有修改数据,这个过程中加了 MDL 读锁;
  • optimize table t 等于 recreate+analyze。

count(*)这么慢,我该怎么办?

不同的 MySQL 引擎中,count(*) 有不同的实现方式。

  • MyISAM 引擎把一个表的总行数存在了磁盘上,因此执行 count(*) 的时候会直接返回这个数,效率很高;
  • 而 InnoDB 引擎就麻烦了,它执行 count(*) 的时候,需要把数据一行一行地从引擎里面读出来,然后累积计数。

为什么 InnoDB 不跟 MyISAM 一样,也把数字存起来呢

因为即使是在同一个时刻的多个查询,由于多版本并发控制(MVCC)的原因,InnoDB 表“应该返回多少行”也是不确定的。

InnoDB 是索引组织表,主键索引树的叶子节点是数据,而普通索引树的叶子节点是主键值。所以,普通索引树比主键索引树小很多。对于 count(*) 这样的操作,遍历哪个索引树得到的结果逻辑上都是一样的。因此,MySQL 优化器会找到最小的那棵树来遍历。

  • MyISAM 表虽然 count(*) 很快,但是不支持事务;
  • show table status 命令虽然返回很快,但是不准确;
  • InnoDB 表直接 count(*) 会遍历全表,虽然结果准确,但会导致性能问题。

保存计数

可以使用 Redis 保存计数,但存在丢失更新一集数据不一致问题。

可以使用数据库其他表保存计数,但要用事务进行控制,增/删数据时,同步改变计数。

不同的 count 用法

对于 count(主键 id) 来说,InnoDB 引擎会遍历整张表,把每一行的 id 值都取出来,返回给 server 层。server 层拿到 id 后,判断是不可能为空的,就按行累加。

对于 count(1) 来说,InnoDB 引擎遍历整张表,但不取值。server 层对于返回的每一行,放一个数字“1”进去,判断是不可能为空的,按行累加。

对于 count(字段) 来说

  • 如果这个“字段”是定义为 not null 的话,一行行地从记录里面读出这个字段,判断不能为 null,按行累加;
  • 如果这个“字段”定义允许为 null,那么执行的时候,判断到有可能是 null,还要把值取出来再判断一下,不是 null 才累加。

但是 count(*) 是例外,并不会把全部字段取出来,而是专门做了优化,不取值。count(*) 肯定不是 null,按行累加。

所以结论是:按照效率排序的话,count(字段)<count(主键 id)<count(1)≈count(*),所以我建议你,尽量使用 count(*)。

order by 是怎么工作的?

用 explain 命令查看执行计划时,Extra 这个字段中的“Using filesort”表示的就是需要排序。

全字段排序

1
select city,name,age from t where city='杭州' order by name limit 1000;

这个语句执行流程如下所示 :

  1. 初始化 sort_buffer,确定放入 name、city、age 这三个字段;
  2. 从索引 city 找到第一个满足 city=’杭州’条件的主键 id,也就是图中的 ID_X;
  3. 到主键 id 索引取出整行,取 name、city、age 三个字段的值,存入 sort_buffer 中;
  4. 从索引 city 取下一个记录的主键 id;
  5. 重复步骤 3、4 直到 city 的值不满足查询条件为止,对应的主键 id 也就是图中的 ID_Y;
  6. 对 sort_buffer 中的数据按照字段 name 做快速排序;
  7. 按照排序结果取前 1000 行返回给客户端。

按 name 排序”这个动作,可能在内存中完成,也可能需要使用外部排序,这取决于排序所需的内存和参数 sort_buffer_size。如果要排序的数据量小于 sort_buffer_size,排序就在内存中完成。但如果排序数据量太大,内存放不下,则不得不利用磁盘临时文件辅助排序。

外部排序一般使用归并排序算法。可以这么简单理解,MySQL 将需要排序的数据分成 N 份,每一份单独排序后存在这些临时文件中。然后把这 N 个有序文件再合并成一个有序的大文件。

rowid 排序

如果表的字段太多,导致单行太大,那么全字段排序的效率就不够好。

这种情况下,Mysql 可以采用 rowid 排序,相比于全字段排序,它的主要差异在于:

取行数据时,不取出整行,而只是取出 id 和用于排序的字段。当排序结束后,再根据 id 取出要查询的字段返回给客户端。

全字段排序 VS rowid 排序

如果内存足够大,Mysql 会优先选择全字段排序,把需要的字段都放到 sort_buffer 中,这样排序后就会直接从内存里面返回查询结果了,不用再回到原表去取数据。

如果内存太小,会影响排序效率,才会采用 rowid 排序算法,这样排序过程中一次可以排序更多行,但是需要再回到原表去取数据。

并不是所有的 order by 语句,都需要排序操作的。MySQL 之所以需要生成临时表,并且在临时表上做排序操作,其原因是原来的数据都是无序的。如果能保证排序字段命中索引,那么就无需再排序了。

覆盖索引是指,索引上的信息足够满足查询请求,不需要再回到主键索引上去取数据。

为什么这些 SQL 语句逻辑相同,性能却差异巨大?

函数操作会破坏索引有序性

对索引字段做函数操作,可能会破坏索引值的有序性,因此优化器就决定放弃走树搜索功能。

示例:

1
2
3
4
5
6
7
8
9
10
11
CREATE TABLE `tradelog` (
`id` int(11) NOT NULL,
`tradeid` varchar(32) DEFAULT NULL,
`operator` int(11) DEFAULT NULL,
`t_modified` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `tradeid` (`tradeid`),
KEY `t_modified` (`t_modified`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

select count(*) from tradelog where month(t_modified)=7;

由于在 t_modified 字段加了 month() 函数操作,导致了全索引扫描。为了能够用上索引的快速定位能力,我们就要把 SQL 语句改成基于字段本身的范围查询。

1
2
3
4
select count(*) from tradelog where
-> (t_modified >= '2016-7-1' and t_modified<'2016-8-1') or
-> (t_modified >= '2017-7-1' and t_modified<'2017-8-1') or
-> (t_modified >= '2018-7-1' and t_modified<'2018-8-1');

隐式转换

下面两个 SQL 的执行流程相同:

1
2
select * from tradelog where tradeid=110717;
select * from tradelog where CAST(tradid AS signed int) = 110717;

交易编号 tradeid 这个字段上,本来就有索引,但是 explain 的结果却显示,这条语句需要走全表扫描。这是由于这条语句隐式增加了转换函数,而对索引字段做函数操作,优化器会放弃走树搜索功能。

隐式字符编码转换

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
CREATE TABLE `trade_detail` (
`id` int(11) NOT NULL,
`tradeid` varchar(32) DEFAULT NULL,
`trade_step` int(11) DEFAULT NULL, /* 操作步骤 */
`step_info` varchar(32) DEFAULT NULL, /* 步骤信息 */
PRIMARY KEY (`id`),
KEY `tradeid` (`tradeid`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

insert into tradelog values(1, 'aaaaaaaa', 1000, now());
insert into tradelog values(2, 'aaaaaaab', 1000, now());
insert into tradelog values(3, 'aaaaaaac', 1000, now());

insert into trade_detail values(1, 'aaaaaaaa', 1, 'add');
insert into trade_detail values(2, 'aaaaaaaa', 2, 'update');
insert into trade_detail values(3, 'aaaaaaaa', 3, 'commit');
insert into trade_detail values(4, 'aaaaaaab', 1, 'add');
insert into trade_detail values(5, 'aaaaaaab', 2, 'update');
insert into trade_detail values(6, 'aaaaaaab', 3, 'update again');
insert into trade_detail values(7, 'aaaaaaab', 4, 'commit');
insert into trade_detail values(8, 'aaaaaaac', 1, 'add');
insert into trade_detail values(9, 'aaaaaaac', 2, 'update');
insert into trade_detail values(10, 'aaaaaaac', 3, 'update again');
insert into trade_detail values(11, 'aaaaaaac', 4, 'commit');

SELECT d.*
FROM tradelog l, trade_detail d
WHERE d.tradeid = l.tradeid AND l.id = 2;
# 等价于
select * from trade_detail where CONVERT(traideid USING utf8mb4)=$L2.tradeid.value;

# 不需要做字符编码转换
EXPLAIN
SELECT l.operator
FROM tradelog l, trade_detail d
WHERE d.tradeid = l.tradeid AND d.id = 2;

字符集 utf8mb4 是 utf8 的超集,所以当这两个类型的字符串在做比较的时候,MySQL 内部的操作是,先把 utf8 字符串转成 utf8mb4 字符集,再做比较。

为什么我只查一行的语句,也执行这么慢?

查询长时间不返回

查询结果长时间不返回。

一般碰到这种情况的话,大概率是表被锁住了。接下来分析原因的时候,一般都是首先执行一下 show processlist 命令,看看当前语句处于什么状态。

使用 show processlist 命令查看 Waiting for table metadata lock 的示意图

出现这个状态表示的是,现在有一个线程正在表 t 上请求或者持有 MDL 写锁,把 select 语句堵住了。

幻读是什么,幻读有什么问题?

产生幻读的原因是,行锁只能锁住行,但是新插入记录这个动作,要更新的是记录之间的“间隙”。因此,为了解决幻读问题,InnoDB 只好引入新的锁,也就是间隙锁 (Gap Lock)。

幻读是什么

幻读指的是一个事务在前后两次查询同一个范围的时候,后一次查询看到了前一次查询没有看到的行。

说明:

  • 在可重复读隔离级别下,普通的查询是快照读,是不会看到别的事务插入的数据的。因此,幻读在“当前读”下才会出现。
  • 幻读专指“新插入的行”。

幻读有什么问题

  • 语义上的破坏
  • 数据一致性的问题
  • 即使把所有记录都加上锁,还是阻止不了新插入的记录

如何解决幻读

  • 间隙锁(gap lock
  • select * from t where d=5 for update,不止给数据库已有的 6 个记录加上了行锁,还同时加了 7 个间隙锁。这样就确保了无法再插入新纪录
  • next-key lock 是前开后闭区间
  • 间隙锁和行锁合称为 next-key lock
  • 跟间隙锁存在冲突关系的,是“往这个间隙中插入一个记录”这个操作
  • 间隙锁是在可重复读隔离级别下才会生效的,所以把隔离级别设置为读提交的话,就没有间隙锁了。但同时,要解决可能出现的数据和日志不一致的问题,需要把binlog格式设置为row

间隙锁和next-key lock的引入,帮我们解决了幻读问题,但同时也带来了一些困扰

  • 间隙锁的引入,可能会导致同样的语句锁住更大的范围,这其实是影响并发度的

为什么我只改一行的语句,锁这么多?

加锁规则里面,包含了两个“原则”、两个“优化”和一个“bug”。

  1. 原则 1:加锁的基本单位是 next-key lock。希望你还记得,next-key lock 是前开后闭区间。
  2. 原则 2:查找过程中访问到的对象才会加锁。
  3. 优化 1:索引上的等值查询,给唯一索引加锁的时候,next-key lock 退化为行锁。
  4. 优化 2:索引上的等值查询,向右遍历时且最后一个值不满足等值条件的时候,next-key lock 退化为间隙锁。
  5. 一个 bug:唯一索引上的范围查询会访问到不满足条件的第一个值为止。

MySQL 有哪些“饮鸩止渴”提高性能的方法?

短连接风暴

短连接模式就是连接到数据库后,执行很少的 SQL 语句就断开,下次需要的时候再重连。

  • MySQL 建立连接的成本很高。
    • 除了正常的网络连接三次握手外,还需要做登录权限判断和获得这个连接的数据读写权限。
  • 短连接模型存在一个风险:一旦数据库处理速度很慢,连接数就会暴涨。
  • max_connections 控制一个 MySQL 实例同时存在的连接数的上限
    • 超过这个值,系统就会拒绝接下来的连接请求,并报错提示“Too many connections”。

解决方法 1:先处理掉那些占着连接但是不工作的线程

  • show processlist 查看 sleep 的线程,然后干掉空闲的连接。注意:可能会误杀事务。
  • 应该优先断开事务外空闲的连接。
    • 通过查 information_schema 库的 innodb_trx 表判断是否处于事务中。
  • 再考虑断开事务内空闲太久的连接。

解决方法 2:减少连接过程的消耗

如果想短时间创建大量数据库连接,有一种做法是跳过权限验证。

跳过权限验证的方法是:重启数据库,并使用 –skip-grant-tables 参数启动。

注意:此方法风险极高,不建议使用。

慢查询性能问题

一般有三种可能:

  1. 索引没有设计好;
  2. SQL 语句没写好;
  3. MySQL 选错了索引。
    • 可以通过 force index 强制使用某个索引

QPS 突增问题

有时候由于业务突然出现高峰,或者应用程序 bug,导致某个语句的 QPS 突然暴涨,也可能导致 MySQL 压力过大,影响服务。

应对方法:

  1. 一种是由全新业务的 bug 导致的。假设你的 DB 运维是比较规范的,也就是说白名单是一个个加的。这种情况下,如果你能够确定业务方会下掉这个功能,只是时间上没那么快,那么就可以从数据库端直接把白名单去掉。
  2. 如果这个新功能使用的是单独的数据库用户,可以用管理员账号把这个用户删掉,然后断开现有连接。这样,这个新功能的连接不成功,由它引发的 QPS 就会变成 0。
  3. 如果这个新增的功能跟主体功能是部署在一起的,那么我们只能通过处理语句来限制。这时,我们可以使用上面提到的查询重写功能,把压力最大的 SQL 语句直接重写成”select 1”返回。
    • 这个方法是用于止血的,但风险很高,不建议使用。

个人观点:以上方法都是基于 DBA 视角的处理方式。实际环境中,应该做好数据库 QPS、CPU 监控,如果发现请求量激增,快要达到瓶颈,可以先紧急弹性扩容,保障业务不损失。然后排查原因,是否是新业务设计不当导致、是否是大数据在也业务高峰期进行数据分析导致,等等。

Mysql 是怎么保证数据不丢的

只要 redo log 和 binlog 保证持久化到磁盘,就能确保 MySQL 异常重启后,数据可以恢复。

binlog 的写入机制

binglog 写入逻辑:事务执行过程中,先把日志写到 binlog cache,事务提交的时候,再把 binlog cache 写到 binlog 文件中。事务提交的时候,执行器把 binlog cache 里的完整事务写入到 binlog 中,并清空 binlog cache。

一个事务的 binlog 是不能被拆开的,因此不论这个事务多大,也要确保一次性写入。

系统给 binlog cache 分配了一片内存,每个线程一个,参数 binlog_cache_size 用于控制单个线程内 binlog cache 所占内存的大小。

write 和 fsync 的时机,是由参数 sync_binlog 控制的:

  1. sync_binlog=0 的时候,表示每次提交事务都只 write,不 fsync;
  2. sync_binlog=1 的时候,表示每次提交事务都会执行 fsync;
  3. sync_binlog=N(N>1) 的时候,表示每次提交事务都 write,但累积 N 个事务后才 fsync。

redo log 的写入机制

redo log 要先写到 redo log buffer

innodb_flush_log_at_trx_commit 参数用于控制 redo log buffer 写入 page cache 和写入磁盘的时机

  1. 设置为 0 的时候,表示每次事务提交时都只是把 redo log 留在 redo log buffer 中 ;
  2. 设置为 1 的时候,表示每次事务提交时都将 redo log 直接持久化到磁盘;
  3. 设置为 2 的时候,表示每次事务提交时都只是把 redo log 写到 page cache。

InnoDB 有一个后台线程,每隔 1 秒,就会把 redo log buffer 中的日志,调用 write 写到文件系统的 page cache,然后调用 fsync 持久化到磁盘。

事务执行中间过程的 redo log 也是直接写在 redo log buffer 中的,这些 redo log 也会被后台线程一起持久化到磁盘。也就是说,一个没有提交的事务的 redo log,也是可能已经持久化到磁盘的。

还有两种场景会让一个没有提交的事务的 redo log 写入到磁盘中。

  1. 一种是,redo log buffer 占用的空间即将达到 innodb_log_buffer_size 一半的时候,后台线程会主动写盘。
  2. 另一种是,并行的事务提交的时候,顺带将这个事务的 redo log buffer 持久化到磁盘。

如果 MySQL 出现了 IO 性能瓶颈,可以通过哪些方法来提升性能

WAL 机制主要得益于两个方面:

  1. redo log 和 binlog 都是顺序写,磁盘的顺序写比随机写速度要快;
  2. 组提交机制,可以大幅度降低磁盘的 IOPS 消耗。

有三种方法提升 Mysql IO 性能:

  • 设置 binlog_group_commit_sync_delay 和 binlog_group_commit_sync_no_delay_count 参数,减少 binlog 的写盘次数。这个方法是基于“额外的故意等待”来实现的,因此可能会增加语句的响应时间,但没有丢失数据的风险。

    • binlog_group_commit_sync_delay 参数,表示延迟多少微秒后才调用 fsync;
    • binlog_group_commit_sync_no_delay_count 参数,表示累积多少次以后才调用 fsync。
  • 将 sync_binlog 设置为大于 1 的值(比较常见是 100~1000)。这样做的风险是,主机掉电时会丢 binlog 日志。

  • 将 innodb_flush_log_at_trx_commit 设置为 2。这样做的风险是,主机掉电的时候会丢数据。

Mysql 是怎么保证主备一致的

MySQL 主备的基本原理

MySQL 主备切换流程

客户端的读写都直接访问主库,备库只是将主库的更新都同步过来,到本地执行。

建议将备库设为 readonly 模式:

  • 有时候一些运营类的查询语句会被放到备库上去查,设置为只读可以防止误操作;
  • 防止切换逻辑有 bug,比如切换过程中出现双写,造成主备不一致;
  • 可以用 readonly 状态,来判断节点的角色。

readonly 设置对超级 (super) 权限用户是无效的,而用于同步更新的线程,就拥有超级权限。

备库跟主库之间维持了一个长连接。主库内部有一个线程,专门用于服务备库的这个长连接。一个事务日志同步的完整过程是这样的:

  1. 在备库上通过 change master 命令,设置主库的 IP、端口、用户名、密码,以及要从哪个位置开始请求 binlog,这个位置包含文件名和日志偏移量。
  2. 在备库上执行 start slave 命令,这时候备库会启动两个线程,就是图中的 io_thread 和 sql_thread。其中 io_thread 负责与主库建立连接。
  3. 主库校验完用户名、密码后,开始按照备库传过来的位置,从本地读取 binlog,发给备库。
  4. 备库拿到 binlog 后,写到本地文件,称为中转日志(relay log)。
  5. sql_thread 读取中转日志,解析出日志里的命令,并执行。

binlog 三种格式对比

binlog 有两种格式,一种是 statement,一种是 row

当 binlog_format=statement 时,binlog 里面记录的就是 SQL 语句的原文。

当 binlog_format=row 时,binlog 里没有了 SQL 语句的原文,而是替换成了两个 event:Table_map 和 Delete_rows。

  1. Table_map event,用于说明接下来要操作的表是 test 库的表 t;
  2. Delete_rows event,用于定义删除的行为。

为什么会有 mixed 这种 binlog 格式的存在场景?

  • 有些 statement 格式的 binlog 可能会导致主备不一致,所以要使用 row 格式。

  • row 格式很占空间

  • mixed 格式的 binlog,是指 MySQL 自己会判断这条 SQL 语句是否可能引起主备不一致,如果有可能,就用 row 格式,否则就用 statement 格式。

循环复制问题

如果两个节点互为主备,就可能出现循环复制问题。

如何解决循环复制问题:

  1. 规定两个库的 server id 必须不同,如果相同,则它们之间不能设定为主备关系;
  2. 一个备库接到 binlog 并在重放的过程中,生成与原 binlog 的 server id 相同的新的 binlog;
  3. 每个库在收到从自己的主库发过来的日志后,先判断 server id,如果跟自己的相同,表示这个日志是自己生成的,就直接丢弃这个日志。

Mysql 是怎么保证高可用的

主备延迟

所谓主备延迟,就是同一个事务,在备库执行完成的时间和主库执行完成的时间之间的差值。

show slave status 命令可用于显示备库延迟(seconds_behind_master),其计算方式如下:

  1. 每个事务的 binlog 里面都有一个时间字段,用于记录主库上写入的时间;
  2. 备库取出当前正在执行的事务的时间字段的值,计算它与当前系统时间的差值,得到 seconds_behind_master。

主备延迟最直接的表现是,备库消费中转日志(relay log)的速度,比主库生产 binlog 的速度要慢。

主备延迟的来源

  • 备库的机器性能比主库的机器性能差。
    • 一般应采用对称部署。
  • 备库的压力大。
    • 因为一般会采用读写分离架构,备库承担读请求,反而导致备库压力过大。
    • 解决方法:
      • 一主多从。除了备库外,可以多接几个从库,让这些从库来分担读的压力。
      • 通过 binlog 输出到外部系统,比如 Hadoop 这类系统,让外部系统提供统计类查询的能力。
  • 大事务
    • 不要一次性地用 delete 语句删除太多数据
    • 大表 DDL:计划内的 DDL,建议使用 gh-ost 方案
    • 备库的并行复制

可靠性优先策略

  1. 判断备库现在的 seconds_behind_master,如果小于某个值(比如 5 秒)继续下一步,否则持续重试这一步;
  2. 把主库改成只读状态,即把 readonly 设置为 true;
  3. 判断备库的 seconds_behind_master 的值,直到这个值变成 0 为止;
  4. 把备库改成可读写状态,也就是把 readonly 设置为 false;
  5. 把业务请求切到备库。

这个切换流程,一般是由专门的 HA 系统来完成的,我们暂时称之为可靠性优先流程。

可用性优先策略

如果强行把步骤 4、5 调整到最开始执行,也就是说不等主备数据同步,直接把连接切到备库,并且让备库可以读写,那么系统几乎就没有不可用时间了。

我们把这个切换流程,暂时称作可用性优先流程。这个切换流程的代价,就是可能出现数据不一致的情况。

备库为什么会延迟好几个小时

按表分发策略

每个 worker 线程对应一个 hash 表,用于保存当前正在这个 worker 的“执行队列”里的事务所涉及的表。hash 表的 key 是“库名. 表名”,value 是一个数字,表示队列中有多少个事务修改这个表。

在有事务分配给 worker 时,事务里面涉及的表会被加到对应的 hash 表中。worker 执行完成后,这个表会被从 hash 表中去掉。

事务在分发时,有三种情况:

  1. 如果跟所有 worker 都不冲突,coordinator 线程就会把这个事务分配给最空闲的 woker;
  2. 如果跟多于一个 worker 冲突,coordinator 线程就进入等待状态,直到和这个事务存在冲突关系的 worker 只剩下 1 个;
  3. 如果只跟一个 worker 冲突,coordinator 线程就会把这个事务分配给这个存在冲突关系的 worker。

这种方案在多个表负载均匀的场景里应用效果很好。但是,如果碰到热点表,比如所有的更新事务都会涉及到某一个表的时候,所有事务都会被分配到同一个 worker 中,就变成单线程复制了。

按行分发策略

要解决热点表的并行复制问题,就需要一个按行并行复制的方案。按行复制的核心思路是:如果两个事务没有更新相同的行,它们在备库上可以并行执行。显然,这个模式要求 binlog 格式必须是 row。

按行复制和按表复制的数据结构差不多,也是为每个 worker,分配一个 hash 表。只是要实现按行分发,这时候的 key,就必须是“库名 + 表名 + 唯一键的值”。

这个“唯一键”只有主键 id 是不够的,还需要考虑唯一键。即 key 应该是“库名 + 表名 + 索引 a 的名字 +a 的值”。因此,coordinator 在解析这个语句的 binlog 的时候,这个事务的 hash 表就有三个项:

  1. key=hash_func(db1+t1+“PRIMARY”+2), value=2; 这里 value=2 是因为修改前后的行 id 值不变,出现了两次。
  2. key=hash_func(db1+t1+“a”+2), value=1,表示会影响到这个表 a=2 的行。
  3. key=hash_func(db1+t1+“a”+1), value=1,表示会影响到这个表 a=1 的行。

可见,相比于按表并行分发策略,按行并行策略在决定线程分发的时候,需要消耗更多的计算资源。

MySQL 5.6 版本的并行复制策略

官方 MySQL5.6 版本,支持了并行复制,只是支持的粒度是按库并行。

MariaDB 的并行复制策略

MariaDB 的并行复制策略利用的就是这个特性:

  1. 能够在同一组里提交的事务,一定不会修改同一行;
  2. 主库上可以并行执行的事务,备库上也一定是可以并行执行的。

在实现上,MariaDB 是这么做的:

  1. 在一组里面一起提交的事务,有一个相同的 commit_id,下一组就是 commit_id+1;
  2. commit_id 直接写到 binlog 里面;
  3. 传到备库应用的时候,相同 commit_id 的事务分发到多个 worker 执行;
  4. 这一组全部执行完成后,coordinator 再去取下一批。

MySQL 5.7 的并行复制策略

由参数 slave-parallel-type 来控制并行复制策略:

  1. 配置为 DATABASE,表示使用 MySQL 5.6 版本的按库并行策略;
  2. 配置为 LOGICAL_CLOCK,表示的就是类似 MariaDB 的策略。不过,MySQL 5.7 这个策略,针对并行度做了优化。这个优化的思路也很有趣儿。

主库出问题了,从库怎么办?

A 和 A’互为主备, 从库 B、C、D 指向的是主库 A

基于位点的主备切换

1
2
3
4
5
6
7
CHANGE MASTER TO
MASTER_HOST=$host_name
MASTER_PORT=$port
MASTER_USER=$user_name
MASTER_PASSWORD=$password
MASTER_LOG_FILE=$master_log_name
MASTER_LOG_POS=$master_log_pos
  • MASTER_HOST、MASTER_PORT、MASTER_USER 和 MASTER_PASSWORD 四个参数,分别代表了主库 A’的 IP、端口、用户名和密码。
  • 最后两个参数 MASTER_LOG_FILE 和 MASTER_LOG_POS 表示,要从主库的 master_log_name 文件的 master_log_pos 这个位置的日志继续同步。

主备切换时,由于找不到精确的同步位点,所以只能采用直接跳过指定错误这种方法来创建从库和新主库的主备关系。

  • 通过 sql_slave_skip_counter 跳过事务
  • 通过 slave_skip_errors 忽略错误

GTID

GTID 的全称是 Global Transaction Identifier,也就是全局事务 ID,是一个事务在提交的时候生成的,是这个事务的唯一标识。GTID 由两部分组成:GTID=server_uuid:gno

  • server_uuid:是一个实例第一次启动时自动生成的,是一个全局唯一的值;
  • gno:是一个整数,初始值是 1,每次提交事务的时候分配给这个事务,并加 1。

启动 Mysql 时,加上参数 gtid_mode=on 和 enforce_gtid_consistency=on 就可以启动 GTID 模式。在 GTID 模式下,每个事务都会跟一个 GTID 一一对应。

在 GTID 模式下,备库 B 要设置为新主库 A’的从库的语法如下:

1
2
3
4
5
6
CHANGE MASTER TO
MASTER_HOST=$host_name
MASTER_PORT=$port
MASTER_USER=$user_name
MASTER_PASSWORD=$password
master_auto_position=1

找位点的工作,由 Mysql 内部完成。

读写分离有哪些坑

读写分离的主要目标就是分摊主库的压力。

还有一种架构是,在 MySQL 和客户端之间有一个中间代理层 proxy,客户端只连接 proxy, 由 proxy 根据请求类型和上下文决定请求的分发路由。

客户端直连 vs. 带 proxy 的读写分离

  • 客户端直连方案:因为少了一层 proxy 转发,所以查询性能稍微好一点儿,并且整体架构简单,排查问题更方便。但是这种方案,由于要了解后端部署细节,所以在出现主备切换、库迁移等操作的时候,客户端都会感知到,并且需要调整数据库连接信息。
    • 你可能会觉得这样客户端也太麻烦了,信息大量冗余,架构很丑。其实也未必,一般采用这样的架构,一定会伴随一个负责管理后端的组件,比如 Zookeeper,尽量让业务端只专注于业务逻辑开发。
  • 带 proxy 的架构:对客户端比较友好。客户端不需要关注后端细节,连接维护、后端信息维护等工作,都是由 proxy 完成的。但这样的话,对后端维护团队的要求会更高。而且,proxy 也需要有高可用架构。因此,带 proxy 架构的整体就相对比较复杂。

解决主从延迟的方案:

  • 强制走主库方案;
  • sleep 方案;
  • 判断主备无延迟方案;
  • 配合 semi-sync 方案;
  • 等主库位点方案;
  • 等 GTID 方案。

强制走主库方案

强制走主库方案其实就是,将查询请求做分类。

  1. 对于必须要拿到最新结果的请求,强制将其发到主库上。比如,在一个交易平台上,卖家发布商品以后,马上要返回主页面,看商品是否发布成功。那么,这个请求需要拿到最新的结果,就必须走主库。
  2. 对于可以读到旧数据的请求,才将其发到从库上。在这个交易平台上,买家来逛商铺页面,就算晚几秒看到最新发布的商品,也是可以接受的。那么,这类请求就可以走从库。

Sleep 方案

主库更新后,读从库之前先 sleep 一下。具体的方案就是,类似于执行一条 select sleep(1) 命令。

判断主备无延迟方案

show slave status 结果里的 seconds_behind_master 参数的值,可以用来衡量主备延迟时间的长短。

所以第一种确保主备无延迟的方法是,每次从库执行查询请求前,先判断 seconds_behind_master 是否已经等于 0。如果还不等于 0 ,那就必须等到这个参数变为 0 才能执行查询请求。

第二种方法,对比位点确保主备无延迟:

  • Master_Log_File 和 Read_Master_Log_Pos,表示的是读到的主库的最新位点;
  • Relay_Master_Log_File 和 Exec_Master_Log_Pos,表示的是备库执行的最新位点。

如果 Master_Log_File 和 Relay_Master_Log_File、Read_Master_Log_Pos 和 Exec_Master_Log_Pos 这两组值完全相同,就表示接收到的日志已经同步完成。

第三种方法,对比 GTID 集合确保主备无延迟:

  • Auto_Position=1 ,表示这对主备关系使用了 GTID 协议。
  • Retrieved_Gtid_Set,是备库收到的所有日志的 GTID 集合;
  • Executed_Gtid_Set,是备库所有已经执行完成的 GTID 集合。

如果这两个集合相同,也表示备库接收到的日志都已经同步完成。

可见,对比位点和对比 GTID 这两种方法,都要比判断 seconds_behind_master 是否为 0 更准确。

配合 semi-sync

semi-sync replication 即半同步复制。

semi-sync 做了这样的设计:

  1. 事务提交的时候,主库把 binlog 发给从库;
  2. 从库收到 binlog 以后,发回给主库一个 ack,表示收到了;
  3. 主库收到这个 ack 以后,才能给客户端返回“事务完成”的确认。

如果启用了 semi-sync,就表示所有给客户端发送过确认的事务,都确保了备库已经收到了这个日志。

semi-sync 配合判断主备无延迟的方案,存在两个问题:

  1. 一主多从的时候,在某些从库执行查询请求会存在过期读的现象;
  2. 在持续延迟的情况下,可能出现过度等待的问题。

等主库位点方案

1
select master_pos_wait(file, pos[, timeout]);

命令的逻辑如下:

  1. 它是在从库执行的;
  2. 参数 file 和 pos 指的是主库上的文件名和位置;
  3. timeout 可选,设置为正整数 N 表示这个函数最多等待 N 秒。

GTID 方案

如果你的数据库开启了 GTID 模式

1
select wait_for_executed_gtid_set(gtid_set, 1);

这条命令的逻辑是:

  1. 等待,直到这个库执行的事务中包含传入的 gtid_set,返回 0;
  2. 超时返回 1。

如何判断一个数据库是不是出问题了

select 1 判断

select 1 成功返回,只能说明这个库的进程还在,并不能说明主库没问题。

查表判断

为了能够检测 InnoDB 并发线程数过多导致的系统不可用情况,我们需要找一个访问 InnoDB 的场景。一般的做法是,在系统库(mysql 库)里创建一个表,比如命名为 health_check,里面只放一行数据,然后定期执行:

1
select * from mysql.health_check;

使用这个方法,我们可以检测出由于并发线程过多导致的数据库不可用的情况。

更新事务要写 binlog,而一旦 binlog 所在磁盘的空间占用率达到 100%,那么所有的更新语句和事务提交的 commit 语句就都会被堵住。但是,系统这时候还是可以正常读数据的。

更新判断

1
2
3
4
5
6
7
8
CREATE TABLE `health_check` (
`id` int(11) NOT NULL,
`t_modified` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
) ENGINE=InnoDB;

/* 检测命令 */
insert into mysql.health_check(id, t_modified) values (@@server_id, now()) on duplicate key update t_modified=now();

由于 MySQL 规定了主库和备库的 server_id 必须不同(否则创建主备关系的时候就会报错),这样就可以保证主、备库各自的检测命令不会发生冲突。

更新语句,如果失败或者超时,就可以发起主备切换了,为什么还会有判定慢的问题呢?

IO 利用率 100% 表示系统的 IO 是在工作的,每个请求都有机会获得 IO 资源,执行自己的任务。而我们的检测使用的 update 命令,需要的资源很少,所以可能在拿到 IO 资源的时候就可以提交成功,并且在超时时间 N 秒未到达之前就返回给了检测系统。

检测系统一看,update 命令没有超时,于是就得到了“系统正常”的结论。

内部统计

MySQL 5.6 版本以后提供的 performance_schema 库,就在 file_summary_by_event_name 表里统计了每次 IO 请求的时间。

打开 redo log 的时间监控

1
update setup_instruments set ENABLED='YES', Timed='YES' where name like '%wait/io/file/innodb/innodb_log_file%';

可以通过 MAX_TIMER 的值来判断数据库是否出问题了。

1
select event_name,MAX_TIMER_WAIT  FROM performance_schema.file_summary_by_event_name where event_name in ('wait/io/file/innodb/innodb_log_file','wait/io/file/sql/binlog') and MAX_TIMER_WAIT>200*1000000000;

发现异常后,取到你需要的信息,再通过下面这条语句:

1
truncate table performance_schema.file_summary_by_event_name;

把之前的统计信息清空。这样如果后面的监控中,再次出现这个异常,就可以加入监控累积值了。

答疑文章(二):用动态的观点看加锁

误删数据后除了跑路还能怎么办?

误删行

事后处理:

  • 使用 delete 语句误删了数据行,可以用 Flashback 工具通过闪回把数据恢复回来。
  • Flashback 恢复数据的原理,是修改 binlog 的内容,拿回原库重放。而能够使用这个方案的前提是,需要确保 binlog_format=row 和 binlog_row_image=FULL。

事前预防:

  • 把 sql_safe_updates 参数设置为 on。这样一来,如果我们忘记在 delete 或者 update 语句中写 where 条件,或者 where 条件里面没有包含索引字段的话,这条语句的执行就会报错。
  • 代码上线前,必须经过 SQL 审计。

误删库 / 表

这种情况下,要想恢复数据,就需要使用全量备份,加增量日志的方式了。这个方案要求线上有定期的全量备份,并且实时备份 binlog。

恢复数据的流程如下:

  1. 取最近一次全量备份,假设这个库是一天一备,上次备份是当天 0 点;
  2. 用备份恢复出一个临时库;
  3. 从日志备份里面,取出凌晨 0 点之后的日志;
  4. 把这些日志,除了误删除数据的语句外,全部应用到临时库。

延迟复制备库

一般的主备复制结构存在的问题是,如果主库上有个表被误删了,这个命令很快也会被发给所有从库,进而导致所有从库的数据表也都一起被误删了。

延迟复制的备库是一种特殊的备库,通过 CHANGE MASTER TO MASTER_DELAY = N 命令,可以指定这个备库持续保持跟主库有 N 秒的延迟。只要在延迟时间内发现了这个误操作命令,这个命令就还没有在这个延迟复制的备库执行。这时候到这个备库上执行 stop slave,再通过之前介绍的方法,跳过误操作命令,就可以恢复出需要的数据。

预防误删库 / 表的方法

第一条建议是,账号分离。这样做的目的是,避免写错命令。比如:

  • 我们只给业务开发同学 DML 权限,而不给 truncate/drop 权限。而如果业务开发人员有 DDL 需求的话,也可以通过开发管理系统得到支持。
  • 即使是 DBA 团队成员,日常也都规定只使用只读账号,必要的时候才使用有更新权限的账号。

第二条建议是,制定操作规范。这样做的目的,是避免写错要删除的表名。比如:

  • 在删除数据表之前,必须先对表做改名操作。然后,观察一段时间,确保对业务无影响以后再删除这张表。
  • 改表名的时候,要求给表名加固定的后缀(比如加 _to_be_deleted),然后删除表的动作必须通过管理系统执行。并且,管理系删除表的时候,只能删除固定后缀的表。

rm 删除数据

对于一个有高可用机制的 MySQL 集群来说,最不怕的就是 rm 删除数据了。只要不是恶意地把整个集群删除,而只是删掉了其中某一个节点的数据的话,HA 系统就会开始工作,选出一个新的主库,从而保证整个集群的正常工作。

这时,你要做的就是在这个节点上把数据恢复回来,再接入整个集群。

为什么还有 kill 不掉的语句

MySQL 中有两个 kill 命令:一个是 kill query + 线程 id,表示终止这个线程中正在执行的语句;一个是 kill connection + 线程 id,这里 connection 可缺省,表示断开这个线程的连接,当然如果这个线程有语句正在执行,也是要先停止正在执行的语句的。

收到 kill 以后,线程做什么?

当用户执行 kill query thread_id_B 时,MySQL 里处理 kill 命令的线程做了两件事:

  1. 把 session B 的运行状态改成 THD::KILL_QUERY(将变量 killed 赋值为 THD::KILL_QUERY);
  2. 给 session B 的执行线程发一个信号。

kill 不掉语句的情况

  • 线程没有执行到判断线程状态的逻辑
  • 终止逻辑耗时较长
    • 超大事务执行期间被 kill。这时候,回滚操作需要对事务执行期间生成的所有新数据版本做回收操作,耗时很长。
    • 大查询回滚。如果查询过程中生成了比较大的临时文件,加上此时文件系统压力大,删除临时文件可能需要等待 IO 资源,导致耗时较长。
    • DDL 命令执行到最后阶段,如果被 kill,需要删除中间过程的临时文件,也可能受 IO 资源影响耗时较久。

关于客户端的误解

如果库里面的表特别多,连接就会很慢。

客户端在连接成功后,需要多做一些操作:

  1. 执行 show databases;
  2. 切到 db1 库,执行 show tables;
  3. 把这两个命令的结果用于构建一个本地的哈希表。

我们感知到的连接过程慢,其实并不是连接慢,也不是服务端慢,而是客户端慢。

我查了这么多数据会不会把数据库内存打爆

全表扫描对 server 层的影响

MySQL 是“边读边发的”

InnoDB 的数据是保存在主键索引上的,所以全表扫描实际上是直接扫描表 t 的主键索引。这条查询语句由于没有其他的判断条件,所以查到的每一行都可以直接放到结果集里面,然后返回给客户端。

查询的结果是分段发给客户端的,因此扫描全表,查询返回大量的数据,并不会把内存打爆。

全表扫描对 InnoDB 的影响

对于 InnoDB 引擎内部,由于有淘汰策略,大查询也不会导致内存暴涨。并且,由于 InnoDB 对 LRU 算法做了改进,冷数据的全表扫描,对 Buffer Pool 的影响也能做到可控。

到底可不可以使用 join

  1. 如果可以使用被驱动表的索引,join 语句还是有其优势的;
  2. 不能使用被驱动表的索引,只能使用 Block Nested-Loop Join 算法,这样的语句就尽量不要使用;
  3. 在使用 join 的时候,应该让小表做驱动表。

join 语句如何优化

大多数的数据都是按照主键递增顺序插入得到的,所以我们可以认为,如果按照主键的递增顺序查询的话,对磁盘的读比较接近顺序读,能够提升读性能。

MRR

MRR 优化后的语句执行流程:

  1. 根据索引 a,定位到满足条件的记录,将 id 值放入 read_rnd_buffer 中 ;
  2. 将 read_rnd_buffer 中的 id 进行递增排序;
  3. 排序后的 id 数组,依次到主键 id 索引中查记录,并作为结果返回。

这里,read_rnd_buffer 的大小是由 read_rnd_buffer_size 参数控制的。如果步骤 1 中,read_rnd_buffer 放满了,就会先执行完步骤 2 和 3,然后清空 read_rnd_buffer。之后继续找索引 a 的下个记录,并继续循环。

MRR 能够提升性能的核心在于,这条查询语句在索引 a 上做的是一个范围查询(也就是说,这是一个多值查询),可以得到足够多的主键 id。这样通过排序以后,再去主键索引查数据,才能体现出“顺序性”的优势。临时表在使用上有以下几个特点:

  1. 建表语法是 create temporary table …。
  2. 一个临时表只能被创建它的 session 访问,对其他线程不可见。所以,图中 session A 创建的临时表 t,对于 session B 就是不可见的。
  3. 临时表可以与普通表同名。
  4. session A 内有同名的临时表和普通表的时候,show create 语句,以及增删改查语句访问的是临时表。
  5. show tables 命令不显示临时表。

为什么临时表可以重名

临时表在使用上有以下几个特点:

  1. 建表语法是 create temporary table …。
  2. 一个临时表只能被创建它的 session 访问,对其他线程不可见。所以,图中 session A 创建的临时表 t,对于 session B 就是不可见的。
  3. 临时表可以与普通表同名。
  4. session A 内有同名的临时表和普通表的时候,show create 语句,以及增删改查语句访问的是临时表。
  5. show tables 命令不显示临时表。

临时表特别适合 join 优化这种场景,原因是:

  1. 不同 session 的临时表是可以重名的,如果有多个 session 同时执行 join 优化,不需要担心表名重复导致建表失败的问题。
  2. 不需要担心数据删除问题。如果使用普通表,在流程执行过程中客户端发生了异常断开,或者数据库发生异常重启,还需要专门来清理中间过程中生成的数据表。而临时表由于会自动回收,所以不需要这个额外的操作。

临时表的应用

由于不用担心线程之间的重名冲突,临时表经常会被用在复杂查询的优化过程中。其中,分库分表系统的跨库查询就是一个典型的使用场景。

分库分表两种实现思路:

第一种思路是,在 proxy 层的进程代码中实现排序。

这种方式的优势是处理速度快,拿到分库的数据以后,直接在内存中参与计算。不过,这个方案的缺点也比较明显:

  1. 需要的开发工作量比较大。我们举例的这条语句还算是比较简单的,如果涉及到复杂的操作,比如 group by,甚至 join 这样的操作,对中间层的开发能力要求比较高;
  2. 对 proxy 端的压力比较大,尤其是很容易出现内存不够用和 CPU 瓶颈的问题。

另一种思路就是,把各个分库拿到的数据,汇总到一个 MySQL 实例的一个表中,然后在这个汇总实例上做逻辑操作。

比如上面这条语句,执行流程可以类似这样:

  • 在汇总库上创建一个临时表 temp_ht,表里包含三个字段 v、k、t_modified;
  • 在各个分库上执行
1
select v,k,t_modified from ht_x where k >= M order by t_modified desc limit 100;
  • 把分库执行的结果插入到 temp_ht 表中;
  • 执行
1
select v from temp_ht order by t_modified desc limit 100;

得到结果。

在实践中,我们往往会发现每个分库的计算量都不饱和,所以会直接把临时表 temp_ht 放到 32 个分库中的某一个上。

什么时候会使用内部临时表

  1. 如果对 group by 语句的结果没有排序要求,要在语句后面加 order by null;
  2. 尽量让 group by 过程用上表的索引,确认方法是 explain 结果里没有 Using temporary 和 Using filesort;
  3. 如果 group by 需要统计的数据量不大,尽量只使用内存临时表;也可以通过适当调大 tmp_table_size 参数,来避免用到磁盘临时表;
  4. 如果数据量实在太大,使用 SQL_BIG_RESULT 这个提示,来告诉优化器直接使用排序算法得到 group by 的结果。

都说 InnoDB 好,那还要不要使用 Memory 引擎

InnoDB 和 Memory 引擎的数据组织方式是不同的:

  • InnoDB 引擎把数据放在主键索引上,其他索引上保存的是主键 id。这种方式,我们称之为索引组织表(Index Organizied Table)。
  • 而 Memory 引擎采用的是把数据单独存放,索引上保存数据位置的数据组织形式,我们称之为堆组织表(Heap Organizied Table)。

内存表不支持行锁,只支持表锁。

数据放在内存中,是内存表的优势,但也是一个劣势。因为,数据库重启的时候,所有的内存表都会被清空。

自增主键为什么不是连续的

表的结构定义存放在后缀名为.frm 的文件中,但是并不会保存自增值。

在 MyISAM 引擎里面,自增值是被写在数据文件上的。而在 InnoDB 中,自增值是被记录在内存的。

InnoDB 中,只保证了自增 id 是递增的,但不保证是连续的。这么做是处于性能考虑:语句执行失败也不回退自增 id。

insert 语句的锁为什么这么多

insert … select 是很常见的在两个表之间拷贝数据的方法。你需要注意,在可重复读隔离级别下,这个语句会给 select 的表里扫描到的记录和间隙加读锁。

而如果 insert 和 select 的对象是同一个表,则有可能会造成循环写入。这种情况下,我们需要引入用户临时表来做优化。

insert 语句如果出现唯一键冲突,会在冲突的唯一值上加共享的 next-key lock(S 锁)。因此,碰到由于唯一键约束导致报错后,要尽快提交或回滚事务,避免加锁时间过长。

怎么最快地复制一张表

mysqldump 方法

使用 mysqldump 命令将数据导出成一组 INSERT 语句。

1
mysqldump -h$host -P$port -u$user --add-locks=0 --no-create-info --single-transaction  --set-gtid-purged=OFF db1 t --where="a>900" --result-file=/client_tmp/t.sql

这条命令中,主要参数含义如下:

  1. –single-transaction 的作用是,在导出数据的时候不需要对表 db1.t 加表锁,而是使用 START TRANSACTION WITH CONSISTENT SNAPSHOT 的方法;
  2. –add-locks 设置为 0,表示在输出的文件结果里,不增加” LOCK TABLES t WRITE;” ;
  3. –no-create-info 的意思是,不需要导出表结构;
  4. –set-gtid-purged=off 表示的是,不输出跟 GTID 相关的信息;
  5. –result-file 指定了输出文件的路径,其中 client 表示生成的文件是在客户端机器上的。

导出 CSV 文件

另一种方法是直接将结果导出成.csv 文件

1
select * from db1.t where a>900 into outfile '/server_tmp/t.csv';

物理拷贝方法

假设我们现在的目标是在 db1 库下,复制一个跟表 t 相同的表 r,具体的执行步骤如下:

  1. 执行 create table r like t,创建一个相同表结构的空表;
  2. 执行 alter table r discard tablespace,这时候 r.ibd 文件会被删除;
  3. 执行 flush table t for export,这时候 db1 目录下会生成一个 t.cfg 文件;
  4. 在 db1 目录下执行 cp t.cfg r.cfg; cp t.ibd r.ibd;这两个命令(这里需要注意的是,拷贝得到的两个文件,MySQL 进程要有读写权限);
  5. 执行 unlock tables,这时候 t.cfg 文件会被删除;
  6. 执行 alter table r import tablespace,将这个 r.ibd 文件作为表 r 的新的表空间,由于这个文件的数据内容和 t.ibd 是相同的,所以表 r 中就有了和表 t 相同的数据。

grant 之后为什么要跟着 flush privilege

grant 语句会同时修改数据表和内存,判断权限的时候使用的是内存数据。因此,规范地使用 grant 和 revoke 语句,是不需要随后加上 flush privileges 语句的。

flush privileges 语句本身会用数据表的数据重建一份内存权限数据,所以在权限数据可能存在不一致的情况下再使用。而这种不一致往往是由于直接用 DML 语句操作系统权限表导致的,所以我们尽量不要使用这类语句。

要不要使用分区表

自增 ID 用完了怎么办

参考资料

SQL Cheat Sheet

查找数据的查询

SELECT: 用于从数据库中选择数据

  • SELECT * FROM table_name;

DISTINCT: 用于过滤掉重复的值并返回指定列的行

  • SELECT DISTINCT column_name;

WHERE: 用于过滤记录/行

  • SELECT column1, column2 FROM table_name WHERE condition;
  • SELECT * FROM table_name WHERE condition1 AND condition2;
  • SELECT * FROM table_name WHERE condition1 OR condition2;
  • SELECT * FROM table_name WHERE NOT condition;
  • SELECT * FROM table_name WHERE condition1 AND (condition2 OR condition3);
  • SELECT * FROM table_name WHERE EXISTS (SELECT column_name FROM table_name WHERE condition);

ORDER BY: 用于结果集的排序,升序(ASC)或者降序(DESC)

  • SELECT * FROM table_name ORDER BY column;
  • SELECT * FROM table_name ORDER BY column DESC;
  • SELECT * FROM table_name ORDER BY column1 ASC, column2 DESC;

SELECT TOP: 用于指定从表顶部返回的记录数

  • SELECT TOP number columns_names FROM table_name WHERE condition;
  • SELECT TOP percent columns_names FROM table_name WHERE condition;
  • 并非所有数据库系统都支持SELECT TOP。 MySQL 中是LIMIT子句
  • SELECT column_names FROM table_name LIMIT offset, count;

LIKE: 用于搜索列中的特定模式,WHERE 子句中使用的运算符

  • % (percent sign) 是一个表示零个,一个或多个字符的通配符
  • _ (underscore) 是一个表示单个字符通配符
  • SELECT column_names FROM table_name WHERE column_name LIKE pattern;
  • LIKE ‘a%’ (查找任何以“a”开头的值)
  • LIKE ‘%a’ (查找任何以“a”结尾的值)
  • LIKE ‘%or%’ (查找任何包含“or”的值)
  • LIKE ‘_r%’ (查找任何第二位是“r”的值)
  • LIKE ‘a*%*%’ (查找任何以“a”开头且长度至少为 3 的值)
  • LIKE ‘[a-c]%’(查找任何以“a”或“b”或“c”开头的值)

IN: 用于在 WHERE 子句中指定多个值的运算符

  • 本质上,IN 运算符是多个 OR 条件的简写
  • SELECT column_names FROM table_name WHERE column_name IN (value1, value2, …);
  • SELECT column_names FROM table_name WHERE column_name IN (SELECT STATEMENT);

BETWEEN: 用于过滤给定范围的值的运算符

  • SELECT column_names FROM table_name WHERE column_name BETWEEN value1 AND value2;
  • SELECT * FROM Products WHERE (column_name BETWEEN value1 AND value2) AND NOT column_name2 IN (value3, value4);
  • SELECT * FROM Products WHERE column_name BETWEEN #01/07/1999# AND #03/12/1999#;

NULL: 代表一个字段没有值

  • SELECT * FROM table_name WHERE column_name IS NULL;
  • SELECT * FROM table_name WHERE column_name IS NOT NULL;

AS: 用于给表或者列分配别名

  • SELECT column_name AS alias_name FROM table_name;
  • SELECT column_name FROM table_name AS alias_name;
  • SELECT column_name AS alias_name1, column_name2 AS alias_name2;
  • SELECT column_name1, column_name2 + ‘, ‘ + column_name3 AS alias_name;

UNION: 用于组合两个或者多个 SELECT 语句的结果集的运算符

  • 每个 SELECT 语句必须拥有相同的列数
  • 列必须拥有相似的数据类型
  • 每个 SELECT 语句中的列也必须具有相同的顺序
  • SELECT columns_names FROM table1 UNION SELECT column_name FROM table2;
  • UNION 仅允许选择不同的值, UNION ALL 允许重复

ANY|ALL: 用于检查 WHERE 或 HAVING 子句中使用的子查询条件的运算符

  • ANY 如果任何子查询值满足条件,则返回 true。
  • ALL 如果所有子查询值都满足条件,则返回 true。
  • SELECT columns_names FROM table1 WHERE column_name operator (ANY|ALL) (SELECT column_name FROM table_name WHERE condition);

GROUP BY: 通常与聚合函数(COUNT,MAX,MIN,SUM,AVG)一起使用,用于将结果集分组为一列或多列

  • SELECT column_name1, COUNT(column_name2) FROM table_name WHERE condition GROUP BY column_name1 ORDER BY COUNT(column_name2) DESC;

HAVING: HAVING 子句指定 SELECT 语句应仅返回聚合值满足指定条件的行。它被添加到 SQL 语言中,因为 WHERE 关键字不能与聚合函数一起使用。

  • SELECT COUNT(column_name1), column_name2 FROM table GROUP BY column_name2 HAVING COUNT(column_name1) > 5;

修改数据的查询

INSERT INTO: 用于在表中插入新记录/行

  • INSERT INTO table_name (column1, column2) VALUES (value1, value2);
  • INSERT INTO table_name VALUES (value1, value2 …);

UPDATE: 用于修改表中的现有记录/行

  • UPDATE table_name SET column1 = value1, column2 = value2 WHERE condition;
  • UPDATE table_name SET column_name = value;

DELETE: 用于删除表中的现有记录/行

  • DELETE FROM table_name WHERE condition;
  • DELETE * FROM table_name;

聚合查询

COUNT: 返回出现次数

  • SELECT COUNT (DISTINCT column_name);

MIN() and MAX(): 返回所选列的最小/最大值

  • SELECT MIN (column_names) FROM table_name WHERE condition;
  • SELECT MAX (column_names) FROM table_name WHERE condition;

AVG(): 返回数字列的平均值

  • SELECT AVG (column_name) FROM table_name WHERE condition;

SUM(): 返回数值列的总和

  • SELECT SUM (column_name) FROM table_name WHERE condition;

连接查询

INNER JOIN: 内连接,返回在两张表中具有匹配值的记录

  • SELECT column_names FROM table1 INNER JOIN table2 ON table1.column_name=table2.column_name;
  • SELECT table1.column_name1, table2.column_name2, table3.column_name3 FROM ((table1 INNER JOIN table2 ON relationship) INNER JOIN table3 ON relationship);

LEFT (OUTER) JOIN: 左外连接,返回左表(table1)中的所有记录,以及右表中的匹配记录(table2)

  • SELECT column_names FROM table1 LEFT JOIN table2 ON table1.column_name=table2.column_name;

RIGHT (OUTER) JOIN: 右外连接,返回右表(table2)中的所有记录,以及左表(table1)中匹配的记录

  • SELECT column_names FROM table1 RIGHT JOIN table2 ON table1.column_name=table2.column_name;

FULL (OUTER) JOIN: 全外连接,全连接是左右外连接的并集. 连接表包含被连接的表的所有记录, 如果缺少匹配的记录, 以 NULL 填充。

  • SELECT column_names FROM table1 FULL OUTER JOIN table2 ON table1.column_name=table2.column_name;

Self JOIN: 自连接,表自身连接

  • SELECT column_names FROM table1 T1, table1 T2 WHERE condition;

视图查询

CREATE: 创建视图

  • CREATE VIEW view_name AS SELECT column1, column2 FROM table_name WHERE condition;

SELECT: 检索视图

  • SELECT * FROM view_name;

DROP: 删除视图

  • DROP VIEW view_name;

修改表的查询

ADD: 添加字段

  • ALTER TABLE table_name ADD column_name column_definition;

MODIFY: 修改字段数据类型

  • ALTER TABLE table_name MODIFY column_name column_type;

DROP: 删除字段

  • ALTER TABLE table_name DROP COLUMN column_name;

《SQL 必知必会》笔记

第一章:SQL 语法基础篇

01 丨了解 SQL:一门半衰期很长的语言

SQL 语言按照功能划分成以下的 4 个部分:

  • DDL,英文叫做 Data Definition Language,也就是数据定义语言,它用来定义我们的数据库对象,包括数据库、数据表和列。通过使用 DDL,我们可以创建,删除和修改数据库和表结构。
  • DML,英文叫做 Data Manipulation Language,数据操作语言,我们用它操作和数据库相关的记录,比如增加、删除、修改数据表中的记录。
  • DCL,英文叫做 Data Control Language,数据控制语言,我们用它来定义访问权限和安全级别。
  • DQL,英文叫做 Data Query Language,数据查询语言,我们用它查询想要的记录,它是 SQL 语言的重中之重。在实际的业务中,我们绝大多数情况下都是在和查询打交道,因此学会编写正确且高效的查询语句,是学习的重点。

02 丨 DBMS 的前世今生

DB、DBS 和 DBMS 的区别:

  • DBMS 的英文全称是 DataBase Management System,数据库管理系统,实际上它可以对多个数据库进行管理,所以你可以理解为 DBMS = 多个数据库(DB) + 管理程序。
  • DB 的英文是 DataBase,也就是数据库。数据库是存储数据的集合,你可以把它理解为多个数据表。
  • DBS 的英文是 DataBase System,数据库系统。它是更大的概念,包括了数据库、数据库管理系统以及数据库管理人员 DBA。

NoSql 不同时期的释义

  • 1970:NoSQL = We have no SQL
  • 1980:NoSQL = Know SQL
  • 2000:NoSQL = No SQL!
  • 2005:NoSQL = Not only SQL
  • 2013:NoSQL = No, SQL!

03 丨学会用数据库的方式思考 SQL 是如何执行的

Oracle 中的 SQL 是如何执行的

  1. 语法检查:检查 SQL 拼写是否正确,如果不正确,Oracle 会报语法错误。
  2. 语义检查:检查 SQL 中的访问对象是否存在。比如我们在写 SELECT 语句的时候,列名写错了,系统就会提示错误。语法检查和语义检查的作用是保证 SQL 语句没有错误。
  3. 权限检查:看用户是否具备访问该数据的权限。
  4. 共享池检查:共享池(Shared Pool)是一块内存池,最主要的作用是缓存 SQL 语句和该语句的执行计划。Oracle 通过检查共享池是否存在 SQL 语句的执行计划,来判断进行软解析,还是硬解析。那软解析和硬解析又该怎么理解呢?
    • 在共享池中,Oracle 首先对 SQL 语句进行 Hash 运算,然后根据 Hash 值在库缓存(Library Cache)中查找,如果存在 SQL 语句的执行计划,就直接拿来执行,直接进入“执行器”的环节,这就是软解析
    • 如果没有找到 SQL 语句和执行计划,Oracle 就需要创建解析树进行解析,生成执行计划,进入“优化器”这个步骤,这就是硬解析
  5. 优化器:优化器中就是要进行硬解析,也就是决定怎么做,比如创建解析树,生成执行计划。
  6. 执行器:当有了解析树和执行计划之后,就知道了 SQL 该怎么被执行,这样就可以在执行器中执行语句了。

共享池是 Oracle 中的术语,包括了库缓存,数据字典缓冲区等。它主要缓存 SQL 语句和执行计划。

而数据字典缓冲区存储的是 Oracle 中的对象定义,比如表、视图、索引等对象。当对 SQL 语句进行解析的时候,如果需要相关的数据,会从数据字典缓冲区中提取。

MySQL 中的 SQL 是如何执行的

MySQL 是典型的 C/S 架构,即 Client/Server 架构,服务器端程序使用的 mysqld。

Mysql 可分为三层:

  1. 连接层:客户端和服务器端建立连接,客户端发送 SQL 至服务器端;
  2. SQL 层:对 SQL 语句进行查询处理;
  3. 存储引擎层:与数据库文件打交道,负责数据的存储和读取。

SQL 层的结构

  1. 查询缓存:Server 如果在查询缓存中发现了这条 SQL 语句,就会直接将结果返回给客户端;如果没有,就进入到解析器阶段。需要说明的是,因为查询缓存往往效率不高,所以在 MySQL8.0 之后就抛弃了这个功能。
  2. 解析器:在解析器中对 SQL 语句进行语法分析、语义分析。
  3. 优化器:在优化器中会确定 SQL 语句的执行路径,比如是根据全表检索,还是根据索引来检索等。
  4. 执行器:在执行之前需要判断该用户是否具备权限,如果具备权限就执行 SQL 查询并返回结果。在 MySQL8.0 以下的版本,如果设置了查询缓存,这时会将查询结果进行缓存。

与 Oracle 不同的是,MySQL 的存储引擎采用了插件的形式,每个存储引擎都面向一种特定的数据库应用环境。同时开源的 MySQL 还允许开发人员设置自己的存储引擎,下面是一些常见的存储引擎:

  1. InnoDB 存储引擎:它是 MySQL 5.5 版本之后默认的存储引擎,最大的特点是支持事务、行级锁定、外键约束等。
  2. MyISAM 存储引擎:在 MySQL 5.5 版本之前是默认的存储引擎,不支持事务,也不支持外键,最大的特点是速度快,占用资源少。
  3. Memory 存储引擎:使用系统内存作为存储介质,以便得到更快的响应速度。不过如果 mysqld 进程崩溃,则会导致所有的数据丢失,因此我们只有当数据是临时的情况下才使用 Memory 存储引擎。
  4. NDB 存储引擎:也叫做 NDB Cluster 存储引擎,主要用于 MySQL Cluster 分布式集群环境,类似于 Oracle 的 RAC 集群。
  5. Archive 存储引擎:它有很好的压缩机制,用于文件归档,在请求写入时会进行压缩,所以也经常用来做仓库。

04 丨使用 DDL 创建数据库&数据表时需要注意什么?

DDL 的核心指令是 CREATEALTERDROP

设计数据表的原则

  • 数据表的个数越少越好 - RDBMS 的核心在于对实体和联系的定义,也就是 E-R 图(Entity Relationship Diagram),数据表越少,证明实体和联系设计得越简洁,既方便理解又方便操作。
  • 数据表中的字段个数越少越好 - 字段个数越多,数据冗余的可能性越大。设置字段个数少的前提是各个字段相互独立,而不是某个字段的取值可以由其他字段计算出来。当然字段个数少是相对的,我们通常会在数据冗余和检索效率中进行平衡。
  • 数据表中联合主键的字段个数越少越好 - 设置主键是为了确定唯一性,当一个字段无法确定唯一性的时候,就需要采用联合主键的方式(也就是用多个字段来定义一个主键)。联合主键中的字段越多,占用的索引空间越大,不仅会加大理解难度,还会增加运行时间和索引空间,因此联合主键的字段个数越少越好。
  • 使用主键和外键越多越好 - 数据库的设计实际上就是定义各种表,以及各种字段之间的关系。这些关系越多,证明这些实体之间的冗余度越低,利用度越高。这样做的好处在于不仅保证了数据表之间的独立性,还能提升相互之间的关联使用率。——不同意

05 丨检索数据:你还在 SELECT 么?

SELECT 的作用是从一个表或多个表中检索出想要的数据行。

  • SELECT 语句用于从数据库中查询数据。
  • DISTINCT 用于返回唯一不同的值。它作用于所有列,也就是说所有列的值都相同才算相同。
  • LIMIT 限制返回的行数。可以有两个参数,第一个参数为起始行,从 0 开始;第二个参数为返回的总行数。
    • ASC :升序(默认)
    • DESC :降序

查询单列

1
SELECT name FROM world.country;

查询多列

1
SELECT name, continent, region FROM world.country;

查询所有列

1
SELECT * FROM world.country;

查询不同的值

1
SELECT distinct(continent) FROM world.country;

限制查询结果

1
2
3
4
5
-- 返回前 5 行
SELECT * FROM world.country LIMIT 5;
SELECT * FROM world.country LIMIT 0, 5;
-- 返回第 3 ~ 5 行
SELECT * FROM world.country LIMIT 2, 3;

06 丨数据过滤:SQL 数据过滤都有哪些方法?

比较操作符

运算符 描述
= 等于
<> 不等于。注释:在 SQL 的一些版本中,该操作符可被写成 !=
> 大于
< 小于
>= 大于等于
<= 小于等于

范围操作符

运算符 描述
BETWEEN 在某个范围内
IN 指定针对某个列的多个可能值

逻辑操作符

运算符 描述
AND 并且(与)
OR 或者(或)
NOT 否定(非)

通配符

运算符 描述
LIKE 搜索某种模式
% 表示任意字符出现任意次数
_ 表示任意字符出现一次
[] 必须匹配指定位置的一个字符

07 丨什么是 SQL 函数?为什么使用 SQL 函数可能会带来问题?

  • 数学函数
  • 字符串函数
  • 日期函数
  • 转换函数
  • 聚合函数

08 丨什么是 SQL 的聚集函数,如何利用它们汇总表的数据?

聚合函数

函 数 说 明
AVG() 返回某列的平均值
COUNT() 返回某列的行数
MAX() 返回某列的最大值
MIN() 返回某列的最小值
SUM() 返回某列值之和

09 丨子查询:子查询的种类都有哪些,如何提高子查询的性能?

EXISTS、IN、ANY、ALL、SOME

10 丨常用的 SQL 标准有哪些,在 SQL92 中是如何使用连接的?

内连接(INNER JOIN)

自连接(=

自然连接(NATURAL JOIN)

外连接(OUTER JOIN)

左连接(LEFT JOIN)

右连接(RIGHT JOIN)

11 丨 SQL99 是如何使用连接的,与 SQL92 的区别是什么?

12 丨视图在 SQL 中的作用是什么,它是怎样工作的?

视图是基于 SQL 语句的结果集的可视化的表。视图是虚拟的表,本身不存储数据,也就不能对其进行索引操作。对视图的操作和对普通表的操作一样。

视图的作用:

  • 简化复杂的 SQL 操作,比如复杂的连接。
  • 只使用实际表的一部分数据。
  • 通过只给用户访问视图的权限,保证数据的安全性。
  • 更改数据格式和表示。

13 丨什么是存储过程,在实际项目中用得多么?

存储过程的英文是 Stored Procedure。它可以视为一组 SQL 语句的批处理。一旦存储过程被创建出来,使用它就像使用函数一样简单,我们直接通过调用存储过程名即可。

存储过程的优点:

  • 执行效率高:一次编译多次使用。
  • 安全性强:在设定存储过程的时候可以设置对用户的使用权限,这样就和视图一样具有较强的安全性。
  • 可复用:将代码封装,可以提高代码复用。
  • 性能好
    • 由于是预先编译,因此具有很高的性能。
    • 一个存储过程替代大量 T_SQL 语句 ,可以降低网络通信量,提高通信速率。

存储过程的缺点:

  • 可移植性差:存储过程不能跨数据库移植。由于不同数据库的存储过程语法几乎都不一样,十分难以维护(不通用)。
  • 调试困难:只有少数 DBMS 支持存储过程的调试。对于复杂的存储过程来说,开发和维护都不容易。
  • 版本管理困难:比如数据表索引发生变化了,可能会导致存储过程失效。我们在开发软件的时候往往需要进行版本管理,但是存储过程本身没有版本控制,版本迭代更新的时候很麻烦。
  • 不适合高并发的场景:高并发的场景需要减少数据库的压力,有时数据库会采用分库分表的方式,而且对可扩展性要求很高,在这种情况下,存储过程会变得难以维护,增加数据库的压力,显然就不适用了。

_综上,存储过程的优缺点都非常突出,是否使用一定要慎重,需要根据具体应用场景来权衡_。

14 丨什么是事务处理,如何使用 COMMIT 和 ROLLBACK 进行操作?

ACID:

  1. A,也就是原子性(Atomicity)。原子的概念就是不可分割,你可以把它理解为组成物质的基本单位,也是我们进行数据处理操作的基本单位。
  2. C,就是一致性(Consistency)。一致性指的就是数据库在进行事务操作后,会由原来的一致状态,变成另一种一致的状态。也就是说当事务提交后,或者当事务发生回滚后,数据库的完整性约束不能被破坏。
  3. I,就是隔离性(Isolation)。它指的是每个事务都是彼此独立的,不会受到其他事务的执行影响。也就是说一个事务在提交之前,对其他事务都是不可见的。
  4. 最后一个 D,指的是持久性(Durability)。事务提交之后对数据的修改是持久性的,即使在系统出故障的情况下,比如系统崩溃或者存储介质发生故障,数据的修改依然是有效的。因为当事务完成,数据库的日志就会被更新,这时可以通过日志,让系统恢复到最后一次成功的更新状态。

事务的控制语句:

  1. START TRANSACTION 或者 BEGIN,作用是显式开启一个事务。
  2. COMMIT:提交事务。当提交事务后,对数据库的修改是永久性的。
  3. ROLLBACK 或者 ROLLBACK TO [SAVEPOINT],意为回滚事务。意思是撤销正在进行的所有没有提交的修改,或者将事务回滚到某个保存点。
  4. SAVEPOINT:在事务中创建保存点,方便后续针对保存点进行回滚。一个事务中可以存在多个保存点。
  5. RELEASE SAVEPOINT:删除某个保存点。
  6. SET TRANSACTION,设置事务的隔离级别。

15 丨初识事务隔离:隔离的级别有哪些,它们都解决了哪些异常问题?

事务隔离级别从低到高分别是:读未提交(READ UNCOMMITTED )、读已提交(READ COMMITTED)、可重复读(REPEATABLE READ)和可串行化(SERIALIZABLE)。

16 丨游标:当我们需要逐条处理数据时,该怎么做?

17 丨如何使用 Python 操作 MySQL?

18 丨 SQLAlchemy:如何使用 PythonORM 框架来操作 MySQL?

19 丨基础篇总结:如何理解查询优化、通配符以及存储过程?

第二章:SQL 性能优化篇

20 丨当我们思考数据库调优的时候,都有哪些维度可以选择?

我的理解:

  • 选择合适数据库
  • 配置优化
  • 硬件优化
  • 优化表设计
  • 优化查询
  • 使用缓存
  • 读写分离+分库分表

21 丨范式设计:数据表的范式有哪些,3NF 指的是什么?

范式定义:

  • 1NF:指的是数据库表中的任何属性都是原子性的,不可再分。
  • 2NF:指的数据表里的非主属性都要和这个数据表的候选键有完全依赖关系。
  • 3NF:在满足 2NF 的同时,对任何非主属性都不传递依赖于候选键。
  • BCNF:在 3NF 的基础上消除了主属性对候选键的部分依赖或者传递依赖关系。

范式化的目标是尽力减少冗余列,节省空间

22 丨反范式设计:3NF 有什么不足,为什么有时候需要反范式设计?

反范式化的目标是适当增加冗余列,以避免关联查询

范式化优点

  • 更节省空间
  • 更新操作更快
  • 更少需要 DISTINCTGROUP BY 语句

范式化缺点

  • 增加了关联查询,而关联查询代价很高

23 丨索引的概览:用还是不用索引,这是一个问题

索引的优缺点

索引的优点

  • 大大减少了服务器需要扫描的数据量
  • 可以帮助服务器避免排序和临时表
  • 可以将随机 I/O 变为顺序 I/O

索引的缺点

  • 创建和维护索引要耗费时间,这会随着数据量的增加而增加。
  • 占用额外物理空间
  • 写操作时很可能需要更新索引,导致数据库的写操作性能降低

索引的适用场景

适用场景

  • 频繁读操作(SELECT)
  • 表的数据量比较大
  • 列名经常出现在 WHERE 或连接(JOIN)条件中

不适用场景

  • 频繁写操作(INSERT/UPDATE/DELETE)
  • 列名不经常出现在 WHERE 或连接(JOIN)条件中
  • 索引会经常无法命中,没有意义
  • 非常小的表(比如不到 1000 行):简单的全表扫描更高效
  • 特大型的表:索引的代价很高昂,可以用分区或 Nosql

24 丨索引的原理:我们为什么用 B+树来做索引?

磁盘的 I/O 操作次数对索引的使用效率至关重要。虽然传统的二叉树数据结构查找数据的效率高,但很容易增加磁盘 I/O 操作的次数,影响索引使用的效率。因此在构造索引的时候,我们更倾向于采用“矮胖”的数据结构。

B 树和 B+ 树都可以作为索引的数据结构,在 MySQL 中采用的是 B+ 树,B+ 树在查询性能上更稳定,在磁盘页大小相同的情况下,树的构造更加矮胖,所需要进行的磁盘 I/O 次数更少,更适合进行关键字的范围查询。

25 丨 Hash 索引的底层原理是什么?

Mysql 中,只有 Memory 存储引擎显示支持哈希索引。

✔️️️️️ 哈希索引的优点

  • 因为索引数据结构紧凑,所以查询速度非常快

❌ 哈希索引的缺点

  • 只支持等值比较查询 - 包括 =IN()<=>
    • 不支持范围查询,如 WHERE price > 100
    • 不支持模糊查询,如 % 开头。
  • 无法用于排序 - 因为 Hash 索引指向的数据是无序的,因此无法起到排序优化的作用。
  • 不支持联合索引的最左侧原则 - 对于联合索引来说,Hash 索引在计算 Hash 值的时候是将索引键合并后再一起计算 Hash 值,所以不会针对每个索引单独计算 Hash 值。因此如果用到联合索引的一个或者几个索引时,联合索引无法被利用。例如:在数据列 (A,B) 上建立哈希索引,如果查询只有数据列 A,无法使用该索引。
  • 不能用索引中的值来避免读取行 - 因为哈希索引只包含哈希值和行指针,不存储字段,所以不能使用索引中的值来避免读取行。不过,访问内存中的行的速度很快,所以大部分情况下这一点对性能影响不大。
  • 哈希索引有可能出现哈希冲突
    • 出现哈希冲突时,必须遍历链表中所有的行指针,逐行比较,直到找到符合条件的行。
    • 如果哈希冲突多的话,维护索引的代价会很高。

提示:因为种种限制,所以哈希索引只适用于特定的场合。而一旦使用哈希索引,则它带来的性能提升会非常显著。

26 丨索引的使用原则:如何通过索引让 SQL 查询效率最大化?

✔️️️️ 什么情况适用索引?

  • 字段的数值有唯一性的限制,如用户名。
  • 频繁作为 WHERE 条件或 JOIN 条件的字段,尤其在数据表大的情况下
  • 频繁用于 GROUP BYORDER BY 的字段。将该字段作为索引,查询时就无需再排序了,因为 B+ 树
  • DISTINCT 字段需要创建索引

❌ 什么情况不适用索引?

  • 频繁写操作INSERT/UPDATE/DELETE ),也就意味着需要更新索引。
  • 列名不经常出现在 WHERE 或连接(JOIN)条件中,也就意味着索引会经常无法命中,没有意义,还增加空间开销。
  • 非常小的表,对于非常小的表,大部分情况下简单的全表扫描更高效。
  • 特大型的表,建立和使用索引的代价将随之增长。可以考虑使用分区技术或 Nosql。

索引失效的场景:

  • 对索引使用左模糊匹配
  • 对索引使用表达式或函数
  • 对索引隐式类型转换
  • 联合索引不遵循最左匹配原则
  • 索引列判空
  • WHERE 子句中的 OR 前后条件存在非索引列

27 丨从数据页的角度理解 B+树查询

在数据库中,不论读一行,还是读多行,都是将这些行所在的页进行加载。也就是说,数据库管理存储空间的基本单位是页(Page)。

一个表空间包括了一个或多个段,一个段包括了一个或多个区,一个区包括了多个页,而一个页中可以有多行记录:

  • 页是数据库存储的最小单位。

  • 区(Extent)是比页大一级的存储结构,在 InnoDB 存储引擎中,一个区会分配 64 个连续的页。因为 InnoDB 中的页大小默认是 16KB,所以一个区的大小是 64*16KB=1MB。

  • 段(Segment)由一个或多个区组成,区在文件系统是一个连续分配的空间(在 InnoDB 中是连续的 64 个页),不过在段中不要求区与区之间是相邻的。段是数据库中的分配单位,不同类型的数据库对象以不同的段形式存在。当我们创建数据表、索引的时候,就会相应创建对应的段,比如创建一张表时会创建一个表段,创建一个索引时会创建一个索引段。

  • 表空间(Tablespace)是一个逻辑容器,表空间存储的对象是段,在一个表空间中可以有一个或多个段,但是一个段只能属于一个表空间。数据库由一个或多个表空间组成,表空间从管理上可以划分为系统表空间、用户表空间、撤销表空间、临时表空间等。

28 丨从磁盘 I/O 的角度理解 SQL 查询的成本

磁盘 I/O 耗时远大于内存,因此数据库会采用缓冲池的方式提升页的查找效率。

SQL 查询是一个动态的过程,从页加载的角度来看:

  1. 位置决定效率。如果页就在数据库缓冲池中,那么效率是最高的,否则还需要从内存或者磁盘中进行读取,当然针对单个页的读取来说,如果页存在于内存中,会比在磁盘中读取效率高很多。
  2. 批量决定效率。如果我们从磁盘中对单一页进行随机读,那么效率是很低的(差不多 10ms),而采用顺序读取的方式,批量对页进行读取,平均一页的读取效率就会提升很多,甚至要快于单个页面在内存中的随机读取。

29 丨为什么没有理想的索引?

30 丨锁:悲观锁和乐观锁是什么?

基于加锁方式分类,Mysql 可以分为悲观锁和乐观锁。

  • 悲观锁 - 假定会发生并发冲突,屏蔽一切可能违反数据完整性的操作
    • 在查询完数据的时候就把事务锁起来,直到提交事务(COMMIT
    • 实现方式:使用数据库中的锁机制
  • 乐观锁 - 假设最好的情况——每次访问数据时,都假设数据不会被其他线程修改,不必加锁。只在更新的时候,判断一下在此期间是否有其他线程更新该数据。
    • 实现方式:更新数据时,先使用版本号机制或 CAS 算法检查数据是否被修改

31 丨为什么大部分 RDBMS 都会支持 MVCC?

MVCC 的核心就是 Undo Log+ Read View

  • Undo Log 保存数据的历史版本,实现多版本的管理;
  • 通过 Read View 原则来决定数据是否显示;
  • 时针对不同的隔离级别,Read View 的生成策略不同,也就实现了不同的隔离级别

32 丨查询优化器是如何工作的?

MySQL 整个查询执行过程,总的来说分为 6 个步骤,分别对应 6 个组件:

  1. 连接器 - 客户端和 MySQL 服务器建立连接;连接器负责跟客户端建立连接、获取权限、维持和管理连接。
  2. 查询缓存 - MySQL 服务器首先检查查询缓存,如果命中缓存,则立刻返回结果。否则进入下一阶段。
  3. 分析器 - MySQL 服务器进行 SQL 分析:语法分析、词法分析。
  4. 优化器 - MySQL 服务器用优化器生成对应的执行计划。
  5. 执行器 - MySQL 服务器根据执行计划,调用存储引擎的 API 来执行查询。
  6. 返回结果 - MySQL 服务器将结果返回给客户端,同时缓存查询结果。

33 丨如何使用性能分析工具定位 SQL 执行慢的原因?

34 丨答疑篇:关于索引以及缓冲池的一些解惑

35 丨数据库主从同步的作用是什么,如何解决数据不一致问题?

Mysql 支持两种复制:基于行的复制和基于语句的复制。

这两种方式都是在主库上记录二进制日志,然后在从库重放日志的方式来实现异步的数据复制。这意味着:复制过程存在时延,这段时间内,主从数据可能不一致。

主要涉及三个线程:binlog 线程、I/O 线程和 SQL 线程。

  • binlog 线程 :负责将主服务器上的数据更改写入二进制文件(binlog)中。
  • I/O 线程 :负责从主服务器上读取二进制日志文件,并写入从服务器的中继日志中。
  • SQL 线程 :负责读取中继日志并重放其中的 SQL 语句。

如何解决主从同步时的数据一致性问题?

异步复制

异步模式就是客户端提交 COMMIT 之后不需要等从库返回任何结果,而是直接将结果返回给客户端,这样做的好处是不会影响主库写的效率,但可能会存在主库宕机,而 Binlog 还没有同步到从库的情况,也就是此时的主库和从库数据不一致。这时候从从库中选择一个作为新主,那么新主则可能缺少原来主服务器中已提交的事务。所以,这种复制模式下的数据一致性是最弱的。

半异步复制

原理是在客户端提交 COMMIT 之后不直接将结果返回给客户端,而是等待至少有一个从库接收到了 Binlog,并且写入到中继日志中,再返回给客户端。这样做的好处就是提高了数据的一致性,当然相比于异步复制来说,至少多增加了一个网络连接的延迟,降低了主库写的效率。——其实是一种两阶段提交的思想。

组复制

这种复制技术是基于 Paxos 的状态机复制。

将多个节点共同组成一个复制组,在执行读写(RW)事务的时候,需要通过一致性协议层(Consensus 层)的同意,也就是读写事务想要进行提交,必须要经过组里“大多数人”(对应 Node 节点)的同意,大多数指的是同意的节点数量需要大于(N/2+1),这样才可以进行提交,而不是原发起方一个说了算。而针对只读(RO)事务则不需要经过组内同意,直接 COMMIT 即可。

在一个复制组内有多个节点组成,它们各自维护了自己的数据副本,并且在一致性协议层实现了原子消息和全局有序消息,从而保证组内数据的一致性。

36 丨数据库没有备份,没有使用 Binlog 的情况下,如何恢复数据?

37 丨 SQL 注入:你的 SQL 是如何被注入的?

SQL 注入攻击(SQL injection),是发生于应用程序之数据层的安全漏洞。简而言之,是在输入的字符串之中注入 SQL 指令,在设计不良的程序当中忽略了检查,那么这些注入进去的指令就会被数据库服务器误认为是正常的 SQL 指令而运行,因此遭到破坏或是入侵。

攻击示例:

考虑以下简单的登录表单:

1
2
3
4
5
<form action="/login" method="POST">
<p>Username: <input type="text" name="username" /></p>
<p>Password: <input type="password" name="password" /></p>
<p><input type="submit" value="登陆" /></p>
</form>

我们的处理里面的 SQL 可能是这样的:

1
2
3
username:=r.Form.Get("username")
password:=r.Form.Get("password")
sql:="SELECT * FROM user WHERE username='"+username+"' AND password='"+password+"'"

如果用户的输入的用户名如下,密码任意

1
myuser' or 'foo' = 'foo' --

那么我们的 SQL 变成了如下所示:

1
SELECT * FROM user WHERE username='myuser' or 'foo' = 'foo' --'' AND password='xxx'

在 SQL 里面 -- 是注释标记,所以查询语句会在此中断。这就让攻击者在不知道任何合法用户名和密码的情况下成功登录了。

对于 MSSQL 还有更加危险的一种 SQL 注入,就是控制系统,下面这个可怕的例子将演示如何在某些版本的 MSSQL 数据库上执行系统命令。

1
2
sql:="SELECT * FROM products WHERE name LIKE '%"+prod+"%'"
Db.Exec(sql)

如果攻击提交 a%' exec master..xp_cmdshell 'net user test testpass /ADD' -- 作为变量 prod 的值,那么 sql 将会变成

1
sql:="SELECT * FROM products WHERE name LIKE '%a%' exec master..xp_cmdshell 'net user test testpass /ADD'--%'"

MSSQL 服务器会执行这条 SQL 语句,包括它后面那个用于向系统添加新用户的命令。如果这个程序是以 sa 运行而 MSSQLSERVER 服务又有足够的权限的话,攻击者就可以获得一个系统帐号来访问主机了。

虽然以上的例子是针对某一特定的数据库系统的,但是这并不代表不能对其它数据库系统实施类似的攻击。针对这种安全漏洞,只要使用不同方法,各种数据库都有可能遭殃。

攻击手段和目的

  • 数据表中的数据外泄,例如个人机密数据,账户数据,密码等。
  • 数据结构被黑客探知,得以做进一步攻击(例如 SELECT * FROM sys.tables)。
  • 数据库服务器被攻击,系统管理员账户被窜改(例如 ALTER LOGIN sa WITH PASSWORD='xxxxxx')。
  • 获取系统较高权限后,有可能得以在网页加入恶意链接、恶意代码以及 XSS 等。
  • 经由数据库服务器提供的操作系统支持,让黑客得以修改或控制操作系统(例如 xp_cmdshell “net stop iisadmin”可停止服务器的 IIS 服务)。
  • 破坏硬盘数据,瘫痪全系统(例如 xp_cmdshell “FORMAT C:”)。

应对手段

  • 使用参数化查询 - 建议使用数据库提供的参数化查询接口,参数化的语句使用参数而不是将用户输入变量嵌入到 SQL 语句中,即不要直接拼接 SQL 语句。例如使用 database/sql 里面的查询函数 PrepareQuery ,或者 Exec(query string, args ...interface{})
  • 单引号转换 - 在组合 SQL 字符串时,先针对所传入的参数进行字符替换(将单引号字符替换为连续 2 个单引号字符)。

第三章:认识 DBMS

内容对我意义不大,略

38 丨如何在 Excel 中使用 SQL 语言?

39 丨 WebSQL:如何在 H5 中存储一个本地数据库?

40 丨 SQLite:为什么微信用 SQLite 存储聊天记录?

41 丨初识 Redis:Redis 为什么会这么快?

42 丨如何使用 Redis 来实现多用户抢票问题

43 丨如何使用 Redis 搭建玩家排行榜?

44 丨 DBMS 篇总结和答疑:用 SQLite 做词云

第四章:SQL 项目实战

45 丨数据清洗:如何使用 SQL 对数据进行清洗?

SQL 可以帮我们进行数据处理,总的来说可以分成 OLTP 和 OLAP 两种方式。

  • OLTP:称之为联机事务处理。对数据进行增删改查,SQL 查询优化,事务处理等就属于 OLTP 的范畴。它对实时性要求高,需要将用户的数据有效地存储到数据库中,同时有时候针对互联网应用的需求,我们还需要设置数据库的主从架构保证数据库的高并发和高可用性。
  • OLAP:称之为联机分析处理。它是对已经存储在数据库中的数据进行分析,帮我们得出报表,指导业务。它对数据的实时性要求不高,但数据量往往很大,存储在数据库(数据仓库)中的数据可能还存在数据质量的问题,比如数据重复、数据中有缺失值,或者单位不统一等,因此在进行数据分析之前,首要任务就是对收集的数据进行清洗,从而保证数据质量。

46 丨数据集成:如何对各种数据库进行集成和转换?

ETL 是英文 Extract、Transform 和 Load 的缩写,也就是将数据从不同的数据源进行抽取,然后通过交互转换,最终加载到目的地的过程。

  • 在 Extract 数据抽取这个过程中,需要做大量的工作,我们需要了解企业分散在不同地方的数据源都采用了哪种 DBMS,还需要了解这些数据源存放的数据结构等,是结构化数据,还是非结构化数据。在抽取中,我们也可以采用全量抽取和增量抽取两种方式。相比于全量抽取,增量抽取使用得更为广泛,它可以帮我们动态捕捉数据源的数据变化,并进行同步更新。
  • 在 Transform 数据转换的过程中,我们可以使用一些数据转换的组件,比如说数据字段的映射、数据清洗、数据验证和数据过滤等,这些模块可以像是在流水线上进行作业一样,帮我们完成各种数据转换的需求,从而将不同质量,不同规范的数据进行统一。
  • 在 Load 数据加载的过程中,我们可以将转换之后的数据加载到目的地,如果目标是 RDBMS,我们可以直接通过 SQL 进行加载,或者使用批量加载的方式进行加载。

47 丨如何利用 SQL 对零售数据进行分析?

参考资料