背景

电脑强制重启后,docker里的mongo不能正常使用,索引不能位置被破坏,错误log是这样的: error: WT_ERROR: non-specific WiredTiger error”}

解决方法

  1. 简历另外一个容器,volume挂载和上一个容器一样的位置:
1
2
3
4
5
6
7
8
9
docker run -it -d --name="mongofix" \
-p 27017:27017 \
-p 27018:27018 \
-p 27019:27019 \
-e TIMEZONE="Australia/Brisbane" \
-e TZ="Australia/Brisbane" \
-v {本地挂载目录}/configdb:/data/configdb \
-v {本地挂载目录}/db:/data/db \
mongo:latest /bin/bash
  1. 删除几个lock文件

    1
    2
    3
    $ rm -r journal
    $ rm -r mongod.lock
    $ rm -r WiredTiger.lock
  2. 进入mongofix容器

1
2
docker exec -it mongo /bin/sh
# mongod --repair

总结

这也提供了一条思路,如果因为挂载文件损坏,容器不能正常启动,所以不能进入容器运行修复命令,可以先创建其它容器,进入容器后进行修复。然后restart老的容器。

引用

https://gist.github.com/mtrunkat/203c33347cc96db51a754a45bb902669?permalink_comment_id=2693735

Comment and share

  1. 常用团队规范,包含Git 使用原则、分支设计、单线分支原则、开发规范
    https://blog.meathill.com/git/git-1-team-sop.html

  2. 常见问题解决,包含处理 hotfix、git rebase dev -i、git reset –hard COMMIT
    https://blog.meathill.com/git/2023-git-2-faq.html

  3. Git推荐配置与小技巧,包含
    https://blog.meathill.com/git/2023-git-3-tips.html

  4. Mac用户使用Item2+zsh中的主题自带了Git的常见alias,可以使用 alias |grep git进行查看:

    常用的有:

    1
    2
    3
    4
    5
    gup = git  pull --rebase
    gp = git push
    gl = git pull
    gcam = git commit -a -m
    gca! = git commit -v -a --amend

Comment and share

一个团队中的开发环境和开发习惯的统一是可以避免很多问题的。这儿是把之前郑大的在我们团队中提出的要求做一个备份。

基础开发环境

Mac 操作系统设置

  • 把 F1、F2 当做标准的功能键(System Preferences -> Keyboard -> 勾选 Use F1, F2, etc. key as standard key function keys.)

安装输入法

安装 Iterm2

  • 推荐使用 Iterm2 作为命令行终端,点我安装
  • Iterm2 的文档如下:

https://www.iterm2.com/documentation.html

安装Homebrew

温馨提示:

  1. 连接被拒绝问题解决请参考:GitHub DNS解析被污染,手动进行域名映射
  2. 官方源下载速度慢问题解决请参考:将官方源切换成国内源

安装 zsh & oh-my-zsh

  • 推荐使用 ZSH 作为缺省的 Shell,请确认 ZSH 是否安装。

  • 使用如下命令检查当前 Shell:

1
echo $SHELL

使用如下命令检查是否 ZSH:

1
which zsh
  • 若未安装,使用如下命令进行安装:
1
brew install zsh

使用如下命令进行 Shell 切换:

chsh -s which zsh``

切换后,重启终端即可。

  • 使用 oh-my-zsh 配置 ZSH,使用如下命令进行安装:
1
sh -c "$(curl -fsSL https://raw.github.com/ohmyzsh/ohmyzsh/master/tools/install.sh)"
  • oh-my-zsh 的文档如下:

https://github.com/ohmyzsh/ohmyzsh/wiki

  • 在 ~/.zshrc 中进行配置
1
2
3
4
export ZSH=/Users/dreamhead/.oh-my-zsh # oh-my-zsh 的安装路径
ZSH_THEME="robbyrussell" # 启用主题,请选用有 Git 分支提示的主题
plugins=(git) # 启用插件,请启用 Git 插件
source $ZSH/oh-my-zsh.sh # 启用 oh-my-zsh
  • 推荐插件:Git

安装 Git & Git Flow

推荐使用 Git 作为版本控制工具,使用 Git Flow 作为基本的研发过程。

1
2
brew install git
brew install git-flow

安装 IntelliJ IDEA

对于 Java 程序员,推荐使用 IntelliJ IDEA Community Edition 作为开发环境,点我安装

  • 推荐Keymap:IntelliJ IDEA Classic (经典配置在各个操作系统上比较一致)
  • 推荐插件:Lombok

开发习惯

需求开发

确认需求

  • 与产品经理确认需求背景,确认需求有效性。
  • 向产品经理复述对需求的理解,以确保二者理解的一致性。
  • 将需求开发挪到开发中(In Dev)。

接口变更

  • 确认该需求是否需要接口变更(增加接口、增加接口字段等)。

  • 若需要接口变更

    • 发起接口变更的审核,审核通过,方可继续开发。
    • (后端)将变更后的接口更新在接口文档中。
    • (前端)按照变更后接口更新模拟接口。

数据库变更

  • 确认该需求是否需要数据库变更(增加表、增加数据库字段等)。

  • 若需要数据库变更

    • 发起数据库变更的审核,审核通过,方可继续开发。
    • (后端)将变更后的接口更新在数据库迁移中。

需求开发

  • 先对要开发的任务进行任务分解。参考 任务分解 一节。
  • 按照任务进行开发。在代码编写的过程中,要及时格式化代码,尽可能消除 IDE 给出的警告
  • 每完成一个任务,提交一次代码。参考 代码提交 一节。
  • 除了要编写基本的功能代码之外,编写代码的过程还要开发相应的 测试
  • 在所有任务完成之后,对照需求的 验收标准,确认代码满足了需求。

需求验收

  • 将开发完成的需求演示给产品经理。
  • 产品经理认为该需求已经达成需求的要求,满足验收标准,需求开发结束,否则,重新调整,满足需求。
  • 将需求卡片挪到待测试(Ready for Test)。

任务分解

  • 推荐在每天工作伊始,先将要完成的工作进行任务分解。

  • 任务的粒度要求

    • 足够小,大约可以在半个小时之内完成。
    • 完整,一个任务完成之后可以独立提交。
  • 在开发过程中,如果遇到新的任务,可以附加在任务列表上。

  • 任务列表工具

    • 贴纸(物理),将贴纸贴到电脑显示器上。
    • stickies(App),Mac 自带的 App。配置为悬浮,在菜单中选择 Window -> Float on Top。
    • 其它 App,比如,微软 ToDo。
  • 下面是一个供参考的任务分解样例,完成了一个用户名密码登录的过程。

  • 具体的分解过程请参考 极客时间的《一起练习:手把手带你分解任务》

img

版本控制

Git 的基本使用

  • 推荐使用 Git 作为版本控制工具
  • 推荐使用 oh-my-zsh 的 Git 插件

基本配置

全局的 Git 建议配置(在 ~/.gitconfig 中)

1
2
3
4
5
6
[alias]
co = checkout
st = status
ci = commit -a
[color]
ui = auto

基本使用

从远程更新代码

1
gup # Git 插件命令 git pull --rebase

向远程推送代码

1
gp # Git 插件命令 git push

查看修改结果

1
2
git st # 使用 Git 全局配置别名
gst # Git 插件命令 git status

提交代码

1
git ci -m"This is commit message"

发布管理

  • 推荐采用无特性分支的 Git Flow,也就是说,不允许使用 feature 分支

分支介绍

分支 用途 提交/合并代码 远程分支 长期保持
develop 用于日常的迭代开发 允许提交代码,允许由 release、hotfix 分支合并代码
master 保持与生产环境一致 不允许提交代码,允许由 release、hotfix 分支合并代码
release 用于迭代发布 在迭代测试期间提交代码,不允许合并分支代码 可以 一般情况不长期保持;有特定版本,允许保留,
hotfix 用于修复线上问题 在修复问题期间提交代码,不允许合并分支代码

基本用法

  • 初始化
1
git flow init
  • 发布(Release)

    • 发布开始
1
git flow release start v20200901
    • 发布结束
1
git flow release finish v20200901
  • 修复问题(Hotfix)

    • 修复问题
1
git flow hotfix start fix_production_bug
    • 发布结束
1
git flow hotfix finish fix_production_bug

代码提交

本地构建

提交代码之前,必须保证代码的本地构建是通过的。

确认提交

提交代码前,需要确认修改是自己要做的修改,防止误操作。

  • 确认修改文件:确定修改文件是自己要修改的文件,运行如下命令。
1
gst

对于误修改的文件,运行如下命令恢复

1
git co 文件路径
  • 确认修改内容:

  • 确定修改的内容是自己要修改的内容,确保代码正确地格式化,运行如下命令:

1
gd

提交代码

  • 加入新文件。将未加入版本管理的文件,加入版本管理
1
git add 文件路径
  • 提交代码
1
git ci -m"This is your commit message"

注释

原则:注释有意义,准确描述出在做的事情。

  • 注释使用英文,首字母大写,首单词为动词。

单一提交线

  • 本地提交后的代码不允许出现提交线分叉,若出现分叉,请使用 rebase。

  • 可能出现分叉的场景

    • 从远程拉代码,建议使用 gup 命令,保证在拉代码的同时,进行 rebase。
    • 从 release/hotfix 分支合并代码,允许保留分叉

获取远程代码

  • 获取远程代码。采用如下命令,获取远程代码,该命令在获取远程代码之后,进行 rebase 操作。
1
gup
  • 修复冲突。如果在获取远程代码之后,产生了冲突,请先解决冲突。冲突解决之后,采用如下命令:
1
git add 冲突文件路径

在所有冲突解决之后,采用如下命令,继续完成 rebase 操作:

1
git rebase --continue # Git 插件别名:grbc
  • 本地构建。在合并了远端代码之后,再次运行本地构建,确保合并之后的代码,依然可以通过本地构建。如有问题,请及时修复。

推送代码

采用如下命令将代码推送至远程。

1
gp

监控持续集成

程序员需要等到持续集成通过之后,再进行后续开发。

Comment and share

本地事务相对比较简单,很容易实现。但是上升到全局事务再到分布式事务就比较麻烦,对保证数据一致性就需要做很多额外的处理。

从全局事务(Global Transactions)谈起

全局事务即外部事务(External Transaction):一种适用于单个服务使用多个数据源场景的事务解决方案。

实际上DTP(Distributed Transaction Processing)中没有上边这种限定。本篇分为两大类:

  1. 在分布式(单服务多个数据源)环境中追求强一致性。
  2. 在分布式(微服务)中放弃ACID追求弱一致性。

XA(Extended Architecture)协议

定义了全局事务管理器(Transaction Manager):用于协调全局事务局部的资源管理器(Resources Manager):用于驱动本地事务之间的通讯接口。

XA接口是双向的,一个事务管理器和多个资源管理器之间通信的桥梁,协调多个数据源保持一致,来实现全局事务的统一提交或者统一回滚。XADataSource XAResource。XA并不是java规范,是一套通用技术。 Java后来专门定义了一套全局事务处理标准JTA

JTA(Java Transaction API)

Java定义的一套全局事务处理标准。

  • 事务管理器接口: javax.transaction.TransactionManager,这套接口是给Java EE服务提供容器事务(由容器自动负责事务管理)使用。javax.transaction.UserTransaction接口,给程序员使用用于通过程序代码手动开启,提交和回滚事务。
  • 满足XA规范的资源定义接口:javax.transaction.xa.XAResource。任何资源(JDBC,JMS等)如果需要支持JTA,只要实现XAResource接口中的方法即可。

JTA 原本是 Java EE 中的技术,一般情况下应该由 JBoss、WebSphere、WebLogic 这些 Java EE 容器来提供支持,但现在Bittronix、Atomikos和JBossTM(以前叫 Arjuna)都以 JAR 包的形式实现了 JTA 的接口,也就是 JOTM(Java Open Transaction Manager)。有了 JOTM 的支持,我们就可以在 Tomcat、Jetty 这样的 Java SE 环境下使用 JTA 了。

XA 和 JTA的关系

XA refers to eXtended Architecture, which is a specification for distributed transaction processing. The goal of XA is to provide atomicity in global transactions involving heterogeneous components.

XA specification provides integrity through a protocol known as a two-phase commit. Two-phase commit is a widely-used distributed algorithm to facilitate the decision to commit or rollback a distributed transaction.

Java Transaction API (JTA) is a Java Enterprise Edition API developed under the Java Community Process. It enables Java applications and application servers to perform distributed transactions across XA resources.

JTA is modeled around XA architecture, leveraging two-phase commit. JTA specifies standard Java interfaces between a transaction manager and the other parties in a distributed transaction.

XA指的是eXtended Architecture,是一种用于分布式事务处理的规范。XA的目标是在涉及异构组件的全局事务中提供原子性。

XA规范通过一个称为两阶段提交的协议提供了一致性。两阶段提交是一种广泛使用的分布式算法,用于促进决定是提交还是回滚分布式事务。

Java事务API(JTA)是在Java社区流程下开发的Java企业版API。它使Java应用程序和应用服务器能够跨XA资源执行分布式事务。

JTA围绕XA架构建模,利用两阶段提交。JTA规定了分布式事务中事务管理器与其他参与方之间的标准Java接口。

两阶段提交(2 Phase Commit 2PC)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

public void buyBook(PaymentBill bill) {
userTransaction.begin();
warehouseTransaction.begin();
businessTransaction.begin();
try {
userAccountService.pay(bill.getMoney());
warehouseService.deliver(bill.getItems());
businessAccountService.receipt(bill.getMoney());
userTransaction.commit();
warehouseTransaction.commit();
businessTransaction.commit();
} catch(Exception e) {
userTransaction.rollback();
warehouseTransaction.rollback();
businessTransaction.rollback();
}
}

如上开启了三个事务,业务处理完成后做了三次事务提交。但是如果三个commit中第二个和第三个commit出现了Exception那么已经提交的事务rollback不了,这样就破坏了全局事务的一致性。为了解决这种问题提出了两阶段提交。

  • 准备阶段:投票阶段,协调者询问所有事务参与者是否已经准备好,准备好:Prepared 否则:Non-Prepared。对于数据库来讲,准备操作是是在重做日志中记录全部事务提交操作所要做的内容,与本地事务主要区别是:暂时不写入最后一条Commit Record。这意味着做完数据持久化后暂时不会释放隔离性,也就是依然持有锁。
  • 提交阶段:协调者受到所有事务参与者恢复的Prepared消息,就会首先在本地持久化事务状态未Commit,然后向所有参与者发送Commit指令。否则任意一个参与者回复Non-Prepared消息,协调者都会将自己事务状态持久化未Abort并且发送给所有参与者。

因为提交阶段相对轻量级,仅仅是持久化一条指令 Commit Record能够快速完成。回滚阶段则相对耗时,收到Abort时需要根据Undo log清理已经提交的数据。

缺点:

  • 单点问题:协调者单点
  • 性能问题: 两阶段提交过程中,所有参与者相当于被绑定成为一个统一调度整体,期间要经历两次远程服务调用,三次数据持久化(准备阶段写Redo Log,协调者做状态持久化,提交阶段在日志写入 Commit Record),整个过程将持续到参与者集群中最慢的处理操作结束为止。
  • 一致性风险: 网络稳定性带来的一致性风险。尽管提交阶段时间很短,但仍是明确存在的危险期。如果协调者在发出准备指令后,根据各个参与者发回的信息确定事务状态是可以提交的,协调者就会先持久化事务状态,并提交自己的事务。如果这时候网络忽然断开了,无法再通过网络向所有参与者发出 Commit 指令的话,就会导致部分数据(协调者的)已提交,但部分数据(参与者的)既未提交也没办法回滚,导致数据不一致。

java中如何使用两阶段提交

Java-atomikos: https://www.baeldung.com/java-atomikos

三段式提交

将两阶段提交的准备阶段再细分两个阶段:

  1. CanCommit: 询问阶段,协调者让每个参与的数据库根据自身状态,评估该事务释放有可能顺利完成。
  2. PreCommit:

提交阶段改为:

  1. DoCommit

将准备阶段一分为二的理由是,这个阶段是重负载的操作,一旦协调者发出开始准备的消息,每个参与者都将马上开始写重做日志,这时候涉及的数据资源都会被锁住。如果此时某一个参与者无法完成提交,相当于所有的参与者都做了一轮无用功。

在事务需要回滚的场景中,三段式的性能通常要比两段式好很多,但在事务能够正常提交的场景中,两段式和三段式提交的性能都很差,三段式因为多了一次询问,性能还要更差一些。

如果协调者在 PreCommit 阶段开始之后发生了宕机,参与者没有能等到 DoCommit 的消息的话,默认的操作策略将是提交事务而不是回滚事务或者持续等待。你看,这就相当于避免了协调者的单点问题。

image-20220715092405378

共享事务

  1. 全局事务是单个服务使用多个数据源,共享事务是指多个服务共用一个数据源。

  2. “数据源”与“数据库”的区别:数据源是指提供数据的逻辑设备,不必与物理设备一一对应。

  3. 现实中只有类似ProxySQL和MaxScale这样用于对多个数据库实例做负载均衡的数据库代理,而几乎没有反过来代理一个数据库为多个应用提供事务协调的交易服务代理。

  4. 让多个微服务去共享一个数据库这个方案,其实还有另一种应用形式:使用消息队列服务器来代替交易服务器,用户、商家、仓库的服务操作业务时,通过消息将所有对数据库的改动传送到消息队列服务器,然后通过消息的消费者来统一处理,实现由本地事务保障的持久化操作。这就是“单个数据库的消息驱动更新”(Message-Driven Update of a Single Database)。

  5. “共享事务”这种叫法,以及我们刚刚讲到的通过交易服务器或者通过消息驱动来更新单个数据库这两种处理方式,在实际应用中并不常见,也几乎没有相应的成功案例,能够查到的资料几乎都来源于十多年前 Spring 的核心开发者Dave Syer的文章“Distributed Transactions in Spring, with and without XA”。

两段式提交和三段式提交仍然追求 ACID 的强一致性,这个目标不仅给它带来了很高的复杂度,而且吞吐量和使用效果上也不佳。因此,现在系统设计的主流,已经变成了不追求 ACID 而是强调 BASE 的弱一致性事务,这就是我们要在下一讲学习的分布式事务了。

分布式事务

多个服务同时访问多个数据源的处理机制。

CAP理论

  1. 一致性(Consistency):代表在任何时刻,任何分布式节点中,我们所看到的数据都是没有矛盾的。与ACID中的C单词相同含义不同。
  2. 可用性(Available):代表系统不间断提供服务。
  3. 分区容忍性(Partition Tolerance):代表在分布式环境中,当部分节点因为网络原因而彼此失联(即与其他节点形成“网络分区”)时,系统仍然能够正常的工作。

放弃分区容忍性:

CA: 这意味着,我们将假设节点之间的通讯永远是可靠的。可是永远可靠的通讯在分布式系统中必定是不成立的,这不是你想不想的问题,而是网络分区现象始终会存在。

在现实场景中,主流的 RDBMS(关系数据库管理系统)集群通常就是采用放弃分区容错性的工作模式。以 Oracle 的 RAC 集群为例,它的每一个节点都有自己的 SGA(系统全局区)、重做日志、回滚日志等,但各个节点是共享磁盘中的同一份数据文件和控制文件的,也就是说,RAC 集群是通过共享磁盘的方式来避免网络分区的出现。

放弃可用性:

CP: 这意味着,我们将假设一旦发生分区,节点之间的信息同步时间可以无限制地延长,那么这个问题就相当于退化到了上一讲所讨论的全局事务的场景之中,即一个系统可以使用多个数据源。我们可以通过 2PC/3PC 等手段,同时获得分区容错性和一致性。

在现实中,除了 DTP 模型的分布式数据库事务外,著名的 HBase 也是属于 CP 系统。以它的集群为例,假如某个 RegionServer 宕机了,这个 RegionServer 持有的所有键值范围都将离线,直到数据恢复过程完成为止,这个时间通常会是很长的。

放弃一致性:

这意味着,我们将假设一旦发生分区,节点之间所提供的数据可能不一致。

AP : 系统目前是分布式系统设计的主流选择,大多数的 NoSQL 库和支持分布式的缓存都是 AP 系统。因为 P 是分布式网络的天然属性,你不想要也无法丢弃;而 A 通常是建设分布式的目的,如果可用性随着节点数量增加反而降低的话,很多分布式系统可能就没有存在的价值了(除非银行这些涉及到金钱交易的服务,宁可中断也不能出错)。

以 Redis 集群为例,如果某个 Redis 节点出现网络分区,那也不妨碍每个节点仍然会以自己本地的数据对外提供服务。但这时有可能出现这种情况,即请求分配到不同节点时,返回给客户端的是不同的数据。

ACID和CAP

把前面我们在 CAP、ACID 中讨论的一致性称为“强一致性”(Strong Consistency),有时也称为“线性一致性”(Linearizability),而把牺牲了 C 的 AP 系统,又要尽可能获得正确的结果的行为,称为追求“弱一致性”。

弱一致性:最终一致性

强一致性:刚性事务

可靠消息队列

eBay系统架构师:丹.普利切特(Dan Pritchett)提出了BASE理论提出了最终一致性概念。

系统建立一个消息服务,定时轮询消息表,将状态是“进行中”的消息同时发送到下游关联系统。

有一些支持分布式事务的消息框架,如 RocketMQ,原生就支持分布式事务操作,这时候前面提到的情况 2、4 也可以交给消息框架来保障。

最大努力交付(Best-Effort Delivery)

靠着持续重试来保证可靠性的操作。比如 TCP 协议中的可靠性保障,就属于最大努力交付。

最大努力一次交付(Best-Effort 1PC)

把可能出错的业务,以本地事务的方式完成后,经过不断的重试(不限于消息系统)来促使同个事务的关联业务完成。

TCC(Try-Confirm-Cancel)和SAGA

可靠消息队列的缺点就是没有隔离性。

TCC

TCC方案,它天生适用于需要强调隔离性的分布式事务中。它是一中业务侵入性比较强的事务方案,要求处理过程必须拆分为:“预留业务资源”和“确认/释放消费资源”两个子过程。

  • Try: 尝试阶段,完成所有业务可执行性的检查(保障一致性),并且预留好事务需要用到的业务资源(保障隔离性)
  • Confirm: 确认执行阶段,不进行任何业务检查,直接使用Try阶段准备的资源来完成业务处理。注意,Confirm阶段可能会重复执行,因此需要满足幂等性。
  • Cancel:取消执行阶段,释放Try阶段预留的业务资源。注意:Cancel阶段也可能重复执行,因此也需要满足幂等性。

实际的业务请求如下:

  1. 业务发出更新请求。
  2. 创建事务,生成事务ID,记录在活动日志中,进入Try阶段。
  3. 如果第二步中所有的业务都反馈业务可行,就将活动日志中的记录为Confirm,进入Confirm阶段
  4. 如果第三步的操作全部完成了,事务就会宣告正常结束。而如果第三步中的任何一方出现了异常,不论是业务异常还是网络异常,都将会根据活动日志中的记录,来重复执行该服务的 Confirm 操作,即进行“最大努力交付”。
  5. 如果是在第二步,有任意一方反馈业务不可行,或是任意一方出现了超时,就将活动日志的状态记录为 Cancel,进入 Cancel 阶段:
  6. 如果第五步全部完成了,事务就会宣告以失败回滚结束。而如果第五步中的任何一方出现了异常,不论是业务异常还是网络异常,也都将会根据活动日志中的记录,来重复执行该服务的 Cancel 操作,即进行“最大努力交付”。

优点:

  • TCC 其实有点类似于 2PC 的准备阶段和提交阶段,但 TCC 是位于用户代码层面,而不是在基础设施层面,这就为它的实现带来了较高的灵活性,我们可以根据需要设计资源锁定的粒度。

  • TCC 在业务执行的时候,只操作预留资源,几乎不会涉及到锁和资源的争用,所以它具有很高的性能潜力。

缺点: TCC 最主要的限制是它的业务侵入性很强,但并不是指由此给开发编码带来的工作量,而是指它所要求的技术可控性上的约束。

SAGA

TCC 在业务执行的时候,只操作预留资源,几乎不会涉及到锁和资源的争用,所以它具有很高的性能潜力。通常我们并不会完全靠裸编码来实现 TCC,而是会基于某些分布式事务中间件(如阿里开源的Seata)来完成,以尽量减轻一些编码工作量。

来源:SAGA 事务模式的历史十分悠久,比分布式事务的概念提出还要更早。SAGA 的意思是“长篇故事、长篇记叙、一长串事件”,它起源于 1987 年普林斯顿大学的赫克托 · 加西亚 · 莫利纳(Hector Garcia Molina)和肯尼斯 · 麦克米伦(Kenneth Salem)在 ACM 发表的一篇论文《SAGAS》(这就是论文的全名)

文中提出了一种如何提升“长时间事务”(Long Lived Transaction)运作效率的方法,大致思路是把一个大事务分解为可以交错运行的一系列子事务的集合。原本提出 SAGA 的目的,是为了避免大事务长时间锁定数据库的资源,后来才逐渐发展成将一个分布式环境中的大事务,分解为一系列本地事务的设计模式。

SAGA的组成部分
  1. 把大事务T拆分成为若干小事务 T1…Tn,每个子事务都能被看作是原子行为。如果分布式事务T能够正常提交,那么它对数据的影响(最终一致性)就应该与连续按顺序成功提交子事务 Ti 等价。

    T - commit == T1 - commit + T2 - commit …Ti - commit… Tn - commit

  2. 每个子事务T1…Tn涉及对应的补偿动作,命名为:C1,C2,…, Ci, …,Cn

    • Ti 与 Ci 都具备幂等性;
    • Ti 与 Ci 满足交换律(Commutative),即不管是先执行 Ti 还是先执行 Ci,效果都是一样的;
    • Ci 必须能成功提交,即不考虑 Ci 本身提交失败被回滚的情况,如果出现就必须持续重试直至成功,或者要人工介入。
恢复模式

如果 T1 到 Tn 均成功提交,那么事务就可以顺利完成。否则,我们就要采取以下两种恢复策略之一:

  • 正向恢复(Forward Recovery):如果 Ti 事务提交失败,则一直对 Ti 进行重试,直至成功为止(最大努力交付)。这种恢复方式不需要补偿,适用于事务最终都要成功的场景,比如在别人的银行账号中扣了款,就一定要给别人发货。正向恢复的执行模式为:T1,T2,…,Ti(失败),Ti(重试)…,Ti+1,…,Tn。
  • 反向恢复(Backward Recovery):如果 Ti 事务提交失败,则一直执行 Ci 对 Ti 进行补偿,直至成功为止(最大努力交付)。这里要求 Ci 必须(在持续重试后)执行成功。反向恢复的执行模式为:T1,T2,…,Ti(失败),Ci(补偿),…,C2,C1。

与 TCC 相比,SAGA 不需要为资源设计冻结状态和撤销冻结的操作,补偿操作往往要比冻结操作容易实现得多。

SAGA 必须保证所有子事务都能够提交或者补偿,但 SAGA 系统本身也有可能会崩溃,所以它必须设计成与数据库类似的日志机制(被称为 SAGA Log),以保证系统恢复后可以追踪到子事务的执行情况,比如执行都到哪一步或者补偿到哪一步了。

SAGA 事务通常也不会直接靠裸编码来实现,一般也是在事务中间件的基础上完成。

AT 事务

AT 事务是参照了 XA 两段提交协议来实现的,但针对 XA 2PC 的缺陷,即在准备阶段,必须等待所有数据源都返回成功后,协调者才能统一发出 Commit 命令而导致的木桶效应(所有涉及到的锁和资源,都需要等到最慢的事务完成后才能统一释放),AT 事务也设计了针对性的解决方案。

  1. 自动拦截所有 SQL,分别保存 SQL 对数据修改前后结果的快照,生成行锁,通过本地事务一起提交到操作的数据源中,这就相当于自动记录了重做和回滚日志。
  2. 如果分布式事务成功提交了,那么我们后续只需清理每个数据源中对应的日志数据即可;而如果分布式事务需要回滚,就要根据日志数据自动产生用于补偿的“逆向 SQL”。
  3. 基于这种补偿方式,分布式事务中所涉及的每一个数据源都可以单独提交,然后立刻释放锁和资源。AT 事务这种异步提交的模式,相比 2PC 极大地提升了系统的吞吐量水平。而使用的代价就是大幅度地牺牲了隔离性,甚至直接影响到了原子性。因为在缺乏隔离性的前提下,以补偿代替回滚不一定总能成功。
  4. 当在本地事务提交之后、分布式事务完成之前,该数据被补偿之前又被其他操作修改过,即出现了脏写(Dirty Wirte),而这个时候一旦出现分布式事务需要回滚,就不可能再通过自动的逆向 SQL 来实现补偿,只能由人工介入处理了。
  5. 一般来说,对于脏写我们是一定要避免的,所有传统关系数据库在最低的隔离级别上,都仍然要加锁以避免脏写。因为脏写情况一旦发生,人工其实也很难进行有效处理。
  6. GTS 增加了一个“全局锁”(Global Lock)的机制来实现写隔离,要求本地事务提交之前,一定要先拿到针对修改记录的全局锁后才允许提交,而在没有获得全局锁之前就必须一直等待。
  7. 这种设计以牺牲一定性能为代价,避免了在两个分布式事务中,数据被同一个本地事务改写的情况,从而避免了脏写。
  8. 在读隔离方面,AT 事务默认的隔离级别是读未提交(Read Uncommitted),这意味着可能会产生脏读(Dirty Read)。读隔离也可以采用全局锁的方案来解决,但直接阻塞读取的话,我们要付出的代价就非常大了,一般并不会这样做。

Comment and share

引言

事务管理是日常开发过程中绕不开的技术,引入事务的目的是为了保证数据持久化过程中的 一致性(Consistency) 。事务的概念最初是源于数据库,但今天的信息系统中,所有需要保证数据正确性(一致性)的场景下,包括但不限于数据库、缓存、事务内存、消息、队列、对象文件存储等等,都有可能会涉及到事务处理。这篇总结主要是总结清楚事务的来龙去脉,无关具体的某个中间件。深度推荐《周志明的软件架构课》

定义事务的ACID

  • 原子性(Atomic)同一业务处理过程中,事务保证了多项对数据的修改,要么全部成功,要么全部失败。
  • 隔离性(Isolation)在不同的业务处理过程中,事务保证了各自读写的业务数据相互不可见,不会彼此影响。
  • 持久性(Durability)事务保证了当业务处理完成后,事务提交后能够被持久化,不会丢失。
  • 一致性(Consistency)一致性。利用A, I, D 保证了业务数据的一致性。

A, I, D是手段,C 是目的。 ACID更像是凑单词。

本地事务(局部事务)Local Transaction

  • 本地事务是指仅操作特定单一事务资源,不需要全局事务管理器进行协调的事务。

  • 本地事务是最基础的一种事务处理方案,通常只适用于单个服务使用单个数据源的场景,它是直接依赖于数据源(通常是数据库系统)本身的事务能力来工作的。

  • 本地事务的开启、终止、提交、回滚、嵌套、设置隔离级别、乃至与应用代码贴近的传播方式,全部都要依赖底层数据库的支持。

  • 例如:Spring提供的事务注解,如果数据库引擎不支持那么这个注解也是无意义的。

    1
    2
    3
    4
    @Transactional(propagation = Propagation.REQUIRED, //传播方式
    rollbackFor = Exception.class, //回滚异常
    isolation = ISOLATION_REPEATABLE_READ //隔离级别
    )

原子性和持久性

扩展知识:ARIES 理论,现代主流数据库的事务实现都受到该理论的影响

原子性和持久性密切相关,原子性保证了事务的多个操作要么都生效要么都不生效,不会存在中间状态。持久性则保证了事务一旦生效则不会因为任何原因而导致其修改的内容被撤销或者丢失。

崩溃回复(Crash Recovery)

  • 未提交事务:程序还没修改完一系列整个业务数据,数据库已经将其中一个或者两个数据的变动写入了磁盘,此时崩溃,一旦重启后,数据库必须想办法得知崩溃前发生过一次不完整的购物操作,将已经修改过的数据从磁盘中恢复成为之前没有改过的样子。
  • 已提交事务:程序已经修改完了所有数据,数据还没将全部的数据写入磁盘,此时出现崩溃。重启之后数据库必须知道崩溃前发生过一次的完整操作,将还没来得及写入磁盘的数据重新写入。保证持久性。

这种数据恢复操作被称作是:崩溃恢复。

Commit Logging

为了能够顺利完成崩溃恢复,那么需要记录下数据操作的全部信息:比如修改的什么数据,位于哪个内存页和磁盘块中,从什么值改成了什么值等等,以顺序追加的文件写入方式记录的磁盘中(顺序写磁盘是高效的)。只有日志记录全部落盘,见到代表事务成功提交的“Commit Record”,数据库才会根据日志上的信息真正的对数据进行修改。修改完成后在日志中加一条“End Record”标识已经完成持久化,这种实现方式被称为“Commit Logging”

另外一种实现:Shadow Paging

SQLite Version 3采用这种方式实现。

将原来数据复制一份副本也可以称之为临时数据,原来数据保持不变,在副本数据上进行操作。再修改完成后改变“两份数据的指针”。

Shadow Paging 相对简单,但涉及到隔离性与锁时,Shadow Paging 实现的事务并发能力相对有限,因此在高性能的数据库中应用不多。

Commit Logging保证持久性:日志一旦写入Commit Logging,那么整个事务就是成功的。即使崩溃,重启后根据日志进行恢复现场,继续修改即可。

Commit Logging保证原子性:如果日志没有写入成功就发生崩溃,重启后会看到一部分没有Commit Logging的数据,那么将这部分数据标识为回滚状态即可,整个事务就像没有发生过。保证了原子性。

Commit Logging 存在一个巨大的缺陷:所有对数据的真实修改都必须发生在事务提交、日志写入了 Commit Record 之后,即使事务提交前磁盘 I/O 有足够空闲、即使某个事务修改的数据量非常庞大,占用大量的内存缓冲,无论何种理由,都决不允许在事务提交之前就开始修改磁盘上的数据,这一点对提升数据库的性能是很不利的。

Write-Ahead Logging(先写日志)

为了修复Commit Logging缺陷ARIES 提出了“Write-Ahead Logging”的日志改进方案,其名字里所谓的“提前写入”(Write-Ahead),就是允许在事务提交之前,提前写入变动数据的意思。

Write-Ahead Logging 先将何时写入变动数据,按照事务提交时点为界,分为了 FORCE 和 STEAL 两类:

  • FORCE:当事务提交后,要求变动数据必须同时完成写入—则称为 FORCE,如果不强制变动数据必须同时完成写入—则称为 NO-FORCE。现实中绝大多数数据库采用的都是 NO-FORCE 策略,只要有了日志,变动数据随时可以持久化,从优化磁盘 I/O 性能考虑,没有必要强制数据写入立即进行。
  • STEAL:在事务提交前,允许变动数据提前写入则称为 STEAL,不允许则称为 NO-STEAL。从优化磁盘 I/O 性能考虑,允许数据提前写入,有利于利用空闲 I/O 资源,也有利于节省数据库缓存区的内存。

Commit Logging允许NO-FORCE但是不允许STEAL。因为在事务提交前写入了一部分数据,如果发生崩溃,那么提前写入的数据就变成了脏数据。

Write-Ahead Logging允许NO-FORCE也允许STEAL,给出的解决办法是增加了Undo Log日志。当数据写入磁盘前,必须先记录Undo Log,写明修改了什么值并生成对应的回滚段。以便事务再发生崩溃恢复的时候根据Undo Log对提前写入的数据变动进行擦除。

Undo Log 现在一般被翻译为“回滚日志”,此前记录的用于崩溃恢复时重演数据变动的日志,就相应被命名为 Redo Log,一般翻译为“重做日志”。

Write-Ahead Logging 在崩溃恢复时,会以此经历以下三个阶段:

  1. 分析阶段(Analysis):从最后一次检查点Checkpoint(从这个检查点之前所有的数据都成功落盘)开始扫描日志,找出所有没有End Record的事务,组成待恢复的事务集合(一般包括 Transaction Table 和 Dirty Page Table);
  2. 重做阶段(Redo):这个阶段依据分析阶段产生的待恢复的事务集合来重演历史(Repeat History)找出所有的包含Commit Record的日志,将它们写入磁盘,写入完成后追加一个End Record,然后移除待恢复 集合。
  3. 回滚阶段(Undo):该阶段处理经过分析、重做阶段后剩余的恢复事务集合,此时剩下的都是需要回滚的事务(被称为 Loser),根据 Undo Log 中的信息回滚这些事务。

数据库按照“是否允许 FORCE 和 STEAL”可以产生四种组合,从优化磁盘 I/O 的角度看,NO-FORCE 加 STEAL 组合的性能无疑是最高的;从算法实现与日志的角度看,NO-FORCE 加 STEAL 组合的复杂度无疑是最高的。这四种组合与 Undo Log、Redo Log 之间的具体关系如下图所示:

image-20220705093501892

隔离性

隔离性保证了每个事务各自读写的相互独立性。主要是为了解决事务并发问题,有并发的地方就有锁。

  • 写锁(Write Lock 也叫排他锁eXclusive Lock,简写X-Lock):只有持有写锁的事务才能对数据进行写入操作,数据加写锁时,其它事务不能写入数据,也不能施加读锁(写锁禁止其他事务施加读锁,而不是禁止事务读取数据。)。

  • 读锁(Read Lock 也叫共享锁Shared Lock,简写S-Lock):多个事务可以对同一个数据添加读锁,添加了读锁后就不能添加写锁,所以事务不能对数据进行写入,但仍然可以读取。对持有读锁的事务,如果该事务只有一个读锁就可以直接升级为写锁,然后写入数据。

  • 范围锁(Range Lock)对某个范围数据直接添加排他锁,在这个范围的数据不能被读取,也不能被写入。如 select * from books where price < 100 for update;

    请注意“范围不能写入”与“一批数据不能写入”的差别,也就是我们不要把范围锁理解成一组排他锁的集合。加了范围锁后,不仅无法修改该范围内已有的数据,也不能在该范围内新增或删除任何数据,这是一组排他锁的集合无法做到的。

可串行化(Serializable)

对事务所有读、写的数据全都加上读锁、写锁和范围锁即可(这种可串行化的实现方案称为 Two-Phase Lock)。

强度最高的隔离性。粗粒度锁,事务顺序执行那么就不会互相产生影响。但是吞吐量最低。

可重复读(Repeatable Read)

可重复读的意思是对事务所涉及到的数据加读锁和写锁,并且一直持续到事务结束,但是不再添加范围锁。

缺点:在可读事务中的幻读问题(MySQL 中的InnoDB在只读事务中不存在幻读问题,只在读写事务提升为当前读后存在这个问题)如下读事务:

1
2
3
4
5
6
7
8
9

/* 时间顺序:1,事务: T1 */
SELECT count(1) FROM books WHERE price < 100

/* 时间顺序:2,事务: T2 */
INSERT INTO books(name,price) VALUES ('深入理解Java虚拟机',90)

/* 时间顺序:3,事务: T1 会查出来 T2 插入的数据,但是InnoDB不会*/
SELECT count(1) FROM books WHERE price < 100

读提交(Read Committed)

读提交会对事务涉及到的数据加写锁,会一直持续到事务结束,加的读锁会在读完之后立马释放。

读提交有了不可重复读—-对同一行数据两次查询得到了不同的结果。

原因是读已提交的隔离级别缺乏贯穿整个事务周期的读锁,无法禁止读取过的数据发生变化。

读未提交(Read UnCommitted)

读未提交只会对事务所涉及到的数据加写锁,不会加读锁。

读未提交有了脏读—-一个事务读到了另外一个事务未提交的数据

其实,不同隔离级别以及幻读、脏读等问题都只是表面现象,它们是各种锁在不同加锁时间上组合应用所产生的结果,锁才是根本的原因。除了锁之外,以上对四种隔离级别的介绍还有一个共同特点,就是一个事务在读数据过程中,受另外一个写数据的事务影响而破坏了隔离性。针对这种“一个事务读 + 另一个事务写”的隔离问题,有一种名为“多版本并发控制”(Multi-Version Concurrency Control,MVCC)的无锁优化方案被主流的商业数据库广泛采用。

多版本并发控制MVCC(Multi-Version Concurrency Control)无锁化。

MVCC是一种读取优化策略,它的“无锁”是特指读取数据时不需要加锁。

MVCC只针对读+写的场景优化,如果是两个事务同时修改数据,即写+写的场景那么几乎没有什么优化的空间

MVCC 的基本思路是对数据库的任何修改都不会直接覆盖之前的数据,而是产生一个新版副本与老版本共存,以此达到读取时可以完全不加锁的目的。这句话里的“版本”是个关键词,你不妨将其理解为数据库中每一行记录都存在两个看不见的字段:CREATE_VERSION 和 DELETE_VERSION,这两个字段记录的值都是事务 ID(事务 ID 是一个全局严格递增的数值),然后:

  1. 数据被插入时:CREATE_VERSION 记录插入数据的事务 ID,DELETE_VERSION 为空。
  2. 数据被删除时:DELETE_VERSION 记录删除数据的事务 ID,CREATE_VERSION 为空。
  3. 数据被修改时:将修改视为“删除旧数据,插入新数据”,即先将原有数据复制一份,原有数据的 DELETE_VERSION 记录修改数据的事务 ID,CREATE_VERSION 为空。复制出来的新数据的 CREATE_VERSION 记录修改数据的事务 ID,DELETE_VERSION 为空。
  • 隔离级别是可重复读:总是读取 CREATE_VERSION 小于或等于当前事务 ID 的记录,在这个前提下,如果数据仍有多个版本,则取最新(事务 ID 最大)的。
  • 隔离级别是读已提交:总是取最新的版本即可,即最近被 Commit 的那个版本的数据记录。

Comment and share

理解I/O模型

in network

IO,英文全称是Input/Output,翻译过来就是输入/输出。平时我们听得挺多,就是什么磁盘IO,网络IO。那IO到底是什么呢?

从计算机结构定义I/O

冯诺依曼结构将计算机分为5个部分:运算器,控制器,存储器,输入设备,输出设备

image-20220628085034583

从计算机架构中看I/O就是:涉及计算机核心与其他设备间数据迁移的过程,就是IO。就是从磁盘读取数据到内存,这算一次输入,对应的,将内存中的数据写入磁盘,就算输出。这就是IO的本质

从操作系统定义I/O

分清用户空间和内核空间

内核空间是操作系统内核访问的区域,是受保护的空间,而用户空间是应用程序访问的内存区域

真正执行IO的是在内核空间

我们应用程序是跑在用户空间的,它不存在实质的IO过程,真正的IO是在操作系统执行的。即应用程序的IO操作分为两种动作:IO调用和IO执行。IO调用是由进程(应用程序的运行态)发起,而IO执行是操作系统内核的工作。此时所说的IO是应用程序对操作系统IO功能的一次触发,即IO调用。

操作系统的一次IO过程

  1. IO调用:应用程序进程向操作系统内核发起调用。

  2. IO执行:操作系统完成IO操作

    操作系统完成IO操作还包括两个步骤

    1. 准备数据阶段:内核等待I/O设备准备好数据

    2. 拷贝数据阶段:将数据从内核缓冲区拷贝到用户进程缓冲区

      image-20220628085126342

其实IO就是把进程的内部数据转移到外部设备,或者把外部设备的数据迁移到进程内部。外部设备一般指硬盘、socket通讯的网卡。一个完整的IO过程包括以下几个步骤:

  • 应用程序进程向操作系统发起IO调用请求
  • 操作系统准备数据,把IO外部设备的数据,加载到内核缓冲区
  • 操作系统拷贝数据,即将内核缓冲区的数据,拷贝到用户进程缓冲区
彻底理解 IO多路复用

阻塞I/O

应用程序的进程发起IO调用,但是如果内核的数据还没准备好的话,那应用程序进程就一直在阻塞等待,一直等到内核数据准备好了,从内核拷贝到用户空间,才返回成功提示,此次IO操作,称之为阻塞IO

Image
  • 阻塞I/O典型:阻塞socket,java BIO
  • 缺点:如果内核数据一直没准备好,那用户进程将一直阻塞,浪费性能,可以使用非阻塞IO优化。

非阻塞I/O 模型

I/O多路复用是一种同步I/O模型,实现一个线程可以监视多个FD(文件句柄);一旦某个文件句柄就绪,就能够通知应用程序进行相应的读写操作。没有文件句柄就绪时会阻塞应用程序,交出CPU。多路指的是网络链接,复用指的是同一个线程(进程)

image-20220628085219103

  1. 应用进程向操作系统内核,发起recvfrom读取数据。

  2. 操作系统内核数据没有准备好,立即返回EWOULDBLOCK错误码。

  3. 应用进程轮训调用,继续向操作系统内核发起recvfrom读取数据。

  4. 操作系统内核数据准备好了,从内核缓冲区拷贝到用户空间。

  5. 完成调用,返回成功。

    缺点:非阻塞IO模型,简称NIONon-Blocking IO。它相对于阻塞IO,虽然大幅提升了性能,但是它依然存在性能问题,即频繁的轮询,导致频繁的系统调用,同样会消耗大量的CPU资源。可以考虑IO复用模型,去解决这个问题。

I/O多路复用

NIO利用无效的轮训会导致CPU资源消耗,解决这种无效轮训最好的方式就是通知机制,内核准备好数据后通知用户进程即可。

IO多路复用的核心:操作系统提供了一类函数:select, poll, epll 等同时监控多个fd操作,任何一个返回内核数据就绪,应用进程再发起recvfrom调用。

文件描述符fd(file descriptor)解释:它是计算机科学中的一个术语,形式上是一个非负整数。当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符。

I/O复用select模式

应用进程通过调用select函数,可以同时监控多个fd,在select函数监控的fd中,只要有任何一个数据状态准备就绪了,select函数都会返回可读状态,这时应用再发起recvfrom调用。

image-20220628085252917

select缺点

  • 监听IO最大连接数有限,在Linux系统上一般为1024
  • select函数返回后,是通过遍历fdset,找到就绪的fd。(仅仅知道有I/O发生,却不知道哪几个流,所以遍历所有流)

因为存在连接数限制,所以后来又提出了poll。与select相比,poll解决了连接数的问题,但是select和poll一样,还是需要通过文件描述符来获取已经就绪的socket。如果同时连接的大量客户端,在一时刻只有极少数就绪状态,伴随着监视的描述符数量的增长,效率也会线性下降。

I/O多路复用之epoll

image-20220628085323206

  1. epoll先通过epoll_ctl()来注册一个fd(文件描述符)
  2. 当fd就绪时,内核会采用回调机制,迅速激活这个fd,当进程调用epoll_wait()时便得到通知。

select poll epoll区别

select poll Epoll
底层数据结构 数组 链表 红黑树和双链表
获取就绪的fd 遍历 遍历 事件回调
事件复杂度 O(n) O(n) O(1)
最大连接数 1024 无限制 无限制
fd数据拷贝 每次调用select,需要将fd数据从用户空间拷贝到内核空间 每次调用poll,需要将fd数据从用户空间拷贝到内核空间 使用内存映射(mmap),不需要从用户空间频繁拷贝fd数据到内核空间

epoll明显优化了IO的执行效率,但在进程调用epoll_wait()时,仍然可能被阻塞。又提出了:等发出请求后,数据准备好通知,这就诞生了信号驱动IO模型。

信号IO驱动模型

信号驱动IO不再用主动询问的方式去确认数据是否就绪,而是向内核发送一个信号(调用signaction的时候建立一个sigio信号),然后应用用户进程可以去做别的事,不用阻塞。当内核数据准备好后,再通过SIGIO信号通知应用进程,数据准备好后的可读状态。应用用户进程收到信号后,立即调用recvfrom,去读取数据。

image-20220628085348962

还不是完全的异步IO:数据复制到应用缓冲的时候,应用进程还是阻塞的。

真正的异步IO(AIO)

BIO,NIO和信号驱动,在数据从内核复制到缓冲区的时候都是阻塞的,都不算真正的异步IO。AIO实现了全流程的非阻塞,就是应用进程发出系统调用后,是立即返回的,但是返回的不是处理结果,而是表示提交成功的意思。等到内核数据准备好,将数据拷贝到用户进程缓冲区,发送信号通知用户进程IO操作

image-20220628085415738

阻塞、非阻塞、同步、异步IO划分

image-20220628085438835

IO模型
阻塞I/O模型 同步阻塞
非阻塞I/O模型 同步非阻塞
I/O多路复用模型 同步阻塞
信号驱动I/O模型 同步非阻塞
异步IO(AIO)模型 异步非阻塞

参考连接

https://mp.weixin.qq.com/s/77G2NxfjZlT-icfqrHCizQ

Comment and share

Redis为什么这么快?在除了它是内存型数据库外,它的数据结构,IO模型也是它这么快速的重要原因。我们都知道Redis是单线程模型,那么Redis是如何利用单线程快速的处理高并发业务的呢?带着疑问来看下边的内容更容易理解。

Redis为什么使用单线程?

了解多线程的开销
  1. 在使用多线程中,如果合理分配资源可以增加系统中处理请求操作的实体,进而能够提升同时处理的请求数,即吞吐率。但是如图所示进一步增加线程时,系统吞吐率就增长延迟了,有时甚至还会出现下降的情况。

Multi-threaded processor

参考文章

  1. 系统中通常会存在被多线程同时访问的共享资源,比如一个共享的数据结构。当有多个线程要修改这个共享资源时,为了保证共享资源的正确性,就需要有额外的机制进行保证,而这个额外的机制,就会带来额外的开销。

    Redis 有 List 的数据类型,并提供出队(LPOP)和入队(LPUSH)操作。假设 Redis 采用多线程设计,如下图所示,现在有两个线程 A 和 B,线程 A 对一个 List 做 LPUSH 操作,并对队列长度加 1。同时,线程 B 对该 List 执行 LPOP 操作,并对队列长度减 1。为了保证队列长度的正确性,Redis 需要让线程 A 和 B 的 LPUSH 和 LPOP 串行执行,这样一来,Redis 可以无误地记录它们对 List 长度的修改。否则,我们可能就会得到错误的长度结果。这就是多线程编程模式面临的共享资源的并发访问控制问题。

  2. 并发访问控制一直是多线程开发中的一个难点问题,如果没有精细的设计,比如说,只是简单地采用一个粗粒度互斥锁,就会出现不理想的结果:即使增加了线程,大部分线程也在等待获取访问共享资源的互斥锁,并行变串行,系统吞吐率并没有随着线程的增加而增加。

  3. 采用多线程开发一般会引入同步原语来保护共享资源的并发访问,这也会降低系统代码的易调试性和可维护性。为了避免这些问题,Redis 直接采用了单线程模式。

单线程Redis为什么快?

  1. 大部分属于内存操作
  2. 高效数据库
  3. IO多路复用

阻塞式I/O模型与阻塞点

阻塞式I/O处理一个GET请求流程:

  1. 监听客户端请求(bind/listen)
  2. 和客户端建立连接(accept)阻塞点1
  3. 从socket中读取请求(recv)阻塞点2
  4. 解析客户端发送请求(parse)
  5. 根据请求读取键值对数据(get)
  6. 向socket中写数据,最后给客户端返回结果(send)
img

潜在的阻塞点,分别是 accept() 和 recv()。当 Redis 监听到一个客户端有连接请求,但一直未能成功建立起连接时,会阻塞在 accept() 函数这里,导致其他客户端无法和 Redis 建立连接。类似的,当 Redis 通过 recv() 从一个客户端读取数据时,如果数据一直没有到达,Redis 也会一直阻塞在 recv()。

非阻塞式I/O

Socket 网络模型的非阻塞模式设置,主要体现在三个关键的函数调用上,如果想要使用 socket 非阻塞模式,就必须要了解这三个函数的调用返回类型和设置模式。

在 socket 模型中,不同操作调用后会返回不同的套接字类型。socket() 方法会返回主动套接字,然后调用 listen() 方法,将主动套接字转化为监听套接字,此时,可以监听来自客户端的连接请求。最后,调用 accept() 方法接收到达的客户端连接,并返回已连接套接字。

img
  1. 针对监听套接字,我们可以设置非阻塞模式:当 Redis 调用 accept() 但一直未有连接请求到达时,Redis 线程可以返回处理其他操作,而不用一直等待。但是,你要注意的是,调用 accept() 时,已经存在监听套接字了。
  2. 针对已连接套接字设置非阻塞模式:Redis 调用 recv() 后,如果已连接套接字上一直没有数据到达,Redis 线程同样可以返回处理其他操作。我们也需要有机制继续监听该已连接套接字,并在有数据达到时通知 Redis。
  3. 这样才能保证 Redis 线程,既不会像基本 I/O 模型中一直在阻塞点等待,也不会导致 Redis 无法处理实际到达的连接请求或数据。

基于多路复用的高性能I/O模型

Linux中的I/O多路复用就是一个线程处理多个I/O流,就是我们经常说的select/epoll(理解IO模型)机制。

Redis单线程运行下该机制允许内核中,同时存在多个监听套接字和已连接套接字。内核会监听这些套接字上的连接请求或者数据请求。一旦有请求到达,就会交给redis线程处理,这样就实现了一个Redis线程处理多个I/O流的效果

下图就是基于多路复用的 Redis IO 模型。图中的多个 FD 就是刚才所说的多个套接字。Redis 网络框架调用 epoll 机制,让内核监听这些套接字。此时,Redis 线程不会阻塞在某一个特定的监听或已连接套接字上,也就是说,不会阻塞在某一个特定的客户端请求处理上。正因为此,Redis 可以同时和多个客户端连接并处理请求,从而提升并发性。

img
  1. select/epoll 提供了基于事件的回调机制,即针对不同事件的发生,调用相应的处理函数。
  2. select/epoll 一旦监测到 FD 上有请求到达时,就会触发相应的事件。
  3. 这些事件会被放进一个事件队列,Redis 单线程对该事件队列不断进行处理。这样一来,Redis 无需一直轮询是否有请求实际发生,这就可以避免造成 CPU 资源浪费。同时,Redis 在对事件队列中的事件进行处理时,会调用相应的处理函数,这就实现了基于事件的回调。因为 Redis 一直在对事件队列进行处理,所以能及时响应客户端请求,提升 Redis 的响应性能。
Accept、Read处理流程

这两个请求分别对应 Accept 事件和 Read 事件,Redis 分别对这两个事件注册 accept 和 get 回调函数。当 Linux 内核监听到有连接请求或读数据请求时,就会触发 Accept 事件和 Read 事件,此时,内核就会回调 Redis 相应的 accept 和 get 函数进行处理。

Comment and share

众所周知redis作为内存数据库有个重要的特点 — 快。除了因为是在内存中读写快之外,那就是它的查找很快,能够快速的定位到存储位置。能够快速找到的原因基于Redis的数据结构。

数据类型

以下是Redis中的基本类型,也就是数据的保存形式,而数据结构则是这些数据类型在底层的实现。

  1. String:简单动态字符串
  2. List:双向链表,压缩列表
  3. Hash:压缩列表,哈希表
  4. Set:哈希表,整数数组
  5. Sorted Set:压缩列表,跳表
img

String类型的底层结构只有一个简单动态字符串,而List,Hash,Set,Sorted Set都有两种底层实现结构。通常情况把这四种类型称为集合类型,特点是:一个键对应了一个集合的数据。

键和值用什么数据结构?

全局哈希表

为了实现快速访问Redis实现了一个全局哈希表保存所有键值对。一个哈希表就是一个数组,数组的每个元素称之为哈希桶。每个哈希桶中保存了键值对数据。哈希桶中的值不保存值本身,而是指向具体的指针。也就是说不管是String还是集合类型,哈希桶中的元素都是指向它们的指针。

img
优点:

时间复杂度为O(1)查找。

缺点:

往Redis写入大量数据后,哈希表的冲突问题和rehash可能带来的阻塞。

如何解决hash冲突 — 链式hash

Redis解决hash冲突的方式,就是链式hash(跟Java中的HashMap一样)。同一个哈希桶中的多个元素用一个链表保存,它们之间依使用指针连接

img
缺点:

针对链表上的查找只能采用O(n)时间复杂度的依次查找。

如何解决hash桶上链表只能依次查找慢的问题 — rehash操作

Redis会对哈希会对哈希表做Rehash操作,rehash也就是增加Hash桶数量,让逐渐增多的entry元素能够在更多的桶之间分散保存。Redis默认使用了两个全局hash表:

  1. 哈希表I 和哈希表II

  2. 一开始插入数据默认使用哈希表I 此时的哈希表II并没有分配空间。

  3. 随着数据的逐步增加Redis开始执行Rehash,分为以下三步:

    1. 给哈希表II 分配更大的空间,例如是哈希表I的二倍。
    2. 把哈希表I 中的数据重新映射并拷贝到哈希表II 中
    3. 释放哈希表I 的空间。
    缺点:

    第二步涉及大量的数据拷贝,如果一次性把哈希表 1 中的数据都迁移完,会造成 Redis 线程阻塞,

如何避免Rehash过程中拷贝带来的线程阻塞 — 渐进式 rehash

Redis 仍然正常处理客户端请求,每处理一个请求时,从哈希表 1 中的第一个索引位置开始,顺带着将这个索引位置上的所有 entries 拷贝到哈希表 2 中;等处理下一个请求时,再顺带拷贝哈希表 1 中的下一个索引位置的 entries。如下图所示:

img

压缩列表

压缩列表类似于一个数组,数组中的每一个元素都对应保存着一个数据,和数组不同的是,压缩列表在表头有三个字段zlbytes(列表长度), zltail(列表尾偏移量) 和 zllen(列表entry个数),zlend(列表结束)

img

如果我们要查找定位第一个元素和最后一个元素,可以通过表头三个字段的长度直接定位,复杂度是 O(1)。

而查找其他元素时,就没有这么高效了,只能逐个查找,此时的复杂度就是 O(N) 了。

跳表

有序链表只能逐一查找元素,导致操作起来非常缓慢,于是就出现了跳表。具体来说,跳表在链表的基础上,增加了多级索引,通过索引位置的几个跳转,实现数据的快速定位

img
  1. 从第一个元素开始,每两个元素选一个出来作为索引。

  2. 这些索引再通过指针指向原始的链表。例如,从前两个元素中抽取元素 1 作为一级索引,从第三、四个元素中抽取元素 11 作为一级索引。

  3. 此时,我们只需要 4 次查找就能定位到元素 33 了。

    可以看到,这个查找过程就是在多级索引上跳来跳去,最后定位到元素。这也正好符合“跳”表的叫法。当数据量很大时,跳表的查找复杂度就是 O(logN)。

数据结构时间复杂度

img

操作复杂度

1. 单元素操作

HGET、HSET 和 HDEL 是对哈希表做操作,所以它们的复杂度都是 O(1);

Set 类型用哈希表作为底层数据结构时,它的 SADD、SREM、SRANDMEMBER 复杂度也是 O(1)。

集合类型支持同时对多个元素进行增删改查,例如 Hash 类型的 HMGET 和 HMSET,Set 类型的 SADD 也支持同时增加多个元素。此时,这些操作的复杂度,就是由单个元素操作复杂度和元素个数决定的。例如,HMSET 增加 M 个元素时,复杂度就从 O(1) 变成 O(M) 了。

2. 范围操作

是指集合类型中的遍历操作,可以返回集合中的所有数据

Hash 类型的 HGETALL 和 Set 类型的 SMEMBERS,或者返回一个范围内的部分数据,List 类型的 LRANGE 和 ZSet 类型的 ZRANGE。这类操作的复杂度一般是 O(N),比较耗时,我们应该尽量避免。

Redis 从 2.8 版本开始提供了 SCAN 系列操作(包括 HSCAN,SSCAN 和 ZSCAN),这类操作实现了渐进式遍历,每次只返回有限数量的数据。这样一来,相比于 HGETALL、SMEMBERS 这类操作来说,就避免了一次性返回所有元素而导致的 Redis 阻塞。

3. 统计操作

集合类型对集合中所有元素个数的记录

LLEN 和 SCARD。这类操作复杂度只有 O(1),这是因为当集合类型采用压缩列表、双向链表、整数数组这些数据结构时,这些结构中专门记录了元素的个数统计,因此可以高效地完成相关操作。

4.例外

是指某些数据结构的特殊记录,例如压缩列表和双向链表都会记录表头和表尾的偏移量。

对于 List 类型的 LPOP、RPOP、LPUSH、RPUSH 这四个操作来说,它们是在列表的头尾增删元素,这就可以通过偏移量直接定位,所以它们的复杂度也只有 O(1),可以实现快速操作。

总结

Redis 的底层数据结构,这既包括了 Redis 中用来保存每个键和值的全局哈希表结构,也包括了支持集合类型实现的双向链表、压缩列表、整数数组、哈希表和跳表这五大底层结构。

Redis 之所以能快速操作键值对,一方面是因为 O(1) 复杂度的哈希表被广泛使用,包括 String、Hash 和 Set,它们的操作复杂度基本由哈希表决定,另一方面,Sorted Set 也采用了 O(logN) 复杂度的跳表。

不过,集合类型的范围操作,因为要遍历底层数据结构,复杂度通常是 O(N)。这里,我的建议是:用其他命令来替代,例如可以用 SCAN 来代替,避免在 Redis 内部产生费时的全集合遍历操作。

当然,我们不能忘了复杂度较高的 List 类型,它的两种底层实现结构:双向链表和压缩列表的操作复杂度都是 O(N)。因此,我的建议是:因地制宜地使用 List 类型。例如,既然它的 POP/PUSH 效率很高,那么就将它主要用于 FIFO 队列场景,而不是作为一个可以随机读写的集合。

实际开发中Redis各个数据结构的应用场景

image-20230115163957142

引用

《Redis核心技术与实战》

Comment and share

1. 给函数起个好名字

动词 + 关键词 : 例如把JUnit中的assertEquals改成 assertExpectedEqualsActual(expected, actual)可能会更好些,大大减轻了记忆参数顺序的负担

2. 函数参数

最理想的参数数量是 0(零参函数)–》其次是1(单参函数)–》 再次是2 (双参函数)–》 应该尽量避免3(三参函数)

如果函数看起来需要2个,3个或者3个以上参数,那说明其中一些需要封装成为类了。

例如:

1
2
Circle makeCircle(double x, double y, double radius);
Circle makeCircle(Point center, double radius);

当一组参数被传递,就像上例中的x和y,往往就是该有自己名称的某个概念的一部分(学会将一组基本变量抽象成为对象)

3. 改变输入参数,从而导致参数成为了输出参数

这一点在java在写业务代码时经常会碰到,改变了输入参数,造成很多迷惑行为

例如:

1
2
//给字符串添加Report添加footer
public void appendFooter(StringBuffer report);

如果不添加注释会产生疑惑:是给report追加东西还是,把report添加到其它东西上?所以写成如下会比较好理解

1
report.appendFooter();

4. 杜绝标识参数

标识参数(True|False)非常丑陋。使用标记参数做了两件事情,则应该拆分它。

1
render(boolean isSuite) => renderForSuite() , renderForSingleTest()

5. 提取try/catch块

try/catch块在函数中很丑陋,将包裹在try/catch中的代码提取出来,减少try/catch的臃肿程度。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
private void writeToClient(HttpServletResponse response, String filePath) {
try (ServletOutputStream os = response.getOutputStream();
InputStream stream = new FileInputStream(file)){
File file = new File(filePath);
response.setContentLength((int)file.length());
int length = 0;
byte[] buff = new byte[1024];
while ((length = stream.read(buff)) > 0) {
os.write(buff, 0, length);
}
} catch (IOException e) {
log.error("Download happened error", e);
} finally {
if (!file.delete()) {
log.error("The file {} is failed to be deleted!", filePath);
}
}
}

提取try中的业务代码:

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
private void writeToClient(HttpServletResponse response, String filePath) {
try (ServletOutputStream os = response.getOutputStream();
InputStream stream = new FileInputStream(file)){

doWrite(os, stream, reponse, filePath)
} catch (IOException e) {
log.error("Download happened error", e);
} finally {
if (!file.delete()) {
log.error("The file {} is failed to be deleted!", filePath);
}
}
}

private void doWrite(ServletOutputStream os,
InputStream stream,
HttpServletResponse response,
String filePath){
File file = new File(filePath);
response.setContentLength((int)file.length());
int length = 0;
byte[] buff = new byte[1024];
while ((length = stream.read(buff)) > 0) {
os.write(buff, 0, length);
}
}

4. 理解OO

面向对象的设计有一个原则(我最初是从 Grady Booch 那里听到的):“如果觉得设计太复杂,那就生成更多对象。”这种说法既违反直觉,又简单得可笑,但我发现它很有用(“生成更多对象”通常等同于“再增加一层抽象”)。总的来说,如果发现有些地方代码很乱,就要考虑用哪种类可以清理代码。通常清理代码带来的副作用是使系统更灵活并且结构更好。

5. 清理火车代码

杜绝过长的调用链如:

隐藏委托关系(Hide Delegate)

迪米特法则(Law of Demeter),这个原则是这样说的:

  1. 每个单元对其它单元只拥有有限的知识,而且这些单元是与当前单元有紧密联系的;
  2. 每个单元只能与其朋友交谈,不与陌生人交谈;
  3. 只与自己最直接的朋友交谈。
1
2
3
4
5
6
7
8
9
10
book.getAuthor().getName();

//重构后:
class Book {
...
public String getAuthorName() {
return this.author.getName();
}
...}
String name = book.getAuthorName();

要想摆脱初级程序员的水平,就要先从少暴露细节开始。声明完一个类的字段之后,请停下生成 getter 的手,转而让大脑开始工作,思考这个类应该提供的行为。

基本类型偏执

对于返回值,和参数能封装为类就封装为类;

比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public double getPrice() { ...}

double price = this.getPrice();
if(price <= 0){
throw new RuntimeException("价格不能为0");
}

//重构:封装为对象后
class Price{
double price;
public Price(final double price) {
if (price <= 0) {
throw new RuntimeException("价格不能为0");
}
this.price = price;
}
}

Comment and share

Author's picture

Topsion

Fullstack Developer


Coder


Xi'an China