Dunwu Blog

大道至简,知易行难

Spring 配置元数据

Spring 配置元信息

  • Spring Bean 配置元信息 - BeanDefinition
  • Spring Bean 属性元信息 - PropertyValues
  • Spring 容器配置元信息
  • Spring 外部化配置元信息 - PropertySource
  • Spring Profile 元信息 - @Profile

Spring Bean 配置元信息

Bean 配置元信息 - BeanDefinition

  • GenericBeanDefinition:通用型 BeanDefinition
  • RootBeanDefinition:无 Parent 的 BeanDefinition 或者合并后 BeanDefinition
  • AnnotatedBeanDefinition:注解标注的 BeanDefinition

Spring Bean 属性元信息

  • Bean 属性元信息 - PropertyValues
    • 可修改实现 - MutablePropertyValues
    • 元素成员 - PropertyValue
  • Bean 属性上下文存储 - AttributeAccessor
  • Bean 元信息元素 - BeanMetadataElement

Spring 容器配置元信息

Spring XML 配置元信息 - beans 元素相关

beans 元素属性 默认值 使用场景
profile null(留空) Spring Profiles 配置值
default-lazy-init default 当 outter beans “default-lazy-init” 属性存在时,继承该值,否则为“false”
default-merge default 当 outter beans “default-merge” 属性存在时,继承该值,否则为“false”
default-autowire default 当 outter beans “default-autowire” 属性存在时,继承该值,否则为“no”
default-autowire-candidates null(留空) 默认 Spring Beans 名称 pattern
default-init-method null(留空) 默认 Spring Beans 自定义初始化方法
default-destroy-method null(留空) 默认 Spring Beans 自定义销毁方法

Spring XML 配置元信息 - 应用上下文相关

XML 元素 使用场景
<context:annotation-config /> 激活 Spring 注解驱动
<context:component-scan /> Spring @Component 以及自定义注解扫描
<context:load-time-weaver /> 激活 Spring LoadTimeWeaver
<context:mbean-export /> 暴露 Spring Beans 作为 JMX Beans
<context:mbean-server /> 将当前平台作为 MBeanServer
<context:property-placeholder /> 加载外部化配置资源作为 Spring 属性配
<context:property-override /> 利用外部化配置资源覆盖 Spring 属

基于 XML 文件装载 Spring Bean 配置元信息

底层实现 - XmlBeanDefinitionReader

XML 元素 使用场景
<beans:beans /> 单 XML 资源下的多个 Spring Beans 配置
<beans:bean /> 单个 Spring Bean 定义(BeanDefinition)配置
<beans:alias /> 为 Spring Bean 定义(BeanDefinition)映射别名
<beans:import /> 加载外部 Spring XML 配置资源

基于 Properties 文件装载 Spring Bean 配置元信息

底层实现 - PropertiesBeanDefinitionReader

Properties 属性名 使用场景
class Bean 类全称限定名
abstract 是否为抽象的 BeanDefinition
parent 指定 parent BeanDefinition 名称
lazy-init 是否为延迟初始化
ref 引用其他 Bean 的名称
scope 设置 Bean 的 scope 属性
${n} n 表示第 n+1 个构造器参数

基于 Java 注解装载 Spring Bean 配置元信息

Spring 模式注解

Spring 注解 场景说明 起始版本
@Repository 数据仓储模式注解 2.0
@Component 通用组件模式注解 2.5
@Service 服务模式注解 2.5
@Controller Web 控制器模式注解 2.5
@Configuration 配置类模式注解 3.0

Spring Bean 定义注解

| Spring 注解 | 场景说明 | 起始版本 |
| ———— | —————————————— | ———– | — |
| @Bean | 替换 XML 元素 <bean> | 3.0 |
| @DependsOn | 替代 XML 属性 <bean depends-on="..."/> | 3.0 |
| @Lazy | 替代 XML 属性 <bean lazy-init="true | falses" /> | 3.0 |
| @Primary | 替换 XML 元素 <bean primary="true | false" /> | 3.0 |
| @Role | 替换 XML 元素 <bean role="..." /> | 3.1 |
| @Lookup | 替代 XML 属性 <bean lookup-method="..."> | 4.1 |

Spring Bean 依赖注入注解

Spring 注解 场景说明 起始版本
@Autowired Bean 依赖注入,支持多种依赖查找方式 2.5
@Qualifier 细粒度的 @Autowired 依赖查找 2.5
Java 注解 场景说明 起始版本
@Resource 类似于 @Autowired 2.5
@Inject 类似于 @Autowired 2.5

Spring Bean 条件装配注解

Spring 注解 场景说明 起始版本
@Profile 配置化条件装配 3.1
@Conditional 编程条件装配 4.0

Spring Bean 生命周期回调注解

Spring 注解 场景说明 起始版本
@PostConstruct 替换 XML 元素 <bean init-method="..." /> 或 InitializingBean 2.5
@PreDestroy 替换 XML 元素 <bean destroy-method="..." /> 或 DisposableBean 2.5

Spring BeanDefinition 解析与注册

Spring 注解 场景说明 起始版本
XML 资源 XmlBeanDefinitionReader 1.0
Properties 资源 PropertiesBeanDefinitionReader 1.0
Java 注解 AnnotatedBeanDefinitionReader 3.0

Spring Bean 配置元信息底层实现

Spring XML 资源 BeanDefinition 解析与注册

核心 API - XmlBeanDefinitionReader

  • 资源 - Resource
  • 底层 - BeanDefinitionDocumentReader
    • XML 解析 - Java DOM Level 3 API
    • BeanDefinition 解析 - BeanDefinitionParserDelegate
    • BeanDefinition 注册 - BeanDefinitionRegistry

Spring Properties 资源 BeanDefinition 解析与注册

核心 API - PropertiesBeanDefinitionReader

  • 资源
    • 字节流 - Resource
    • 字符流 - EncodedResouce
  • 底层
    • 存储 - java.util.Properties
    • BeanDefinition 解析 - API 内部实现
    • BeanDefinition 注册 - BeanDefinitionRegistry

Spring Java 注册 BeanDefinition 解析与注册

核心 API - AnnotatedBeanDefinitionReader

  • 资源
    • 类对象 - java.lang.Class
  • 底层
    • 条件评估 - ConditionEvaluator
    • Bean 范围解析 - ScopeMetadataResolver
    • BeanDefinition 解析 - 内部 API 实现
    • BeanDefinition 处理 - AnnotationConfigUtils.processCommonDefinitionAnnotations
    • BeanDefinition 注册 - BeanDefinitionRegistry

基于 XML 文件装载 Spring IoC 容器配置元信息

Spring IoC 容器相关 XML 配置

命名空间 所属模块 Schema 资源 URL
beans spring-beans https://www.springframework.org/schema/beans/spring-beans.xsd
context spring-context https://www.springframework.org/schema/context/spring-context.xsd
aop spring-aop https://www.springframework.org/schema/aop/spring-aop.xsd
tx spring-tx https://www.springframework.org/schema/tx/spring-tx.xsd
util spring-beans beans https://www.springframework.org/schema/util/spring-util.xsd
tool spring-beans https://www.springframework.org/schema/tool/spring-tool.xsd

基于 Java 注解装载 Spring IoC 容器配置元信息

Spring IoC 容器装配注解

Spring 注解 场景说明 起始版本
@ImportResource 替换 XML 元素 <import> 3.0
@Import 导入 Configuration Class 3.0
@ComponentScan 扫描指定 package 下标注 Spring 模式注解的类 3.1

Spring IoC 配属属性注解

Spring 注解 场景说明 起始版本
@PropertySource 配置属性抽象 PropertySource 注解 3.1
@PropertySources @PropertySource 集合注解 4.0

基于 Extensible XML authoring 扩展 SpringXML 元素

Spring XML 扩展

  • 编写 XML Schema 文件:定义 XML 结构
  • 自定义 NamespaceHandler 实现:命名空间绑定
  • 自定义 BeanDefinitionParser 实现:XML 元素与 BeanDefinition 解析
  • 注册 XML 扩展:命名空间与 XML Schema 映射

Extensible XML authoring 扩展原理

触发时机

  • AbstractApplicationContext#obtainFreshBeanFactory
    • AbstractRefreshableApplicationContext#refreshBeanFactory
      • AbstractXmlApplicationContext#loadBeanDefinitions
          • XmlBeanDefinitionReader#doLoadBeanDefinitions
              • BeanDefinitionParserDelegate#parseCustomElement

核心流程

BeanDefinitionParserDelegate#parseCustomElement(org.w3c.dom.Element, BeanDefinition)

  • 获取 namespace
  • 通过 namespace 解析 NamespaceHandler
  • 构造 ParserContext
  • 解析元素,获取 BeanDefinintion

基于 Properties 文件装载外部化配置

注解驱动

  • @org.springframework.context.annotation.PropertySource
  • @org.springframework.context.annotation.PropertySources

API 编程

  • org.springframework.core.env.PropertySource
  • org.springframework.core.env.PropertySources

基于 YAML 文件装载外部化配置

API 编程

  • org.springframework.beans.factory.config.YamlProcessor
    • org.springframework.beans.factory.config.YamlMapFactoryBean
    • org.springframework.beans.factory.config.YamlPropertiesFactoryBean

问题

Spring 內建 XML Schema 常见有哪些

命名空间 所属模块 Schema 资源 URL
beans spring-beans https://www.springframework.org/schema/beans/spring-beans.xsd
context spring-context https://www.springframework.org/schema/context/spring-context.xsd
aop spring-aop https://www.springframework.org/schema/aop/spring-aop.xsd
tx spring-tx https://www.springframework.org/schema/tx/spring-tx.xsd
util spring-beans beans https://www.springframework.org/schema/util/spring-util.xsd
tool spring-beans https://www.springframework.org/schema/tool/spring-tool.xsd

Spring 配置元信息具体有哪些

  • Bean 配置元信息:通过媒介(如 XML、Proeprties 等),解析 BeanDefinition
  • IoC 容器配置元信息:通过媒介(如 XML、Proeprties 等),控制 IoC 容器行为,比如注解驱动、AOP 等
  • 外部化配置:通过资源抽象(如 Proeprties、YAML 等),控制 PropertySource
  • Spring Profile:通过外部化配置,提供条件分支流程

Extensible XML authoring 的缺点

  • 高复杂度:开发人员需要熟悉 XML Schema,spring.handlers,spring.schemas 以及 Spring API
  • 嵌套元素支持较弱:通常需要使用方法递归或者其嵌套解析的方式处理嵌套(子)元素
  • XML 处理性能较差:Spring XML 基于 DOM Level 3 API 实现,该 API 便于理解,然而性能较差
  • XML 框架移植性差:很难适配高性能和便利性的 XML 框架,如 JAXB

参考资料

Spring Bean 生命周期

Spring Bean 元信息配置阶段

BeanDefinition 配置

  • 面向资源
    • XML 配置
    • Properties 资源配置
  • 面向注解
  • 面向 API

Spring Bean 元信息解析阶段

  • 面向资源 BeanDefinition 解析
    • BeanDefinitionReader
    • XML 解析器 - BeanDefinitionParser
  • 面向注解 BeanDefinition 解析
    • AnnotatedBeanDefinitionReader

Spring Bean 注册阶段

BeanDefinition 注册接口:BeanDefinitionRegistry

Spring BeanDefinition 合并阶段

BeanDefinition 合并

父子 BeanDefinition 合并

  • 当前 BeanFactory 查找
  • 层次性 BeanFactory 查找

Spring Bean Class 加载阶段

  • ClassLoader 类加载
  • Java Security 安全控制
  • ConfigurableBeanFactory 临时 ClassLoader

Spring Bean 实例化前阶段

实例化方式

  • 传统实例化方式:实例化策略(InstantiationStrategy)
  • 构造器依赖注入

Spring Bean 实例化阶段

非主流生命周期 - Bean 实例化前阶段

InstantiationAwareBeanPostProcessor#postProcessBeforeInstantiation

Spring Bean 实例化后阶段

Bean 属性赋值(Populate)判断

InstantiationAwareBeanPostProcessor#postProcessAfterInstantiation

Spring Bean 属性赋值前阶段

  • Bean 属性值元信息
    • PropertyValues
  • Bean 属性赋值前回调
    • Spring 1.2 - 5.0:InstantiationAwareBeanPostProcessor#postProcessPropertyValues
    • Spring 5.1:InstantiationAwareBeanPostProcessor#postProcessProperties

Spring Bean Aware 接口回调阶段

Spring Aware 接口:

  • BeanNameAware
  • BeanClassLoaderAware
  • BeanFactoryAware
  • EnvironmentAware
  • EmbeddedValueResolverAware
  • ResourceLoaderAware
  • ApplicationEventPublisherAware
  • MessageSourceAware
  • ApplicationContextAware

Spring Bean 初始化前阶段

已完成:

  • Bean 实例化

  • Bean 属性赋值

  • Bean Aware 接口回调

方法回调:

  • BeanPostProcessor#postProcessBeforeInitialization

Spring Bean 初始化阶段

Bean 初始化(Initialization)

  • @PostConstruct 标注方法
  • 实现 InitializingBean 接口的 afterPropertiesSet() 方法
  • 自定义初始化方法

Spring Bean 初始化后阶段

方法回调:BeanPostProcessor#postProcessAfterInitialization

Spring Bean 初始化完成阶段

方法回调:Spring 4.1 +:SmartInitializingSingleton#afterSingletonsInstantiated

Spring Bean 销毁前阶段

方法回调:DestructionAwareBeanPostProcessor#postProcessBeforeDestruction

Spring Bean 销毁阶段

Bean 销毁(Destroy)

  • @PreDestroy 标注方法
  • 实现 DisposableBean 接口的 destroy() 方法
  • 自定义销毁方法

Spring Bean 垃圾收集

Bean 垃圾回收(GC)

  • 关闭 Spring 容器(应用上下文)
  • 执行 GC
  • Spring Bean 覆盖的 finalize() 方法被回调

问题

BeanPostProcessor 的使用场景有哪些

BeanPostProcessor 提供 Spring Bean 初始化前和初始化后的生命周期回调,分别对应 postProcessBeforeInitialization 以及 postProcessAfterInitialization 方法,允许对关心的 Bean 进行扩展,甚至是替换。

加分项:其中,ApplicationContext 相关的 Aware 回调也是基于 BeanPostProcessor 实现,即 ApplicationContextAwareProcessor。

BeanFactoryPostProcessor 与 BeanPostProcessor 的区别

BeanFactoryPostProcessor 是 Spring BeanFactory(实际为 ConfigurableListableBeanFactory) 的后置处理器,用于扩展 BeanFactory,或通过 BeanFactory 进行依赖查找和依赖注入。

BeanFactoryPostProcessor 必须有 Spring ApplicationContext 执行,BeanFactory 无法与其直接交互。

而 BeanPostProcessor 则直接与 BeanFactory 关联,属于 N 对 1 的关系。

BeanFactory 是怎样处理 Bean 生命周期

BeanFactory 的默认实现为 DefaultListableBeanFactory,其中 Bean生命周期与方法映射如下:

  • BeanDefinition 注册阶段 - registerBeanDefinition
  • BeanDefinition 合并阶段 - getMergedBeanDefinition
  • Bean 实例化前阶段 - resolveBeforeInstantiation
  • Bean 实例化阶段 - createBeanInstance
  • Bean 初始化后阶段 - populateBean
  • Bean 属性赋值前阶段 - populateBean
  • Bean 属性赋值阶段 - populateBean
  • Bean Aware 接口回调阶段 - initializeBean
  • Bean 初始化前阶段 - initializeBean
  • Bean 初始化阶段 - initializeBean
  • Bean 初始化后阶段 - initializeBean
  • Bean 初始化完成阶段 - preInstantiateSingletons
  • Bean 销毁前阶段 - destroyBean
  • Bean 销毁阶段 - destroyBean

参考资料

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 讲》笔记

极客时间教程 - MySQL 实战 45 讲 学习笔记

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

大体来说,MySQL 可以分为 Server 层和存储引擎层两部分。

Server 层包括连接器、查询缓存、解析器、优化器、执行器等,涵盖 MySQL 的大多数核心服务功能,以及所有的内置函数(如日期、时间、数学和加密函数等),所有跨存储引擎的功能都在这一层实现,比如存储过程、触发器、视图等。

存储引擎层负责数据的存储和提取。其架构模式是插件式的,支持 InnoDB、MyISAM、Memory 等多个存储引擎。现在最常用的存储引擎是 InnoDB,它从 MySQL 5.5.5 版本开始成为了默认存储引擎。

MySQL 整个查询执行过程,总的来说分为 6 个步骤:

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

02 日志系统:一条 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,与原库的值不同。
  1. 先写 binlog 后写 redo log。如果在 binlog 写完之后 crash,由于 redo log 还没写,崩溃恢复以后这个事务无效,所以这一行 c 的值是 0。但是 binlog 里面已经记录了“把 c 从 0 改成 1”这个日志。所以,在之后用 binlog 来恢复的时候就多了一个事务出来,恢复出来的这一行 c 的值就是 1,与原库的值不同。

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

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

隔离级别

事务就是要保证一组数据库操作,要么全部成功,要么全部失败。

在 MySQL 中,事务支持是在引擎层实现的。并不是所有的引擎都支持事务。比如 MyISAM 引擎就不支持事务,这也是 MyISAM 被 InnoDB 取代的重要原因之一。

事务特性 ACID(Atomicity、Consistency、Isolation、Durability,即原子性、一致性、隔离性、持久性)。

SQL 标准的事务隔离级别包括:读未提交(read uncommitted)、读提交(read committed)、可重复读(repeatable read)和串行化(serializable )。隔离级别越高,效率越低。

  • Oracle 的默认隔离级别是“读提交”
  • MySQL 的默认隔离级别是“可重复读”

事务隔离的实现

假设一个值从 1 被按顺序改成了 2、3、4,在回滚日志里面就会有类似下面的记录。

当前值是 4,但是在查询这条记录的时候,不同时刻启动的事务会有不同的 read-view。在视图 A、B、C 里面,这一个记录的值分别是 1、2、4,同一条记录在系统中可以存在多个版本,就是数据库的多版本并发控制(MVCC)

04 深入浅出索引(上)

索引的出现是为了提高数据查询的效率。对于数据库的表而言,索引就像是书的目录。

索引的常见模型

哈希索引

哈希索引适用于只有等值查询的场景

哈希索引的限制

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

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

有序数组索引

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

可以应用二分查找法检索数据,时间复杂度为 O(logN)

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

N 叉搜索树

二叉搜索树的特点是:每个节点的左儿子小于父节点,父节点又小于右儿子。检索数据时,可以采用二分查找法,这个时间复杂度是 O(logN)。为了维持二叉搜索树的有序,就需要保证这棵树是平衡二叉树。为了做这个保证,更新的时间复杂度也是 O(logN)

树可以有二叉,也可以有多叉。多叉树就是每个节点有多个儿子,儿子之间的大小保证从左到右递增。二叉树是搜索效率最高的,但是实际上大多数的数据库存储却并不使用二叉树。其原因是,索引不止存在内存中,还要写到磁盘上。

树的高度意味着机械磁盘的最大扫描次数。假设一棵 100 万节点的平衡二叉树,树高 20。一次查询可能需要访问 20 个数据块,也就意味着需要磁盘扫描 20 次。磁盘扫描是比较耗时的,所以应尽量减少磁盘扫描次数。因此,通过使用 N 叉树,来减少树的高度,是一个行之有效的策略。以 InnoDB 的一个整数字段索引为例,这个 N 差不多是 1200。这棵树高是 4 的时候,就可以存 1200 的 3 次方个值,这已经 17 亿了。

InnoDB 的索引模型

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

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

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

  • 主键索引的叶子节点存的是整行数据。在 InnoDB 里,主键索引也被称为聚簇索引(clustered index)
  • 非主键索引的叶子节点内容是主键的值。在 InnoDB 里,非主键索引也被称为二级索引(secondary index)

基于非主键索引的查询需要多扫描一次主键索引树,这个过程称为回表

索引维护

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

  • 为了保证有序,插入新值时,可能需要按序挪动已有数据
  • 此外,如果所在的数据页满了,需要申请一个新的数据页,然后挪动部分数据过去。这个过程称为页分裂
  • 当相邻两个页由于删除了数据,利用率很低之后,会将数据页合并。合并的过程,可以认为是分裂过程的逆过程。

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

适合用业务字段直接做主键的场景:

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

05 深入浅出索引(下)

覆盖索引

能覆盖查询字段的索引,可以直接提供查询结果,无需回表,称为覆盖索引覆盖索引可以减少树的搜索次数,显著提升查询性能,所以使用覆盖索引是一个常用的性能优化手段。

最左前缀原则

不只是索引的全部定义,只要满足最左前缀,就可以利用索引来加速检索。这里的最左,可以是联合索引的最左 N 个字段,也可以是字符串索引的最左 M 个字符

如果是联合索引,那么 key 也由多个列组成,同时,索引只能用于查找 key 是否存在(相等),遇到范围查询 (><BETWEENLIKE) 就不能进一步匹配了,后续退化为线性查找。因此,列的排列顺序决定了可命中索引的列数

应该将选择性高的列或基数大的列优先排在多列索引最前列。但有时,也需要考虑 WHERE 子句中的排序、分组和范围条件等因素,这些因素也会对查询性能造成较大影响。“索引的选择性”是指不重复的索引值和记录总数的比值,最大值为 1,此时每个记录都有唯一的索引与其对应。索引的选择性越高,查询效率越高。如果存在多条命中前缀索引的情况,就需要依次扫描,直到最终找到正确记录。

索引下推

在 MySQL 5.6 之前,只能从 ID3 开始一个个回表。到主键索引上找出数据行,再对比字段值。

而 MySQL 5.6 引入的索引下推优化(index condition pushdown), 可以在索引遍历过程中,对索引中包含的字段先做判断,直接过滤掉不满足条件的记录,减少回表次数。

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

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

全局锁(FTWRL)

  • 作用:对整个数据库加锁,使数据库进入只读状态(阻塞所有数据更新、DDL 操作和事务提交)。
  • 使用场景
    • 全库逻辑备份(确保备份数据的一致性)。
    • 问题
      • 主库备份会导致业务停摆(无法更新)。
      • 从库备份会阻塞主从同步(binlog 延迟)。
  • 替代方案
    • **mysqldump --single-transaction**(InnoDB 适用):
      • 通过事务的可重复读隔离级别MVCC 实现一致性视图,备份期间允许数据更新。
    • readonly=true的缺陷
      • 影响主备库判断逻辑;异常时不会自动释放锁,风险更高。
  • 适用引擎
    • InnoDB:优先用--single-transaction
    • MyISAM:必须用 FTWRL(不支持事务)。

表级锁

表锁(LOCK TABLES ... READ/WRITE

  • 行为:显式加锁,限制其他线程的读写,同时限制本线程的操作范围(如LOCK TABLES t1 READ后,本线程只能读t1)。
  • 应用场景
    • MyISAM 等不支持行锁的引擎。
    • InnoDB 一般不用(行锁更细粒度)。

元数据锁(MDL)

  • 自动加锁

    • 读锁:增删改查时自动加(多个读锁不互斥)。
    • 写锁:修改表结构时加(与读锁/其他写锁互斥)。
  • 常见问题

    • 长事务阻塞 DDL:未提交的事务会持有 MDL 读锁,导致后续 DDL(如加字段)被阻塞,进而阻塞所有后续查询(线程爆满)。
  • 解决方案

    • 监控长事务(information_schema.innodb_trx),必要时 kill。

    • 使用 WAIT/NOWAIT 语法(MariaDB/AliSQL 支持):

      1
      2
      ALTER TABLE tbl_name WAIT 10 ADD COLUMN ...;  -- 等待 10 秒超时
      ALTER TABLE tbl_name NOWAIT ADD COLUMN ...; -- 立即放弃

关键实践建议

  • 备份策略
    • InnoDB 库:用mysqldump --single-transaction(非阻塞)。
    • 含 MyISAM 的库:用 FTWRL(需业务低峰期)。
  • DDL 操作
    • 避免在高峰期执行,优先检查长事务。
    • 使用支持超时的 DDL 语法(如 MariaDB 的WAIT N)。
  • 锁升级:将 MyISAM 表迁移到 InnoDB,避免使用表锁。

小结

锁类型 命令/机制 适用场景 风险与解决方案
全局锁 FTWRL MyISAM 备份 业务阻塞 → 改用 InnoDB+事务
表锁 LOCK TABLES MyISAM 并发控制 影响粒度大 → 升级 InnoDB
MDL 锁 自动加锁(读/写) 防止表结构不一致 长事务阻塞 DDL → 监控/Kill

通过合理选择锁机制和引擎,可以平衡数据一致性与并发性能。

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

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

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

两阶段锁

在 InnoDB 事务中,行锁是在需要的时候才加上的,但并不是不需要了就立刻释放,而是要等到事务结束时才释放。这个就是两阶段锁协议。

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

死锁和死锁检测

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

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

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

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

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

事务的启动时机

  • begin/start transaction 命令并不是事务的起点,事务的真正启动是在执行第一个操作 InnoDB 表的语句时。
  • 使用 start transaction with consistent snapshot 可以立即启动事务并创建一致性视图。

一致性视图(Consistent Read View)

  • 在可重复读隔离级别下,事务启动时会创建一个一致性视图,事务执行期间看到的数据与该视图一致。
  • 一致性视图是基于事务 ID(transaction id)和数据版本(row trx_id)来实现的。

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

InnoDB 里面每个事务有一个唯一的事务 ID,叫作 transaction id。它是在事务开始的时候向 InnoDB 的事务系统申请的,是按申请顺序严格递增的。而每行数据也都是有多个版本的。每次事务更新数据的时候,都会生成一个新的数据版本,并且把 transaction id 赋值给这个数据版本的事务 ID,记为 row trx_id。同时,旧的数据版本要保留,并且在新的数据版本中,能够有信息可以直接拿到它。

也就是说,数据表中的一行记录,其实可能有多个版本 (row),每个版本有自己的 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);而事务更新数据的时候,只能用当前读。如果当前的记录的行锁被其他事务占用的话,就需要进入锁等待。

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

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

以下为【实践篇】

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

查询过程的性能差异:对于查询操作,普通索引和唯一索引的性能差异微乎其微。唯一索引在找到第一个满足条件的记录后会停止检索,而普通索引需要继续查找下一个记录,但由于数据页的读取方式,这种差异可以忽略不计。

更新过程的性能差异:更新操作中,普通索引可以利用 change buffer 来优化性能,而唯一索引则不能使用 change buffer。

  • change buffer 是一种将更新操作缓存在内存中的机制,减少了对磁盘的随机读取,从而提升了更新操作的性能。
  • 唯一索引在更新时需要检查唯一性约束,必须将数据页读入内存,增加了磁盘 I/O 的开销。

change buffer 的应用

  • change buffer 的数据是持久化的,即使机器掉电重启,change buffer 中的数据也不会丢失,因为它会被写入磁盘。
  • change buffer 适用于写多读少的场景,如账单类、日志类系统,因为这些场景下数据页在写入后不会立即被访问,change buffer 可以显著减少磁盘 I/O。
  • 对于写后立即查询的场景,change buffer 的效果不明显,甚至可能增加维护成本。

change buffer vs. redo log

  • redo log 主要减少随机写磁盘的 I/O 消耗,将随机写转换为顺序写。
  • change buffer 主要减少随机读磁盘的 I/O 消耗,通过缓存更新操作来减少磁盘读取。

总结:

  • 唯一索引的主要作用是保证数据的唯一性,而普通索引则更灵活。在业务代码保证不会写入重复数据的情况下,普通索引和唯一索引在查询性能上几乎没有差别。
  • 普通索引 在更新操作中性能更优,尤其是在写多读少的场景下,能够利用 change buffer 减少磁盘 I/O。
  • 唯一索引 适用于需要保证数据唯一性的场景,但在更新操作中性能较差,因为它无法使用 change buffer。
  • 在业务允许的情况下,优先选择普通索引,因为它可以利用 change buffer 来提升更新性能。如果业务要求必须保证数据的唯一性,则必须使用唯一索引。

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

MySQL 优化器负责选择索引,但有时会选错索引,导致查询性能下降。

优化器选择索引的依据是执行代价,主要考虑扫描行数、是否使用临时表、是否排序等因素

  • 扫描行数的估计依赖于索引的“区分度”和“基数”(cardinality),基数越大,区分度越好。
  • MySQL 通过采样统计来估算基数,但由于采样统计的不准确性,可能导致优化器误判。

索引选择异常的处理方法:

  • analyze table:如果只是统计信息不对,可以使用 analyze table 命令重新统计索引信息,修正优化器的误判。
  • force index:强制使用指定索引,但这种方法不够优雅且维护成本高。
  • 修改查询语句:通过改写 SQL 语句引导优化器选择正确的索引,例如调整 order by 条件。
  • 新建或删除索引:通过调整索引来影响优化器的选择。

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

字符串字段索引的挑战

  • 字符串字段(如邮箱、身份证号)通常较长,直接创建完整索引会占用大量存储空间。
  • 使用前缀索引可以节省空间,但可能会增加查询时的扫描行数,影响查询性能,因为前缀相同的字符串可能会导致多次回表查询。选择合适的前缀长度是关键。
  • 可以通过 count(distinct left(column, length)) 来计算不同前缀长度的区分度,选择区分度足够高的前缀长度。

前缀索引对覆盖索引的影响

  • 覆盖索引是指查询可以直接从索引中获取所需数据,而不需要回表查询。
  • 使用前缀索引时,无法利用覆盖索引的优势,因为前缀索引可能无法完全覆盖查询所需的字段。

其他优化方式

有些情况下,前缀的区分度不够好,如我国身份证前 6 位表示地区,即同一地区的身份证号前 6 位一般是相同的。对此,有以下优化方式:

  • 倒序存储:将字符串倒序存储后创建前缀索引,适用于某些特定场景(如身份证号),可以提高区分度。
  • hash 字段:在表中增加存储字符串 hash 值的字段并作为索引。hash 字段索引占用空间小,查询性能稳定,但不支持范围查询。

倒序存储与 hash 字段的相同点是,都不支持范围查询

倒序存储与 hash 字段的区别是:

  • 倒序存储:不占用额外存储空间,但每次查询需要调用 reverse 函数,且仍然使用前缀索引,可能会增加扫描行数。
  • hash 字段:需要额外存储空间,查询性能稳定,但需要调用 crc32 函数,且不支持范围查询。

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

有时 SQL 语句执行速度突然变慢,持续时间短且难以复现,这种现象称为 MySQL“抖动”。这种现象通常与 InnoDB 的刷脏页(flush)操作有关。

InnoDB 使用 WAL(Write-Ahead Logging)机制,更新操作先写 redo log,再写内存,最后刷到磁盘。当内存数据页跟磁盘数据页内容不一致的时候,我们称这个内存页为“脏页”。内存数据写入到磁盘后,内存和磁盘上的数据页的内容就一致了,称为“干净页”。

刷脏页的触发场景

  • redo log 写满:当 redo log 写满时,系统会停止所有更新操作,推进 checkpoint,刷脏页以释放 redo log 空间。
  • 内存不足:当内存不足时,InnoDB 会淘汰一些数据页。如果淘汰的是脏页,则需要先将脏页刷到磁盘。
  • 系统空闲时:MySQL 在系统空闲时,会主动刷脏页。
  • 数据库关闭时:MySQL 正常关闭时,会将所有脏页刷到磁盘,以便下次启动时快速恢复。

刷脏页对性能的影响

  • redo log 写满:这种情况会导致系统无法接受更新操作,更新数跌为 0,影响写性能。
  • 内存不足:查询需要淘汰脏页时,会导致查询响应时间变长。

InnoDB 刷脏页的控制策略

  • innodb_io_capacity:该参数用于告诉 InnoDB 磁盘的 IO 能力,建议设置为磁盘的 IOPS。
  • 脏页比例控制:InnoDB 通过脏页比例和 redo log 写入速度来控制刷脏页的速度。脏页比例上限由 innodb_max_dirty_pages_pct 参数控制,默认值为 75%。
  • 刷脏页速度计算:InnoDB 根据脏页比例和 redo log 写入速度计算出刷脏页的速度,取两者中的较大值。

刷脏页的“连坐”机制

  • InnoDB 在刷脏页时,可能会连带刷掉相邻的脏页,以减少随机 IO。该行为由 innodb_flush_neighbors 参数控制。
  • 对于 SSD 等高性能存储设备,建议将 innodb_flush_neighbors 设置为 0,以避免不必要的 IO 操作。

监控脏页比例

  • 可以通过查询 Innodb_buffer_pool_pages_dirtyInnodb_buffer_pool_pages_total 来监控脏页比例,确保其不要经常接近 75%。

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

表数据删除后空间不回收的原因

  • 当使用 DELETE 命令删除表中的数据时,InnoDB 引擎只是将数据标记为“可复用”,并不会立即释放磁盘空间。这些被标记为可复用的空间称为“空洞”。
  • 空洞不仅由删除操作引起,插入和更新操作也可能导致空洞。例如,随机插入数据可能导致页分裂,从而产生空洞。

innodb_file_per_table 参数

  • 该参数控制表数据的存储方式。设置为 ON 时,每个表的数据存储在一个单独的 .ibd 文件中;设置为 OFF 时,表数据存储在共享表空间中。
  • 建议将该参数设置为 ON,因为单独存储表数据文件更容易管理,且在删除表时可以直接回收空间。

数据删除流程

  • 删除操作只是标记数据为可复用,不会立即释放磁盘空间。数据页的复用与记录的复用不同,数据页可以被复用到任何位置,而记录的复用仅限于符合特定条件的数据。

重建表以回收空间

  • 为了回收表空间,可以通过重建表来去除空洞。重建表的操作可以通过 ALTER TABLE t ENGINE=InnoDB 命令实现。
  • 在 MySQL 5.5 及之前版本,重建表操作会阻塞表的增删改操作(非 Online DDL)。
  • 从 MySQL 5.6 开始,引入了 Online DDL,允许在重建表的过程中继续对表进行增删改操作。

Online DDL 和 inplace 操作

  • Online DDL 允许在重建表的过程中继续对表进行增删改操作,减少了锁表时间。
  • inplace 操作指的是在 InnoDB 内部完成数据重建,不需要将数据移动到临时表。Online DDL 一定是 inplace 操作,但 inplace 操作不一定是 Online 的。

重建表的其他方式

  • ANALYZE TABLE:重新统计表的索引信息,不修改数据。
  • OPTIMIZE TABLE:相当于 RECREATE + ANALYZE,会重建表并重新统计索引信息。

思考题

  • 文章最后提出了一个思考题:为什么在某些情况下,执行 ALTER TABLE t ENGINE=InnoDB 后,表空间不仅没有缩小,反而变大了?可能的原因包括数据页的重新排列、索引的重建等。

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

COUNT(*) 的实现方式

  • MyISAM 引擎:将表的总行数存储在磁盘上,执行 COUNT(*) 时直接返回该值,效率很高。
  • InnoDB 引擎:由于支持事务和 MVCC,COUNT(*) 需要逐行扫描数据并判断可见性,导致性能较差。

为什么 InnoDB 不存储行数

  • 由于 MVCC 的存在,不同事务在同一时刻看到的行数可能不同,因此 InnoDB 无法像 MyISAM 那样直接存储行数。
  • InnoDB 在执行 COUNT(*) 时会选择最小的索引树进行遍历,以减少扫描的数据量。

SHOW TABLE STATUS 的局限性

  • SHOW TABLE STATUS 命令中的 TABLE_ROWS 是通过采样估算的,误差可能达到 40% 到 50%,因此不能替代 COUNT(*)

不同 COUNT 用法的性能差异

  • **COUNT(主键 id)**:InnoDB 引擎会遍历整张表,把每一行的 id 值都取出来,返回给 server 层。server 层拿到 id 后,判断是不可能为空的,就按行累加。
  • **COUNT(1)**:InnoDB 引擎遍历整张表,但不取值。server 层对于返回的每一行,放一个数字“1”进去,判断是不可能为空的,按行累加。
  • **COUNT(字段)**:
    • 如果这个“字段”是定义为 not null 的话,一行行地从记录里面读出这个字段,判断不能为 null,按行累加;
    • 如果这个“字段”定义允许为 null,那么执行的时候,判断到有可能是 null,还要把值取出来再判断一下,不是 null 才累加。
  • **COUNT(*)**:InnoDB 做了专门优化,不取值,直接按行累加,性能最好。

结论:按照效率排序的话,COUNT(字段) < COUNT(主键 id) < COUNT(1)COUNT(*)推荐采用 COUNT(*)

优化查询计数

  • 可以使用 Redis 保存计数,但存在数据丢失和逻辑不一致的问题。

  • 可以使用数据库其他表保存计数,利用事务的原子性和隔离性,可以避免数据丢失和逻辑不一致的问题。

15 答疑文章(一):日志和索引相关问题

日志相关问题

  • 两阶段提交与崩溃恢复:MySQL 使用两阶段提交(2PC)来保证 binlog 和 redo log 的一致性。在两阶段提交的不同时刻,如果发生崩溃,MySQL 会根据 redo log 和 binlog 的状态来决定是提交事务还是回滚事务。
  • 崩溃恢复的判断规则
    • 如果 redo log 中有 commit 标识,直接提交事务。
    • 如果 redo log 处于 prepare 状态,检查 binlog 是否完整,完整则提交事务,否则回滚。
  • binlog 的完整性:binlog 有固定的格式(statement 格式有 COMMIT,row 格式有 XID event),并且可以通过 binlog-checksum 参数验证其完整性。
  • redo log 和 binlog 的关联:通过 XID 字段关联 redo log 和 binlog。崩溃恢复时,MySQL 会扫描 redo log,并根据 XID 查找对应的 binlog。
  • 为什么需要两阶段提交:两阶段提交是为了保证事务的持久性和数据一致性。如果 redo log 直接提交,而 binlog 写入失败,会导致数据不一致。
  • redo log 的大小设置:redo log 太小会导致频繁刷盘,建议设置为 4 个文件,每个文件 1GB。
  • 数据最终落盘:数据最终落盘是从 buffer pool 中的脏页写入磁盘,而不是从 redo log 更新过来。redo log 只用于崩溃恢复时恢复数据页。

互相关注的业务场景

  • 在并发场景下,A 和 B 同时关注对方可能导致无法成为好友的问题。解决方案是通过在 like 表中增加 relation_ship 字段,并使用 insert ... on duplicate key update 语句来确保行锁的生效。
  • 通过按位或操作和 insert ignore 语句,确保在并发场景下也能正确处理互相关注的逻辑。

更新操作的内部处理

  • 当执行 update t set a=2 where id=1 时,MySQL 会先读取数据,发现 a 的值已经是 2,因此不会进行实际的更新操作,直接返回。
  • 这种行为是为了减少不必要的写操作,提升性能。

参考资料