开发者博客 – IT技术 尽在开发者博客

开发者博客 – 科技是第一生产力


  • 首页

  • 归档

  • 搜索

Gradle(10)一篇文章看懂 v1/v2/v3 签名机制

发表于 2020-12-16

请点赞关注,你的支持对我意义重大。

🔥 Hi,我是小彭。本文已收录到 GitHub · AndroidFamily 中。这里有 Android 进阶成长知识体系,有志同道合的朋友,关注公众号 [彭旭锐] 带你建立核心竞争力。

前言

  • 在打生产包时,一定需要对 apk 签名,那么你知道为什么要给应用签名吗?
  • 在这篇文章里,我将分析 Android apk 的签名机制,并介绍 v1、v2 和 v3 三种安装包签名方案的原理与演进。如果能帮上忙,请务必点赞加关注,这真的对我非常重要。

这篇文章是 Gradle 系列文章第 10 篇,相关 Android 工程化专栏完整文章列表:

一、Gradle 基础:

  • 1、Gradle 基础 :Wrapper、Groovy、生命周期、Project、Task、增量
  • 2、Gradle 插件:Plugin、Extension 扩展、NamedDomainObjectContainer、调试
  • 3、Gradle 依赖管理
  • 4、Maven 发布:SHAPSHOT 快照、uploadArchives、Nexus、AAR
  • 5、Gradle 插件案例:EasyPrivacy、so 文件适配 64 位架构、ABI

二、AGP 插件:

  • 1、AGP 构建过程
  • 2、AGP 常用配置项:Manifest、BuildConfig、buildTypes、壳工程、环境切换
  • 3、APG Transform:AOP、TransformTask、增量、字节码、Dex
  • 4、AGP 代码混淆:ProGuard、R8、Optimize、Keep、组件化
  • 5、APK 签名:认证、完整性、v1、v2、v3、Zip、Wallet
  • 6、AGP 案例:多渠道打包

三、组件化开发:

  • 1、方案积累:有赞、蘑菇街、得到、携程、支付宝、手淘、爱奇艺、微信、美团
  • 2、组件化架构基础
  • 3、ARouter 源码分析
  • 4、组件化案例:通用方案
  • 5、组件化案例:组件化事件总线框架
  • 6、组件化案例:组件化 Key-Value 框架

四、AOP 面向切面编程:

  • 1、AOP 基础
  • 2、Java 注解
  • 3、Java 注解处理器:APT、javac
  • 4、Java 动态代理:代理模式、Proxy、字节码
  • 5、Java ServiceLoader:服务发现、SPI、META-INF
  • 6、AspectJ 框架:Transform
  • 7、Javassist 框架
  • 8、ASM 框架
  • 9、AspectJ 案例:限制按钮点击抖动

五、相关计算机基础

  • 1、Base64 编码
  • 2、安全传输:加密、摘要、签名、CA 证书、防窃听、完整性、认证

目录


前置知识

这篇文章的内容会涉及以下前置 / 相关知识,贴心的我都帮你准备好了,请享用~

  • 加密、摘要、签名、证书,一次说明白!

  1. 什么是应用签名?

1.1 数字签名模型

数字签名(Digital Signature)也叫作数字指纹(Digital Fingerprint),它是消息摘要算法和非对称加密算法的结合体,能够验证数据的完整性,并且认证数据的来源。

数据签名算法的模型分为两个主要阶段:

  • 1、签名: 先计算数据的 [摘要],再使用私钥对 [摘要] 进行加密生成 [签名],将 [数据 + 签名] 一并发送给接收方;
  • 2、验证: 先使用相同的摘要算法计算接收数据的 [摘要],再使用预先得到的公钥解密 [签名],对比 [解密的签名] 和 [计算的摘要] 是否一致。若一致,则说明数据没有被篡改。

需要注意的是,Android 目前不对应用证书进行 CA 认证,应用可以由第三方(OEM、运营商、其他应用市场)签名,也可以自行签名。

1.2 为什么要给应用签名?

应用 APK 其实是一种特殊的 Zip 压缩包,无法避免恶意破解者解压 / 反编译修改内容,针对这个问题有何解决方案呢?他山之石,可以攻玉 ——数字签名算法。应用签名正是数字签名算法的应用场景之一,与其他应用场景类似,目的无非是:

  • 认证

Android 平台上运行的每个应用都必须有开发者的签名。在安装应用时,软件包管理器会验证 APK 是否已经过适当签名,安装程序会拒绝没有获得签名就尝试安装的应用。

  • 验证完整性

软件包管理器在安装应用前会验证应用摘要,如果破解者修改了 apk 里的内容,那么摘要就不再匹配,验证失败(验证流程见下文方案)。

提示: 使用数字签名的优点是验证过程无须复杂的接口和权限,只需要在本机验证。

1.3 应用签名方案演进

截止至 Android 11,Android 支持以下三种应用签名方案:

  • v1 签名方案:基于 Jar 签名;
  • v2 签名方案:提高验证速度和覆盖度(在 Android 7.0 Nougat 中引入);
  • v3 签名方案:实现密钥轮转(在 Android 9.0 Pie 中引入)。

为了提高兼容性,必须按照 v1、v2、v3 的先后顺序采用签名方案,低版本平台会忽略高版本的签名方案在 APK 中添加的额外数据。

引用自 source.android.com/security/ap… —— Android Developers


  1. 签名方案 v1

v1 签名方案是基于 Jar 的签名。

2.1 签名产物

首先,我们先来分析其签名产物。v1 签名后会增加 META-INF 文件夹,其中会有如下三个文件。考虑到使用不同的证书和签名方式,得到的文件名可能不同,因此你只要留意文件的后缀即可:

1
2
3
4
复制代码META-INF
├── MANIFEST.MF
├── CERT.SF
├── CERT.RSA
文件 描述
MANIFEST.MF 记录「apk 中每一个文件对应的摘要」(除了 META-INF 文件夹)
*.SF 记录「MANIFEST.MF 文件的摘要」和「MANIFEST.MF 中每个数据块的摘要」
*.RSA 包含了「*.SF 文件的签名」和「包含公钥的开发者证书」

提示: 如果 apk 中文件数很多,而且文件名很长,那么 MANIFEST.MF 和 *.SF 两个文件会变得很大。有没有办法优化呢?见 第 5.1 节 优化摘要记录文件大小。

2.2 签名流程

v1 签名流程如下:

  • 1、计算每个文件的 SHA-1 摘要,进行 BASE64 编码后写入 MANIFEST.MF 文件;

MANIFEST.MF(Message Digest File,摘要文件)

1
2
3
4
5
6
7
8
makefile复制代码Manifest-Version: 1.0
Built-By: Generated-by-ADT
Created-By: Android Gradle 3.1.0

Name: AndroidManifest.xml
SHA1-Digest: 9hTSmRfzHEeQc7V2wxBbTT3DmCY= 【文件的摘要】

...
  • 2、计算整个 MANIFEST.MF 文件的 SHA-1 摘要,进行 BASE64 编码后写入 *.SF 文件;
  • 3、计算 MANIFEST.MF 文件中每一块摘要的 SHA-1 摘要,进行 BASE64 编码后写入 *.SF 文件;

\*.SF(Signature File,签名文件)

1
2
3
4
5
6
7
8
9
makefile复制代码Signature-Version: 1.0
Created-By: 1.0 (Android)
SHA1-Digest-Manifest: MJQyZ0dc4dv7G9nlJPAMQLwEwbU= 【MANIFEST.MF 文件的摘要】
X-Android-APK-Signed: 2

Name: AndroidManifest.xml
SHA1-Digest: IJioMmfD693T4qnUJcPKhq9woHQ= 【摘要的摘要】

...
  • 4、计算整个 *.SF 文件的数字签名(先摘要再私钥加密);
  • 5、将数字签名和 X.509 开发者数字证书(公钥)写入 *.RSA 文件。

提示:*.RSA 文件加密了,需要使用 openssl 工具打开。

引用自 zhuanlan.zhihu.com/p/108034286 —— 木质的旋律 著

2.3 验证流程

验证流程可以分为验证签名和验证完整性两个步骤:

验证签名步骤:

  • 1、取出 *.RSA 中包含的开发者证书;
  • 2、【注意:这里不向 CA 认证开发者证书合法性】;
  • 3、用证书中的公钥解密 *.RSA 中包含的签名,得到摘要;
  • 4、计算 *.SF 的摘要;
  • 5、对比 (3) 和 (4) 的摘要是否一致;

如果上述签名验证结果正确,才会验证完整性:

  • 1、计算 MANIFEST.MF 的摘要;
  • 2、对比 *.SF 记录中的文件摘要和 (1) 的摘要是否一致;
  • 3、如果一致,再用 MANIFEST.MF 中的每一块数据去校验每一个文件是否被修改。

以上任何步骤验证失败,则整个 APK 验证失败。

2.4 存在的问题

  • 完整性覆盖范围不足:Zip 文件中部分内容不在验证范围,例如 META-INF 文件夹;
  • 验证速度较差:验证程序必须解压所有压缩的条目,这需要花费更多时间和内存。

为了解决这些问题,Android 7.0 中引入了 APK 签名方案 v2。


  1. 签名方案 v2

v2 签名方案是一种 全文件签名方案,该方案能够发现对 APK 的受保护部分进行的所有更改,相对于 v1 签名方案验证速度更快,完整性覆盖范围更广。

提示: 为了兼容低版本,使用 v2 签名方案的同时,还需要使用 v1 签名方案。

3.1 Zip 文件简介

在分析 v2 签名方案之前,我们先简单了解一下 Zip 文件格式:

  • Zip 文件主体结构分为三个部分:「条目内容区」&「中央目录区」&「中央目录结尾区(EoCD)」。
  • EoCD 中记录了中央目录的起始位置,在「条目内容区」和「中央目录区」之间插入了其他数据不会影响 Zip 解压。

3.2 签名产物

首先,我们先来分析其签名产物。v2 签名后会在 「条目内容区」和「中央目录区」之间插入「APK 签名分块(APK Signing Block)」。

引用自 source.android.com/security/ap… —— Android Developers

从左到右边,我们定义为区块 1~4。

3.2 签名流程

相对与 v1 签名方案,v2 签名方案不再以文件为单位计算摘要了,而是以 1 MB 为单位将文件拆分为多个连续的块(chunk),每个分区的最后一个块可能会小于 1 MB。

v2 签名流程如下:

  • 1、对区块 1、3、4,按照 1MB 大小分割为多个块(chunk);
  • 2、计算每个块的摘要;
  • 3、计算 (2) 中所有摘要的签名。
  • 4、添加 X.509 开发者数字证书(公钥)

引用自 source.android.com/security/ap… —— Android Developers

3.3 验证流程

验证流程可以分为验证签名和验证完整性两个步骤:

  • 验证签名步骤:用公钥验证区块 2 的签名;
  • 验证完整性步骤:用「APK数据摘要集」验证每一块数据的摘要。

  1. 签名方案 v3

签名方案 v3 支持密钥轮换,应用能够在 APK 更新过程中更改其签名密钥。

【累了,后面先不写了…】


  1. 衍生应用场景

这一节,我们介绍基于 Android 应用签名机制的衍生应用场景。

5.1 优化摘要记录文件大小

在 v1 方案中,MANIFEST.MF 和 *.SF 这两个文件会记录大量的文件名和文件摘要。如果 apk 中文件数很多,而且文件名很长,那么这两个文件会变得很大。使用 AndResGuard 工具,可以将文件名转换为短路径文件名,从而减少这两个文件的大小。

引用自 time.geekbang.org/column/arti… —— 张绍文 著

5.2 多渠道打包方案

在实际生产中,往往需要生成多个渠道的 APK 包,传统的方法是使用 APKTool 逆向工具、Flavor + BuildType 等方案,这一类多渠道打包方案的缺点是耗时严重。随着 Android 应用签名方案的演进,演变出了不同的多渠道打包方案:

v1 方案时代下的多渠道打包

  • 添加空文件

在 v1 方案中,我们提到了完整性校验不覆盖到 META-INF 文件夹的问题。有些多渠道打包方案就是利用了这个问题,在 META-INF 文件夹下添加空文件,用空文件的名称来作为渠道的唯一标识,就可以节省打包的时间,提高打渠道包的速度。

  • Zip Comment

除了添加空文件的方法,还可以向 APK 添加 Zip Comment 来生成多渠道包(APK 本身就是特殊的 Zip 包)。

v2 方案时代下的多渠道打包

在 v2 签名方案中,几乎整个 APK 都纳入保护范围,如果向 APK 添加空文件或 Zip Comment 的话,在安装时会报以下错误:

1
2
3
csharp复制代码Failure [INSTALL_PARSE_FAILED_NO_CERTIFICATES: 
Failed to collect certificates from base.apk: META-INF/CERT.SF indicates base.apk is signed using APK Signature Scheme v2,
but no such signature was found. Signature stripped?]

新背景下的多渠道打包方案,则是利用了 APK 签名分块(区块 2)不受保护 & 字段可扩展的特点,向区块中添加多渠道信息(ID-Value),例如 美团多渠道打包方案 Walle。


  1. 总结

  • 签名应用是处于两个目的:认证 & 验证完整性,即:认证 APK 的开发者以及验证 APK 内容是否被篡改。截止到 Android 11,一共有 v1、v2、v3 三种签名方案。
  • v1 是基于 Jar 的签名方案,它存在完整性覆盖范围不足 & 验证速度较差两个问题。
  • Android 7.0 推出的 v2 签名方案优化了这两个问题,通过「条目内容区」和「中央目录区」之间插入「APK 签名分块(APK Signing Block)」,优化了 v1 方案的两大问题。
  • Android 9.0 推出的 v3 方案是 v2 方案的优化版本,满足了密钥轮换的需求。

参考资料

  • 《Signed_JAR_File》 —— Oracle
  • 《应用签名》 —— Android Developers
  • 《对应用进行签名》 —— Android Developers
  • 《APK 签名方案 v2》 —— Android Developers
  • 《APK 签名方案 v3》 —— Android Developers
  • 《Android 应用安全防护和逆向分析》(第 12 章)—— 姜维 著
  • 《Android 端 V1/V2/V3 签名的原理》 —— 木质的旋律 著(阿里巴巴技术团队)
  • 《分析Android V2新签名打包机制》 —— pisazzpan 著(腾讯音乐技术团队)
  • 《新一代开源Android渠道包生成工具Walle》 —— 建帅 陈潼 著(美团技术团队)
  • 《Android V1及V2签名原理简析》 —— 看书的蜗牛 著(网易技术团队)
  • 《Android App包瘦身优化实践》 —— 建帅 著(美团技术团队)
  • 《Android 开发高手课 · 包体积优化(下)》 —— 张绍文 著(微信技术团队),极客时间 出品
  • 《深入理解 Android 内核设计思想》(第 20 章) —— 林学森 著

我是小彭,带你构建 Android 知识体系。技术和职场问题,请关注公众号 [彭旭锐] 私信我提问。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

【Deprecated】密码学 高屋建瓴!从全局理解消息

发表于 2020-12-16

提示 2021年8月12日

这篇文章是 2020 年写的,今年我更新了一些新内容,你可以直接看:加密、摘要、签名、证书,一次说明白!

点赞关注,不再迷路,你的支持对我意义重大!

🔥 Hi,我是丑丑。本文 GitHub · Android-NoteBook 已收录,这里有 Android 进阶成长路线笔记 & 博客,欢迎跟着彭丑丑一起成长。(联系方式在 GitHub)

前言

  • 在分析 HTTPS 协议 时,会涉及到数据安全传输的问题。其实不止 HTTPS,在很多场景下都会遇到数据安全传输的问题,例如 应用签名 等。
  • 在这篇文章里,我将带你从全局看待数据安全传输的原理,包括数据安全传输的 三个要求(加密 & 认证 & 验证完整性)和对应的安全技术。如果能帮上忙,请务必点赞加关注,这真的对我非常重要。

系列文章

  • 《密码学 | 庐山真面!你认为 Base64 是加密算法吗?》
  • 《密码学 | 蓄势待发!说说什么是散列算法?》
  • 《密码学 | 高屋建瓴!从全局理解消息摘要、加密、签名与数字证书》

相关文章

  • 《计算机网络 | 图解 DNS & HTTPDNS 原理》
  • 《Android | 他山之石,可以攻玉!一篇文章看懂 v1/v2/v3 签名机制》

目录


  1. 密码学概述

1.1 密码学发展的重要节点

时间 事件 描述
古典密码学 / 保密性依赖于算法的保密性,如果加密算法泄露,则立即失去保密性
19世纪 柯克霍夫原则(Kerckhoffs’Principle) 系统的安全性取决于密钥,密钥保密,而加密算法公开
/ 对称密码体制 加密和解密使用相同密钥
1976年 非对称密码体制 / 公开密钥密码体制 加密和解密使用是不同密钥
1999年 破解了 DES 密钥 电子前沿基金会
2004年 实现了 MD5 碰撞 王小云教授
2005年 实现了 SHA-1 碰撞 王小云教授
2008年 伪造了 SSL 证书 荷兰埃因霍芬技术大学的科研人员通过 MD5 碰撞,生成了伪造的 SSL 证书,这使得在 HTTPS 协议中服务器可以伪造一些根 CA 的签名

1.2 非安全信道的问题

  • 防止窃听 —— 加密(通道加密或内容加密)
  • 防止篡改 —— 验证完整性
  • 防止伪装 —— 认证

1.3 保密通信模型

根据柯克霍夫原则,现代的保密通信模型是基于密钥的保密模型模型。在这个模型中,加密和解密使用相同密钥,就是对称加密密码体制;反之,加密和解密使用是不同密钥,就是非对称密码体制。


  1. 消息摘要算法(验证完整性)

这一节我们来讨论消息摘要算法,这一类算法常用于验证数据的完整性,是 第 5 节 数字签名算法 的核心算法。

2.1 概述

消息摘要(Message Digest)算法的本质是散列算法,关于散列算法在我之前写过的一篇文章里讲过:《密码学 | 什么是散列算法?》。散列算法有很多,但是都要满足以下性质 & 要求:

性质 描述
单向性 通过散列值无法反推输入数据
一致性 同一个输入数据,计算后的散列值总是相同的
高效性 散列运算过程尽量快速 & 高效
随机性 散列值在输出值域的分布尽量随机
输入敏感性 相似的数据,计算后的散列值差别很大

正是因为消息摘要的 单向性、一致性 的特点,使得它可以验证消息 & 数据的完整性,即数据从产生到接受的整个过程中没有被篡改。

2.2 消息摘要 & 验证模型

下面是一个极简的消息摘要生成 & 验证模型:

  • 1、发送方使用消息摘要算法生成摘要
  • 2、发送方将消息与消息摘要发送给接收方(注意,若通过不安全信道,则消息和消息摘要可能被监听并篡改)
  • 3、接收方收到消息后,使用相同的消息摘要算法生成摘要
  • 4、接收方验证两个摘要是否相同

2.3 算法实现

消息摘要算法的具体实现可以分为 MD、SHA 和 MAC 三大类。Editting…


  1. 对称加密算法(保密性)

3.1 概述

对称加密指的是 加密和解密使用相同密钥 的加密算法,这一类算法虽然在安全性上比不上非对称加密,却在加密 / 解密速度上占优。

3.2 对称加密模型

3.3 算法实现

举例:DES、DESede、AES、IDEA 和 PBE
Editting…


  1. 非对称加密算法(保密性)

4.1 概述

非对称加密指的是 加密和解密使用是不同密钥 的加密算法,公开的密钥叫公钥,保密的密钥叫私钥,所以非对称加密又称为公钥加密算法 / 双钥加密算法。

相对于对称加密算法,核心区别如下:

  • 1、解决了对称加密算法中的密钥分配问题,防止了私钥外传
  • 2、公钥加密的数据,只可使用私钥对其解密。反之,私钥加密的数据,只可使用公钥对其解密
  • 3、加密 / 解密效率远远低于对称加密算法。
  • 4、私钥只有发送方持有,具备认证性 / 抗否认性(第 5 节 数字签名算法 应用了此特性)。

提示: 由于非对称加密算法的加密 / 解密效率低,实际中往往采用对称加密结合非对称加密的复合方法。

4.2 非对称加密模型

4.3 算法实现

非对称密码算法的安全性完全依赖于基于计算复杂度上的难题,通常来自于数论。Editting…

举例:
RSA —— 整数因子分解问题;
DSA —— 离散对数问题;
ECC —— 离散对数问题。


  1. 数字签名算法(认证)

5.1 概述

在 第 2 节,我们讨论了验证数据完整性的消息摘要算法。在数据完整性的基础上,我们往往需要认证消息的来源,并具备抗否认的作用,能够实现这一功能的算法称为 数字签名算法(Signature Algorithm)。

提示: 数字签名也称为数字指纹(Digital Fingerprint)。

数据签名算法分为两个主要步骤为签名 & 验证,具体如下:

  • 1、签名(sign): 发送方生成数字签名,并且在发送数据时需要将原始消息和签名作为整体发送(如何签名见下文);
  • 2、验证(vertify): 接收方收到消息和签名后,验证两者是否匹配(如何验证见下文)。

需要注意的是,如果任何机构都可以进行签名处理,就无法确保消息的来源,因此签名时就必须引入发送方的私有信息,而验证时使用公共信息,这正好与非对称加密不谋而合。所以,数字签名算法往往结合了消息摘要算法和非对称加密算法。

  • 消息摘要算法: 用于对数据本身做摘要处理,确保数据完整性;
  • 非对称加密算法: 用于对消息摘要做签名 / 验证,确保数据的抗否认性。

5.2 数字签名 & 验证模型

现在具体说下签名 & 验证两个操作:

  • 1、私钥签名: 先使用消息摘要算法对原始消息做摘要处理,然后再使用私钥对摘要进行签名;
  • 2、公钥验证: 使用公钥验证签名,检查是否与消息的摘要值。

提示: 为什么不是先使用私钥对原数据签名,再对签名进行摘要呢?

因为消息摘要具有单向性,无法从摘要反向获得签名,其次,如果原数据越长,对其进行签名需要的时间越长,所以上述方法不可行。

提示: 如果需要对原始消息加密,应该先做签名,再做消息加密。即:加密与签名都应该只针对原始消息(明文)做处理。 加密是为了确保消息在传送过程中避免被破解,签名是为了确保消息的有效性。消息本身可能就是一个可执行的文件,而这个文件本身是不需要加密的。

5.3 算法实现

举例:RSA、DSA、ECDSA

RSA算法则既是最为常用的非对称加密算法,又是最为常用的签名算法。DSA算法是典型的数字签名算法,虽然其本身属于非对称加密算法不具备数据加密与解密的功能。Editting…


  1. 数字证书

在 第 4 节,我们讨论了非对称加密算法,它解决了密钥分配的问题。在私钥保密性的基础上,我们还需要保证公钥的来源合法,这就需要借助数字证书,数字证书本质上就是提供了一个认证的公钥。

签名文件和证书文件是成对出现的,二者不可分离,而且后面通过源码可以看到,这两个文件的名字也是一样的,只是后缀名不一样。数字签名要确保可靠通信,必须要解决两个问题:首先,要确定消息的来源确实是其申明的那个人;其次,要保证信息在传递的过程中不被第三方篡改,即使被篡改了,也可以发觉出来。所谓数字签名,就是为了解决这两个问题而产生的,它是对前面提到的非对称加密技术与数字摘要技术的一个具体的应用。对于消息的发送者来说,先要生成一对公私钥对,将公钥给消息的接收者。

6.1 数字证书签发 & 验证模型

Editting…

6.2 数字证书的数据结构

Editting…


  1. 总结

  • 非对称加密算法用于对数据进行加密/解密操作,确保数据的机密性;
  • 消息摘要算法用于对数字证书本身做摘要处理,确保数字证书完整性。
  • 数字签名算法用于对数据进行签名/验证操作,确保数据的完整性和认证性;

参考资料

  • 《Java加密与解密的艺术》(第 2、6、7、8、9、10 章) —— 梁栋 著
  • 《数据结构与算法之美 · 哈希算法(上/下)》 —— 王争 讲,极客时间 出品
  • 《趣谈网络协议 · HTTPS 协议》 —— 刘超 著,极客时间出品
  • 《图解 HTTP》(第 7、8 章)—— 上野宜
  • 《HTTP权威指南》(第 12、13 章) —— [美] David Gourley,Brian Totty等 著

推荐阅读

  • 算法 | 链表问题总结
  • 算法 | 回溯算法解题框架
  • 操作系统 | 中断 & 系统调用浅析
  • 图形学 | 格物致知!PNG 除了无损压缩你还知道什么?
  • 数据结构 | 微博 Top 10 热搜是怎么计算出来的?(二叉堆)
  • 计算机网络 | 图解 DNS & HTTPDNS 原理
  • Android | 毫分缕析!说说图片加载的整个过程
  • Android | 食之无味!App Startup 可能比你想象中要简单
  • Android | 适可而止!看Glide如何把生命周期安排得明明白白

感谢喜欢!你的点赞是对我最大的鼓励!欢迎关注彭旭锐的GitHub!

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

【Deprecated】密码学 蓄势待发!说说什么是散列

发表于 2020-12-16

2022 年 11 月 26日 修订备注:这篇文章已经重写,你直接看:《如何实现一个优秀的 HashTable 散列表?》

本文已收录到 AndroidFamily,技术和职场问题,请关注公众号 [彭旭锐] 提问。

前言

  • 在计算机科学中,散列算法 的应用场景有很多,例如:散列表的散列函数、消息完整性验证的消息摘要算法,除此之外你还知道哪些应用场景呢?
  • 在这篇文章里,我将带你列举散列算法的 基本要求 & 应用场景。如果能帮上忙,请务必点赞加关注,这真的对我非常重要。

系列文章

  • 《密码学 | 庐山真面!你认为 Base64 是加密算法吗?》
  • 《密码学 | 蓄势待发!说说什么是散列算法?》
  • 《密码学 | 高屋建瓴!从全局理解消息摘要、加密、签名与数字证书》

相关文章

  • 《计算机网络 | 图解 DNS & HTTPDNS 原理》
  • 《Android | 他山之石,可以攻玉!一篇文章看懂 v1/v2/v3 签名机制》

目录

  1. 概述

1.1 散列函数的定义

散列算法(Hash算法,又译哈希算法) 是一种将任意长度输入转换为固定长度输出的算法,输出的结果就是散列值。

散列算法一定是 压缩映射,即:值域会远小于输入值域。例如,MD5的输出散列值为 128 位,SHA256的输出散列值为 256 位。

1.2 散列算法的性质

散列算法有很多,但是都要满足以下性质 & 要求:

性质 描述
单向性 通过散列值无法反推输入数据
一致性 同一个输入数据,计算后的散列值总是相同的
高效性 散列运算过程尽量快速 & 高效
随机性 散列值在输出值域的分布尽量随机
输入敏感性 相似的数据,计算后的散列值差别很大

  1. 散列冲突

2.1 什么是散列冲突?

上一节提到,散列算法是压缩映射(输出值域远小于输入值域),因此肯定会存在两个甚至多个输入数据映射到同一个散列值的情况,这就是发生了 散列冲突(又称散列碰撞,Hash Collision)。

需要注意的是,散列冲突是无法完全避免的,这其实只要用鸽巢原理(又称:抽屉原理)就很好理解了,假设有 10 个鸽巢,现有 11 只鸽子,无论分配多么平均,也肯定有一个鸽巢里有两只甚至多只鸽子。

举个例子,Java中的字符串"Aa"与"BB"的散列值就冲突了:

1
2
3
4
ini复制代码String str1 = "Aa";
String str2 = "BB";
System.out.println(str1.hashCode()); 2112
System.out.println(str2.hashCode()); 2112 散列冲突

既然散列冲突是无法完全避免的,那么只能采取应对措施,主要有两种:降低概率 & 处理冲突。

2.2 降低散列冲突概率

降低散列冲突概率的思路主要有:

  • 1、优化散列算法
    前面提到了散列算法的随机性:散列值在输出值域的分布尽量随机。这是为了避免出现“堆积”现象,即散列值集中于输出值域的某一块区域,这种情况无疑会增大冲突概率。
  • 2、扩大输出值域
    在输入值域相对稳定的情况下,扩大输出值域可以降低冲突概率。例如SHA的散列值长度就比MD5长,相应的冲突概率更低。HashMap 在达到阈值时执行扩容,本质上也是扩大了输出值域。

2.3 处理散列冲突

在《数据结构 | 散列表是如何避免散列冲突的?》中,我们将讨论散列表中的解决方案。


  1. 散列算法的应用场景

Editting…


  1. 总结

  • 散列算法是一种将任意长度输入转换为固定长度输出的算法,由于是压缩映射,因而无法避免散列冲突。

参考资料

  • 《Java加密与解密的艺术》(第6、7、8、9章) —— 梁栋 著
  • 《数据结构与算法之美》(第21、22章) —— 王争 讲,极客时间 出品
  • 《散列算法》—— 维基百科

推荐阅读

  • 算法 | 链表问题总结
  • 算法 | 回溯算法解题框架
  • 操作系统 | 中断 & 系统调用浅析
  • 图形学 | 格物致知!PNG 除了无损压缩你还知道什么?
  • 数据结构 | 微博 Top 10 热搜是怎么计算出来的?(二叉堆)
  • 计算机网络 | 图解 DNS & HTTPDNS 原理
  • Android | 毫分缕析!说说图片加载的整个过程
  • Android | 食之无味!App Startup 可能比你想象中要简单
  • Android | 适可而止!看Glide如何把生命周期安排得明明白白

感谢喜欢!你的点赞是对我最大的鼓励!欢迎关注彭旭锐的GitHub!

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

SpringMVC整合Springfox-Swagger

发表于 2020-12-16

关于Swagger的简介就不占篇幅了…

本文使用的Springfox-Swagger版本为2.8.0

spring的版本为4.3.10

jackson版本为2.9.8

1、在ssm的框架上面做操作添加Swagger

2、在Maven中添加pom.xml

1
2
3
4
5
6
7
8
9
10
xml复制代码<dependency>
<groupId>io.springfox</groupId>
<artifactId>springfox-swagger2</artifactId>
<version>2.8.0</version>
</dependency>
<dependency>
<groupId>io.springfox</groupId>
<artifactId>springfox-swagger-ui</artifactId>
<version>2.8.0</version>
</dependency>

选择版本时,最好保持两个包的版本一致,以免出现不可预知的问题~

以上两个是使用Swagger的基本包,如果需要接口自动完成对象和JSON串的转换的话,需要再导入Jackson支持
在整合ssm中我已经添加了jackson-databind架包

3、导入包以后,创建一个简单的Swagger配置类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
java复制代码package com.zichen.config;

import io.swagger.annotations.ApiOperation;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
import springfox.documentation.builders.ApiInfoBuilder;
import springfox.documentation.builders.PathSelectors;
import springfox.documentation.builders.RequestHandlerSelectors;
import springfox.documentation.service.ApiInfo;
import springfox.documentation.service.Contact;
import springfox.documentation.spi.DocumentationType;
import springfox.documentation.spring.web.plugins.Docket;
import springfox.documentation.swagger2.annotations.EnableSwagger2;

// 仅在没有Spring-boot的项目中需要开启此配置
@EnableWebMvc
// 启用Swagger2
@EnableSwagger2
// 让Spring来加载该类配置
@Configuration
/**
* 也可在Spring配置文件中配置
* <context:component-scan base-package="com.zichen.controller"/>
*/
@ComponentScan(basePackages = "com.zichen.controller")
/**
* @author Sealin
* Created by Sealin on 2018-03-28.
*/
public class SwaggerConfig {
@Bean
public Docket buildDocket() {
return new Docket(DocumentationType.SWAGGER_2)
.apiInfo(buildApiInf())
.select()
//controller匹配规则
.apis(RequestHandlerSelectors.withMethodAnnotation(ApiOperation.class))
.paths(PathSelectors.any())
.build();
}

private ApiInfo buildApiInf() {
return new ApiInfoBuilder()
.title("开放接口API")
.termsOfServiceUrl("http://localhost:8989/v2/api-docs")
.description("项目名称等描述性词语")
.contact(new Contact("紫晨", "http://www.baidu.net/", "927761@zichen.net"))
.version("1.0")
.build();

}
}

4、要让此配置类生效,需要Spring上下文配置中存在如下选项springmvc-config.xml

1
2
3
4
5
6
7
8
xml复制代码<!--springmvc注解支持-->
<mvc:annotation-driven/>

<!--不拦截静态资源-->
<mvc:default-servlet-handler/>

<!-- 将我们建立的配置类加入Spring容器 -->
<bean class="com.zichen.config.SwaggerConfig" />

swagger官方案例

1
2
3
4
5
6
7
8
xml复制代码<!-- 官方说明 -->
<!-- Required so springfox can access spring's RequestMappingHandlerMapping -->
<mvc:annotation-driven/>

<!-- Required to enable Spring post processing on @Configuration classes. -->
<context:annotation-config/>

<bean class="com.yourapp.configuration.MySwaggerConfig"/>

5、此外,因为我们用Spring实现的Servlet取代了默认的,在处理Swagger-UI的静态资源时,Spring-Servlet并不会帮我们映射这些资源文件,会导致不能访问swagger-ui.html的情况,两种方式可以解决这个问题,任选一种即可。

第一种方案

1
2
xml复制代码<!--不拦截静态资源-->
<mvc:default-servlet-handler />

第二种方案

给Spring-servlet指定我们需要映射的资源文件路径

1
2
xml复制代码    <mvc:resources mapping="swagger-ui.html" location="classpath:/META-INF/resources/"/>
<mvc:resources mapping="/webjars/**" location="classpath:/META-INF/resources/webjars/"/>

上面两种方案可以任意选择一种😀😀😀😀😀😀。

6、到了关键的时刻

至此,Spring和Swagger的整合过程就告一段落了,运行试试:

1
2
3
4
host复制代码API文档视图及操作界面:
http://localhost:8989/swagger/swagger-ui.html
所有API的汇总信息(JSON)
http://localhost:8989/swagger/v2/api-docs

请求路径http://localhost:8989/swagger-ui.html

你们可以试试路径是哪个。不要放弃加油。😀

😀😀😀😀😀😀😀😀

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

太难了!面试官居然要我停止一个正在运行的线程? 1 停止不

发表于 2020-12-15

停止一个线程意味着在任务处理完任务之前停掉正在做的操作,也就是放弃当前的操作。停止一个线程可以用Thread.stop()方法,但最好不要用它。虽然它确实可以停止一个正在运行的线程,但是这个方法是不安全的,而且是已被废弃的方法。

在java中有以下3种方法可以终止正在运行的线程:

  1. 使用退出标志,使线程正常退出,也就是当run方法完成后线程终止。
  2. 使用stop方法强行终止,但是不推荐这个方法,因为stop和suspend及resume一样都是过期作废的方法。
  3. 使用interrupt方法中断线程。
  1. 停止不了的线程

interrupt()方法的使用效果并不像for+break语句那样,马上就停止循环。调用interrupt方法是在当前线程中打了一个停止标志,并不是真的停止线程。

太难了!面试官居然要我停止一个正在运行的线程?

输出结果:

太难了!面试官居然要我停止一个正在运行的线程?

  1. 判断线程是否停止状态

Thread.java类中提供了两种方法:

  1. this.interrupted(): 测试当前线程是否已经中断;
  2. this.isInterrupted(): 测试线程是否已经中断;

那么这两个方法有什么图区别呢?

我们先来看看this.interrupted()方法的解释:测试当前线程是否已经中断,当前线程是指运行this.interrupted()方法的线程。

太难了!面试官居然要我停止一个正在运行的线程?

运行结果:

1
2
ruby复制代码??
??

类Run.java中虽然是在thread对象上调用以下代码:thread.interrupt(), 后面又使用

1
2
arduino复制代码(" 1??" + ());
(" 2??" + ());

来判断thread对象所代表的线程是否停止,但从控制台打印的结果来看,线程并未停止,这也证明了interrupted()方法的解释,测试当前线程是否已经中断。这个当前线程是main,它从未中断过,所以打印的结果是两个false.

如何使main线程产生中断效果呢?

太难了!面试官居然要我停止一个正在运行的线程?

运行效果为:

1
2
ruby复制代码 ??
??

方法interrupted()的确判断出当前线程是否是停止状态。但为什么第2个布尔值是false呢?官方帮助文档中对interrupted方法的解释:

测试当前线程是否已经中断。线程的中断状态由该方法清除。

换句话说,如果连续两次调用该方法,则第二次调用返回false。

下面来看一下inInterrupted()方法。

太难了!面试官居然要我停止一个正在运行的线程?

运行结果:

1
2
ruby复制代码 ??
??

isInterrupted()并为清除状态,所以打印了两个true。

  1. 能停止的线程–异常法

有了前面学习过的知识点,就可以在线程中用for语句来判断一下线程是否是停止状态,如果是停止状态,则后面的代码不再运行即可:

太难了!面试官居然要我停止一个正在运行的线程?

运行结果:

太难了!面试官居然要我停止一个正在运行的线程?

上面的示例虽然停止了线程,但如果for语句下面还有语句,还是会继续运行的。看下面的例子:

太难了!面试官居然要我停止一个正在运行的线程?

使用Run.java执行的结果是:

太难了!面试官居然要我停止一个正在运行的线程?

如何解决语句继续运行的问题呢?看一下更新后的代码:

太难了!面试官居然要我停止一个正在运行的线程?

使用Run.java运行的结果如下:

太难了!面试官居然要我停止一个正在运行的线程?

  1. 在沉睡中停止

如果线程在sleep()状态下停止线程,会是什么效果呢?

太难了!面试官居然要我停止一个正在运行的线程?

使用Run.java运行的结果是:

太难了!面试官居然要我停止一个正在运行的线程?

从打印的结果来看, 如果在sleep状态下停止某一线程,会进入catch语句,并且清除停止状态值,使之变为false。

前一个实验是先sleep然后再用interrupt()停止,与之相反的操作在学习过程中也要注意:

太难了!面试官居然要我停止一个正在运行的线程?

运行结果:

太难了!面试官居然要我停止一个正在运行的线程?

  1. 能停止的线程—暴力停止

使用stop()方法停止线程则是非常暴力的。

太难了!面试官居然要我停止一个正在运行的线程?

运行结果:

太难了!面试官居然要我停止一个正在运行的线程?

6.方法stop()与java.lang.ThreadDeath异常

调用stop()方法时会抛出java.lang.ThreadDeath异常,但是通常情况下,此异常不需要显示地捕捉。

太难了!面试官居然要我停止一个正在运行的线程?

stop()方法以及作废,因为如果强制让线程停止有可能使一些清理性的工作得不到完成。另外一个情况就是对锁定的对象进行了解锁,导致数据得不到同步的处理,出现数据不一致的问题。

  1. 释放锁的不良后果

使用stop()释放锁将会给数据造成不一致性的结果。如果出现这样的情况,程序处理的数据就有可能遭到破坏,最终导致程序执行的流程错误,一定要特别注意:

1
typescript复制代码public class SynchronizedObject {    private String name = "a";    private String password = "aa";    public synchronized void printString(String name, String password){        try {            this.name = name;            Thread.sleep(100000);            this.password = password;        } catch (InterruptedException e) {            e.printStackTrace();        }    }    public String getName() {        return name;    }    public void setName(String name) {        this.name = name;    }    public String getPassword() {        return password;    }    public void setPassword(String password) {        this.password = password;    }}public class MyThread extends Thread {    private SynchronizedObject synchronizedObject;    public MyThread(SynchronizedObject synchronizedObject){        this.synchronizedObject = synchronizedObject;    }    public void run(){        synchronizedObject.printString("b", "bb");    }}public class Run {    public static void main(String args\[\]) throws InterruptedException {        SynchronizedObject synchronizedObject = new SynchronizedObject();        Thread thread = new MyThread(synchronizedObject);        thread.start();        Thread.sleep(500);        thread.stop();        System.out.println(synchronizedObject.getName() + "  " + synchronizedObject.getPassword());    }}

输出结果:

1
css复制代码b  aa

由于stop()方法以及在JDK中被标明为“过期/作废”的方法,显然它在功能上具有缺陷,所以不建议在程序张使用stop()方法。

  1. 使用return停止线程

将方法interrupt()与return结合使用也能实现停止线程的效果:

1
arduino复制代码public class MyThread extends Thread {    public void run(){        while (true){            if(this.isInterrupted()){                System.out.println("线程被停止了!");                return;            }            System.out.println("Time: " + System.currentTimeMillis());        }    }}public class Run {    public static void main(String args\[\]) throws InterruptedException {        Thread thread = new MyThread();        thread.start();        Thread.sleep(2000);        thread.interrupt();    }}

输出结果:

1
sql复制代码...Time: 1467072288503Time: 1467072288503Time: 1467072288503线程被停止了!

不过还是建议使用“抛异常”的方法来实现线程的停止,因为在catch块中还可以将异常向上抛,使线程停止事件得以传播。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

快来!开源一份阿里微服务指导手册:springBoot+sp

发表于 2020-12-15

今天开源的这份阿里巴巴内部的微服务的指导手册,涵盖了当下使用和面试过程中的流程技术,包含了:SpringBoot, Thymeleaf、 Jetty、 Redis 整合、C3P0 整合、Druid 整合、MyBatis 整合、ActiveMQ 整合、RabbitMQ 整合、Kafka 整合、Shiro 整合、SpringDataJPA整合、Mail整合、Actuator监控、Restful、RestTemplate、Eureka、Ribbon、 Feign、 Hystrix、Turbine、Zuul、SpringCloudConfig、 SpringCloudBus、SpringCloudStream、 SpringCloudSleuth、 Zipkin、OAuth、RabbitMQ和Docker等。

这份手册内容如此之多,那么应该如何进行学习呢,今天小编就会分门别类的进行了整理,分为了三大部分分别是:SpringBoot篇、SpringCloud篇,微服务辅助篇。

小编已经为大家整理成PDF版本,需要这份资料的,点击这里即可查看获取方式

SpringBoot篇

第1章SpringBoot编程起步

快来!开源一份阿里微服务指导手册:Boot+Cloud+MQ

第2章SpringBoot程序开发

快来!开源一份阿里微服务指导手册:Boot+Cloud+MQ

第3章Thymeleaf模板渲染

快来!开源一份阿里微服务指导手册:Boot+Cloud+MQ

快来!开源一份阿里微服务指导手册:Boot+Cloud+MQ

第4章SpringBoot与Web应用

快来!开源一份阿里微服务指导手册:Boot+Cloud+MQ

第5章SpringBoot服务整合

快来!开源一份阿里微服务指导手册:Boot+Cloud+MQ

快来!开源一份阿里微服务指导手册:Boot+Cloud+MQ

SpringCloud篇

第6章SpringCloud简介

快来!开源一份阿里微服务指导手册:Boot+Cloud+MQ

第7章SpringCloud与Restful

快来!开源一份阿里微服务指导手册:Boot+Cloud+MQ

第8章Eureka注册服务

快来!开源一份阿里微服务指导手册:Boot+Cloud+MQ

快来!开源一份阿里微服务指导手册:Boot+Cloud+MQ

第9章SpringCloud服务组件

快来!开源一份阿里微服务指导手册:Boot+Cloud+MQ

第10章SpringCloudConfig

快来!开源一份阿里微服务指导手册:Boot+Cloud+MQ

第11章SpringCloudStream

快来!开源一份阿里微服务指导手册:Boot+Cloud+MQ

快来!开源一份阿里微服务指导手册:Boot+Cloud+MQ

第12章SpringCloudSleuth

快来!开源一份阿里微服务指导手册:Boot+Cloud+MQ

第13章OAuth认证管理

快来!开源一份阿里微服务指导手册:Boot+Cloud+MQ

微服务辅助篇

第14章RabbitMQ消息组件

快来!开源一份阿里微服务指导手册:Boot+Cloud+MQ

第15章Docker虚拟化容器

快来!开源一份阿里微服务指导手册:Boot+Cloud+MQ

快来!开源一份阿里微服务指导手册:Boot+Cloud+MQ

总结

这份手册围绕着当前的主流方案(高性能+高可用+分布式)进行展开,不仅讲解了所有微架构中的内容,还给出了真实有效的学习案例;不仅可以与虚拟化Docker整合开发,还可以实现大型企业分布式授权OAuth解决方案。可以说,这份手册就像Java微服务实现架构的一个技术宝典,您学习后完全可以直接在实际项目之中进行应用。

这份Alibaba Java微服务手手册的免费领取方式:转发这篇文章,需要这份资料的,点击这里即可查看获取方式

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

领域驱动设计(DDD)实践之路(四):领域驱动在微服务设计中

发表于 2020-12-15

这是“领域驱动设计实践之路”系列的第四篇文章,从单体架构的弊端引入微服务,结合领域驱动的概念介绍了如何做微服务划分、设计领域模型并展示了整体的微服务化的系统架构设计。结合分层架构、六边形架构和整洁架构的思想,以实际使用场景为背景,展示了一个微服务的程序结构设计。

一、单体架构的弊端

单体结构示例(引用自互联网)

一般在业务发展的初期,整个应用涉及的功能需求较少,相对比较简单,单体架构的应用比较容易部署、测试,横向扩展也比较易实现。

然而,随着需求的不断增加, 越来越多的人加入开发团队,代码库也在飞速地膨胀。慢慢地,单体应用变得越来越臃肿,可维护性、灵活性逐渐降低,维护成本越来越高。

下面分析下单体架构应用存在的一些弊端:

1、复杂性高

在项目初期应该有人可以做到对应用各个功能和实现了如指掌,随着业务需求的增多,各种业务流程错综复杂的揉在一起,整个系统变得庞大且复杂,以至于很少有开发者清楚每一个功能和业务流程细节。

这样会使得新业务的需求评估或者异常问题定位会占用较多的时间,同时也蕴含着未知风险。更糟糕的是,这种极度的复杂性会形成一种恶性循环,每一次更改都会使得系统变得更复杂,更难懂。

2.技术债务多

随着时间推移、需求变更和人员更迭,会逐渐形成应用程序的技术债务,并且越积越多。比如,团队必须长期使用一套相同的技术栈,很难采用新的框架和编程语言。有时候想引入一些新的工具时,就会使得项目中需要同时维护多套技术框架,比如同时维护Hibernate和Mybatis,使得成本变高。

3.错误难隔离

由于业务项目的所有功能模块都在一个应用上承担,包括核心和非核心模块,任何一个模块或者一个小细节的地方,因为设计不合理、代码质量差等原因,都有可能造成应用实例的崩溃,从而使得业务全面受到影响。其根本原因就是核心和非核心功能的代码都运行在同一个环境中。

  1. 项目团队间协同成本高,业务响应越来越慢

多个类似的业务项目之间势必会存在类似的功能模块,如果都采用单体模式,就会带来重复功能建设和维护。而且,有时候还需要互相产生交互,打通单体系统之间的交互集成和协作的成本也需要额外付出。

再者,当项目大到一定程度,不同的模块可能是不同的团队来维护,迭代联调的冲突,代码合并分支的冲突都会影响整个开发进度,从而使得业务响应速度越来越慢。

5.扩展成本高

随着业务的发展,系统在出现业务处理瓶颈的时候,往往是由于某一个或几个功能模块负载较高造成的,但因为所有功能都打包在一起,在出现此类问题时,只能通过增加应用实例的方式分担负载,没办法对单独的几个功能模块进行服务能力的扩展,从而带来资源额外配置的消耗,成本较高。

针对以上痛点,近年来越来越多的互联网公司采用“微服务”架构构建自身的业务平台,而“微服务”也获得了越来越多技术人员的肯定。

微服务其实是SOA的一种演变后的形态,与SOA的方法和原则没有本质区别。SOA理念的核心价值是,松耦合的服务带来业务的复用,按照业务而不是技术的维度,结合高内聚、低耦合的原则来划分微服务,这正好与领域驱动设计所倡导的理念相契合。

二、微服务设计

  1. 微服务划分

从广义上讲,领域即是一个组织所做的事情以及其中包含的一切。每个组织都有它自己的业务范围和做事方式,这个业务范围以及在其中所进行的活动便是领域。

DDD的子域和限界上下文的概念,可以很好地跟微服务架构中的服务进行匹配。而且,微服务架构中的自治化团队负责服务开发的概念,也与DDD中每个领域模型都由一个独立团队负责开发的概念吻合。DDD倡导按业务领域来划分系统,微服务架构更强调从业务维度去做分治来应对系统复杂度,跳过业务架构设计出来的架构关注点不在业务响应上,可能就是个大泥球,在面临需求迭代或响应市场变化时就很痛苦。

DDD的核心诉求就是将业务架构映射到系统架构上,在响应业务变化调整业务架构时,也随之变化系统架构。而微服务追求业务层面的复用,设计出来的系统架构和业务一致;在技术架构上则系统模块之间充分解耦,可以自由地选择合适的技术架构,去中心化地治理技术和数据。

以电商的资源订购系统为例,典型业务用例场景包括查看资源,购买资源,查询用户已购资源等。

领域驱动为每一个子域定义单独的领域模型,子域是领域的一部分,从业务的角度分析我们需要覆盖的业务用例场景,以高内聚低耦合的思想,结合单一职责原则(SRP)和闭包原则(CCP),从业务领域的角度,划分出用户管理子域,资源管理子域,订单子域和支付子域共四个子域。

每个子域对应一个限界上下文。限界上下文是一种概念上的边界,领域模型便工作于其中,每个限界上下文都有自己的通用语言。限界上下文使得你在领域模型周围加上了一个显式的、清晰的边界。当然,限界上下文不仅仅包含领域模型。当使用微服务架构时,每个限界上下文对应一个微服务。

  1. 领域模型

聚合是一个边界内领域对象的集群,可以将其视为一个单元,它由根实体和可能的一个或多个其他实体和值对象组成。聚合将领域模型分解为块,每个聚合都可以作为一个单元进行处理。

聚合根是聚合中唯一可以由外部类引用的部分,客户端只能通过调用聚合根上的方法来更新聚合。

聚合代表了一致的边界,对于一个设计良好的聚合来说,无论由于何种业务需求而发生改变,在单个事务中,聚合中的所有不变条件都是一致的。聚合的一个很重要的经验设计原则是,一个事务中只修改一个聚合实例。更新聚合时需要更新整个聚合而不是聚合中的一部分,否则容易产生一致性问题。

比如A和B同时在网上购买东西,使用同一张订单,同时意识到自己购买的东西超过预算,此时A减少点心数量,B减少面包数量,两个消费者并发执行事务,那么订单总额可能会低于最低订单限额要求,但对于一个消费者来说是满足最低限额要求的。所以应该站在聚合根的角度执行更新操作,这会强制执行一致性业务规则。

另外,我们不应该设计过大的聚合,处理大聚合构成的”巨无霸”对象时,容易出现不同用例同时需要修改其中的某个部分,因为聚合设计时考虑的一致性约束是对整个聚合产生作用的,所以对聚合的修改会造成对聚合整体的变更,如果采用乐观并发,这样就容易产生某些用例会被拒绝的场景,而且还会影响系统的性能和可伸缩性。

使用大聚合时,往往为了完成一项基本操作,需要将成百上千个对象一同加载到内存中,造成资源的浪费。所以应尽量采用小聚合,一方面使用根实体来表示聚合,其中只包含最小数量的属性或值类型属性,这里的最小数量表示所需的最小属性集合,不多也不少。必须与其他属性保持一致的属性是所需的属性。

在聚合中,如果你认为有些被包含部分应该建模成一个实体,此时,思考下这个部分是否会随着时间而改变,或者该部分是否能被全部替换。如果可以全部替换,那么可以建模成值对象,而非实体。因为值对象本身是不可变的,只能进行全部替换,使用起来更安全,所以,一般情况下优先使用值对象。很多情况下,许多建模成实体的概念都可以重构成值对象。小聚合还有助于事务的成功执行,即它可以减少事务提交冲突,这样不仅可以提升系统的性能和可伸缩性,另外系统的可用性也得到了增强。

另外聚合直接的引用通过唯一标识实现,而不是通过对象引用,这样不仅减少聚合的使用空间,更重要的是可以实现聚合直接的松耦合。如果聚合是另一个服务的一部分,则不会出现跨服务的对象引用问题,当然在聚合内部对象之间是可以相互引用的。

上述关于聚合的主要使用原则总结起来可以归纳为以下几点:

  1. 只引用聚合根。
  2. 通过唯一标识引用其他聚合。
  3. 一个事务中只能创建或修改一个聚合。
  4. 聚合边界之外使用最终一致性。

当然在实际使用的过程中,比如某一个业务用例需要获取到聚合中的某个领域对象,但该领域对象的获取路径较繁琐,为了兼容该特殊场景,可以将聚合中的属性(实体或值对象)直接返回给应用层,使得应用层直接操作该领域对象。

我们经常会遇到在一个聚合上执行命令方法时,还需要在其他聚合上执行额外的业务规则,尽量使用最终一致性,因为最终一致性可以按聚合维度分步骤处理各个环节,从而提升系统的吞吐量。对于一个业务用例,如果应该由执行该用例的用户来保证数据的一致性,那么可以考虑使用事务一致性,当然此时依然需要遵循其他聚合原则。如果需要其他用户或者系统来保证数据一致性,那么使用最终一致性。实际上,最终一致性可以支持绝大部分的业务场景。

基于上面对电商的资源订购系统业务子域的划分,设计出资源聚合,订单聚合,支付聚合和用户聚合,资源聚合与订单聚合之间通过资源ID进行关联,订单聚合与支付聚合之间通过订单ID和用户ID进行关联,支付聚合和用户聚合之间通过用户ID进行关联。资源聚合根中包含多个资源包值对象,一个资源包值对象又包含多个预览图值对象。当然在实际开发的过程中,根据实际情况聚合根中也可以包含实体对象。每个聚合对应一个微服务,对于特别复杂的系统,一个子域可能包含多个聚合,也就包含多个微服务。

  1. 微服务系统架构设计

基于上面对电商的资源订购系统子域的分析,服务器后台使用用户服务,资源服务,订单服务和支付服务四个微服务实现。上图中的API Gateway也是一种服务,同时可以看成是DDD中的应用层,类似面向对象设计中的外观(Facade)模式。

作为整个后端架构的统一门面,封装了应用程序内部架构,负责业务用例的任务协调,每个用例对应了一个服务方法,调用多个微服务并将聚合结果返回给客户端。它还可能有其他职责,比如身份验证,访问授权,缓存,速率限制等。以查询已购资源为例,API Gateway需要查询订单服务获取当前用户已购的资源ID列表,然后根据资源ID列表查询资源服务获取已购资源的详细信息,最终将聚合结果返回给客户端。

当然在实际应用的过程中,我们也可以根据API请求的复杂度,从业务角度,将API Gateway划分为多个不同的服务,防止又回归到API Gateway的单体瓶颈。

另外,有时候从业务领域角度划分出来的某些子域比较小,从资源利用率的角度,单独放到一个微服务中有点单薄。这个时候我们可以打破一个限界上下文对应一个微服务的理念,将多个子域合并到同一个微服务中,由微服务自己的应用层实现多子域任务的协调。

所以,在我们的系统架构中可能会出现微服务级别的小应用层和API Gateway级别的大应用层使用场景,理论固然是理论,还是需要结合实际情况灵活应用。

三、领域驱动概念在单个微服务设计中的应用

  1. 架构选择分析

分层架构图(引用自互联网)

六边形架构图(引用自互联网)

整洁架构图(引用自互联网)

上面整洁架构图中的同心圆分别代表了软件系统中的不同层次,通常越靠近中心,其所在的软件层次就越高。

整洁架构的依赖关系规则告诉我们,源码中的依赖关系必须只指向同心圆的内层,即由低层机制指向高层策略。换句话说,任何属于内层圆中的代码都不应该牵涉外层圆中的代码,尤其是内层圆中的代码不应该引用外层圆中代码所声明的名字,包括函数、类、变量以及一切其他有命名的软件实体。同样,外层圆使用的数据格式也不应该被内层圆中的代码所使用,尤其是当数据格式由外层圆的框架所生成时。

总之,不应该让外层圆中发生的任何变更影响到内层圆的代码。业务实体这一层封装的是整个业务领域中最通用、最高层的业务逻辑,它们应该属于系统中最不容易受外界影响而变动的部分,也就是说一般情况下我们的核心领域模型部分是比较稳定的,不应该因为外层的基础设施比如数据存储技术选型的变化,或者UI展示方式等的变化受影响,从而需要做相应的改动。

在以往的项目经验中,大多数同学习惯也比较熟悉分层架构,一般包括展示层、应用层,领域层和基础设施层。六边形架构的一个重要好处是它将业务逻辑与适配器中包含的表示层和数据访问层的逻辑分离开来,业务逻辑不依赖于表示层逻辑或数据访问层逻辑,由于这种分离,单独测试业务逻辑要容易得多。

另一个好处是,可以通过多个适配器调用业务逻辑,每个适配器实现特定的API或用户界面。业务逻辑还可以调用多个适配器,每个适配器调用不同的外部系统。所以六边形架构是描述微服务架构中每个服务的架构的好方法。

根据我们具体的实践经验,比如在我们平时的项目中最常见的就是MySQL和Redis存储,而且也很少改变为其他存储结构。这里将分层架构和六边形架构进行思想融合,目的是一方面希望我们的微服务设计结构更优美,另一方面希望在已有编程习惯的基础上,更容易接受新的整洁架构思想。

我们项目中微服务的实现结合分层架构,六边形架构和整洁架构的思想,以实际使用场景为背景,采用的应用程序结构图如下。

从上图可以看到,我们一个应用总共包含应用层application,领域层domain和基础设施层infrastructure。领域服务的facade接口需要暴露给其他三方系统,所以单独封装为一个模块。因为我们一般习惯于分层架构模式构建系统,所以按照分层架构给各层命名。

站在六边形架构的角度,应用层application等同于入站适配器,基础设施层infrastructure等同于出站适配器,所以实际上应用层和基础设施层同属外层,可以认为在同一层。

facade模块其实是从领域层domain剥离出来的,站在整洁架构的角度,领域层就是内核业务实体,这里封装的是整个业务领域中最通用、最高层的业务逻辑,一般情况下核心领域模型部分是比较稳定的,不受外界影响而变动。facade是微服务暴露给外界的领域服务能力,一般情况下接口的设定应符合当前领域服务的边界界定,所以facade模块属于内核领域层。

facade接口的实现在应用层application的impl部分,符合整洁架构外层依赖内层的思想,对于impl输入端口和入站适配器,可以采用不同的协议和技术框架实现,比如dubbo或HSF等。下面对各个模块的构成进行逐一解释。

  1. 领域层Domain

工厂Factory

对象的创建本身是一个主要操作,但被创建的对象并不适合承担复杂的装配操作。将这些职责混在一起可能会产生难以理解的拙劣设计。让客户直接负责创建对象又会使客户的设计陷入混乱,并且破坏装配对象的封装,而且导致客户与被创建对象的实现之间产生过于紧密的耦合。

复杂对象的创建是领域层的职责,但这项任务并不属于那些用于表示模型的对象。所以一般使用一个单独的工厂类或者在领域服务中提供一个构造领域对象的接口来负责领域对象的创建。

这里,我们选择给领域服务增加一个领域对象创建接口来承担工厂的角色。

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
java复制代码/**
* description: 资源领域服务
*
* @author Gao Ju
* @date 2020/7/27
*/
public class ResourceServiceImpl implements ResourceService {

/**
* 创建资源聚合模型
*
* @param resourceCreateCommand 创建资源命令
* @return
*/
@Override
public ResourceModel createResourceModel(ResourceCreateCommand resourceCreateCommand) {
ResourceModel resourceModel = new ResourceModel();
Long resId = SequenceUtil.generateUuid();
resourceModel.setResId(resId);
resourceModel.setName(resourceCreateCommand .getName());
resourceModel.setAuthor(resourceCreateCommand .getAuthor());
List<PackageItem> packageItemList = new ArrayList<>();
...
resourceModel.setPackageItemList(packageItemList);
return resourceModel;
}
}

)

资源库Repository

通常将聚合实例存放在资源库中,之后再通过该资源库来获取相同的实例。

如果修改了某个聚合,那么这种改变将被资源库持久化,如果从资源库中移除了某个实例,则将无法从资源库中重新获取该实例。

资源库是针对聚合维度创建的,聚合类型与资源库存在一对一的关系。

简单来说,资源库是对聚合的CRUD操作的封装。资源库内部采用哪种存储设施MySQL,MongoDB或者Redis等,对领域层来说其实是不感知的。

资源repository构成图

在我们的项目中采用MySQL作为资源repository的持久化存储,上图中每个DO对应一个数据库表,当然你也可以采用其他存储结构或设计为其他表结构,具体的处理流程均由repository进行封装,对领域服务来说只感知Resource聚合维度的CRUD操作,示例代码如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
scss复制代码/**
* description: 资源仓储
*
* @author Gao Ju
* @date 2020/08/23
*/
@Repository("resourceRepository")
public class ResourceRepositoryImpl implements ResourceRepository {

/**
* 资源Mapper
*/
@Resource
private ResourceMapper resourceMapper;

/**
* 资源包Mapper
*/
@Resource
private PackageMapper packageMapper;

/**
* 资源包预览图Mapper
*/
@Resource
private PackagePreviewMapper packagePreviewMapper;

/**
* 创建订单信息
*
* @param resourceModel 资源聚合模型
* @return
*/
@Override
public void add(ResourceModel resourceModel) {
ResourceDO resourceDO = new ResourceDO();
resourceDO.setName(resourceModel.getName());
resourceDO.setAuthor(resourceModel.getAuthor());
List<PackageDO> packageDOList = new ArrayList<>();
List<PackagePreviewDO> packagePreviewDOList = new ArrayList<>();
for (PackageItem packageItem : resourceModel.getPackageItemList()) {
PackageDO packageDO = new PackageDO();
packageDO.setResId(resourceModel.getResId());
Long packageId = SequenceUtil.generateUuid();
packageDO.setPackageId(packageId);
for (PreviewItem previewItem: packageItem.getPreviewItemList()) {
PackagePreviewDO packagePreviewDO = new PackagePreviewDO();
...
packagePreviewDOList.add(packagePreviewDO);
}
packageDOList.add(packageDO);
}

resourceMapper.insert(resourceDO);
packageMapper.insertBatch(packageDOList);
packagePreviewMapper.insertBatch(packagePreviewDOList);
}
}

)

你可能有疑问,按照整洁架构的思想,repository的接口定义在领域层,repository的实现应该定义在基础设施层,这样就符合外层依赖稳定度较高的内层了。

结合我们实际开发过程,一般存储结构选定或者表结构设定后,一般不太容易做很大的调整,所以就按照习惯的分层结构使用,领域层直接依赖基础设施层实现,降低编码时带来的额外习惯上的成本。

领域服务Service

领域驱动强调我们应该创建充血领域模型,将数据和行为封装在一起,将领域模型与现实世界中的业务对象相映射。各类具备明确的职责划分,将领域逻辑分散到各个领域对象中。

领域中的服务表示一个无状态的操作,它用于实现特定于某个领域的任务。当某个操作不适合放在领域对象上时,最好的方式是使用领域服务。

简单总结领域服务本身所承载的职责,就是通过串联领域对象、资源库,生成并发布领域事件,执行事务控制等一系列领域内的对象的行为,为上层应用层提供交互的接口。

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
scss复制代码/**
* description: 订单领域服务
*
* @author Gao Ju
* @date 2020/8/24
*/
public class UserOrderServiceImpl implements UserOrderService {

/**
* 订单仓储
*/
@Autowired
private OrderRepository orderRepository;

/**
* 消息发布器
*/
@Autowired
private MessagePublisher messagePublisher;

/**
* 订单逻辑处理
*
* @param userOrder 用户订单
*/
@Override
public void createOrder(UserOrder userOrder) {
orderRepository.add(userOrder);
OrderCreatedEvent orderCreatedEvent = new OrderCreatedEvent();
orderCreatedEvent.setUserId(userOrder.getUserId());
orderCreatedEvent.setOrderId(userOrder.getOrderId());
orderCreatedEvent.setPayPrice(userOrder.getPayPrice());
messagePublisher.send(orderCreatedEvent);
}
}

)

在实践的过程中,为了简单方便,我们仍然采用贫血领域模型,将领域对象自身行为和不属于领域对象的行为都放在领域服务中实现。

大部分场景领域服务返回聚合根或者简单类型,某些特殊场景也可以将聚合根中包含的实体或值对象返回给调用方。领域服务也可以同时操作多个领域对象,多个聚合,将其转换为另外的输出。

介于我们实际的使用场景,领域比较简单,领域服务只操作一个领域的对象,只操作一个聚合,由应用服务来协调多个领域对象。

  1. 领域事件DomainEvent

在领域驱动设计的上下文中,聚合在被创建时,或发生其他重大更改时发布领域事件,领域事件是聚合状态更改时所触发的。

领域事件命名时,一般选择动词的过去分词,因为状态改变时就代表当前事件已经发生,领域事件的每个属性都是原始类型值或值对象,比如事件ID和创建时间等,事件ID也可以用来做幂等用。

从概念上讲,领域事件由聚合负责发布,聚合知道其状态何时发生变化,从而知道要发布的事件。

由于聚合不能使用依赖注入,需要通过方法参数的形式将消息发布器传递给聚合,但这将基础设施和业务逻辑交织在一起,有悖于我们解耦设计的原则。

更好的方法是将事件发布放到领域服务中,因为服务可以使用依赖注入来获取对消息发布器的引用,从而轻松发布事件。只要状态发生变化,聚合就会生成事件,聚合方法的返回值中包括一个事件列表,并将它们返回给领域服务。

Saga是一种在微服务架构中维护数据一致性的机制,Sage由一连串的本地事务组成,每一个本地事务负责更新它所在服务的私有数据库,通过异步消息的方式来协调一系列本地事务,从而维护多个服务之间数据的最终一致性。Saga包括协同式和编排式,

我们采用协同式来实现分布式事务,发布的领域事件以命令式消息的方式发送给Saga参与方。如果领域事件是自我发布自我消费,不依赖消息中间件实现,则可以使用事件总线模式来进行管理。下面以购买资源的过程为例进行说明。

购买资源的过程

  • 提交创建订单请求,OrderService创建一个处于PAYING状态的UserOrder,并发布OrderCreated事件。
  • UserService消费OrderCreated事件,验证用户是否可以下单,并发布UserVerified事件。
  • PaymentService消费UserVerified事件,进行实际的支付操作,并发布PaySuccess事件。
  • OrderService接收PaySuccess事件,将UserOrder状态改为PAY_SUCCESS。

补偿过程

  • PaymentService消费UserVerified事件,进行实际的支付操作,若支付失败,并发布PayFailed事件。
  • OrderService接收PayFailed事件,将UserOrder状态改为PAY_FAILED。

在Saga的概念中,

第1步叫可补偿性事务,因为后面的步骤可能会失败。

第3步叫关键性事务,因为它后面跟着不可能失败的步骤。第4步叫可重复性事务,因为其总是会成功。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
scala复制代码/**
* description: 领域事件基类
*
* @author Gao Ju
* @date 2020/7/27
*/
public class BaseEvent {
/**
* 消息唯一ID
*/
private String messageId;

/**
* 事件类型
*/
private Integer eventType;

/**
* 事件创建时间
*/
private Date createTime;

/**
* 事件修改时间
*/
private Date modifiedTime;
}


/**
* description: 订单创建事件
*
* @author Gao Ju
* @date 2020/8/24
*/
public class OrderCreatedEvent extends BaseEvent {

/**
* 用户ID
*/
private String userId;

/**
* 订单ID
*/
private String orderId;

/**
* 支付价格
*/
private Integer payPrice;
}

)

4.Facade模块

facade和domain属于同一层,某些提供给三方使用的类定义在facade,比如资源类型枚举CategoryEnum限制三方资源使用范围,然后domain依赖facade中enum定义。

另外,根据迪米特法则和告诉而非询问原则,客户端应该尽量少地知道服务对象内部结构,通过调用服务对象的公共接口的方式来告诉服务对象所要执行的操作。

所以,我们不应该把领域模型泄露到微服务之外,对外提供facade服务时,根据领域对象包装出一个数据传输对象DTO(Data Transfer Object),来实现和外部三方系统的交互,比如上图中的ResourceDTO。

5.应用层Application

应用层是业务逻辑的入口,由入站适配器调用。facade的实现,定时任务的执行和消息监听处理器都属于入站适配器,所以他们都位于应用层。

正常情况下一个微服务对应一个聚合,实践过程中,某些场景下一个微服务可以包含多个聚合,应用层负责用例流的任务协调。领域服务依赖注入应用层,通过领域服务执行领域业务规则,应用层还会处理授权认证,缓存,DTO与领域对象之间的防腐层转换等非领域操作。

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
scss复制代码/**
* description: 订单facade
*
* @author Gao Ju
* @date 2020/8/24
*/
public class UserOrderFacadeImpl implements UserOrderFacade {

/**
* 订单服务
*/
@Resource
private UserOrderService userOrderService;

/**
* 创建订单信息
*
* @param orderPurchaseParam 订单交易参数
* @return
*/
@Override
public FacadeResponse<UserOrderPurchase> createOrder(OrderPurchaseParam orderPurchaseParam ) {
UserOrder userOrder = new UserOrder();
userOrder.setUserId(request.getUserId());
userOrder.setResId(request.getResId());
userOrder.setPayPrice(request.getPayAmount());
userOrder.setOrderStatus(OrderStatusEnum.Create.getCode());
userOrderService.handleOrder(userOrder);
userOrderPurchase.setOrderId(userOrderDO.getId());
userOrderPurchase.setCreateTime(new Date());
return FacadeResponseFactory.getSuccessInstance(userOrderPurchase);
}
}

)

6.基础设施层 Infrastructure

基础设施的职责是为应用程序的其他部分提供技术支持。与数据库的交互dao模块,与Redis缓存,本地缓存交互的cache模块,与参数中心,三方rpc服务的交互,消息框架消息发布者都封装在基础设施层。

另外,程序中用到的工具类util模块和异常类exception也统一封装在基础设施层。

从分层架构的角度,领域层可以依赖基础设施层实现与其他外设的交互。另外,无论从分层架构的上层application层还是从六边形架构的角度的输入端口和适配器application,都可以依赖作为底层或处于同层的输出端口和适配器的infrastructure层,比如调用util或者exception模块。

四、结束语

其实,无论是面向服务架构SOA,微服务,领域驱动,还是中台,其目的都是在说,我们做架构设计的时候,应该从业务视角出发,对所涉及的业务领域,基于高内聚、低耦合的思想进行划分,最大限度且合理的实现业务重用。

这样不仅方便提供专业且稳定的业务服务,更有利于业务的沉淀和可持续发展。业务之下是基于技术的系统实现,技术造就业务,业务引领技术,两者相辅相成,共同为社会进步做出贡献。

五、参考文献

  • [1] 《领域驱动设计软件核心复杂性应对之道》Eric Evans著, 赵俐 盛海燕 刘霞等译,人民邮电出版社
  • [2] 《实现领域驱动设计》Vaughn Vernon著, 滕云译, 张逸审,电子工业出版社
  • [3] 《微服务架构设计模式》[美]克里斯.理查森(Chris Richardson) 著, 喻勇译,机械工业出版社
  • [4] 《架构整洁之道》[美]Robert C.Martin 著,孙宇聪 译,电子工业出版社
  • [5] 《企业IT架构转型之道阿里巴巴中台战略思想与架构实践》钟华编著,机械工业出版社
  • [6] 领域驱动设计(DDD)实践之路(二):事件驱动与CQRS,vivo互联网技术
  • [7] 领域驱动设计在互联网业务开发中的实践,美团技术团队

作者:Angel Gao

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

求求你,别再用wait和notify了! 1notify

发表于 2020-12-15

Condition 是 JDK 1.5 中提供的用来替代 wait 和 notify 的线程通讯方法,那么一定会有人问:为什么不能用 wait 和 notify 了? 哥们我用的好好的。老弟别着急,听我给你细说…

之所以推荐使用 Condition 而非 Object 中的 wait 和 notify 的原因有两个:

  1. 使用 notify 在极端环境下会造成线程“假死”;
  2. Condition 性能更高。

接下来怎们就用代码和流程图的方式来演示上述的两种情况。

1.notify 线程“假死”

所谓的线程“假死”是指,在使用 notify 唤醒多个等待的线程时,却意外的唤醒了一个没有“准备好”的线程,从而导致整个程序进入了阻塞的状态不能继续执行。

以多线程编程中的经典案例生产者和消费者模型为例,我们先来演示一下线程“假死”的问题。

1.1 正常版本

在演示线程“假死”的问题之前,我们先使用 wait 和 notify 来实现一个简单的生产者和消费者模型,为了让代码更直观,我这里写一个超级简单的实现版本。我们先来创建一个工厂类,工厂类里面包含两个方法,一个是循环生产数据的(存入)方法,另一个是循环消费数据的(取出)方法,实现代码如下。

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
java复制代码/**
* 工厂类,消费者和生产者通过调用工厂类实现生产/消费
*/
class Factory {
private int[] items = new int[1]; // 数据存储容器(为了演示方便,设置容量最多存储 1 个元素)
private int size = 0; // 实际存储大小

/**
* 生产方法
*/
public synchronized void put() throws InterruptedException {
// 循环生产数据
do {
while (size == items.length) { // 注意不能是 if 判断
// 存储的容量已经满了,阻塞等待消费者消费之后唤醒
System.out.println(Thread.currentThread().getName() + " 进入阻塞");
this.wait();
System.out.println(Thread.currentThread().getName() + " 被唤醒");
}
System.out.println(Thread.currentThread().getName() + " 开始工作");
items[0] = 1; // 为了方便演示,设置固定值
size++;
System.out.println(Thread.currentThread().getName() + " 完成工作");
// 当生产队列有数据之后通知唤醒消费者
this.notify();

} while (true);
}

/**
* 消费方法
*/
public synchronized void take() throws InterruptedException {
// 循环消费数据
do {
while (size == 0) {
// 生产者没有数据,阻塞等待
System.out.println(Thread.currentThread().getName() + " 进入阻塞(消费者)");
this.wait();
System.out.println(Thread.currentThread().getName() + " 被唤醒(消费者)");
}
System.out.println("消费者工作~");
size--;
// 唤醒生产者可以添加生产了
this.notify();
} while (true);
}
}

接下来我们来创建两个线程,一个是生产者调用 put 方法,另一个是消费者调用 take 方法,实现代码如下:

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
java复制代码public class NotifyDemo {
public static void main(String[] args) {
// 创建工厂类
Factory factory = new Factory();

// 生产者
Thread producer = new Thread(() -> {
try {
factory.put();
} catch (InterruptedException e) {
e.printStackTrace();
}
}, "生产者");
producer.start();

// 消费者
Thread consumer = new Thread(() -> {
try {
factory.take();
} catch (InterruptedException e) {
e.printStackTrace();
}
}, "消费者");
consumer.start();
}
}

执行结果如下:
image.png
从上述结果可以看出,生产者和消费者在循环交替的执行任务,场面非常和谐,是我们想要的正确结果。

1.2 线程“假死”版本

当只有一个生产者和一个消费者时,wait 和 notify 方法不会有任何问题,然而**将生产者增加到两个时就会出现线程“假死”的问题了,**程序的实现代码如下:

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
java复制代码public class NotifyDemo {
public static void main(String[] args) {
// 创建工厂方法(工厂类的代码不变,这里不再复述)
Factory factory = new Factory();

// 生产者
Thread producer = new Thread(() -> {
try {
factory.put();
} catch (InterruptedException e) {
e.printStackTrace();
}
}, "生产者");
producer.start();

// 生产者 2
Thread producer2 = new Thread(() -> {
try {
factory.put();
} catch (InterruptedException e) {
e.printStackTrace();
}
}, "生产者2");
producer2.start();

// 消费者
Thread consumer = new Thread(() -> {
try {
factory.take();
} catch (InterruptedException e) {
e.printStackTrace();
}
}, "消费者");
consumer.start();
}
}

程序执行结果如下:
image.png
从以上结果可以看出,当我们将生产者的数量增加到 2 个时,就会造成线程“假死”阻塞执行的问题,当生产者 2 被唤醒又被阻塞之后,整个程序就不能继续执行了。

线程“假死”问题分析

我们先把以上程序的执行步骤标注一下,得到如下结果:
image.png
从上图可以看出:当执行到第 ④ 步时,此时生产者为工作状态,而生产者 2 和消费者为等待状态,此时正确的做法应该是唤醒消费着进行消费,然后消费者消费完之后再唤醒生产者继续工作;但此时生产者却错误的唤醒了生产者 2,而生产者 2 因为队列已经满了,所以自身并不具备继续执行的能力,因此就导致了整个程序的阻塞,流程图如下所示:

image.png
正确执行流程应该是这样的:
image.png

1.3 使用 Condition

为了解决线程的“假死”问题,我们可以使用 Condition 来尝试实现一下,Condition 是 JUC(java.util.concurrent)包下的类,需要使用 Lock 锁来创建,Condition 提供了 3 个重要的方法:

  • await:对应 wait 方法;
  • signal:对应 notify 方法;
  • signalAll: notifyAll 方法。

Condition 的使用和 wait/notify 类似,也是先获得锁然后在锁中进行等待和唤醒操作,Condition 的基础用法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
java复制代码// 创建 Condition 对象
Lock lock = new ReentrantLock();
Condition condition = lock.newCondition();
// 加锁
lock.lock();
try {
// 业务方法....

// 1.进入等待状态
condition.await();

// 2.唤醒操作
condition.signal();
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
}

小知识:Lock的正确使用姿势

切记 Lock 的 lock.lock() 方法不能放入 try 代码中,如果 lock 方法在 try 代码块之内,可能由于其它方法抛出异常,导致在 finally 代码块中, unlock 对未加锁的对象解锁,它会调用 AQS 的 tryRelease 方法(取决于具体实现类),抛出 IllegalMonitorStateException 异常。

回归主题

回到本文的主题,我们如果使用 Condition 来实现线程的通讯就可以避免程序的“假死”情况,因为 Condition 可以创建多个等待集,以本文的生产者和消费者模型为例,我们可以使用两个等待集,一个用做消费者的等待和唤醒,另一个用来唤醒生产者,这样就不会出现生产者唤醒生产者的情况了(生产者只能唤醒消费者,消费者只能唤醒生产者)这样整个流程就不会“假死”了,它的执行流程如下图所示:
image.png
了解了它的基本流程之后,咱们来看具体的实现代码。

基于 Condition 的工厂实现代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
java复制代码class FactoryByCondition {
private int[] items = new int[1]; // 数据存储容器(为了演示方便,设置容量最多存储 1 个元素)
private int size = 0; // 实际存储大小
// 创建 Condition 对象
private Lock lock = new ReentrantLock();
// 生产者的 Condition 对象
private Condition producerCondition = lock.newCondition();
// 消费者的 Condition 对象
private Condition consumerCondition = lock.newCondition();

/**
* 生产方法
*/
public void put() throws InterruptedException {
// 循环生产数据
do {
lock.lock();
while (size == items.length) { // 注意不能是 if 判断
// 生产者进入等待
System.out.println(Thread.currentThread().getName() + " 进入阻塞");
producerCondition.await();
System.out.println(Thread.currentThread().getName() + " 被唤醒");
}
System.out.println(Thread.currentThread().getName() + " 开始工作");
items[0] = 1; // 为了方便演示,设置固定值
size++;
System.out.println(Thread.currentThread().getName() + " 完成工作");
// 唤醒消费者
consumerCondition.signal();
try {
} finally {
lock.unlock();
}
} while (true);
}

/**
* 消费方法
*/
public void take() throws InterruptedException {
// 循环消费数据
do {
lock.lock();
while (size == 0) {
// 消费者阻塞等待
consumerCondition.await();
}
System.out.println("消费者工作~");
size--;
// 唤醒生产者
producerCondition.signal();
try {
} finally {
lock.unlock();
}
} while (true);
}
}

两个生产者和一个消费者的实现代码如下:

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
java复制代码public class NotifyDemo {
public static void main(String[] args) {
FactoryByCondition factory = new FactoryByCondition();

// 生产者
Thread producer = new Thread(() -> {
try {
factory.put();
} catch (InterruptedException e) {
e.printStackTrace();
}
}, "生产者");
producer.start();

// 生产者 2
Thread producer2 = new Thread(() -> {
try {
factory.put();
} catch (InterruptedException e) {
e.printStackTrace();
}
}, "生产者2");
producer2.start();

// 消费者
Thread consumer = new Thread(() -> {
try {
factory.take();
} catch (InterruptedException e) {
e.printStackTrace();
}
}, "消费者");
consumer.start();
}
}

程序的执行结果如下图所示:
image.png
从上述结果可以看出,当使用 Condition 时,生产者、消费者、生产者 2 会一直交替循环执行,执行结果符合我们的预期。

2.性能问题

在上面我们演示 notify 会造成线程的“假死”问题的时候,一定有朋友会想到,如果把 notify 换成 notifyAll 线程就不会“假死”了。

这样做法确实可以解决线程“假死”的问题,但同时会到来新的性能问题,空说无凭,直接上代码展示。

以下是使用 wait 和 notifyAll 改进后的代码:

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
java复制代码/**
* 工厂类,消费者和生产者通过调用工厂类实现生产/消费功能.
*/
class Factory {
private int[] items = new int[1]; // 数据存储容器(为了演示方便,设置容量最多存储 1 个元素)
private int size = 0; // 实际存储大小

/**
* 生产方法
* @throws InterruptedException
*/
public synchronized void put() throws InterruptedException {
// 循环生产数据
do {
while (size == items.length) { // 注意不能是 if 判断
// 存储的容量已经满了,阻塞等待消费者消费之后唤醒
System.out.println(Thread.currentThread().getName() + " 进入阻塞");
this.wait();
System.out.println(Thread.currentThread().getName() + " 被唤醒");
}
System.out.println(Thread.currentThread().getName() + " 开始工作");
items[0] = 1; // 为了方便演示,设置固定值
size++;
System.out.println(Thread.currentThread().getName() + " 完成工作");
// 唤醒所有线程
this.notifyAll();
} while (true);
}

/**
* 消费方法
* @throws InterruptedException
*/
public synchronized void take() throws InterruptedException {
// 循环消费数据
do {
while (size == 0) {
// 生产者没有数据,阻塞等待
System.out.println(Thread.currentThread().getName() + " 进入阻塞(消费者)");
this.wait();
System.out.println(Thread.currentThread().getName() + " 被唤醒(消费者)");
}
System.out.println("消费者工作~");
size--;
// 唤醒所有线程
this.notifyAll();
} while (true);
}
}

依旧是两个生产者加一个消费者,实现代码如下:

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
java复制代码public static void main(String[] args) {
Factory factory = new Factory();
// 生产者
Thread producer = new Thread(() -> {
try {
factory.put();
} catch (InterruptedException e) {
e.printStackTrace();
}
}, "生产者");
producer.start();

// 生产者 2
Thread producer2 = new Thread(() -> {
try {
factory.put();
} catch (InterruptedException e) {
e.printStackTrace();
}
}, "生产者2");
producer2.start();

// 消费者
Thread consumer = new Thread(() -> {
try {
factory.take();
} catch (InterruptedException e) {
e.printStackTrace();
}
}, "消费者");
consumer.start();
}

执行的结果如下图所示:
image.png
通过以上结果可以看出:当我们调用 notifyAll 时确实不会造成线程“假死”了,但会造成所有的生产者都被唤醒了,但因为待执行的任务只有一个,因此被唤醒的所有生产者中,只有一个会执行正确的工作,而另一个则是啥也不干,然后又进入等待状态,这就行为对于整个程序来说,无疑是多此一举,只会增加线程调度的开销,从而导致整个程序的性能下降。

反观 Condition 的 await 和 signal 方法,即使有多个生产者,程序也只会唤醒一个有效的生产者进行工作,如下图所示:
image.png
生产者和生产者 2 依次会被交替的唤醒进行工作,所以这样执行时并没有任何多余的开销,从而相比于 notifyAll 而言整个程序的性能会提升不少。

总结

本文我们通过代码和流程图的方式演示了 wait 方法和 notify/notifyAll 方法的使用缺陷,它的缺陷主要有两个,一个是在极端环境下使用 notify 会造成程序“假死”的情况,另一个就是使用 notifyAll 会造成性能下降的问题,因此在进行线程通讯时,强烈建议使用 Condition 类来实现。

PS:有人可能会问为什么不用 Condition 的 signalAll 和 notifyAll 进行性能对比?而使用 signal 和 notifyAll 进行对比?我只想说,既然使用 signal 可以实现此功能,为什么还要使用 signalAll 呢?这就好比在有暖气的 25 度的房间里,穿一件短袖就可以了,为什么还要穿一件棉袄呢?

关注公众号「Java中文社群」查看更多干货,查看 Github 发现更多精彩:github.com/vipstone/al…

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

一口气说出四种幂等性解决方案,面试官露出了姨母笑~

发表于 2020-12-15

什么是幂等性?

幂等是一个数学与计算机学概念,在数学中某一元运算为幂等时,其作用在任一元素两次后会和其作用一次的结果相同。

在计算机中编程中,一个幂等操作的特点是其任意多次执行所产生的影响均与一次执行的影响相同。

幂等函数或幂等方法是指可以使用相同参数重复执行,并能获得相同结果的函数。这些函数不会影响系统状态,也不用担心重复执行会对系统造成改变。

什么是接口幂等性?

在HTTP/1.1中,对幂等性进行了定义。它描述了一次和多次请求某一个资源对于资源本身应该具有同样的结果(网络超时等问题除外),即第一次请求的时候对资源产生了副作用,但是以后的多次请求都不会再对资源产生副作用。

这里的副作用是不会对结果产生破坏或者产生不可预料的结果。也就是说,其任意多次执行对资源本身所产生的影响均与一次执行的影响相同。

为什么需要实现幂等性?

在接口调用时一般情况下都能正常返回信息不会重复提交,不过在遇见以下情况时可以就会出现问题,如:

  1. 前端重复提交表单: 在填写一些表格时候,用户填写完成提交,很多时候会因网络波动没有及时对用户做出提交成功响应,致使用户认为没有成功提交,然后一直点提交按钮,这时就会发生重复提交表单请求。
  2. 用户恶意进行刷单: 例如在实现用户投票这种功能时,如果用户针对一个用户进行重复提交投票,这样会导致接口接收到用户重复提交的投票信息,这样会使投票结果与事实严重不符。
  3. 接口超时重复提交:很多时候 HTTP 客户端工具都默认开启超时重试的机制,尤其是第三方调用接口时候,为了防止网络波动超时等造成的请求失败,都会添加重试机制,导致一个请求提交多次。
  4. 消息进行重复消费: 当使用 MQ 消息中间件时候,如果发生消息中间件出现错误未及时提交消费信息,导致发生重复消费。

使用幂等性最大的优势在于使接口保证任何幂等性操作,免去因重试等造成系统产生的未知的问题。

引入幂等性后对系统有什么影响?

幂等性是为了简化客户端逻辑处理,能放置重复提交等操作,但却增加了服务端的逻辑复杂性和成本,其主要是:

  1. 把并行执行的功能改为串行执行,降低了执行效率。
  2. 增加了额外控制幂等的业务逻辑,复杂化了业务功能;

所以在使用时候需要考虑是否引入幂等性的必要性,根据实际业务场景具体分析,除了业务上的特殊要求外,一般情况下不需要引入的接口幂等性。

Restful API 接口幂等性如何?

现在流行的 Restful 推荐的几种 HTTP 接口方法中,分别存在幂等行与不能保证幂等的方法,如下:

  1. √ 满足幂等
  2. x 不满足幂等
  3. - 可能满足也可能不满足幂等,根据实际业务逻辑有关

方案一:数据库唯一主键如何实现幂等性?

数据库唯一主键的实现主要是利用数据库中主键唯一约束的特性,一般来说唯一主键比较适用于“插入”时的幂等性,其能保证一张表中只能存在一条带该唯一主键的记录。

使用数据库唯一主键完成幂等性时需要注意的是,该主键一般来说并不是使用数据库中自增主键,而是使用分布式 ID 充当主键,这样才能能保证在分布式环境下 ID 的全局唯一性。

适用操作

  • 插入操作
  • 删除操作

使用限制

  • 需要生成全局唯一主键 ID;

主要流程

主要流程如下:

  1. 客户端执行创建请求,调用服务端接口。
  2. 服务端执行业务逻辑,生成一个分布式 ID,将该 ID 充当待插入数据的主键,然
    后执数据插入操作,运行对应的 SQL 语句。
  3. 服务端将该条数据插入数据库中,如果插入成功则表示没有重复调用接口。如果抛出主键重复异常,则表示数据库中已经存在该条记录,返回错误信息到客户端。

方案二:数据库乐观锁如何实现幂等性?

数据库乐观锁方案一般只能适用于执行更新操作的过程,我们可以提前在对应的数据表中多添加一个字段,充当当前数据的版本标识。

这样每次对该数据库该表的这条数据执行更新时,都会将该版本标识作为一个条件,值为上次待更新数据中的版本标识的值。

适用操作

  • 更新操作

使用限制

  • 需要数据库对应业务表中添加额外字段

描述示例

例如,存在如下的数据表中:

为了每次执行更新时防止重复更新,确定更新的一定是要更新的内容,我们通常都会添加一个 version 字段记录当前的记录版本,这样在更新时候将该值带上,那么只要执行更新操作就能确定一定更新的是某个对应版本下的信息。

这样每次执行更新时候,都要指定要更新的版本号,如下操作就能准确更新 version=5 的信息:

1
java复制代码UPDATE my_table SET price=price+50,version=version+1 WHERE id=1 AND version=5

上面 WHERE 后面跟着条件 id=1 AND version=5 被执行后,id=1 的 version 被更新为 6,所以如果重复执行该条 SQL 语句将不生效,因为 id=1 AND version=5 的数据已经不存在,这样就能保住更新的幂等,多次更新对结果不会产生影响。

方案三:防重 Token 令牌如何实现幂等性?

针对客户端连续点击或者调用方的超时重试等情况,例如提交订单,此种操作就可以用 Token 的机制实现防止重复提交。

简单的说就是调用方在调用接口的时候先向后端请求一个全局 ID(Token),请求的时候携带这个全局 ID 一起请求(Token 最好将其放到 Headers 中),后端需要对这个 Token 作为 Key,用户信息作为 Value 到 Redis 中进行键值内容校验,如果 Key 存在且 Value 匹配就执行删除命令,然后正常执行后面的业务逻辑。如果不存在对应的 Key 或 Value 不匹配就返回重复执行的错误信息,这样来保证幂等操作。

适用操作

  • 插入操作
  • 更新操作
  • 删除操作

使用限制

  • 需要生成全局唯一 Token 串
  • 需要使用第三方组件 Redis 进行数据效验

主要流程:

  1. 服务端提供获取 Token 的接口,该 Token 可以是一个序列号,也可以是一个分布式 ID 或者 UUID 串。
  2. 客户端调用接口获取 Token,这时候服务端会生成一个 Token 串。
  3. 然后将该串存入 Redis 数据库中,以该 Token 作为 Redis 的键(注意设置过期时间)。
  4. 将 Token 返回到客户端,客户端拿到后应存到表单隐藏域中。
  5. 客户端在执行提交表单时,把 Token 存入到 Headers 中,执行业务请求带上该 Headers。
  6. 服务端接收到请求后从 Headers 中拿到 Token,然后根据 Token 到 Redis 中查找该 key 是否存在。
  7. 服务端根据 Redis 中是否存该 key 进行判断,如果存在就将该 key 删除,然后正常执行业务逻辑。如果不存在就抛异常,返回重复提交的错误信息。

注意,在并发情况下,执行 Redis 查找数据与删除需要保证原子性,否则很可能在并发下无法保证幂等性。其实现方法可以使用分布式锁或者使用 Lua 表达式来注销查询与删除操作。

方案四: 下游传递唯一序列号如何实现幂等性?

所谓请求序列号,其实就是每次向服务端请求时候附带一个短时间内唯一不重复的序列号,该序列号可以是一个有序 ID,也可以是一个订单号,一般由下游生成,在调用上游服务端接口时附加该序列号和用于认证的 ID。

当上游服务器收到请求信息后拿取该 序列号 和下游 认证ID 进行组合,形成用于操作 Redis 的 Key,然后到 Redis 中查询是否存在对应的 Key 的键值对,根据其结果:

  1. 如果存在,就说明已经对该下游的该序列号的请求进行了业务处理,这时可以直接响应重复请求的错误信息。
  2. 如果不存在,就以该 Key 作为 Redis 的键,以下游关键信息作为存储的值(例如下游商传递的一些业务逻辑信息),将该键值对存储到 Redis 中 ,然后再正常执行对应的业务逻辑即可。

适用操作

  • 插入操作
  • 更新操作
  • 删除操作

使用限制

  • 要求第三方传递唯一序列号;
  • 需要使用第三方组件 Redis 进行数据效验;

主要流程

  1. 下游服务生成分布式 ID 作为序列号,然后执行请求调用上游接口,并附带唯一序列号与请求的认证凭据ID。
  2. 上游服务进行安全效验,检测下游传递的参数中是否存在序列号和凭据ID。
  3. 上游服务到 Redis 中检测是否存在对应的序列号与认证ID组成的 Key,如果存在就抛出重复执行的异常信息,然后响应下游对应的错误信息。如果不存在就以该序列号和认证ID组合作为 Key,以下游关键信息作为 Value,进而存储到 Redis 中,然后正常执行接来来的业务逻辑。

上面步骤中插入数据到 Redis 一定要设置过期时间。这样能保证在这个时间范围内,如果重复调用接口,则能够进行判断识别。如果不设置过期时间,很可能导致数据无限量的存入 Redis,致使 Redis 不能正常工作。

实现接口幂等示例

这里使用防重 Token 令牌方案,该方案能保证在不同请求动作下的幂等性,实现逻辑可以看上面写的”防重 Token 令牌”方案,接下来写下实现这个逻辑的代码。

1. Maven 引入相关依赖

这里使用 Maven 工具管理依赖,这里在 pom.xml 中引入 SpringBoot、Redis、lombok 相关依赖。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
xml复制代码<dependencies>
<!--springboot web-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!--springboot data redis-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>
<!--lombok-->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
</dependencies>

2. 配置连接 Redis 的参数

在 application 配置文件中配置连接 Redis 的参数,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
yml复制代码spring:
redis:
ssl: false
host: 127.0.0.1
port: 6379
database: 0
timeout: 1000
password:
lettuce:
pool:
max-active: 100
max-wait: -1
min-idle: 0
max-idle: 20

3. 创建与验证 Token 工具类

创建用于操作 Token 相关的 Service 类,里面存在 Token 创建与验证方法,其中:

  1. Token 创建方法: 使用 UUID 工具创建 Token 串,设置以 “idempotent_token:“+“Token串” 作为 Key,以用户信息当成 Value,将信息存入 Redis 中。
  2. Token 验证方法: 接收 Token 串参数,加上 Key 前缀形成 Key,再传入 value 值,执行 Lua 表达式(Lua 表达式能保证命令执行的原子性)进行查找对应 Key 与删除操作。执行完成后验证命令的返回结果,如果结果不为空且非0,则验证成功,否则失败。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
java复制代码@Slf4j
@Service
public class TokenUtilService {

@Autowired
private StringRedisTemplate redisTemplate;

/**
* 存入 Redis 的 Token 键的前缀
*/
private static final String IDEMPOTENT_TOKEN_PREFIX = "idempotent_token:";

/**
* 创建 Token 存入 Redis,并返回该 Token
*
* @param value 用于辅助验证的 value 值
* @return 生成的 Token 串
*/
public String generateToken(String value) {
// 实例化生成 ID 工具对象
String token = UUID.randomUUID().toString();
// 设置存入 Redis 的 Key
String key = IDEMPOTENT_TOKEN_PREFIX + token;
// 存储 Token 到 Redis,且设置过期时间为5分钟
redisTemplate.opsForValue().set(key, value, 5, TimeUnit.MINUTES);
// 返回 Token
return token;
}

/**
* 验证 Token 正确性
*
* @param token token 字符串
* @param value value 存储在Redis中的辅助验证信息
* @return 验证结果
*/
public boolean validToken(String token, String value) {
// 设置 Lua 脚本,其中 KEYS[1] 是 key,KEYS[2] 是 value
String script = "if redis.call('get', KEYS[1]) == KEYS[2] then return redis.call('del', KEYS[1]) else return 0 end";
RedisScript<Long> redisScript = new DefaultRedisScript<>(script, Long.class);
// 根据 Key 前缀拼接 Key
String key = IDEMPOTENT_TOKEN_PREFIX + token;
// 执行 Lua 脚本
Long result = redisTemplate.execute(redisScript, Arrays.asList(key, value));
// 根据返回结果判断是否成功成功匹配并删除 Redis 键值对,若果结果不为空和0,则验证通过
if (result != null && result != 0L) {
log.info("验证 token={},key={},value={} 成功", token, key, value);
return true;
}
log.info("验证 token={},key={},value={} 失败", token, key, value);
return false;
}

}

4、创建测试的 Controller 类

创建用于测试的 Controller 类,里面有获取 Token 与测试接口幂等性的接口,内容如下:

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
java复制代码@Slf4j
@RestController
public class TokenController {

@Autowired
private TokenUtilService tokenService;

/**
* 获取 Token 接口
*
* @return Token 串
*/
@GetMapping("/token")
public String getToken() {
// 获取用户信息(这里使用模拟数据)
// 注:这里存储该内容只是举例,其作用为辅助验证,使其验证逻辑更安全,如这里存储用户信息,其目的为:
// - 1)、使用"token"验证 Redis 中是否存在对应的 Key
// - 2)、使用"用户信息"验证 Redis 的 Value 是否匹配。
String userInfo = "mydlq";
// 获取 Token 字符串,并返回
return tokenService.generateToken(userInfo);
}

/**
* 接口幂等性测试接口
*
* @param token 幂等 Token 串
* @return 执行结果
*/
@PostMapping("/test")
public String test(@RequestHeader(value = "token") String token) {
// 获取用户信息(这里使用模拟数据)
String userInfo = "mydlq";
// 根据 Token 和与用户相关的信息到 Redis 验证是否存在对应的信息
boolean result = tokenService.validToken(token, userInfo);
// 根据验证结果响应不同信息
return result ? "正常调用" : "重复调用";
}

}

最后总结

幂等性是开发当中很常见也很重要的一个需求,尤其是支付、订单等与金钱挂钩的服务,保证接口幂等性尤其重要。在实际开发中,我们需要针对不同的业务场景我们需要灵活的选择幂等性的实现方式:

  1. 对于下单等存在唯一主键的,可以使用“唯一主键方案”的方式实现。
  2. 对于更新订单状态等相关的更新场景操作,使用“乐观锁方案”实现更为简单。
  3. 对于上下游这种,下游请求上游,上游服务可以使用“下游传递唯一序列号方案”更为合理。
  4. 类似于前端重复提交、重复下单、没有唯一ID号的场景,可以通过 Token 与 Redis 配合的“防重 Token 方案”实现更为快捷。

上面只是给与一些建议,再次强调一下,实现幂等性需要先理解自身业务需求,根据业务逻辑来实现这样才合理,处理好其中的每一个结点细节,完善整体的业务流程设计,才能更好的保证系统的正常运行。最后做一个简单总结,然后本博文到此结束,如下:

文章已经收录GitHub:github.com/JavaFamily

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

维护不了就跑路,然后我被坑了!记一次采坑优化记录

发表于 2020-12-15

问题概述

使用阿里云rds for MySQL数据库(就是MySQL5.6版本),有个用户上网记录表6个月的数据量近2000万,保留最近一年的数据量达到4000万,查询速度极慢,日常卡死。严重影响业务。

问题前提:老系统,当时设计系统的人大概是大学没毕业,表设计和sql语句写的不仅仅是垃圾,简直无法直视。原开发人员都已离职,到我来维护,这就是传说中的维护不了就跑路,然后我就是掉坑的那个!!!

我尝试解决该问题,so,有个这个日志。

方案概述

  • 方案一:优化现有mysql数据库。优点:不影响现有业务,源程序不需要修改代码,成本最低。缺点:有优化瓶颈,数据量过亿就玩完了。
  • 方案二:升级数据库类型,换一种100%兼容mysql的数据库。优点:不影响现有业务,源程序不需要修改代码,你几乎不需要做任何操作就能提升数据库性能,缺点:多花钱
  • 方案三:一步到位,大数据解决方案,更换newsql/nosql数据库。优点:没有数据容量瓶颈,缺点:需要修改源程序代码,影响业务,总成本最高。

以上三种方案,按顺序使用即可,数据量在亿级别一下的没必要换nosql,开发成本太高。三种方案我都试了一遍,而且都形成了落地解决方案。该过程心中慰问跑路的那几个开发者一万遍 :)

方案一详细说明:优化现有mysql数据库

跟阿里云数据库大佬电话沟通 and Google解决方案 and 问群里大佬,总结如下(都是精华):

  • 1.数据库设计和表创建时就要考虑性能
  • 2.sql的编写需要注意优化
  • 4.分区
  • 4.分表
  • 5.分库

1.数据库设计和表创建时就要考虑性能

mysql数据库本身高度灵活,造成性能不足,严重依赖开发人员能力。也就是说开发人员能力高,则mysql性能高。这也是很多关系型数据库的通病,所以公司的dba通常工资巨高。

设计表时要注意:

  • 表字段避免null值出现,null值很难查询优化且占用额外的索引空间,推荐默认数字0代替null。
  • 尽量使用INT而非BIGINT,如果非负则加上UNSIGNED(这样数值容量会扩大一倍),当然能使用TINYINT、SMALLINT、MEDIUM_INT更好。
  • 使用枚举或整数代替字符串类型
  • 尽量使用TIMESTAMP而非DATETIME
  • 单表不要有太多字段,建议在20以内
  • 用整型来存IP

索引

  • 索引并不是越多越好,要根据查询有针对性的创建,考虑在WHERE和ORDER BY命令上涉及的列建立索引,可根据EXPLAIN来查看是否用了索引还是全表扫描
  • 应尽量避免在WHERE子句中对字段进行NULL值判断,否则将导致引擎放弃使用索引而进行全表扫描
  • 值分布很稀少的字段不适合建索引,例如”性别”这种只有两三个值的字段
  • 字符字段只建前缀索引
  • 字符字段最好不要做主键
  • 不用外键,由程序保证约束
  • 尽量不用UNIQUE,由程序保证约束
  • 使用多列索引时主意顺序和查询条件保持一致,同时删除不必要的单列索引
    简言之就是使用合适的数据类型,选择合适的索引

选择合适的数据类型

(1)使用可存下数据的最小的数据类型,整型 < date,time < char,varchar < blob
(2)使用简单的数据类型,整型比字符处理开销更小,因为字符串的比较更复杂。如,int类型存储时间类型,bigint类型转ip函数
(3)使用合理的字段属性长度,固定长度的表会更快。使用enum、char而不是varchar
(4)尽可能使用not null定义字段
(5)尽量少用text,非用不可最好分表

选择合适的索引列

(1)查询频繁的列,在where,group by,order by,on从句中出现的列
(2)where条件中<,<=,=,>,>=,between,in,以及like 字符串+通配符(%)出现的列
(3)长度小的列,索引字段越小越好,因为数据库的存储单位是页,一页中能存下的数据越多越好
(4)离散度大(不同的值多)的列,放在联合索引前面。查看离散度,通过统计不同的列值来实现,count越大,离散程度越高:
原开发人员已经跑路,该表早已建立,我无法修改,故:该措辞无法执行,放弃!

2.sql的编写需要注意优化

  • 使用limit对查询结果的记录进行限定
  • 避免select *,将需要查找的字段列出来
  • 使用连接(join)来代替子查询
  • 拆分大的delete或insert语句
  • 可通过开启慢查询日志来找出较慢的SQL
  • 不做列运算:SELECT id WHERE age + 1 = 10,任何对列的操作都将导致表扫描,它包括数据库教程函数、计算表达式等等,查询时要尽可能将操作移至等号右边
  • sql语句尽可能简单:一条sql只能在一个cpu运算;大语句拆小语句,减少锁时间;一条大sql可以堵死整个库
  • OR改写成IN:OR的效率是n级别,IN的效率是log(n)级别,in的个数建议控制在200以内
  • 不用函数和触发器,在应用程序实现
  • 避免%xxx式查询
  • 少用JOIN
  • 使用同类型进行比较,比如用’123’和’123’比,123和123比
  • 尽量避免在WHERE子句中使用!=或<>操作符,否则将引擎放弃使用索引而进- 行全表扫描
  • 对于连续数值,使用BETWEEN不用IN:SELECT id FROM t WHERE num BETWEEN 1 AND 5
  • 列表数据不要拿全表,要使用LIMIT来分页,每页数量也不要太大
  • 原开发人员已经跑路,程序已经完成上线,我无法修改sql,故:该措辞无法执行,放弃!

引擎

目前广泛使用的是MyISAM和InnoDB两种引擎:

MyISAM

MyISAM引擎是MySQL 5.1及之前版本的默认引擎,它的特点是:

  • 不支持行锁,读取时对需要读到的所有表加锁,写入时则对表加排它锁
  • 不支持事务
  • 不支持外键
  • 不支持崩溃后的安全恢复
  • 在表有读取查询的同时,支持往表中插入新纪录
  • 支持BLOB和TEXT的前500个字符索引,支持全文索引
  • 支持延迟更新索引,极大提升写入性能
  • 对于不会进行修改的表,支持压缩表,极大减少磁盘空间占用
InnoDB

InnoDB在MySQL 5.5后成为默认索引,它的特点是:

  • 支持行锁,采用MVCC来支持高并发
  • 支持事务
  • 支持外键
  • 支持崩溃后的安全恢复
  • 不支持全文索引
    总体来讲,MyISAM适合SELECT密集型的表,而InnoDB适合INSERT和UPDATE密集型的表

MyISAM速度可能超快,占用存储空间也小,但是程序要求事务支持,故InnoDB是必须的,故该方案无法执行,放弃!

3.分区

MySQL在5.1版引入的分区是一种简单的水平拆分,用户需要在建表的时候加上分区参数,对应用是透明的无需修改代码

对用户来说,分区表是一个独立的逻辑表,但是底层由多个物理子表组成,实现分区的代码实际上是通过对一组底层表的对象封装,但对SQL层来说是一个完全封装底层的黑盒子。MySQL实现分区的方式也意味着索引也是按照分区的子表定义,没有全局索引

用户的SQL语句是需要针对分区表做优化,SQL条件中要带上分区条件的列,从而使查询定位到少量的分区上,否则就会扫描全部分区,可以通过EXPLAIN PARTITIONS来查看某条SQL语句会落在那些分区上,从而进行SQL优化,我测试,查询时不带分区条件的列,也会提高速度,故该措施值得一试。

分区的好处是:

  • 可以让单表存储更多的数据
  • 分区表的数据更容易维护,可以通过清楚整个分区批量删除大量数据,也可以增加新的分区来支持新插入的数据。另外,还可以对一个独立分区进行优化、检查、修复等操作
  • 部分查询能够从查询条件确定只落在少数分区上,速度会很快
  • 分区表的数据还可以分布在不同的物理设备上,从而搞笑利用多个硬件设备
  • 可以使用分区表赖避免某些特殊瓶颈,例如InnoDB单个索引的互斥访问、ext3文件系统的inode锁竞争
  • 可以备份和恢复单个分区

分区的限制和缺点:

  • 一个表最多只能有1024个分区
  • 如果分区字段中有主键或者唯一索引的列,那么所有主键列和唯一索引列都必须包含进来
  • 分区表无法使用外键约束
  • NULL值会使分区过滤无效
  • 所有分区必须使用相同的存储引擎

分区的类型:

  • RANGE分区:基于属于一个给定连续区间的列值,把多行分配给分区
  • LIST分区:类似于按RANGE分区,区别在于LIST分区是基于列值匹配一个离散值集合中的某个值来进行选择
  • HASH分区:基于用户定义的表达式的返回值来进行选择的分区,该表达式使用将要插入到表中的这些行的列值进行计算。这个函数可以包含MySQL中有效的、产生非负整数值的任何表达式
  • KEY分区:类似于按HASH分区,区别在于KEY分区只支持计算一列或多列,且MySQL服务器提供其自身的哈希函数。必须有一列或多列包含整数值
    具体关于mysql分区的概念请自行google或查询官方文档,我这里只是抛砖引玉了。

我首先根据月份把上网记录表RANGE分区了12份,查询效率提高6倍左右,效果不明显,故:换id为HASH分区,分了64个分区,查询速度提升显著。问题解决!

结果如下:PARTITION BY HASH (id)PARTITIONS 64

1
2
3
4
5
6
7
csharp复制代码select count() from readroom_website; --11901336行记录

/ 受影响行数: 0 已找到记录: 1 警告: 0 持续时间 1 查询: 5.734 sec. /

select * from readroom_website where month(accesstime) =11 limit 10;

/ 受影响行数: 0 已找到记录: 10 警告: 0 持续时间 1 查询: 0.719 sec. */

4.分表

分表就是把一张大表,按照如上过程都优化了,还是查询卡死,那就把这个表分成多张表,把一次查询分成多次查询,然后把结果组合返回给用户。

分表分为垂直拆分和水平拆分,通常以某个字段做拆分项。比如以id字段拆分为100张表: 表名为 tableName_id%100

但:分表需要修改源程序代码,会给开发带来大量工作,极大的增加了开发成本,故:只适合在开发初期就考虑到了大量数据存在,做好了分表处理,不适合应用上线了再做修改,成本太高!!!而且选择这个方案,都不如选择我提供的第二第三个方案的成本低!故不建议采用。

5.分库

把一个数据库分成多个,建议做个读写分离就行了,真正的做分库也会带来大量的开发成本,得不偿失!不推荐使用。

方案二详细说明:升级数据库,换一个100%兼容mysql的数据库
mysql性能不行,那就换个。为保证源程序代码不修改,保证现有业务平稳迁移,故需要换一个100%兼容mysql的数据库。

开源选择

  • tiDB github.com/pingcap/tid…
  • Cubrid www.cubrid.org/
    开源数据库会带来大量的运维成本且其工业品质和MySQL尚有差距,有很多坑要踩,如果你公司要求必须自建数据库,那么选择该类型产品。
  • 云数据选择
  • 阿里云POLARDB

www.aliyun.com/product/pol…

官方介绍语:POLARDB 是阿里云自研的下一代关系型分布式云原生数据库,100%兼容MySQL,存储容量最高可达 100T,性能最高提升至 MySQL 的 6 倍。POLARDB 既融合了商业数据库稳定、可靠、高性能的特征,又具有开源数据库简单、可扩展、持续迭代的优势,而成本只需商用数据库的 1/10。

我开通测试了一下,支持免费mysql的数据迁移,无操作成本,性能提升在10倍左右,价格跟rds相差不多,是个很好的备选解决方案!

  • 阿里云OcenanBase

淘宝使用的,扛得住双十一,性能卓著,但是在公测中,我无法尝试,但值得期待

  • 阿里云HybridDB for MySQL (原PetaData)

www.aliyun.com/product/pet…

官方介绍:云数据库HybridDB for MySQL (原名PetaData)是同时支持海量数据在线事务(OLTP)和在线分析(OLAP)的HTAP(Hybrid Transaction/Analytical Processing)关系型数据库。

我也测试了一下,是一个olap和oltp兼容的解决方案,但是价格太高,每小时高达10块钱,用来做存储太浪费了,适合存储和分析一起用的业务。

  • 腾讯云DCDB
    cloud.tencent.com/product/dcd…
    官方介绍:DCDB又名TDSQL,一种兼容MySQL协议和语法,支持自动水平拆分的高性能分布式数据库——即业务显示为完整的逻辑表,数据却均匀的拆分到多个分片中;每个分片默认采用主备架构,提供灾备、恢复、监控、不停机扩容等全套解决方案,适用于TB或PB级的海量数据场景。

腾讯的我不喜欢用,不多说。原因是出了问题找不到人,线上问题无法解决头疼!但是他价格便宜,适合超小公司,玩玩。

方案三详细说明:去掉mysql,换大数据引擎处理数据

数据量过亿了,没得选了,只能上大数据了。

开源解决方案

hadoop家族。hbase/hive怼上就是了。但是有很高的运维成本,一般公司是玩不起的,没十万投入是不会有很好的产出的!

2.云解决方案

这个就比较多了,也是一种未来趋势,大数据由专业的公司提供专业的服务,小公司或个人购买服务,大数据就像水/电等公共设施一样,存在于社会的方方面面。

国内做的最好的当属阿里云。

我选择了阿里云的MaxCompute配合DataWorks,使用超级舒服,按量付费,成本极低。

MaxCompute可以理解为开源的Hive,提供sql/mapreduce/ai算法/python脚本/shell脚本等方式操作数据,数据以表格的形式展现,以分布式方式存储,采用定时任务和批处理的方式处理数据。DataWorks提供了一种工作流的方式管理你的数据处理任务和调度监控。

当然你也可以选择阿里云hbase等其他产品,我这里主要是离线处理,故选择MaxCompute,基本都是图形界面操作,大概写了300行sql,费用不超过100块钱就解决了数据处理问题。

来自:头条
链接:www.toutiao.com/a6633207458…

最后

喜欢文章的小伙伴可以点个赞哦~,最后,照旧安利一波我们的公众号:「终端研发部」,目前每天都会推荐一篇优质的技术相关的文章,主要分享java相关的技术与面试技巧, 学习java不迷路。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

1…753754755…956

开发者博客

9558 日志
1953 标签
RSS
© 2025 开发者博客
本站总访问量次
由 Hexo 强力驱动
|
主题 — NexT.Muse v5.1.4
0%