system desing 系统设计(七):数据库索引index和(分布式distributed)事务transaction
1、要想数据查的块,索引是必须来相建!在一堆杂乱无章的数据中查找,那就只能顺序扫描了!为了提升效率,排序和建索引是必须的!以B+树建索引为例,MyISAM树如下:
这里的叶子节点并不是最终的数据,而是地址,需要进一步通过这个地址查到最终的数据;MyISAM不支持transaction,并且拿到叶子节点的地址后又要再次寻址才能找到最终的数据,所以生产环境用的并不多!另一种InnoDB在叶子节点直接放最终的数据,并且支持transaction,用途广多了,如下:
还能建secondary key的辅助索引:
索引建好后查询的效率可以提升数量级!所以为了提升整体的查询效率,是不是可以把每个字段都拿来建索引了?很明显是不可能的,索引的优点是查询速度快,但缺点也不少:
- 建索引要额外存储key、point、data,需要额外消耗磁盘空间(不过好在如今磁盘价格便宜,额外消耗点存储空间花不了多少成本)
- insert、updata、delete操作时,需要更新索引结构,额外增加耗时(索引也就查询的时候爽歪歪,增删改时就遭罪了;不过一般情况下,查的需求远大于增删改,所以这里也能忍)
正式因为建索引的成本并不低,所以不是每个字段都能建索引的!那该怎么评价一个字段是否适合建索引了?有个公式可以参考:
- Selectivity = Cardinality / T
Cardinality : 不重复的索引值数量;T: 表中数据条数;Selectivity 越趋近于 1【说明索引不重复,区分度很大】,索引价值越大;站在业务角度,适合建索引的字段:
- 全局自增的uid
- 可能需要范围检索的字段,比如身高、体重、分数、timestamp等;
不适合建索引的字段:
- 不太可能会在查询时用到的列
- 本身就是杂乱无序的,或则特别长,比如地址
- 选择性低,即重复率高,可枚举的(Enumerable)列,比如性别
2、现在商用的数据库,100%是要支持多线程、高并发访问的。同时访问数据库的请求多了,访问之间的互斥和同步就很重要了。为了保证在多线程、高并发访问下数据的读写在逻辑上任然正确,并且遇到故障后(服务器power off、bug等)数据在逻辑上也不出错,sql型的数据库引入了transaction事务的概念,其要点如下(主要是ACID特性):
- 事务可以包含一个或多个对数据库的操作,这些操作构成一个逻辑上的整体。
- 这些数据库操作,要么全部执行成功,要么全部不执行,这就是所谓的原子性(automicity)。比如A给B转账,刚从A的账户减去100元,还没来得及给B账户加100时就断电了,导致转账失败,这时怎么办?必须要rollback,把减去A账户100元的操作撤回!
- 这些数据库操作,要么全都对数据库产生影响,要么全都不产生影响。即不管事务是否执行成功,数据库总能保持一致性(consistency)状态。比如同一家银行下A给B转账,不管是否成功,银行总的存款金额不变!
- 并发执行的transaction不会互相影响,最终结果就像串行执行的一样,这就是隔离性(Isolation)
- transaction一旦commit,结果就必须写入数据库,后续遇到任何故障都不会影响结果,这就是持久性(durability)
然而理想是丰满的,现实是骨感的,上述的功能很美好,该怎么实现了?这里有个总纲来,下面挨个解释具体的做法!
其实最核心的实现方式有俩种:并发控制concurrency control和日志恢复Log recovery!这两种方式各自实现了隔离性/一致性+一致性/原子性/持久性。从上图可以看到,ACID里面最重要的就是一致性了,所以这利用这两种方式都能实现一致性!下面挨个解释具体的实现细节!
3、先来看看第一个隔离性Isolation:所谓的隔离,本质史就是在做并发控制concurrency control!理论上来说事务之间的执行不应该相互产生影响,其对数据库的影响应该和它们串行执行时一样;完全的隔离性会导致系统并发性能低下(只能排队串行了),降低了资源利用率。所以实际上对隔离性的要求会有所放宽,但这也会造成对数据库一致性(consistency)要求降低;
(1)为了理清隔离的重要性,先要明白不隔离或隔离不彻底带来的损害:
- dirty write:事务A回滚了事务B已经commit的数据,导致B的commit失效,如下:
- dirty read:事务B读了事务A还未commit的数据。一旦事务A回滚,会导致事务B读取的是错误数据,所以事务B只能读取事务A已经commit的数据!
- Non repeatable reads:事务A前后两次读同一个变量,但是结果不一样!这种情况怎么办了?只能硬性规定:A事务读取变量后,B事务不能修改和commit!
- phantom reads:事务A读取了某个范围的数据,结果事务B此时insert了数据并且commit,事务A重新读的时候发现多了一个数据,此种情况只能让事务之间串行执行了!
以上4种情况衍生出了事务隔离的4个级别:
- 读未提交 Read Uncommitted
- 读已提交 Read Committed
- 可重复读 Repeatable Read
- 串行化 Serializable
这4种隔离级别可能导致的问题列举如下:
至于采取哪中隔离级别,就要根据实际的业务情况定了!如果业务要求非常高,一点错误都不允许,那就只能选最后一种串行化了!
(2)隔离的级别确定了,怎么实现事务之间的隔离或并发控制了? 先看看并发控制的分类,如下:
- 乐观并发控制(Optimistic Concurrency Control):对于并发执行可能冲突的操作,假定其并不会真的冲突,允许并发执行。直到真正发生冲突时才去解决冲突,比如让事务回滚。
- 悲观并发控制(Pessimistic Concurrency Control):对于并发执行可能冲突的操作,假定其必定发生冲突,不允许并发执行。通过让事务等待或者终止的方式使并行的操作串行化(Serializable)执行。
基于以上两种控制的思路,诞生了3种并发控制的方式:锁lock、时间戳timstamp、有效性检查;
(3)做多线程、高并发,大家第一个想到的肯定就是锁lock了!这里的锁也分了两种:
- 共享锁 Shared Locks(读锁):如果 事务T 对 数据A 加上共享锁后,则其他事务只能对 A 再加共享锁,不能加排他锁。获准共享锁的事务只能读数据,不能修改数据,也叫读锁。
- 排他锁 Exclusive lock(写锁):如果 事务T 对 数据A 加上排他锁后,则其他事务不能再对 A 加任何类型的锁。获准排他锁的事务既能读数据,又能修改数据,也叫写锁。
既然是多线程、高并发,意味着冲突是必然的,所以这里采用悲观并发控制(Pessimistic Concurrency Control),流程展示如下:
transaction在执行前就要申请锁。读就申请共享锁,写就申请排他锁,通过锁管理器等级和分配!如果有锁,并且不冲突,锁管理器授予事务相应的锁,开始执行;如果锁用光了,或有冲突,锁管理器拒绝授予锁,事务只能排队等其他事务释放锁(因为是悲观Pessimistic的,所以只能排队了)!
(4)除了锁,第二个transaction isolate的方式就是时间戳timestamp了!对于可能造成冲突的任何并发操作,基于时间戳排序规则,选定某事务继续执行,其他事务回滚(Rollback)。
- 事务时间戳:每个事务开始时赋予其一个时间戳。可以是系统时钟,也可以是自增的计数器值。事务回滚时会赋予其一个新的时间戳,先开始的事务时间戳小于后开始事务的时间戳。
- 标记为 ts(T):数据项时间戳,记录读写该数据的最新事务的时间戳,标记为 r_ts(X), w_ts(X)
读写数据的流程如下:
在读写之前都要先检查时间戳,根据时间戳判断数据是不是已经被覆盖了?数据是不是已经被其他事务读取过了?数据是不是已经被其他事务写过了?如果都不是,说明还是安全的,继续往下执行。如果是,为了不影响其他事务,自己就只能先rollback回滚了,然后老实排队找合适的时机继续执行!
(5)lock和timstamp都是悲观并发控制(Pessimistic Concurrency Control),遇到了冲突只能终止或串行。上面不是说了还有乐观并发控制(Optimistic Concurrency Control)机制么?这就是有效性检查(validity check)。事务对数据的更新首先在自己的工作空间进行,等到要写回数据库时才进行有效性检查,对不符合要求的事务进行回滚。其本质是维护活跃事务正在做什么的一个记录,在事务写入的一瞬间,将事务已读的、将写的元素与其他活跃事务的写集合做比较,如果存在事实上不可实现的行为,那么将回滚事务;
- 读阶段:数据项被读入并保存在事务的局部变量(Local Variable)中。所有后续写操作都是对局部变量进行,并不对数据库进行真正的更新,也就是不commit。
- 有效性检查阶段:对事务进行有效性检查,判断是否可以执行写操作而不违反可串行性(Serializable)。如果失败,则回滚该事务。
- 写阶段:事务已通过有效性检查,将临时变量中的结果更新到数据库中,这时才commit。
- Start(T):T开始执行时间
- Validation(T):完成效性检查的时间
- Finish(T):完成写阶段的时间
- RS(T) :读数据项的集合
- WS(T):写数据项的集合
总结:通过锁、时间戳、有效性检查三种方式控制并发,多线程一旦发生冲突,要么终止回滚,要么排队等待串行化的执行!三管齐下,一并解决了隔离和consistenct一致性问题!
4、解决了隔离性和一致性,还有原子性(Atomicity)和持久性(Durability),这两个又咋咋整了? 就要靠另一终思路了:Log Recovery!以日志(Write Ahead Log, WAL)的方式记录对数据库的操作,在故障时根据日志进行恢复,称为日志恢复Log Recovery技术。那么哪些操作都需要用日志记录了?增删改,注意这里可以没有查!在做这些操作前,都先做相应的日志记录,一旦出现故障,就可以读取日志,根据这些日志恢复了!
(1)为了更好地解释Log recovery,这里介绍一下延迟操作的原理:简而言之,就是commit写入数据库之前所有的操作都在内存的buffer!
- 数据库系统为每个事务分配一个私有的工作区
- 事务的读操作从磁盘中拷贝数据项到工作区中。执行写操作前,仅操作工作区内数据的拷贝。
- 事务的写操作把数据输出到内存的缓冲区中。等到合适的时机,数据库的缓冲区管理器将数据写入到磁盘
(2)buffer的数据一旦更改,也就是transaction执行期间数据一旦更改,是立即写入磁盘,还是等待合适的时机写入磁盘了?这两种写入方式对应了两种不同的结果,如下:
- 立即修改:数据库在事务提交前出现故障,但是事务的部分修改已经写入磁盘中,简而言之就是部分数据写入磁盘,剩余还没执行部分就没来得及写入磁盘了,这破坏了事务的原子性(Atomicity)。
- 延迟修改:数据库在事务提交后出现故障,但数据还在内存缓冲区(Buffer)中,未写入磁盘,简而言之就是commit失败,数据没能成功写入磁盘。系统恢复时将丢失此次已提交的修改。这破坏了事务的持久性(Durability)。
一旦遇到了以上两种情况,这可咋整?可以根据日志做下面两种操作:
- Undo 日志(撤销事务):记录数据的旧值,事务撤销时,将事务更新的所有数据项恢复为日志中的旧值,这是为了实现事务的原子性(Atomicity);
- Redo 日志(重做事务):记录数据的新值,故障恢复时,将事务更新的所有数据项恢复为日志中的新值,这是为了实现事务的持久性(Durability);
- 事务正常回滚 / 因事务故障终止事务:执行 Undo 日志
- 数据库系统从崩溃中恢复时:先执行 Redo,再执行 Undo。
5、(1)单机事务通过并发控制和日志恢复解决了,分布式的事务了?比如:这种分布式微服务产生的(跨JVM进程产生分布式事务),订单和库存分别放在两个不同的数据库实例,订单数据库一旦增加订单,库存数据库就要减少库存,这两个操作必须统一(要么都成功,要么都失败),不能一个成功、另一个失败;
还有这种跨数据库实例产生分布式事务:原理同上
最后:多服务访问同一个数据库实例 比如:订单微服务和库存微服务即使访问同一个数据库也会产生分布式事务,原因就是跨JVM进程,两个微服务持有了不同的数据库链接【sql语句都是通过connection执行的,不同的connection之间无法直接通信协调,导致了两个transaction可能的不一致】进行数据库操作,此时产生分布式事务。
(2)分布式事务著名的CAP理论: consistency、available、Partition tolerance
- consistency:写操作后的读操作可以读取到最新的数据状态;当数据分布在多个节点上,从任意结点读取到的数据都是最新的状态。比如mysql这种master-slave架构,master一旦更新,需要立即更新到slave。但这中间有个gap:跟新需要耗时,在更新期间怎么办了?只能短暂lock slave,不让外面的读,趁此时机让slave更新数据,完成后unlock,再让外面读,这种情况能保证强一致性,但lock期间的服务就不可用了!强一致性的代表就是zookeeper了!举个例子:A给B跨银行转账,A转账后,手机银行APP会开始倒计时(一般是5s),倒计时期间用户什么操作都做不了,等倒计时借结束,只要网络或双方的数据库不出错,转账就是成功的,这就是典型的牺牲available来保证强consistency的例子!
- available:任何事务操作都可以得到响应结果,且不会出现响应超时或响应错误。大伙看看这定义,和consistency是不是矛盾的了?所以在实际的生产环境,consistency和available只能二选一,鱼与熊掌不可兼得!通常情况下:为了available,可以适当牺牲一点consistency,用时间换consistenc,最终实现强一致性!说人话就是:不同数据库/节点之间的数据同步可以有延迟,适当牺牲强一致性,但最终的数据必须要一致,这就是所谓的柔事务【BASE 是 Basically Available(基本可用)、Soft state(软状态)和 Eventually consistent (最终一致性)三个短语的缩写】!举个例子:
订单退款,今日退款成功,明日账户到账,只要用户可以接受在一定时间内到账即可;
- Partition tolerance:通常分布式系统的各各结点部署在不同的子网,这就是网络分区,不可避免的会出现由于网络问题而导致结点之间通信失败,此时仍可对外提供服务,这叫分区容忍性;
可尽量使用异步取代同步操作,例如使用异步方式将数据从主数据库同步到从数据,这样结点之间能有效的实现松耦合。
(3) 分布式事务问题,说到底还是参与交易的多方不同步导致的。还是上面的订单和库存数据库为例:增加了新订单,就必须减少库存,这两个操作要么都成功,要么都失败,双方的步调必须协调一致,不能一个成功,另一个失败!怎么才能让参与交易事务的多方保持步调一致了?
相信大家都在学校读过书,幼儿园、小学生、甚至初中生这类自控力比较差的人群汇聚在一起形成行政班级,一个班几十号人很难统一一致,比如上自习的时候各个不同的小团体内部吵吵闹闹、说说笑笑,整个班级看起来就是一锅乱滚粥,每个人都在做布朗运动!怎么让所有同学都安静下来统一学习了?估计只有让强有力的老师进场介入维持秩序了,所有的同学都必须听老师的统一安排和指挥,整个班级才能井然有序!其实在分布式事务这块原理也是一样的:出问题的核心原因还是参与交易事务的各方各自为政,各干各的,老死不相往来,现在要想办法让交易各方互相协作,互通有无,大致的方式有一下两种:
- 需要有个master做总控:master分别给各个数据库发命令,让数据库执行。数据库执行完毕后给master汇报,如果master发现有所有的数据库都执行成功,就给没每个数据库发送commit的命令;一旦有一个数据库执行失败,那么master就需要给其他数据发送rollback的命令了!master的角色就相当于老师一样,让班级上的所有同学统一号令,统一行动!这种思路典型的解决方案有2PC、TCC等!2PC的框架有阿里开源的seata:
TCC著名的框架:
框架名称
|
Gitbub地址
|
tcc-transaction
|
https://github.com/changmingxie/tcc-transaction
|
Hmily
|
https://github.com/yu199195/hmily
|
ByteTCC
|
https://github.com/liuyangming/ByteTCC
|
EasyTransaction
|
https://github.com/QNJR-GROUP/EasyTransaction
|
整个流程如图示:由master发起事务,通知resource们执行!如果resource们确认没问题后给master反馈ok!master再给所有的resource发出confirm的命令!
一旦有一方失败,master会给其他所有参与的resource发送rollback的命令!
- 上述方式是通过强有力的master来协调不同数据库的执行,这是典型的master-slave思路;除了这种思路,还有另一种著名的架构:P2P啊!节点之间按照某种事先协商好的协议通力配合!只要大家都遵守同一个协议,整个网络就能有条不紊地运行,block chain不就是这样地么? 整个computer network不也是这样运行的么?围绕着这种思路,衍生出了“可靠消息最终一致性”和“最大努力通知”两种方式!
- 可靠消息最终一致性方案是指当事务发起方执行完成本地事务后并发出一条消息,事务参与方(消息消费者)一定能够接收消息并处理事务成功,此方案强调的是只要消息发给事务参与方最终事务要达到一致。事务发起方(消息生产方)将消息发给消息中间件,事务参与方从消息中间件接收消息,事务发起方和消息中间件之间,事务参与方(消息消费方)和消息中间件之间都是通过网络通信,由于网络通信的不确定性会导致分布式事务问题,图示如下:
上面的解释很官网,说人话就是一项交易可能由多方参与,第一个执行完事务后必须想尽一切办法(这里用的是messageQueue)让第二个需要执行事务的知道,然后第二个事务开始执行;第二个事务执行完毕后,也必须想尽一切办法让通知到第三个事务,以此类推!比较著名的解决方案是ebay提出的本地消息表方案!
用户注册时候,新增用户和增加积分的消息日志都是在本地数据库做的了,能合并在一个transaction里面,所以1、2是能保证事务性的!然后通过MQ给积分服务发消息,让积分服务给用户增加积分。怎么保证积分服务一定能收到给xxx用户增加积分的消息了?可以用roketMQ消息事务解决方案:
支付系统和订单系统类似:用户支付后,支付系统有记录,订单系统也要更新,怎么能保证支付系统和订单系统数据的一致性了?也可以用MessageQueue的形式,让两个系统之间异步通信;虽然有一定的延迟,但能保证两个系统之间最终的强一致性!
-
- 最大努力通知,举个充值的案例,如下:
发起通知方通过一定的机制最大努力将业务处理结果通知到接收方。具体包括:
-
-
- 有一定的消息重复通知机制,因为接收通知方可能没有接收到通知,此时要有一定的机制对消息重复通知。消息校对机制。
-
-
-
- 如果尽最大努力也没有通知到接收方,或者接收方消费消息后要再次消费,此时可由接收方主动向通知方查询消息信息来满足需求
-
最大努力通知和可靠消息最终一致性的区别:可靠消息一致性,发起通知方需要保证将消息发出去,并且将消息发到接收通知方,消息的可靠性关键由发起通知方来保证。最大努力通知,发起 通知方尽最大的努力将业务处理结果通知为接收通知方,但是可能消息接收不到,此时需要接收通知方主动调用发起通知方的接口查询业务处理结果,通知的可靠性关键在接收通知方。
(4)分布式事务对比分析
- 2PC 最大的诟病是一个阻塞block协议。RM在执行分支事务后需要等待TM的决定,此时服务会阻塞并锁定资源。由于其阻塞机制和最差时间复杂度高, 因此,这种设计不能适应随着事务涉及的服务数量增加而扩展的需要,很难用于并发较高以及子事务生命周期较长 (long-running transactions) 的分布式服务中。
- 如果拿TCC事务的处理流程与2PC两阶段提交做比较,2PC通常都是在跨库的DB层面,而TCC则在应用层面的处理,需要通过业务逻辑来实现。这种分布式事务的实现方式的优势在于,可以让应用自己定义数据操作的粒度,使得降低锁冲突、提高吞吐量成为可能。而不足之处则在于对应用的侵入性非常强,业务逻辑的每个分支都需要实现try、confirm、cancel三个操作。此外,其实现难度也比较大,需要按照网络状态、系统故障等不同的失败原因实现不同的回滚策略。典型的使用场景:满,登录送优惠券等。
- 可靠消息最终一致性事务适合执行周期长且实时性要求不高的场景。引入消息机制后,同步的事务操作变为基于消息执行的异步操作, 避免了分布式事务中的同步阻塞操作的影响,并实现了两个服务的解耦。典型的使用场景:注册送积分,登录送优惠券等。
- 最大努力通知是分布式事务中要求最低的一种,适用于一些最终一致性时间敏感度低的业务;允许发起通知方处理业务失败,在接收通知方收到通知后积极进行失败处理,无论发起通知方如何处理结果都会不影响到接收通知方的后续处理;发起通知方需提供查询执行情况接口,用于接收通知方校对结果。典型的使用场景:银行通知、支付结果通知等。
在条件允许的情况下,我们尽可能选择本地事务单数据源,因为它减少了网络交互带来的性能损耗,且避免了数据弱一致性带来的种种问题。若某系统频繁且不合理的使用分布式事务,应首先从整体设计角度观察服务的拆分是否合理,是否高内聚低耦合?是否粒度太小?分布式事务一直是业界难题,因为网络的不确定性,而且我们习惯于拿分布式事务与单机事务ACID做对比。无论是数据库层的XA、还是应用层TCC、可靠消息、最大努力通知等方案,都没有完美解决分布式事务问题,它们不过是各自在性能、一致性、可用性等方面做取舍,寻求某些场景偏好下的权衡。
- 分布式事务无法100%解决,只能想尽各种办法提高成功率!
- 2PC、3PC、TCC等可以概括为Master-slave模式,统一由master发号施令,slave负责执行和反馈执行结果
- 可靠消息、最大努力通知更像是P2P,双方按照事先约定协议通信,告诉下游的参与者该干啥了!
参考:
1、https://blog.csdn.net/DreamFarLoveNear/article/details/105093373 并发控制机制
2、https://www.bilibili.com/video/BV1Za411Y7rz?p=157&vd_source=241a5bcb1c13e6828e519dd1f78f35b2 事务介绍
3、https://zhuanlan.zhihu.com/p/433276682 java事务
4、https://www.bilibili.com/video/BV1FJ411A7mV?p=17&spm_id_from=pageDriver&vd_source=241a5bcb1c13e6828e519dd1f78f35b2 分布式事务
5、https://blog.csdn.net/qq_41694906/article/details/124893566 消息队列+定时任务+本地事件表