业务中使用缓存的实践总结(一)

背景

之前我在团队中一直负责的是数据开发的相关工作。因此,对于后台系统开发并没有很多工作实践。不过近期我也着手做了一些后台开发的相关工作,在实践过程中使用到了缓存,在实践一段时间后,在此文中对我使用缓存的实践做一总结。

实际上,我们的业务场景非常简单,没有很高的并发以及时延要求。因此,在设计缓存时,我们也没有把问题复杂化,方案设计的很简单。不过也并不影响我们进一步的思考缓存的使用。本文主要总结的是在工作实践这一场景下更多的联想思考,包括为什么要使用缓存、我们的缓存方案选择以及使用缓存中可能要注意的问题。一篇文章肯定远远总结不完我们对于缓存的实践,因此后续还可能会继续写文章讨论一这话题。

业务场景

我当前在做的其中一个工作是游戏中心的后台开发。所谓游戏中心,对于外部来说其实就是一个H5页面,该页面个性化的展示了我们IEG的各个游戏,之后规划会将该H5页面嵌入到一些中小媒体的APP中,用于做腾讯游戏的推广和分发。目前我们系统的QPS大约只有500,不过我相信随着后续媒体的不断接入,QPS会持续升高的。

游戏中心的前端页面是这样的:
在这里插入图片描述
该前端页面主要是调用我们后台的两个接口:游戏列表、游戏详情。

后台的这两个接口会去查询到相应的数据并返回给前端做展示。因此,对于前端页面来说,只有对后台数据的读取,没有写入。

后台数据的写入是通过游戏中心的管理端进行的。

整个业务示意如图所示:
在这里插入图片描述

为什么要使用缓存

我们的后台服务在查询数据时使用到了缓存,那么,我们为什么要使用缓存呢?

其中有三点原因:

  1. 减轻数据库的压力
    具体的例子来说,我们使用Redis做缓存。一个Redis实例可以有万级的吞吐量,而一个MySQL实例大概是千级的吞吐量。在业务请求并发量很高的情况下,如果每次查询数据都要到MySQL数据库里去查,那么MySQL会压力非常大。极端情况下,有可能会导致MySQL挂掉。就算我们MySQL可以堆很多资源,从成本角度来说,也不如加上缓存。
  2. 提升用户的体验
    缓存使用的是计算机内存进行存储,而数据库使用的是计算机的磁盘。从读写性能上来说,当然缓存的读写性能远优于数据库。因此,在查询数据的时候,缓存的时延要远低于数据库查询的时延。对于用户来说,就可以有更好的使用体验。
  3. 提高系统并发量
    如前面所述,缓存的并发量远大于数据库的并发量。使用缓存可以有效提高系统的请求并发量。

实现方案的选择

缓存设计模式主要有三种:Cache Aside、Read/Write Through和Write Behind Caching。

这里就不一一详细说明了,简单来说的话,这三者就是更新数据库的时机有所不同:

Cache Aside:在更新缓存的同时更新数据库。
Read/Write Through:更新缓存后,由缓存来更新数据库。
Write Behind Caching:更新缓存后,缓存定时异步更新数据库。

我们实际的方案设计中有Cache Aside的设计思想,但是也不完全一样。

方案的思考

方案一

第一种方案的操作示意如图所示:
在这里插入图片描述
外部会先从Redis中读取数据,如果读取到了数据则直接返回。若没有读取到数据,则会去MySQL中读取相应数据,并同时将MySQL中读取到的数据更新至Redis中。若MySQL中也读不到该数据,则报错返回。

在MySQL数据发生更新时,会同时将数据更新到Redis中,并设置Redis数据的过期时间。

外部读取数据时的流程如下图所示:
在这里插入图片描述
该方案的优缺点如下

优点:

  1. 数据的更新非常及时,运营人员操作更新数据后,外部获取到的数据也会立即更新。
  2. 一般情况下不会出现数据不一致的情况。

缺点:

  1. 若发生缓存雪崩或缓存击穿的情况,那么就有导致MySQL出问题的风险。
  2. 相较于本地Cache,读取Redis的数据还是需要一定的网络IO。

方案二

第二种方案的操作示意如下图所示:
在这里插入图片描述
这里使用了定时更新的方式,运营人员通过管理端往MySQL中写数据这块不用赘述。

MySQL中的数据会每隔3分钟往Redis中刷新数据。而外部在请求数据时,首先会访问本地的Cache,如果本地Cache中没有该条数据,则会去Redis中进行查询,并同时会将该条数据更新至本地Cache(本地Cache会设置过期时间)。而若Redis中也查询不到该条数据,那么我们就会返回报错信息,不会再到MySQL中进行查询。

外部读取数据时的流程如下图所示:
在这里插入图片描述
该方案的优缺点如下

优点:

  1. 实现很简单,不需要访问数据库,只需要写一些定时任务执行。
  2. 不用访问数据库,可以完全避免缓存雪崩与缓存击穿所对数据库会带来的风险。
  3. 使用本地Cache,会比访问Redis获取数据更快一些。

缺点:

  1. 数据的更新延迟很大,完全不适用对数据及时性要求高的系统。
  2. 使用本地Cache,若有多节点对外提供服务,很可能会有数据不一致的问题。

方案三

第三种方案的操作示意如下图所示:
在这里插入图片描述
方案三在方案一的基础之上增加了本地Cache缓存。

该方案在外部读取数据时的流程如下图所示:
在这里插入图片描述
该方案的优缺点如下

优点:

  1. 使用本地Cache,会比访问Redis获取数据更快一些。
  2. 每次有更新都会非常及时的同步到外部访问的数据。

缺点:

  1. 若发生缓存雪崩或缓存击穿的情况,那么就有导致MySQL出问题的风险。
  2. 使用本地Cache,若有多节点对外提供服务,很可能会有数据不一致的问题。

缓存实现方案的对比

方案数据一致性请求时延数据更新时机方案风险
方案一没有不一致的风险Redis的IO时延立即更新缓存雪崩/击穿时,会导致MySQL风险
方案二有不一致的风险内存读取时延不会立即更新缓存雪崩/击穿时,不会导致MySQL风险
方案三有不一致的风险内存读取时延立即更新缓存雪崩/击穿时,会导致MySQL风险

业务场景下的缓存实现方案选择

通过上面的缓存实现方案对比,基本上我们可以明确三种不同的方案的优劣势。

最终我们的业务选择了方案二来进行具体的实现,原因是:

  1. 我们当前业务有几个模块共用了同一个数据库,方案二可以完全保证数据库的稳定。
  2. 当前业务对请求时延有要求,但对数据一致性没有什么要求。
  3. 当前业务场景下,对数据的更新时机没有高要求,就算数据变更后隔段时间再更新至用户侧,也不会对业务有影响。

当然,之后如果业务的要求有调整,我们后台这边的缓存策略也会做相应的调整的。

使用缓存可能会带来的问题的思考

我们的业务中引入了缓存机制,是为了有效的利用缓存的一些优势,比如减轻数据库的压力、提升用户的体验、提高系统并发量。

但是,缓存的引入也可能会带来一些新的问题。虽然我们当前的业务场景较为简单,可能不会碰到缓存带来的问题,但是也不影响在该场景下的一些延伸思考。接下来,就从几个方面来思考一下缓存可能会带来的问题。

数据不一致

首先,只要引入了缓存机制,那么就不可避免的会要考虑到数据一致性的问题。我们先在这里明确一下数据一致性的定义:

  1. 缓存中存在数据时,缓存中的数据需要与数据库中的数据一致。
  2. 缓存中不存在数据时,数据库中的数据需要是最新的数据。

如果以上两点有其中一点不满足,那么我们就认为数据是不一致的了。因此,我们在使用缓存的过程中要思考的问题就是如何去保证数据的一致性。

缓存可以分为两种类型:读写缓存以及只读缓存。接下来我们就以上述方案一为例分别对这两种类型缓存进行数据一致性的分析:

读写缓存

读写缓存不仅可以被读取数据,还可以在缓存中进行增删改操作。因此,使用读写缓存时,一般会有两种数据写回数据库的方式。

一种是外部只操作缓存,然后每隔一段时间将缓存数据写回到数据库一次。这种方式叫做异步写回策略。一般适用于非核心的业务数据。因为如果在缓存数据写回到数据库之前就挂了,那么这些数据的变更记录也就永久丢失了。

另一种是外部在更新缓存的同时,也会同步的更新数据库。这种方式叫做同步直写策略。对于核心的业务数据我们就应该用这种策略。

对于该策略来说,要保证数据的一致性,那么就必须保证缓存和数据库更新的原子性,也就是说,如果要更新成功就都成功,如果更新失败就都回到更新前的状态。只有这样才能保证数据的一致。在这样的前提下,我们具体分别通过增、删、改操作来进行描述。

增:无论是先更新缓存还是先更新数据库,都不会影响到数据的最终一致性。

删:若先更新缓存,后更新数据库。假设操作A更新缓存完成后,且更新数据库前,这是如果有操作B对缓存该数据进行查询,那么就可能会出现将数据库的数据重新刷回缓存的情况,之后A再更新数据库,就会导致最终的数据不一致。若先更新数据库,后更新缓存。那么就不会影响到数据的最终一致性。

改:无论是先更新缓存还是先更新数据库,都不会影响到数据的最终一致性。

可以看到,无论我们是进行增、删、改的哪个操作,最有利的更新策略就是先更新数据库后更新缓存,这样就不会造成数据不一致的问题。

只读缓存

只读缓存与读写缓存不同,对于读操作和删操作来说是一样的逻辑,但是对于增操作和改操作则不同。

此处读操作和删操作不再赘述,只看一下只读缓存的增操作和改操作。

增:对于只读缓存,在做增操作时,只需要对数据库进行操作,也就是只需要在数据库中增加新的数据即可,在下次外部访问缓存时会自动将缓存更新。

改:在进行改操作时,我们有两步需要做,一个是将缓存中对应的数据设置为失效,一个是更改数据库中的数据。若我们先更新缓存,再更新数据库,则可能会碰到数据不一致的问题,原因这里不再赘述。若我们先更新数据库,再更新缓存,则可以保证数据的一致。只不过是数据更改的生效会稍稍慢一些。

对于只读缓存来说,我们也是要保证缓存和数据库操作的原子性。同时,最优的策略依然是先更新数据库后更新缓存。

整理这些情况,如表所示:
在这里插入图片描述
上表看着复杂,实际上整体来看,无论是读写缓存还是只读缓存,我们首先要保证的是缓存和数据库操作的原子性,否则就可能出现数据不一致的情况。同时,我们在进行数据操作时,尽可能考虑优先操作数据库后操作缓存,这样会极大程度的保证数据的一致性。

缓存雪崩

所谓缓存雪崩,指的是缓存在一段时间内无法对外提供服务,这样导致的结果就是会突然有大量的外部请求直接访问到后台数据库。而我们都知道,后台数据库的吞吐能力一般来说都没有缓存大,如果本来应该是缓存的请求,全部都打到数据库上,则很有可能会出现数据库卡顿的情况,甚至可能会引起数据库宕机。

如图所示:
在这里插入图片描述
一般来说,会有两种情况会导致缓存雪崩:

第一种,就是缓存服务真的宕机了。这种情况,我们一般有三种手段来进行处理。

  1. 服务熔断
    所谓服务熔断,是一种比较暴力的解决方式,也就是在缓存服务宕机时,让客户端的请求不再打到缓存服务上,而是直接返回我们预设的默认值,或者干脆直接返回报错信息。以避免因为缓存服务的宕机而导致的后续连锁反应。这样,当缓存服务恢复正常时,我们也可以快速的将服务恢复到正常状态。
    如图所示:
    在这里插入图片描述
  2. 服务限流
    上面讨论的服务熔断的处理方式或许太过粗暴,会直接导致用户的请求都暂时受到影响。我们还可以用另一种服务限流的方式来进行缓存宕机后的处理。
    所谓服务限流,指的是由前端服务限制每秒请求的个数,将每秒请求的个数限制在数据库所能承受的数量内,那么就算是每个请求都直接访问数据库也不会导致数据库出问题了。
    在这里插入图片描述
  3. 服务降级
    还有一种比较合理的处理方式是服务降级。
    所谓服务降级,指的是在缓存服务宕机的情况下,只对核心接口请求进行处理,而将非核心接口的请求直接返回。这种处理方式更能够考虑到业务的需求。

前面就考虑完了在缓存服务宕机所引发的缓存雪崩的情况下该如何去处理。

另一种会引起缓存雪崩的场景,是大量的缓存同时过期。这时,虽然缓存服务没有问题,但是由于大量的请求都查询不到对应的缓存数据,也会一下子有大量的请求打到数据库上的。像这种情况所引起的雪崩是可以通过合理的设置缓存过期时间来避免的。

具体来说,就是我们在设置缓存过期时间时,不要将缓存过期时间设置为一个固定值,而应该将缓存时间设置为一个固定值加一个上下浮动的随机数。这样就能够比较好的避免因缓存同时过期而导致的缓存雪崩问题。

对于我们的系统而言,Redis缓存是存放在腾讯云上的,基本上可以放心使用不用担心雪崩的问题。但给缓存设置一个合理的过期时间还是非常重要的。

缓存击穿

缓存击穿有别于缓存雪崩,指的是由于某个热点数据的过期,而导致在同一时间有大量请求打到数据库上进行查询的情况。

如图:
在这里插入图片描述
对于这种情况的处理比较简单,就是我们要判别哪些数据在缓存中属于热点数据,这些数据很可能是每次请求都会去访问的。这种数据就不要设置过期时间了,让这些数据长期在缓存中,只有在数据库更新的同时再主动去更新这些缓存数据即可。

对于我们的系统来说,比如首页的游戏列表,这样的数据我们都是长期在缓存中保存的,因为这些数据只有可能被更新,而不会被淘汰。

缓存穿透

缓存穿透指的是外部请求的数据既不在缓存中,也不在数据库中。这种情况更为严重,因为会使缓存和数据库同时承受很大的压力。
在这里插入图片描述
可能会导致缓存穿透的原因有两个:

  1. 外部黑客的恶意攻击
  2. 对应数据被误删除

针对缓存穿透的情况,这里可以考虑三点:

  1. 接口做严格的校验
    这是预防的策略,防患于未然,外部之所以可以对系统进行恶意攻击,访问到不存在的数据,是因为前置的接口权限校验以及参数校验做的不够严谨导致。
  2. 给数据设置缺省值
    在数据被误删除的情况下,一个紧急的处理办法就是在缓存中写入对应数据的缺省值。在这种情况下起码可以对数据库做到保护作用。
  3. 使用布隆过滤器前置值存在与否的查询
    这种是比较优雅的预防以及解决缓存穿透的方案。我们在数据被写入到数据库之前先在布隆过滤器做一个标记,在缓存中查询不到数据时,我们就可以先查询布隆过滤器,若未查到标记的数据,那么我们也就可以直接给请求做返回处理了,从而保护了数据库免于被大量无效请求访问。

当前我们的系统隔断了对数据库的访问,因此也就不会出现缓存穿透的问题。不过,严格的接口校验也是必不可少的。另外,如果我们后续可以在Redis中做一个布隆过滤器的话,就可以用方案三来替代掉方案二了。

总结以上缓存问题以及对应的解决方案如下表:
在这里插入图片描述

总结

本文从近期业务使用的缓存场景出发,首先简单介绍了我们的业务场景,然后描述了我们为什么要使用缓存,接着对方案的选择过程做了记录,最后分析了我们之后使用缓存过程中可能会遇到的问题以及对应的解决方案。一篇文章肯定远远总结不完我们对于缓存的实践,因此后续还可能会继续写文章讨论一这话题。

etcd后端存储源码解析——底层读写操作

背景

最近想找一些用Go语言实现的优秀开源项目学习一下,etcd作为一个被广泛应用的高可用、强一致性服务发现存储仓库,非常值得分析学习。
本篇文章主要是对etcd的后台存储源码做一解析,希望可以从中学到一些东西。

etcd大版本区别

目前etcd常用的是v2和v3两个大版本。两个版本不同之处主要在于:

  1. v2版本仅在内存中对数据进行了存储,没有做持久化存储。而v3版本做了持久化存储,且还使用了缓存机制加快查询速度。
  2. v2版本和v3版本对外提供的接口做了一些改变。在命令行界面中,可以使用环境变量ETCDCTL_API来设置对外接口。

我们在这里主要是介绍v3版本的后台存储部分实现。 并且这里仅涉及到底层的读写操作接口,并不涉及到更上层的读写步骤(键值的revision版本选择等)。

etcd的后端存储接口

分析思路:

  1. 查看etcd封装的后端存储接口
  2. 查看etcd实现了后端存储接口的结构体
  3. 查看上述结构体的初始化方法
  4. 查看上述结构体的初始化值
  5. 查看上述结构体初始化方法的具体初始化过程

首先,我们先来看下etcd封装的后端存储接口:
路径:https://github.com/etcd-io/etcd/blob/master/mvcc/backend/backend.go

type Backend interface {
    // ReadTx returns a read transaction. It is replaced by ConcurrentReadTx in the main data path, see #10523.
    ReadTx() ReadTx
    BatchTx() BatchTx
    // ConcurrentReadTx returns a non-blocking read transaction.
    ConcurrentReadTx() ReadTx

    Snapshot() Snapshot
    Hash(ignores map[IgnoreKey]struct{}) (uint32, error)
    // Size returns the current size of the backend physically allocated.
    // The backend can hold DB space that is not utilized at the moment,
    // since it can conduct pre-allocation or spare unused space for recycling.
    // Use SizeInUse() instead for the actual DB size.
    Size() int64
    // SizeInUse returns the current size of the backend logically in use.
    // Since the backend can manage free space in a non-byte unit such as
    // number of pages, the returned value can be not exactly accurate in bytes.
    SizeInUse() int64
    // OpenReadTxN returns the number of currently open read transactions in the backend.
    OpenReadTxN() int64
    Defrag() error
    ForceCommit()
    Close() error
}

Backend接口封装了etcd后端所提供的接口,最主要的是:
ReadTx(),提供只读事务的接口,以及BatchTx(),提供读写事务的接口。
Backend作为后端封装好的接口,而backend结构体则实现了Backend接口。
路径:https://github.com/etcd-io/etcd/blob/master/mvcc/backend/backend.go

type backend struct {
    // size and commits are used with atomic operations so they must be
    // 64-bit aligned, otherwise 32-bit tests will crash

    // size is the number of bytes allocated in the backend
    // size字段用于存储给后端分配的字节大小
    size int64
    // sizeInUse is the number of bytes actually used in the backend
    // sizeInUse字段是后端实际上使用的内存大小
    sizeInUse int64
    // commits counts number of commits since start
    // commits字段用于记录启动以来提交的次数
    commits int64
    // openReadTxN is the number of currently open read transactions in the backend
    // openReadTxN存储目前读取事务的开启次数
    openReadTxN int64

    // mu是互斥锁
    mu sync.RWMutex
    // db表示一个boltDB实例,此处可以看到,Etcd默认使用Bolt数据库作为底层存储数据库
    db *bolt.DB

    // 用于读写操作
    batchInterval time.Duration
    batchLimit    int
    batchTx       *batchTxBuffered

    // 该结构体用于只读操作,Tx表示transaction
    readTx *readTx

    stopc chan struct{}
    donec chan struct{}

    // 日志信息
    lg *zap.Logger
}

通过19行 db *bolt.DB 我们可以看到,etcd的底层存储数据库为BoltDB。
好了,接下来我们就看一下这个backend结构体是如何初始化的。
还是在该路径下,我们可以看到New函数

// 创建一个新的backend实例
func New(bcfg BackendConfig) Backend {
    return newBackend(bcfg)
}

该函数传入了参数bcfg,类型为BackendConfig,这是后端存储的配置信息。
我们先看下这个配置信息中包含了什么
依然在该路径下,找到BackendConfig结构体

type BackendConfig struct {
    // Path is the file path to the backend file.
    Path string
    // BatchInterval is the maximum time before flushing the BatchTx.
    // BatchInterval表示提交事务的最长间隔时间
    BatchInterval time.Duration
    // BatchLimit is the maximum puts before flushing the BatchTx.
    BatchLimit int
    // BackendFreelistType is the backend boltdb's freelist type.
    BackendFreelistType bolt.FreelistType
    // MmapSize is the number of bytes to mmap for the backend.
    // MmapSize表示分配的内存大小
    MmapSize uint64
    // Logger logs backend-side operations.
    Logger *zap.Logger
    // UnsafeNoFsync disables all uses of fsync.
    UnsafeNoFsync bool `json:"unsafe-no-fsync"`
}

可以看到,有许多backend初始化所需要的信息都在这个结构体中。
既然有这些配置信息,那么一定会有相应的默认配置信息,
我们来看下在默认情况下etcd存储部分会被赋怎样的值。
依然在该目录下,找到DefaultBackendConfig函数。

func DefaultBackendConfig() BackendConfig {
    return BackendConfig{
    BatchInterval: defaultBatchInterval,
    BatchLimit: defaultBatchLimit,
    MmapSize: initialMmapSize,
    }
}

随便查看其中某个全局变量的值,比如defaultBatchInterval,则可以看到默认值:

var (
    defaultBatchLimit = 10000
    defaultBatchInterval = 100 * time.Millisecond
    defragLimit = 10000
    // initialMmapSize is the initial size of the mmapped region. Setting this larger than
    // the potential max db size can prevent writer from blocking reader.
    // This only works for linux.
    initialMmapSize = uint64(10 * 1024 * 1024 * 1024)
    // minSnapshotWarningTimeout is the minimum threshold to trigger a long running snapshot warning.
    minSnapshotWarningTimeout = 30 * time.Second
)

defaultBatchInterval变量为例,就是说默认情况下,etcd会100秒做一次自动的事务提交。
etcd后端存储默认赋值的部分说完了,就说回对结构体的初始化上。
我们继续看函数New,它调用了函数newBackend
我们看下函数newBackend做了些什么

func newBackend(bcfg BackendConfig) *backend {
    if bcfg.Logger == nil {
        bcfg.Logger = zap.NewNop()
    }

    // 一些配置载入
    bopts := &bolt.Options{}
    if boltOpenOptions != nil {
        *bopts = *boltOpenOptions
    }
    bopts.InitialMmapSize = bcfg.mmapSize()
    bopts.FreelistType = bcfg.BackendFreelistType
    bopts.NoSync = bcfg.UnsafeNoFsync
    bopts.NoGrowSync = bcfg.UnsafeNoFsync

    // 初始化Bolt数据库
    db, err := bolt.Open(bcfg.Path, 0600, bopts)
    if err != nil {    
        bcfg.Logger.Panic("failed to open database", zap.String("path", bcfg.Path), zap.Error(err))
    }

    // In future, may want to make buffering optional for low-concurrency systems
    // or dynamically swap between buffered/non-buffered depending on workload.
    // 对backend结构体做初始化,包括了readTx只读事务以及batchTx读写事务
    b := &backend{
        db: db,

        batchInterval: bcfg.BatchInterval,
        batchLimit: bcfg.BatchLimit,

        readTx: &readTx{
            baseReadTx: baseReadTx{
                buf: txReadBuffer{
                    txBuffer: txBuffer{make(map[string]*bucketBuffer)},
                },
                buckets: make(map[string]*bolt.Bucket),
                txWg: new(sync.WaitGroup),
                txMu: new(sync.RWMutex),
            },
        },

        stopc: make(chan struct{}),
        donec: make(chan struct{}),

        lg: bcfg.Logger,
    }
    b.batchTx = newBatchTxBuffered(b)
    // 开启一个新的etcd后端存储连接
    go b.run()
    return b
}

我们可以看到6-19行在初始化boltDB的同时载入了一些数据库的配置信息。
23-41行是对backend结构体做了初始化,包括了只读事务readTx、读写事务batchTx结构体的初始化,以及初始化了两个通道stopc、donec,这个后面会用到。
43行开启了一个协程去并发的处理run()函数内的工作。
我们继续看一下run()函数做了什么。依然在该目录下

func (b *backend) run() {
    // 关闭结构体的donec通道
    defer close(b.donec)
    // 开启一个定时器
    t := time.NewTimer(b.batchInterval)
    // 最后要关闭定时器
    defer t.Stop()
    for {
        select {
            // 当定时器到时间了,则t.C会有值
            case <-t.C:
            case <-b.stopc:
                b.batchTx.CommitAndStop()
                return
            }
        // 定时器到时间了,且数据的偏移量非0,即有数据的情况下,则会进行一次事务的自动提交
        if b.batchTx.safePending() != 0 {
            b.batchTx.Commit()
        }
        // 重新设置定时器的时间
        t.Reset(b.batchInterval)    
    }
}

我在代码中注释的比较详细了,简单的说,就是在初始化backend结构体时,开启了一个协程用于事务的自动提交,事务自动提交的时间间隔为batchInterval,这个默认值为100秒。
注意12-14行,这段代码表示,如果是停止信号进来的话,则事务会立即提交并且停止。
到这里,backend结构体就初始化完成了,接下来我们看一下用于读操作的只读事务接口ReadTx

etcd后端存储的读操作

路径:https://github.com/etcd-io/etcd/blob/master/mvcc/backend/read_tx.go

type ReadTx interface {
    Lock()
    Unlock()
    RLock()
    RUnlock()

    UnsafeRange(bucketName []byte, key, endKey []byte, limit int64) (keys [][]byte, vals [][]byte)
    UnsafeForEach(bucketName []byte, visitor func(k, v []byte) error) error
}

该接口是用结构体baseReadTx实现的,来看一下baseReadTx结构体,文件路径与ReadTx接口一样

type baseReadTx struct {
    // buf与buckets都是用于增加读效率的缓存
    // mu用于保护txReadBuffer缓存的操作
    mu  sync.RWMutex
    buf txReadBuffer

    // txMu用于保护buckets缓存和tx的操作
    txMu    *sync.RWMutex
    tx      *bolt.Tx
    buckets map[string]*bolt.Bucket
    // txWg可以防止tx在批处理间隔结束时回滚,直到使用该tx完成所有读取为止
    txWg *sync.WaitGroup
}

只读事务ReadTx的读取数据的接口有两个,分别是UnsafeRange以及UnsafeForEach。我们以UnsafeRange接口为例进行代码分析。
UnsafeRange接口的实现依然在上述路径中

// 该方法用于底层数据的只读操作
func (baseReadTx *baseReadTx) UnsafeRange(bucketName, key, endKey []byte, limit int64) ([][]byte, [][]byte) {
    // 不使用范围查询
    if endKey == nil {
        // forbid duplicates for single keys
        limit = 1
    }
    // 当范围值异常时,则传入最大范围
    if limit <= 0 {
        limit = math.MaxInt64
    }
    if limit > 1 && !bytes.Equal(bucketName, safeRangeBucket) {
        panic("do not use unsafeRange on non-keys bucket")
    }
    // 将buf缓存中的数据读取出来
    keys, vals := baseReadTx.buf.Range(bucketName, key, endKey, limit)
    // 如果取出的数据满足了需求,那么则直接返回数据
    if int64(len(keys)) == limit {
        return keys, vals
    }

    // find/cache bucket
    // 从bucket缓存中查询bucket实例,查询到了则返回缓存中的实例,查询不到,则在BoltDB中查找
    bn := string(bucketName)
    baseReadTx.txMu.RLock()
    bucket, ok := baseReadTx.buckets[bn]
    baseReadTx.txMu.RUnlock()
    lockHeld := false
    // 缓存中取不到bucket的话,会从bolt中查找,并写入缓存中
    if !ok {
        baseReadTx.txMu.Lock()
        lockHeld = true
        bucket = baseReadTx.tx.Bucket(bucketName)
        baseReadTx.buckets[bn] = bucket
    }

    // ignore missing bucket since may have been created in this batch
    if bucket == nil {
        if lockHeld {
            baseReadTx.txMu.Unlock()
        }
        return keys, vals
    }
    if !lockHeld {
        baseReadTx.txMu.Lock()
        lockHeld = true
    }
    c := bucket.Cursor()
    baseReadTx.txMu.Unlock()

    // 从bolt的该bucket中查找键值对
    k2, v2 := unsafeRange(c, key, endKey, limit-int64(len(keys)))
    return append(k2, keys...), append(v2, vals...)
}

4-14行是一些前置的判断步骤,16-20则是从buf缓存中读取数据,前面提到过,buf是etcd用于提高读取效率的缓存。
我们看下具体的从buf读取数据的过程。
Range函数在路径:https://github.com/etcd-io/etcd/blob/master/mvcc/backend/tx_buffer.go

func (txr *txReadBuffer) Range(bucketName, key, endKey []byte, limit int64) ([][]byte, [][]byte) {
    if b := txr.buckets[string(bucketName)]; b != nil {
        return b.Range(key, endKey, limit)
    }
    return nil, nil
}

可以看到,该方法就是实例化了名为bucketName的桶,然后从该桶中按照范围读取键值数据。
我们可以看到,bucket的实例为结构体bucketBuffer

type bucketBuffer struct {
    buf []kv
    // used字段记录了正在使用的元素个数,这样buf无需重新分配内存就可以覆盖写入
    used int
}

看回到Range方法代码的第3行,我们来看一下b.Range方法的代码。b.Range与buf.Range方法不同,b.Range是结构体bucketBuffer实现的方法。
依然与Range方法相同路径

func (bb *bucketBuffer) Range(key, endKey []byte, limit int64) (keys [][]byte, vals [][]byte) {
    // 查找到key在buf中的索引idx
    f := func(i int) bool { return bytes.Compare(bb.buf[i].key, key) >= 0 }
    // sort.Search用于从某个切片中查找某个值的索引
    idx := sort.Search(bb.used, f)
    if idx < 0 {
        return nil, nil
    }
    // 只查找一个key值,而非范围查找
    if len(endKey) == 0 {
        if bytes.Equal(key, bb.buf[idx].key) {
            keys = append(keys, bb.buf[idx].key)
            vals = append(vals, bb.buf[idx].val)
        }
        return keys, vals
    }
    // 根据字节的值来比较字节切片的大小
    // 如果endKey比key值小,则返回nil
    if bytes.Compare(endKey, bb.buf[idx].key) <= 0 {
        return nil, nil
    }
    // 在个数限制limit内,且小于endKey的所有键值对都取出来
    for i := idx; i < bb.used && int64(len(keys)) < limit; i++ {
        if bytes.Compare(endKey, bb.buf[i].key) <= 0 {
            break
        }
        keys = append(keys, bb.buf[i].key)
        vals = append(vals, bb.buf[i].val)
    }
    return keys, vals
}

3-5行代码表示要从buf结构体中找到第一个满足包含key的索引值。该两行代码一般结合使用,是一种常见的用于查找值对应索引值的方式。

10-16行代码表示,如果endKey为0,即不使用范围查找,只查找key这一个精确值,那么就需要判断3-5代码找到的值是否与该key完全相等,只有完全相等了才会返回keys与vals。

19-21行代码表示,如果输入的endKey比key值还要小,那么就认为是输入的问题,则返回nil值。

最后19-30行代码表示,key与endKey都输入正常的情况下,则将limit内,大于等于key且小于endKey的键值对都取出来,并返回keys、vals结果。

到此,从buf缓存中就可以读取所需要的数据了,那么,我们回过头接着看UnsafeRange方法的实现,该方法在前面有提到。
我再次把代码贴在这里:

// 该方法用于底层数据的只读操作
func (baseReadTx *baseReadTx) UnsafeRange(bucketName, key, endKey []byte, limit int64) ([][]byte, [][]byte) {
    // 不使用范围查询
    if endKey == nil {
        // forbid duplicates for single keys
        limit = 1
    }
    // 当范围值异常时,则传入最大范围
    if limit <= 0 {
        limit = math.MaxInt64
    }
    if limit > 1 && !bytes.Equal(bucketName, safeRangeBucket) {
        panic("do not use unsafeRange on non-keys bucket")
    }
    // 将buf缓存中的数据读取出来
    keys, vals := baseReadTx.buf.Range(bucketName, key, endKey, limit)
    // 如果取出的数据满足了需求,那么则直接返回数据
    if int64(len(keys)) == limit {
        return keys, vals
    }

    // find/cache bucket
    // 从bucket缓存中查询bucket实例,查询到了则返回缓存中的实例,查询不到,则在BoltDB中查找
    bn := string(bucketName)
    baseReadTx.txMu.RLock()
    bucket, ok := baseReadTx.buckets[bn]
    baseReadTx.txMu.RUnlock()
    lockHeld := false
    // 缓存中取不到bucket的话,会从bolt中查找,并写入缓存中
    if !ok {
        baseReadTx.txMu.Lock()
        lockHeld = true
        bucket = baseReadTx.tx.Bucket(bucketName)
        baseReadTx.buckets[bn] = bucket
    }

    // ignore missing bucket since may have been created in this batch
    if bucket == nil {
        if lockHeld {
            baseReadTx.txMu.Unlock()
        }
        return keys, vals
    }
    if !lockHeld {
        baseReadTx.txMu.Lock()
        lockHeld = true
    }
    c := bucket.Cursor()
    baseReadTx.txMu.Unlock()

    // 从bolt的该bucket中查找键值对
    k2, v2 := unsafeRange(c, key, endKey, limit-int64(len(keys)))
    return append(k2, keys...), append(v2, vals...)
}

看第16-20行,刚才我们已经分析了16行代码的具体实现。

18-19行则表示,如果返回的key的数量与limit相等,则就直接返回缓存中的数据即可。如果不是相等的,一般取出的key的数量小于limit值,也就是说,缓存中的数据不完全满足我们的查询需求,那么则需要继续向下执行,到etcd的底层数据库bolt中查询数据。

注意24-43行,首先etcd会从baseReadTx结构体的buckets缓存中查询查询bucket实例,如果缓存中查询不到该实例,则会从bolt数据库中查询并且将实例写入到缓存中。而如果bolt中也查询不到该bucket,则会直接返回之前从buf中查询到的keys与vals值。

如果从缓存或者bolt中查询到了bucket实例,那么,后续就可以直接从bolt中查询该bucket下的键值对了。

我们看一下52行的具体实现。

func unsafeRange(c *bolt.Cursor, key, endKey []byte, limit int64) (keys [][]byte, vs [][]byte) {
    if limit <= 0 {
        limit = math.MaxInt64
    }
    var isMatch func(b []byte) bool
    // 如果有终止的key,则将找到的key与终止的key比较,是否key小于endkey
    // 否则,将找到的key与自身比较是否相等
    if len(endKey) > 0 {
        isMatch = func(b []byte) bool { return bytes.Compare(b, endKey) < 0 }
    } else {
        isMatch = func(b []byte) bool { return bytes.Equal(b, key) }
        limit = 1
    }

    // 循环查找key所对应的值, 然后与endkey做对比(如果有endkey的话)
    // 直到不满足所需条件
    for ck, cv := c.Seek(key); ck != nil && isMatch(ck); ck, cv = c.Next() {
        vs = append(vs, cv)
        keys = append(keys, ck)
        if limit == int64(len(keys)) {
            break
        }
    }
    return keys, vs
}

先看一下传入unsafeRange方法的参数。

c为cursor实例,key为我们要查询的初始key值,endkey为我们要查询的终止key值。而limit是我们查询的范围值,由于我们之前用缓存已经查询出来了一些数据,因此,该范围其实是我们的总范围减去已经查询到的key值数。

其中,5-13行代码用到了匿名函数,用于判断查询到的key值是否依然满足需求。如果我们给到了endkey,那么就会对查到的key与endkey做比较。如果我们没有给endkey,那么就会直接判断查询到的key值是否等于我们要查询的key。

17-23行则为用于DB查询实现代码。

etcd后端存储的写操作

文章开头部分,我们讲到过etcd后端存储对外的接口Backend,其中包括了两个重要的接口:ReadTx以及BatchTx,ReadTx接口负责只读操作,这个我们在前面已经讲到了。
接下来,我们看一下etcd后端存储的读写接口BatchTx。
路径:https://github.com/etcd-io/etcd/blob/master/mvcc/backend/batch_tx.go

type BatchTx interface {
    ReadTx
    UnsafeCreateBucket(name []byte)
    UnsafePut(bucketName []byte, key []byte, value []byte)
    UnsafeSeqPut(bucketName []byte, key []byte, value []byte)
    UnsafeDelete(bucketName []byte, key []byte)
    // Commit commits a previous tx and begins a new writable one.
    Commit()
    // CommitAndStop commits the previous tx and does not create a new one.
    CommitAndStop()
}

我们可以看到,BatchTx接口也包含了前面讲到的ReadTx接口,以及其他用于写操作的方法。batchTx结构体实现了BatchTx接口。

type batchTx struct {
    sync.Mutex
    tx      *bolt.Tx
    backend *backend

    // 数据的偏移量
    pending int
}

具体接口中方法的实现我们就不一一看了,因为都是直接调用了bolt数据库的接口,比较简单。

总结

本篇文章主要从源码角度分析了etcd后端存储的底层读写操作的具体实现。无论我们是使用命令行操作etcd,还是调用etcd的对外接口。最终在对键值对进行读写操作时,底层都会涉及到今天分析的这两个接口:ReadTx以及BatchTx。

然而,etcd的键值对读写其实还会涉及到许多其他的知识,比如revision的概念。接下来还会有文章继续对这些知识做解析。

etcd的安装与命令行使用

背景

etcd是CoreOS团队于2013年6月发起的一个开源项目,它是一个优秀的高可用分布式键值对存储数据库。etcd内部采用了Raft协议作为一致性算法,且使用Go实现。

这篇文章主要记录etcd在Linux上的单机版安装与简单的命令行使用。主要是供初学者参考。

etcd的安装

etcd的github路径地址为:https://github.com/etcd-io/etcd

我们可以直接从github中将安装压缩包下载到测试机上,使用命令

wget https://github.com/coreos/etcd/releases/download/v3.3.25/etcd-v3.3.25-linux-amd64.tar.gz

其中3.3.25为etcd的版本号,目前etcd主要有v2和v3两个大版本。

我们也可以将版本号改为更早的版本,可以在地址https://github.com/etcd-io/etcd/releases查看etcd的已发布版本信息。

下载之后,我们使用命令:tar xzvf etcd-v3.3.25-linux-amd64.tar.gz进行解压,

然后使用命令mv etcd-v3.3.25-linux-amd64 /opt/etcd把目录移到opt目录下,/opt目录下一般用于存放下载的第三方软件。

到此,etcd就安装完毕了。

etcd服务端启动

如果我们只是在测试环境学习etcd,那么可以直接进入etcd的安装目录/opt/etc,用./etcd启动etcd。

这时etcd服务端会打印出一系列信息:
在这里插入图片描述

包括了etcd、go、git等版本信息,以及raft协议选举出来的leader节点信息等等。

etcd客户端的使用

这时,我们可以再次开启一个终端,然后配置一下环境变量:

/etc/profile文件底部添加一行:export PATH=${PATH}:/opt/etcd

然后运行命令source /etc/profile生效环境变量。

这时可以直接使用etcd提供的命令行操作工具etcdctl了,我们输入命令etcdctl -h可以看到etcdctl简单的使用方法。
在这里插入图片描述
我们演示几个命令行使用:

etcdctl mkdir v  // 创建目录v
etcdctl mkdir v/sub_v  // 在目录v下创建目录sub_v
etcdctl mk v/sub_v/key1 value1  // 在目录v/sub_v下创建键key1以及对应的值value1
etcdctl ls -r  // 查看所有的目录以及键

结果:

/v
/v/sub_v
/v/sub_v/key1

使用etcdctl get查看某个键值:

etcdctl get v/sub_v/key1

结果:

value1

etcdctl还有许多的命令以及可选项,可以参考help中的介绍练习使用。

当然,etcd还提供了REST API的方式让我们可以通过HTTP来访问etcd的存储,HTTP提供的接口操作基本与命令行操作差不多,我们可以用etcdctl来熟悉相关操作。

在生产环境中修改MySQL库表结构

背景

如果我们需要在生产环境中修改MySQL数据库中某个库表的结构。那么,需要考虑哪些要点,才能确保不会出问题呢?

碰到的问题

这里先描述一下我在生产环境MySQL数据库中修改库表结构时遇到的问题。
在开发过程中,我发现MySQL中某个库表需要添加一个字段,比如库表:

需要给Sname后面添加一个字段:Sheight。那么就使用命令:
alter table practice.Student add column Sheight int(4) not null default 0 comment '"身高"
输入完这个命令后,我就去做别的事情去了。
直到过了一小会,有人反馈说线上的系统有些界面没有数据。这个时候我才意识到,是这个操作出了问题。导致了线上bug。

问题的解决

我立马查看这个操作,发现还没有执行结束。首先kill了这个执行任务,于是线上系统恢复了正常。

导致该问题的原因

当时,我用命令:show processlist查询,看到这个语句的State显示的是:
Waiting for table metadata lock。我们知道,这个状态是说,该表在等待获取表的metadata lock,也就是MDL。
也就是说,由于前面有MDL读锁没有被释放,因此我这个命令也就获取不到MDL写锁。导致后面再过来的各种操作都无法被执行,都在等待MDL读锁。
这里解释下metadata的概念,metadata lock(MDL)也就是元数据锁,它是一种表级锁。
各种对该表的操作,比如增删改查,都会占有MDL的读锁。当修改表结构时,会占用MDL的写锁。
读锁和读锁之间互不冲突,而读锁与写锁、写锁与写锁之间互相冲突。
简单说,就是对一个表增删改查同时进行,MDL锁不会冲突,我们可以用多线程同时执行这些操作,只会导致行锁,而不会锁整个表。
但是,如果在对表增删改查的同时,要对表结构进行修改,那么就会造成锁等待的状态。
如果有一个长事务在对该表进行操作,那么在修改表结构时,就会有状态:Waiting for table metadata lock,也就是锁等待。如果这个时候,另外又有查询操作过来,那么,后面这个操作就也要进行Waiting for table metadata lock,也就是锁等待了。当然,对该表的查询操作就会全部阻塞。
我当时的情况就是这样,有一个事务操作了该表,但是可能由于大意没有关掉该事务,该事务长时间存在。而我同时又进行表结构的更改,于是导致了这次事故。

如何做

首先,我们要了解一下有没有什么事务对该表进行了操作,却长期没有提交。因为,只有对该表操作的事务最终提交了,MDL锁才会被释放。
换句话说,如果某个事务对该表进行了操作,比如读操作,但是最终没有做提交,那么,该事务依然会占用MDL锁的。
查看事务可以用命令:SELECT * FROM information_schema.INNODB_TRX
做完这一步之后,基本上可以避免出现Waiting for table metadata lock的情况了,但还有一点需要注意,就是线上会不会对该表进行频繁的操作,
有些热表可能一直处于有人在查询的状态,这种时候怎么做呢?
我们可以在变更表结构的命令中添加一个超时时间,如果这个命令在该时间段内一直无法执行,那么会自动超时的,起码可以保证不会长时间的影响用户的操作。
该命令为:alter table practice.Student wait 100 add column Sheight int(4) not null default 0 comment "身高"

总结

在生产环境中变更MySQL数据库中库表结构是一件比较有风险的事情,所以一定要三思而后行,避免引起任何可能的线上事故。

Warning: (1265, u”Data truncated for column ‘XXX’ at row 1″)问题解决

背景

在使用MySQL数据库时,有时会遇到Warning: (1265, u”Data truncated for column ‘XXX’ at row 1″)这样的报错信息。
具体可以考虑哪些问题呢?这里简单记录一下问题原因与解决方法。

报错原因

  1. 写入该字段的数据长度大于该字段定义的最大长度,比如定义了字段user_name VARCHAR(10),这个字段定义了最长写入10位字符,但是,如果你写入的数据超过了10位字符,那么就会出现该警告信息。
  2. 传入的数据类型有误。比如定义了字段cost DECIMAL(10,2),这个字段小数点后有两位,但是,如果你写入的数据为超过了两位小数,比如10.1122,则会出现该警告信息。
  3. 插入了非法字符。

解决方法

具体问题具体分析,可以对库表的字段类型做调整,或者检查插入的数据是否不符合预期。

MySQL中常用于检索的字符串字段如何创建索引?

背景

我们在业务场景中经常会碰到通过某个字符串查询对应记录的情况。比如常见的邮箱登录、或是手机号登录。
如果不给它创建索引,则MySQL就会进行全局扫描,非常耗时。那么,类似邮箱地址这样的字符串,我们应该如何给它创建索引呢?这里简单介绍几种方法。

几种方法

比如我们有一张表user_info用于存储用户登录信息,包括自增主键ID、邮箱地址、对应密码。
这里我们经常会用到的SQL操作是select email, password from user_info where email="XXXXXXX"

直接创建索引

第一种方法当然是直接使用email来创建索引。这种方法虽然简单粗暴且会占用大量的存储空间,但是有一个好处,就是回表操作只需一次就行。

使用前缀索引

所谓前缀索引,比如前面的示例,就是将email字段的前缀截取下来,然后作为索引来使用。

添加索引的SQL语句为:alter table user_info add index index_eamil(email(9)),这里就添加了一个email字段前9位字符为前缀索引。

假如email字段的前9位字符就可以唯一的标示一个email地址,比如前9位是不同的数字,后面都是@qq.com。那么这样就做到了完全的区分度。在选择某email时,比如select email, password from user_info where email="123456789@qq.com",MySQL会拿前9位去索引查找,查找得到则会回表到主键树中获取到对应的记录。

使用前缀索引,一定要权衡好前缀选取的长度,因为前缀的长度就意味着区分度。如果前缀索引可以完全区分,那当然是最好的,这样只需要回表一次就可以拿到数据了。区分度越高越好,区分度越高,也就使得回表操作次数越少。

只要前缀长度取的合适,就可以既有满意的区分度,又有满意的存储空间占用。

不过使用前缀索引也有一个劣势,在于使用前缀索引会影响到覆盖索引的使用。

倒序存储

倒序存储即是将对应字段倒序过来再取前缀创建索引。针对于前缀区分度低,但后缀区分度高时使用。

比如我们需要存储一个user_info用户信息表,存储的是自增ID、身份证号、密码。身份证号就属于前缀区分度低(前缀都是一些通用代号),但后缀区分度高的字符串。

在存储以及查询时也需要将身份证号倒过来使用。比如,在查询时,使用语句:select user_id, password from user_info where user_id=reverse(XXXXXX)

hash存储

将对应字段hash之后作为索引存储,hash之后冲突的概率较低,也可大大降低回表的次数。只是由于需要存储字段hash之后的数据,会增加额外存储空间的占用。

总结

前面简单介绍了一下几种不同的对字符串创建索引的方法。
总结如下:

索引方式优势劣势适用场景
直接创建索引回表次数少索引空间占用大,且每次字符串匹配时间较长数据量不大或字符串不长的情况
前缀索引索引空间占用小,且每次字符串匹配时间较短可能增加回表次数选取出的前缀可以有较高的区分度
倒序存储无需添加新字段只能支持单条查询,不支持范围查询前缀区分度低,后缀区分度高
hash存储冲突概率低,得出的索引区分度较高只能支持单条查询,不支持范围查询,且需要增加新的存储字段无需范围查询且普适性较高

MySQL中查询表的总行数该用什么命令?

背景

我们经常会使用到一个SQL语句,就是查询某张表的总行数。常常使用的查询命令有几种,比如:select count(*) from tselect count(id) from t(id为主键)select count(1) from tselect count(某普通字段) from t以及show table status的rows字段。然而却不知道用哪种查询方式最合适。接下来简单介绍一下我们在MySQL中查询表的总行数时该用什么命令。

不同存储引擎,查询效率不同

在分析几种查询方式之间的不同之前,我们先来看下以select count(*) from t为例,不同存储引擎之间查询行数方式的不同。常见的存储引擎有MyISAM和InnoDB。

InnoDB:每次我们需要查询某张表的总行数时,都会遍历整张表计算出结果。

MyISAM:每张表的总行数会记录在磁盘中存储,在我们需要查询某张表的总行数时,命令会直接取出对应字段信息。(不过需要注意,当命令查询的不是表总行数,而是where查询某些行时,也依然需要像InnoDB一样遍历整张表计算出结果)

很显然,在表行数较多时,MyISAM的方式是要比InnoDB更快的。

那么,为什么InnoDB不采用与MyISAM相同的处理方式呢?

本质原因在于InnoDB支持事务,我们可以看个具体的例子就明白了。

假设我们的表t中包含了100行数据,且有如下三个会话在做SQL操作。(会话2与会话3是事务,会话1是普通语句)
在这里插入图片描述
先不管中间是怎样的过程,我们看最后三个会话输出的查询结果。

如果是MyISAM存储引擎,它是不支持事务的,查询出的结果三个会话就都是102。

而如果是InnoDB存储引擎,它是支持事务的,可重复读是默认的隔离级别,所以查询出的结果三个会话各不相同,会话1是101,会话2是100,会话3是102。

那么,为什么InnoDB不采用与MyISAM相同的处理方式呢?这就很明显了。因为在相同时刻,InnoDB由于支持事务,所以可能会读出的结果不同,

因此,InnoDB只能在需要查询表总行数时去遍历计算了。

MyISAM由于不存在这样的问题,当然就可以把总行数存储下来,每次需要时直接读取就行了。

几个常用查询语句的效率不同

说回到开始说的几个常用查询语句,我们到底该用哪个查询语句呢?我们以InnoDB引擎为例进行说明。

首先,我们要了解一下count()函数的作用,count()函数会判断所查询字段是否为NULL,会将非NULL的行累加起来,得到最终的行数值。

接下来分别分析这几个常用查询语句:

select count(字段) from t(字段为非NOT NULL且非索引):服务器会将字段返回给服务器,服务器判断每一个字段内容是否为NULL,如果不为NULL,则会计数加1,累加出来的结果即为语句结果返回。

select count(id) from t(id为主键):InnoDB会将每一行id返回给服务器,服务器判断主键不可能为NULL,累加出来的结果即为语句结果返回。

select count(1) from t:InnoDB会遍历整张表,不过不会取字段,也不会取值。而是服务器放一个数字1进去,并将累加出来的值作为结果返回。

select count(*) from t:优化器针对该语句做了优化,InnoDB不需要取字段给服务器,而是服务器直接计算行总数并返回。

由于 count(*)count(1) 都无需从InnoDB中取值,而是直接计算行总数,则两者运行时间差不多。

count(id) 会取出id值进行判断,虽然主键值都不会为NULL,但运行时间会比前两者长一些。

count(字段)count(id) 判断方式一样,只是其不是索引,所以遍历起来较count(id) 会慢。

总结来说,四种常用查询方式的速度顺序为:count(字段)

注意:select count(id) from t语句,虽然id为主键不可能是NULL值,理论上来说,MySQL是无需一个一个再进行数值判断的。然而目前MySQL并没有对此做优化,应该是在查询总行数时,MySQL就建议使用select count() from t,因此专门对count() 做了优化。其余的查询就都按照需要什么数据则取什么数据这样的原则统一处理。

show table status命令

show table status命令中的Rows字段也会显示出表的总行数,但是该命令是通过采样统计来计算出的表总行数,因此会有很大的误差。作为参考值还可以,如果要使用精确值,就不推荐了。

总结

简单总结一下,我们在查询某张表总行数时,如果需要知道大体行数,则可使用show table status的rows字段查看。如果需要知道精确行数,则需使用select count(*) from t命令来查看。

在Mysql中执行一条SQL,会经历什么?

背景

我们都经常使用Mysql作为数据库来存储与查询较常用的数据。当我们输入一行如SELECT * FROM table_name WHERE id=26这样的语句之后,Mysql如果正确执行的情况下,会输出你想要的信息。

那么,在你输入这行语句之后,一直到它显示出你想要的信息,这中间Mysql都经历了什么呢?这篇文章会简单聊一下这个事情。

Mysql基本架构图

我们先看下Mysql的一个较整体的架构图。

接下来我会以具体的SQL语句为例,详细的叙述从你在客户端输入了这个语句之后,到它返回你想要的信息,这中间具体经历了什么。
enter image description here

客户端

所谓客户端,即是我们登录与操作Mysql所使用的终端。我们都是在客户端对Mysql进行操作的,无论是输入连接数据库的信息,还是输入查询某个表的SQL,或者是收到Mysql返回给我们的查询信息,这些都是在客户端完成的。

连接器

用户信息验证

我们在一个客户端跟前,想要使用Mysql数据库,那么第一步就是要先连接上你要使用的数据库。

我们都知道,我们要输入命令mysql -h$ip -P$port -u$username -p

之后客户端会要求我们输入密码。再之后,如果我们输入的信息都没有问题了,我们就进入Mysql的操作界面了。

如果我们输入的信息有问题,就会收到客户端返回的报错信息。比如我们将密码输入错误了,这时就会收到”Access denied for user”这样的报错信息。

那么,这中间连接器具体做了什么呢?

首先,连接器会拿着我们输入的IP和端口,去做最经典的TCP握手,握手如果都失败了,那就自然没有后续了,直接返回相应的报错信息。

如果握手成功了,此时则会去验证我们输入的用户名和密码,验证失败则同样会返回相应的报错信息。

用户权限获取

如果用户名密码也没有问题,接下来连接器则会取出权限表读取该用户相应的权限数据。用户跟着所做的所有操作,都基于此时读取到的用户权限。

权限表共有4个:user, db, tables_priv, columns_priv

当用户通过权限验证,进行权限分配时,按照user, db, tables_priv, columns_priv的顺序进行分配。即先检查用户的全局权限表user,如果user中对应的权限为Y,则此用户对所有数据库的权限都为Y,将不再检查剩余3个表;如果为N,则到db表中检查此用户对应的具体数据库,并得到db中为Y的权限;如果db中为N,则检查tables_priv中此数据库对应的具体表,取得表中的权限Y;如果为N,则到columns_priv中检查具体的列。

这也就意味着,当我们修改了某个用户的用户权限,只有到下一次该用户登录(创建新的连接)时,才会影响到该用户。

连接与等待超时

我们可以通过show processlist来查看当前所有的用户连接及其行为。
enter image description here
Command中的字段显示该用户目前的状态,此时这个用户是查询状态。

但若Command显示的状态是Sleep,那么说明该用户当前在等待状态。若等待超过了一段时间,则连接器会自动断开。

该超时时间由wait_timeout变量控制,可以通过show global variables like 'wait_timeout'来查看。
enter image description here
mysql默认为28800秒,即8小时。

长连接与短连接

所谓长连接,即用户的持续操作使用的都是同一个连接,连接在一段时间内长时间建立。

所谓短连接,即用户每做几次操作则断开,再下次操作时再进行连接。

长连接的优点是,在持续操作时,可以节省很多建立连接所需要消耗的时间。但是长连接所要存储的临时数据都在连接对象中,长时间积累,会导致系统内存溢出,具体表现
为Mysql异常重启。

短连接的优缺点与长连接相反,虽然不用担心内存溢出的问题,但短连接在持续操作的情况下多次连接,连接消耗很多时间,整体操作效率会很低。

缓存器

连接器连接完成的下一步就是缓存器的缓存查询,如果我们需要对一张静态表(不常更新)经常做查询操作,那么可能会用到缓存器。

缓存器中使用的是key-value的存储形式,key值存储的是查询语句,value值存储的是对应结果。

要注意的是,只要该表做了一次更新操作,那么该表对应的缓存就会全部被清理。因此使用场景并不多。

所以当前缓存器的使用较少。我们可以通过query_cache_type来查看缓存器是否开启。
enter image description here
现在一般都是默认关闭的状态。且Mysql从8.0版本会开始彻底弃用该功能。

分析器

假设我们不使用缓存器,或者通过缓存器没有命中SQL语句。

那么连接器做连接操作之后,接下来我们就输入了一个查询语句,比如:SELECT host FROM mysql.user LIMIT 1

而分析器做的事情就是对你输入的语句做 “词法分析”“语法分析”

所谓 “词法分析” ,就是判断每一个你输入的词,比如分析器首先会判断出你输入的第一个词是“SELECT”,第二个词你输入了“host”,等等。

“语法分析” 则是跟在 “词法分析” 之后,就是依据你输入的这些词来判断你输入的是否符合语法规则。

假如符合语法规则,则会顺利进行下去并返回相应信息。
enter image description here
假如不符合语法规则,则分析器会返回报错信息给客户端。
enter image description here
具体出错的地方,一般都是跟在use near之后,我们看这里就能知道语法错误出在了哪一块。

优化器

在分析器工作结束后,如果语法有问题,那么就会直接返回报错信息,且不继续向下运行。

若语法正确,那么,则会到优化器部分的工作。优化器顾名思义,就是对该语句的执行做优化。

比如,在一个语句查询某个表时,该表可能有多个索引,此时使用哪个索引会使语句的执行效率最高?这就是优化器要做的事情。

再比如,执行语句select * from t1 join t2 on t1.ID=1 and t2.ID=2

该语句执行时,是先从t1表中找到ID=1的行关联到t2表之后,再从t2表中查找ID=2的行。

还是先从t2表中找到ID=2的行关联到t1表之后,再从t1表中查找ID=1的行。

两种执行顺序可能就导致执行效率的不同,怎样选择执行顺序会提高执行效率,这也是优化器要做的事情。

执行器

在上述步骤完成之后,就轮到执行器去执行具体的语句了。

例如语句:select * from mysql.tables_priv

在执行器做具体的语句执行之前,会对该表的操作权限进行验证,验证失败则返回权限错误的报错。如下:
enter image description here
而实际上,权限验证不仅仅在执行器这部分会做,在分析器之后,也就是知道了该语句要“干什么”之后,也会先做一次权限验证。叫做precheck。

而precheck是无法对运行时涉及到的表进行权限验证的,比如使用了触发器的情况。因此在执行器这里也要做一次执行时的权限验证。

如果验证成功,那么则会使用该表对应的存储引擎的接口,继续执行语句。
最后将成功执行的结果返回给客户端。

总结

简单来说,一条SQL语句在Mysql中执行,一共会经历四步(算上连接Mysql),分别是连接、分析、优化与执行。每一步都会精确执行,如果发现有问题就会返回给客户端相应的报错。只有每一步都正确执行,最终才会在客户端得到你想要查询或操作的结果。

mysql报错:2003, “Can’t connect to MySQL server on ‘ ‘ [Errno 99] Cannot assign requested address 解决方法

背景

使用Python脚本高并发的连接Mysql数据库时遇到了此报错。
但是场景不仅限于Python,其它程序在高并发连接Mysql数据库时也可能会遇到此问题。

原因定位

出现该报错信息是因为在高并发连接Mysql数据库时,由于同时连接Mysql数据库的链接过多,且每次连接都是非常短的时间,导致有许多的TIME_WAIT,以致用光了服务器端口,所以新的连接就没有端口可用,导致了该报错产生。

我们可以看下当前端口占用的个数:
netstat -an|wc -l
在这里插入图片描述
也可以看下当前可用端口的范围:
cat /proc/sys/net/ipv4/ip_local_port_range
在这里插入图片描述

解决方法

1

知道了原因,最好的解决方法是减少这种高并发的连接,由于是每次连接的处理时间较短时,不然很容易出现这种问题。

2

修改可用端口的范围
echo 1024 65000 > /proc/sys/net/ipv4/ip_local_port_range

3

查看内核的时间戳支持:
cat /proc/sys/net/ipv4/tcp_timestamps
如果是0,则修改为1。开启对于TCP时间戳的支持。
运行sysctl -w net.ipv4.tcp_timestamps=1

查看是否开启快速回收:
cat /proc/sys/net/ipv4/tcp_tw_recycle
如果是0,则修改为1。开启TCP连接中TIME-WAIT sockets的快速回收。
运行sysctl -w net.ipv4.tcp_tw_recycle=1

修改tcp_max_tw_buckets
运行sudo sh -c "echo '5000'> /proc/sys/net/ipv4/tcp_max_tw_buckets"

Druid安装(单机环境)

背景

本篇文章将简单介绍Druid在单机上的安装与安装过程中可能会遇到的问题。主要目的是供初次接触Druid的同学了解Druid所用。

准备工作

软件

  • Java8(8u92+)
  • Linux或其他类Unix系统

硬件

  • 4CPU/16GB RAM及以上

下载Druid

Apache Druid 0.16.0-incubating下载到机器中。这里我们下载apache-druid-0.16.0-incubating-bin.tar.gz
下载完成后运行如下命令:

tar -xzf apache-druid-0.16.0-incubating-bin.tar.gz
cd apache-druid-0.16.0-incubating

进入apache-druid的安装目录后,我们可以看一下各子目录和文件的功能:

  • bin/*:用于快速入门的脚本。
  • conf/*:单机和集群设置的配置示例。
  • extensions/*:核心Druid扩展。
  • hadoop-dependencies/*:Druid的Hadoop依赖。
  • lib/*:核心Druid的库和依赖。
  • quickstart/*:快速入门的配置文件、样例数据和其他文件。
  • DISCLAIMER、LICENSE和NOTICE文件。

下载Zookeeper

Druid在Zookeeper在分布式协作上有依赖关系。所以我们还需要下载Zookeeper。
在Druid的安装目录根目录下运行如下命令:

curl https://archive.apache.org/dist/zookeeper/zookeeper-3.4.14/zookeeper-3.4.14.tar.gz -o zookeeper-3.4.14.tar.gz
tar -xzf zookeeper-3.4.14.tar.gz
mv zookeeper-3.4.14 zk

Druid入门教程的启动脚本要求Zookeeper安装目录必须在其根目录下,且重命名为zk。

启动Druid服务

apache-druid-0.16.0-incubating(Druid的安装目录)下,运行./bin/start-micro-quickstart命令。这个命令用于启动Druid和Zookeeper。且是单机版的。
我这里碰到了问题,报错说是服务未启动,查看Druid的日志,发现是这样的报错信息:
在这里插入图片描述
这是说未识别出JVM的ExitOnOutOfMemoryError这个选项。引起该报错的原因是Java的版本不符合要求。建议从Oracle官网下载Java,不要从yum来安装Java,就不会出现该问题了。
Druid和Zookeeper正常启动后如下所示:
在这里插入图片描述
如果是安装在本机中的话,在浏览器中输入:localhost:8888即可访问Druid了
在这里插入图片描述