offer来了(原理篇)学习笔记-第7章数据库及分布式事务

本章将针对数据库存储引擎数据库索引存储过程数据库锁分布式事务展开介绍,希望读者能够站在更高的层次理解这些原理,以便在数据库出现性能瓶颈时做出正确的判断。

数据库的基本概念及原则

存储引擎

数据库的存储引擎是数据库的底层软件组织,数据库管理系统(DBMS)使用存储引擎创建、查询、更新和删除数据。不同的存储引擎提供了不同的存储机制、索引技巧、锁定水平等功能,都有其特定的功能。现在,许多数据库管理系统都支持多种存储引擎,常用的存储引擎主要有MyISAMInnoDBMemoryArchiveFederated

MyIASM

MyIASM是MySQL默认的存储引擎,不支持数据库事务、行级锁和外键,因此在INSERT(插入)或UPDATE(更新)数据即写操作时需要锁定整个表,效率较低。MyIASM的特点是执行读取操作的速度快,且占用的内存和存储资源较少。

它在设计之初就假设数据被组织成固定长度的记录,并且是按顺序存储的。在查找数据时,MyIASM直接查找文件的OFFSET,定位比InnoDB要快(InnoDB寻址时要先映射到块,再映射到行)。

总体来说,MyIASM的缺点是更新数据慢且不支持事务处理优点是查询速度快

InnoDB

InnoDB为MySQL提供了事务(Transaction)、支持回滚(Rollback)、崩溃修复能力(CrashRecovery Capabilities)、多版本并发控制(Multi-versioned Concurrency Control)、事务安全(Transaction-safe)的操作。InnoDB的底层存储结构为B+树,B+树的每个节点都对应InnoDB的一个Page, Page大小是固定的,一般被设为16KB。其中,非叶子节点只有键值,叶子节点包含完整的数据。

InnoDB适用于有以下需求的场景

  • 经常有数据更新的表,适合处理多重并发更新请求。
  • 支持事务。
  • 支持灾难恢复(通过bin-log日志等)。
  • 支持外键约束,只有InnoDB支持外键。
  • 支持自动增加列属性auto_increment。

TokuDB

TokuDB的底层存储结构为Fractal Tree。Fractal Tree的结构与B+树有些类似,只是在Fractal Tree中除了每一个指针(key),都需要指向一个child(孩子)节点,child节点带一个MessageBuffer,这个Message Buffer是一个先进先出队列,用来缓存更新操作,具体的数据结构如图7-2所示。这样,每一次插入操作都只需落在某节点的Message Buffer上,就可以马上返回,并不需要搜索到叶子节点。这些缓存的更新操作会在后台异步合并并更新到对应的节点上。

TokuDB在线添加索引,不影响读写操作,有非常高的写入性能,主要适用于要求写入速度快、访问频率不高的数据或历史数据归档。

Memory

Memory表使用内存空间创建。每个Memory表实际上都对应一个磁盘文件用于持久化。Memory表因为数据是存放在内存中的,因此访问速度非常快,通常使用Hash索引来实现数据索引。Memory表的缺点是一旦服务关闭,表中的数据就会丢失。

Memory还支持散列索引和B树索引。B树索引可以使用部分查询和通配查询,也可以使用不等于和大于等于等操作符方便批量数据访问,散列索引相对于B树索引来说,基于Key的查询效率特别高,但是基于范围的查询效率不是很高。

tips
持续化:狭义的理解: “持久化”仅仅指把域对象永久保存到数据库中;广义的理解,“持久化”包括和数据库相关的各种操作(持久化就是将有用的数据以某种技术保存起来,将来可以再次取出来应用,数据库技术,将内存数据一文件的形式保存在永久介质中(磁盘等)都是持久化的例子.)。

创建索引的原则

创建索引是我们提高数据库查询数据效率最常用的办法,也是很重要的办法。下面是常见的创建索引的原则:

  • 选择唯一性索引:唯一性索引一般基于Hash算法实现,可以快速、唯一地定位某条数据。
  • 为经常需要排序、分组和联合操作的字段建立索引。
  • 为常作为查询条件的字段建立索引。
  • 限制索引的数量:索引越多,数据更新表越慢,因为在数据更新时会不断计算和添加索引。
  • 尽量使用数据量少的索引:如果索引的值很长,则占用的磁盘变大,查询速度会受到影响。
  • 尽量使用前缀来索引:如果索引字段的值过长,则不但影响索引的大小,而且会降低索引的执行效率,这时需要使用字段的部分前缀来作为索引。
  • 删除不再使用或者很少使用的索引。
  • 尽量选择区分度高的列作为索引:区分度表示字段值不重复的比例。
  • 索引列不能参与计算:带函数的查询不建议参与索引。
  • 尽量扩展现有索引:联合索引的查询效率比多个独立索引高。

数据库三范式

第一范式

如果每列都是不可再分的最小数据单元(也叫作最小的原子单元),则满足第一范式,第一范式的目标是确保每列的原子性。如图所示,其中的Address列违背了第一范式列不可再分的原则,要满足第一范式,就需要将Address列拆分为Country列和City列。

第二范式

第二范式在第一范式的基础上,规定表中的非主键列不存在对主键的部分依赖,即第二范式要求每个表只描述一件事情。如图所示,Orders表既包含订单信息,也包含产品信息,需要将其拆分为两个单独的表。

第三范式

第三范式的定义为:满足第一范式和第二范式,并且表中的列不存在对非主键列的传递依赖。如图所示,除了主键的订单编号,顾客姓名依赖于非主键的顾客编号,因此需要将该列去除。

数据库事务

数据库事务执行一系列基本操作,这些基本操作组成一个逻辑工作单元一起向数据库提交,要么都执行,要么都不执行。事务是一个不可分割的工作逻辑单元。事务必须具备以下4个属性,简称ACID属性。

  • 原子性(Atomicity):事务是一个完整操作,参与事务的逻辑单元要么都执行,要么都不执行。
  • 一致性(Consistency):在事务执行完毕时(无论是正常执行完毕还是异常退出),数据都必须处于一致状态。
  • 隔离性(Isolation):对数据进行修改的所有并发事务都是彼此隔离的,它不应以任何方式依赖或影响其他事务。
  • 永久性(Durability):在事务操作完成后,对数据的修改将被持久化到永久性存储中。

存储过程

存储过程指一组用于完成特定功能的SQL语句集,它被存储在数据库中,经过第一次编译后再次调用时不需要被再次编译,用户通过指定存储过程的名字并给出参数(如果该存储过程带有参数)来执行它。存储过程是数据库中的一个重要对象,我们可以基于存储过程快速完成复杂的计算操作。以下为常见的存储过程的优化思路,也是我们编写事务时需要遵守的原则。

  • 尽量利用一些SQL语句代替一些小循环,例如聚合函数、求平均函数等。
  • 中间结果被存放于临时表中,并加索引。
  • 少使用游标(Cursors):SQL是种集合语言,对于集合运算有较高的性能,而游标是过程运算。比如,对一个50万行的数据进行查询时,如果使用游标,则需要对表执行50万次读取请求,将占用大量的数据库资源,影响数据库的性能。
  • 事务越短越好:SQL Server支持并发操作,如果事务过长或者隔离级别过高,则都会造成并发操作的阻塞、死锁,导致查询速度极慢、CPU占用率高等。
  • 使用try-catch处理异常。
  • 尽量不要将查找语句放在循环中,防止出现过度消耗系统资源的情况。

触发器

触发器是一段能自动执行的程序,和普通存储过程的区别是“触发器在对某一个表或者数据进行操作时触发”,例如进行UPDATE、INSERT、DELETE操作时,系统会自动调用和执行该表对应的触发器。触发器一般用于数据变化后需要执行一系列操作的情况,比如对系统核心数据的修改需要通过触发器来存储操作日志的信息等

数据库的并发操作和锁

数据库的并发策略

数据库的并发控制一般采用三种方法实现,分别是乐观锁悲观锁时间戳

乐观锁

乐观锁在读数据时,认为别人不会去写其所读的数据;悲观锁就刚好相反,觉得自己读数据时,别人可能刚好在写自己刚读的数据,态度比较保守;时间戳在操作数据时不加锁,而是通过时间戳来控制并发出现的问题。

悲观锁

悲观锁指在其修改某条数据时,不允许别人读取该数据,直到自己的整个事务都提交并释放锁,其他用户才能访问该数据。悲观锁又可分为排它锁(写锁)和共享锁(读锁)。

时间戳

时间戳指在数据库表中额外加一个时间戳列TimeStamp。每次读数据时,都把时间戳也读出来,在更新数据时把时间戳加1,在提交之前跟数据库的该字段比较一次,如果比数据库的值大,就允许保存,否则不允许保存。这种处理方法虽然不使用数据库系统提供的锁机制,但是可以大大提高数据库处理的并发量。

数据库锁

行级锁

行级锁指对某行数据加锁,是一种排他锁,防止其他事务修改此行。在执行以下数据库操作时,数据库会自动应用行级锁。

  • INSERT、UPDATE、DELETE、SELECT … FOR UPDATE [OF columns] [WAIT n|NOWAIT]。
  • SELECT … FOR UPDATE语句允许用户一次针对多条记录执行更新。
  • 使用COMMIT或ROLLBACK语句释放锁。

表级锁

表级锁指对当前操作的整张表加锁,它的实现简单,资源消耗较少,被大部分存储引擎支持。最常使用的MyISAM与InnoDB都支持表级锁定。表级锁定分为表共享读锁(共享锁)与表独占写锁(排他锁)

页级锁

**页级锁的锁定粒度介于行级锁和表级锁之间。**表级锁的加锁速度快,但冲突多,行级冲突少,但加锁速度慢。页级锁在二者之间做了平衡,一次锁定相邻的一组记录。

基于Redis的分布式锁

数据库锁是基于单个数据库实现的,在我们的业务跨多个数据库时,就要使用分布式锁来保证数据的一致性。下面介绍使用Redis实现一个分布式锁的流程。Redis实现的分布式锁以Redis setnx命令为中心实现,setnx是Redis的写入操作命令,具体语法为setnx(key val)。在且仅在key不存在时,则插入一个key为val的字符串,返回1;若key存在,则什么都不做,返回0。通过setnx实现分布式锁的思路如下:

  • 获取锁:在获取锁时调用setnx,如果返回0,则该锁正在被别人使用;如果返回1,则成功获取锁。
  • 释放锁:在释放锁时,判断锁是否存在,如果存在,则执行Redis的delete操作释放锁。

简单的Redis实现分布式锁的代码如下,注意,如果锁并发比较大,则可以设置一个锁的超时时间,在超时时间到后,Redis会自动释放锁给其他线程使用:

public  class  RedisLock  {
    private  final  static  Log  logger  =  LogFactory.getLog(BuilderDemo.class);
    private  Jedis  jedis;
    public  RedisLock(Jedis  jedis)  {
      this.jedis  =  jedis;
    }
    //获取锁
    public  synchronized  boolean  lock(String  lockId){
        //设置锁
        Long  status  =  jedis.setnx(lockId, System.currentTimeMillis()+"")  ;
        if (0 == status){//有人在使用该锁,获取锁失败
          return  false;
        }else{
          return true; //创建、获取锁成功,锁id=lockId
        }
    }
    //释放锁
    public  synchronized  boolean  unlock(String  lockId)  {
        String  lockValue  =  jedis.get(lockId);
        if (lockValue ! = null) {//释放锁成功
          jedis.del(lockId);
          return  true;
        }else  {
          return false; //释放锁失败
        }
    }
    public  static  void  main(String[]  args)  {
        JedisPoolConfig  jcon  =  new  JedisPoolConfig();
        JedisPool  jp  =  new  JedisPool(jcon, "127.0.0.1",6379);
        Jedis  jedis  =  jp.getResource();
        RedisLock  lock  =  new  RedisLock(jedis);
        String  lockId  =  "123";
        try  {
          if  (lock.lock(lockId))  {
              //加锁后需要执行的逻辑代码
          }
        }  catch  (Exception  e)  {
          e.printStackTrace();
        }  finally  {
          lock.unlock(lockId);
        }
    }
}

以上代码定义了RedisLock类,在该类中定义了一个Redis数据库连接Jedis,同时定义了lock方法来获取一个锁,在获取锁时首先通过setnx设置锁id获取Redis内锁的信息,如果返回信息为0,则表示锁正在被人使用(锁id存在于Redis中);如果不为0,则表示成功在内存中设置了该锁。同时在RedisLock类中定义了unlock方法用于释放一个锁,具体做法是在Redis中查找该锁并删除。

数据库分表

数据库分表有垂直切分水平切分两种,下面简单介绍二者的区别:

  • 垂直切分:将表按照功能模块、关系密切程度划分并部署到不同的库中。例如,我们会创建定义数据库workDB、商品数据库payDB、用户数据库userDB、日志数据库logDB等,分别用于存储项目数据定义表、商品定义表、用户数据表、日志数据表等。
  • 水平切分:在一个表中的数据量过大时,我们可以把该表的数据按照某种规则如userID散列进行划分,然后将其存储到多个结构相同的表和不同的库上。

数据库分布式事务

CAP

CAP原则又称CAP定理,指的是在一个分布式系统中,一致性(Consistency)、可用性(Availability)和分区容错性(Partition tolerance)三者不可兼得。

  • 一致性:在分布式系统的所有数据备份中,在同一时刻是否有同样的值(等同于所有节点都访问同一份最新的数据副本)。
  • 可用性:在集群中一部分节点发生故障后,集群整体能否响应客户端的读写请求(对数据更新具备高可用性)。
  • 分区容错性:系统如果不能在时限内达成数据的一致性,就意味着发生了分区,必须就当前操作在C和A之间做出选择。以实际效果而言,分区相当于对通信的时限要求。

两阶段提交协议

分布式事务指涉及操作多个数据库的事务,在分布式系统中,各个节点之间在物理上相互独立,通过网络进行沟通和协调。

二阶段提交(Two-Phase Commit)指在计算机网络及数据库领域内,为了使分布式数据库的所有节点在进行事务提交时都保持一致性而设计的一种算法。在分布式系统中,每个节点虽然都可以知道自己的操作是否成功,却无法知道其他节点的操作是否成功。

在一个事务跨越多个节点时,为了保持事务的ACID特性,需要引入一个作为协调者的组件来统一掌控所有节点(称作参与者)的操作结果,并最终指示这些节点是否真正提交操作结果(比如将更新后的数据写入磁盘等)。因此,二阶段提交的算法思路可以概括为:参与者将操作成败通知协调者,再由协调者根据所有参与者的反馈决定各参与者是提交操作还是中止操作。

Prepare(准备阶段)

事务协调者(事务管理器)给每个参与者(源管理器)都发送Prepare消息,每个参与者要么直接返回失败(如权限验证失败),要么在本地执行事务,写本地的redo和undo日志但不提交,是一种“万事俱备,只欠东风”的状态。

Commit(提交阶段)

如果协调者接收到了参与者的失败消息或者超时,则直接给每个参与者都发送回滚消息,否则发送提交消息,参与者根据协调者的指令执行提交或者回滚操作,释放在所有事务处理过程中使用的锁资源。

两阶段提交的缺点

两阶段提交的缺点如下:

  • 同步阻塞问题:在执行过程中,所有参与者的任务都是阻塞执行的。
  • 单点故障:所有请求都需要经过协调者,在协调者发生故障时,所有参与者都会被阻塞。
  • 数据不一致:在二阶段提交的第2阶段,在协调者向参与者发送Commit(提交)请求后发生了局部网络异常,或者在发送Commit请求过程中协调者发生了故障,导致只有一部分参与者接收到Commit请求,于是整个分布式系统出现了数据不一致的现象,这也被称为脑裂。
  • 协调者宕机后事务状态丢失:协调者在发出Commit消息之后宕机,唯一接收到这条消息的参与者也宕机,即使协调者通过选举协议产生了新的协调者,这条事务的状态也是不确定的,没有人知道事务是否已被提交。

三阶段提交协议

三阶段提交(Three-Phase Commit),也叫作三阶段提交协议(Three-Phase CommitProtocol),是二阶段提交(2PC)的改进版本。具体改进如下:

  • 引入超时机制:在协调者和参与者中引入超时机制,如果协调者长时间接收不到参与者的反馈,则认为参与者执行失败。
  • 预准备阶段:在第1阶段和第2阶段都加入一个预准备阶段,以保证在最后的任务提交之前各参与节点的状态是一致的。也就是说,除了引入超时机制,三阶段提交协议(3PC)把两阶段提交协议(2PC)的准备阶段再次一分为二,这样三阶段提交就有CanCommit、PreCommit、DoCommit三个阶段

CanCommit阶段

协调者向参与者发送Commit请求,参与者如果可以提交就返回Yes响应,否则返回No响应。

PreCommit阶段

协调者根据参与者的反应来决定是否继续进行,有以下两种可能。

  • 假如协调者从所有参与者那里获得的反馈都是Yes响应,就预执行事务。
  • 假如有任意参与者向协调者发送了No响应,或者在等待超时之后协调者都没有接收到参与者的响应,则执行事务的中断。

DoCommit阶段

该阶段进行真正的事务提交,主要包括:协调者发送提交请求,参与者提交事务,参与者响应反馈(在事务提交完之后向协调者发送Ack响应),协调者确定完成事务。

分布式事务

传统事务

传统事务遵循ACID原则,即原子性、一致性、隔离性和持久性。

  • 原子性:事务是包含一系列操作的原子操作,事务的原子性确保这些操作全部完成或者全部失败。
  • 一致性:事务执行的结果必须使数据库从不一致性状态转为一致性状态。保证数据库的一致性指在事务完成时,必须使所有数据都有一致的状态。
  • 隔离性:因为可能在相同的数据集上同时有许多事务要处理,所以每个事务都应该与其他事务隔离,避免数据被破坏。
  • 持久性:一旦事务完成,其结果就应该能够承受任何系统的错误,比如在事务提交过程中服务器的电源被切断等。在通常情况下,事务的结果被写入持续性存储中。

柔性事务

在分布式数据库领域,基于CAP理论及BASE理论,阿里巴巴提出了柔性事务的概念。BASE理论是CAP理论的延伸,包括基本可用(Basically Available)、柔性状态(Soft State)、最终一致性(Eventual Consistency)三个原则,并基于这三个原则设计出了柔性事务。

我们通常所说的柔性事务分为:两阶段型、补偿型、异步确保型、最大努力通知型

两阶段型事务指分布式事务的两阶段提交,对应技术上的XA和JTA/JTS,是分布式环境下事务处理的典型模式。

TCC型事务(Try、Confirm、Cancel)为补偿型事务,是一种基于补偿的事务处理模型。如图所示,服务器A发起事务,服务器B参与事务,如果服务器A的事务和服务器B的事务都顺利执行完成并提交,则整个事务执行完成。但是,如果事务B执行失败,事务B本身就回滚,这时事务A已被提交,所以需要执行一个补偿操作,将已经提交的事务A执行的操作进行反操作,恢复到未执行前事务A的状态。需要注意的是,发起提交的一般是主业务服务,而状态补偿的一般是业务活动管理者,因为活动日志被存储在业务活动管理中,补偿需要依靠日志进行恢复。TCC事务模型牺牲了一定的隔离性和一致性,但是提高了事务的可用性。

异步确保型事务指将一系列同步的事务操作修改为基于消息队列异步执行的操作,来避免分布式事务中同步阻塞带来的数据操作性能下降,在写业务数据A触发后将执行以下流程。

  1. 业务A的模块在数据库A上执行数据更新操作。
  2. 业务A调用写消息数据模块。
  3. 写消息日志模块将数据库的写操作状态写入数据库A中。
  4. 写消息日志模块将写操作日志发送给消息服务器。
  5. 读消息日志模块接收操作日志。
  6. 读消息数据调用写业务B的模块。
  7. 写业务B更新数据到数据库B。
  8. 写业务数据B的模块发送异步消息更新数据库A中的写消息日志状态,说明自己已经完成了异步数据更新操作。

最大努力通知型事务也是通过消息中间件实现的,与前面异步确保型操作不同的是:在消息由MQ服务器发送到消费者之后,允许在达到最大重试次数之后正常结束事务,因此无法保障数据的最终一致性。如图所示,写业务数据A在更新数据库后调用写消息日志将数据操作以异步消息的形式发送给读消息日志模块;读消息日志模块在接收到数据操作后调用写业务B写数据库。和异步确保型不同的是,数据库B在写完之后将不再通知写状态到数据库A,如果因为网络或其他原因,在如图所示的第4步没有接收到消息,则消息服务器将不断重试发送消息到读消息日志,如果经过N次重试后读消息日志还是没有接收到日志,则消息不再发送,这时会出现数据库A和数据库B数据不一致的情况。最大努力型通知事务通过消息服务使分布式事务异步解耦,并且模块简单、高效,但是牺牲了数据的一致性,在金融等对事务要求高的业务中不建议使用,但在日志记录类等对数据一致性要求不是很高的应用上执行效率很高。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值