关于存储技术:如何设计并实现存储QoS

43次阅读

共计 9254 个字符,预计需要花费 24 分钟才能阅读完成。

本文来自 OPPO 互联网技术团队,转载请注名作者。同时欢送关注咱们的公众号:OPPO_tech,与你分享 OPPO 前沿互联网技术及流动。

一、资源抢占问题

随着存储架构的调整,泛滥应用服务会运行在同一资源池中,对外提供对立的存储能力。资源池外部可能存在多种流量类型,如下层业务的 IO 流量、存储外部的数据迁徙、修复、压缩等,不同的流量通过竞争的形式确定下发到硬件的 IO 程序,因而无奈确保某种流量 IO 服务质量,比方外部数据迁徙流量可能占用过多的带宽影响业务流量读写,导致存储对外提供的服务质量降落,因为资源竞争后果的不确定性无奈保障存储对外能提供稳固的集群环境。

如上面交通图所示,车辆逆行、加塞随心随遇,行人横穿、闲聊胡作非为,最终呈现交通拥堵甚至安全事故。

二、如何解决资源抢占

类比上一幅交通图,如何躲避这样的景象大家可能都有本人的一些认识,这里先引入两个名词

  • QoS,即服务质量,依据不同服务类型的不同需要提供端到端的服务质量。
  • 存储 QoS,在保障服务带宽与 IOPS 的状况下,正当调配存储资源,无效缓解或管制应用服务对资源的抢占,实现流量监控、资源正当调配、重要服务质量保障以及外部流量躲避等成果,是存储畛域必不可少的一项关键技术。

那么 QoS 应该怎么去做呢?上面还是联合交通的例子进行介绍阐明。

2.1 流量分类

从后面的图咱们看到不论是什么车,都以自我为核心,不受任何束缚,咱们首先能先到的方法是对路线进行分类划分,比方分为公交车专用车道、小型车专用车道、大货车专用车道、非机动车道以及人行横道等,失常状况下公交车车道只容许公交车运行,而非机动车道上是不容许呈现机动车的,这样咱们能够保障车道与车道之间不受制约烦扰。

同样,存储外部也会有很多流量,咱们能够为不同的流量类型调配不同的 “车道”,比方业务流量的车道咱们划分宽一些,而外部压缩流量的车道相对来说能够窄一些,由此引入了 QoS 中一个比拟重要的概览就是 流量分类,依据分类后果能够进行更加精准个性化的限流管制。

2.2 流量优先级

仅仅依附分类是不行的,因为总有一些非凡状况,比方急救车救人、警车抓人等,咱们总不能说这个车道只能跑一般私家小轿车把,一些非凡车辆(救护车,消防车以及警车等)应该具备优先通行的权限。

对于存储来说业务流量就是咱们的 非凡车辆,咱们须要保障业务流量的稳定性,比方业务流量的带宽跟 IOPS 不受限制,而外部流量如迁徙、修复则须要限定其带宽或者 IOPS,为其调配固定的“车道”。在资源短缺的状况下,外部流量能够安安静静的在本人的车道上行驶,然而当资源缓和,比方业务流量突增或者持续性的高流量水位,这个时候须要限度外部流量的情理宽度,极其状况下能够暂停。当然,如果外部流量都停了还是不能满足失常业务流量的读写需要,这个时候就须要思考扩容的事件了。

QoS 中另外一个比拟重要的概念就是 优先级划分,在资源短缺的状况下执行预分配资源策略,当资源缓和时对优先级低的服务资源进行动静调整,进行适当的躲避或者暂停,在肯定水平上能够补救预调配计划的有余。

2.3 流量监控

后面提到当资源有余时,咱们能够动静的去调整其余流量的阈值,那咱们如何晓得资源有余呢?这个时候咱们是须要有个流量监控的组件。

咱们出行时常常会应用地图,通过抉择适合的线路以最快达到目的地。个别线路会通过不同的色彩标记线路拥挤状况,比方红色示意堵车、绿色示意畅通。

存储想要晓得机器或者磁盘以后的流量状况有两种形式:

  • 统计机器负载状况,比方咱们常常去机器上通过 iostat 命名查看各个磁盘的 io 状况,这种形式与机器上的利用解耦,只关注机器自身
  • 统计各个利用下发的读写流量,比方某台机器上部署了一个存储节点利用,那咱们能够统计这个利用下发上来的读写带宽及 IOPS

第二种形式绝对第一种能够实现利用外部更细的流量分类,比方后面提到的一个存储利用节点,就蕴含了多种流量,咱们不能通过机器的粒度对所有流量对立限流。

三、常见 QoS 限流算法

3.1 固定窗口算法

  • 按工夫划分为多个限流窗口,比方 1 秒为一个限流窗口大小;
  • 每个窗口都有一个计数器,每通过一个申请计数器会加一;
  • 当计数器大小超过了限度大小(比方一秒内只能通过 100 个申请),则窗口内的其余申请会被抛弃或排队期待,等到下一个工夫节点计数器清零再解决申请。

固定窗口算法的现实流量管制成果如上左侧图所示,假设设置 1 秒内容许的最大申请数为 100,那么 1 秒内的最大申请数不会超过 100。

然而大多数状况下咱们会失去右侧的曲线图,即可能会呈现流量翻倍的成果。比方前 T1\~T2 时间段没有申请,T2\~T3 来了 100 个申请,全副通过。下一个限流窗口计数器清零,而后 T3T4 工夫内来了 100 个申请,全副解决胜利,这个时候时间段 T4T5 时间段就算有申请也是不能解决的,因而超过了设定阈值,最终 T2~T4 这一秒工夫解决的申请为 200 个,所以流量翻倍。

小结

  • 算法易于了解,实现简略;
  • 流量管制不够精密,容易呈现流量翻倍状况;
  • 适宜流量平缓并容许流量翻倍的模型。

3.2 滑动窗口算法

后面提到固定窗口算法容易呈现流量管制不住的状况(流量翻倍),滑动窗口能够认为是固定窗口的降级版本,能够躲避固定窗口导致的流量翻倍问题。

  • 工夫窗口被细分若干个小区间,比方之前一秒一个窗口(最大容许通过 60 个申请),当初一秒分成 3 个小区间,每个小区间最大容许通过 20 个申请;
  • 每个区间都有一个独立的计数器,能够了解一个区间就是固定窗口算法中的一个限流窗口;
  • 当一个区间的工夫用完,滑动窗口往后挪动一个分区,老的分区 (T1~T2) 被抛弃,新的分区 (T4~T5) 退出滑动窗口,如图所示。

小结

  • 流量管制更加精准,解决了固定窗口算法导致的流量翻倍问题;
  • 区间划分粒度不易确定,粒度太小会减少计算资源,粒度太大又会导致整体流量曲线不够平滑,使得零碎负载忽高忽低;
  • 适宜流量较为稳固,没有大量流量突增模型。

3.3 漏斗算法

  • 所有的水滴(申请)都会先通过“漏斗”存储起来(排队期待);
  • 当漏斗满了之后,多余的水会被抛弃或者进入一个期待队列中;
  • 漏斗的另外一端会以一个固定的速率将水滴排出。

对于漏斗而言,他不分明水滴(申请)什么时候会流入,然而总能保障出水的速度不会超过设定的阈值,申请总是以一个比拟平滑的速度被解决,如图所示,零碎通过漏斗算法限流之后,流量能保障在一个恒定的阈值之下。

小结

  • 稳固的处理速度,能够达到整流的成果,次要对上游的零碎起到爱护作用;
  • 无奈应答流量突增状况,所有的申请通过漏斗都会被削缓,因而不适宜有流量突发的限流场景;
  • 适宜没有流量突增或想达到流量整合以固定速率解决的模型。

3.4 令牌桶算法

令牌桶算法是漏斗算法的一种改良,次要解决漏斗算法不能应答流量突发的场景

  • 以固定的速率产生令牌并投入桶中,比方一秒投放 N 个令牌;
  • 令牌桶中的令牌数如果大于令牌桶大小 M,则多余的令牌会被抛弃;
  • 所有申请达到时,会先从令牌桶中获取令牌,拿到令牌则执行申请,如果没有获取到令牌则申请会被抛弃或者排队期待下一次尝试获取令牌。

如图所示,假如令牌投放速率为 100/s,桶能寄存最大令牌数 200,当申请速度大于另外投放速率时,申请会被限度在 100/s。如果某段时间没有申请,这个时候令牌桶中的令牌数会缓缓减少直到 200 个,这是申请能够一次执行 200,即容许设定阈值内的流量并发。

小结

  • 流量平滑;
  • 容许特定阈值内的流量并发;
  • 适宜整流并容许肯定水平流量突增的模型。

就单纯的以算法而言,没有哪个算法最好或者最差的说法,须要结合实际的流量特色以及零碎需要等因素抉择最合适的算法。

四、存储 QoS 设计及实现

4.1 需要

一般而言一台机器会至多部署一个存储节点,节点负责多块磁盘的读写申请,而存储申请由分为多种类型,比方失常业务的读写流量、磁盘损坏的修复流量、数据删除呈现数据空洞后的空间压缩流量以及多为了升高多正本存储老本的纠删码(EC)迁徙流量等等,不同流量呈现在同一个存储节点会相互竞争抢占系统资源,为了更好的保障业务服务质量,须要对流量的带宽以及 IOPS 进行限度管控,比方须要满足以下条件:

  • 能够同时限度流量的带宽跟 IOPS,独自的带宽或者 IOPS 限度都会导致另外一个参数不受管制而影响零碎稳定性,比方只管制了带宽,然而没有限度 IOPS,对于大量小 IO 的场景就会导致机器的 ioutil 过高;
  • 能够实现磁盘粒度的限流,防止机器粒度限流导致磁盘流量过载,比如图所示,ec 流量限度节点的带宽最大值为 10Mbps,预期成果是想每块磁盘调配 2Mbps,然而很有可能这 10Mbps 全副调配到了第一个磁盘;
  • 能够反对流量分类管制,依据不同的流量个性设置不同的限流参数,比方业务流量是咱们须要重点保护的,因而不能对业务流量进行限流,而 EC、压缩等其余流量均为外部流量,能够依据其个性配置适合的限流阈值;
  • 能够反对限流阈值的动静适配,因为业务流量不能进行流控,对于零碎而言就像一匹“脱缰野马”,可能突增、突减或继续顶峰,针对突增或继续顶峰的场景零碎须要尽可能的为其分配资源,这就意味着须要对外部流量的限流阈值进行动静的打压设置是暂停躲避。

4.2 算法抉择

后面提到了 QoS 的算法有很多,这里咱们结合实际需要抉择滑动窗口算法,次要有以下起因:

  • 零碎须要管制外部流量而外部流量绝对比较稳定平缓;
  • 能够防止流量突发状况而影响业务流量;

QoS 组件除了滑动窗口,还须要增加一个 缓存队列,当申请被限流之后不能被抛弃,须要增加至缓存队列中,期待下一个工夫窗口执行,如下图所示。

4.3 带宽与 IOPS 同时限度

为了实现带宽与 IOPS 的同时管制,QoS 组件将由两局部组成:IOPS 管制组件负责管制读写的 IOPS,带宽管制组件负责管制读写的带宽,带宽管制跟 IOPS 管制相似,比方带宽限度阈值为 1 Mbps,那么示意一秒最多只能读写 1048576Bytes大小数据;假设 IOPS 限度为 20iops,示意一秒内最多只能发送 20 次读写申请,至于每次读写申请的大小并不关怀。

两个组件外部互相隔离,整体来看又相互影响,比方当 IOPS 管制很低时,对应的带宽可能也会较小,而当带宽管制很小时对应的 IOPS 也会比拟小。

上面以修复流量为例,分三组进行测试

  1. 第一组:20iops1Mbps
  2. 第二组:40iops-2Mbps
  3. 第三组:80iops-4Mbps

测试后果如上图所示,从图中能够看到 qos 模块能管制流量的带宽跟 iops 维持在设定阈值范畴内。

4.4 流量分类限度

为了辨别不同的流量,咱们对流量进行标记分类,并为不同磁盘上的不同流量都初始化一个 QoS 组件,QoS 组件之间互相独立互不影响,最终能够达到磁盘粒度的带宽跟 IOPS 管制。

4.5 动静阈值调整

后面提到的 QoS 限流计划,尽管可能很好的管制外部流量带宽或者 IOPS 在阈值范畴内,然而存在以下有余

  • 不感知业务流量现状,当业务流量突增或者继续顶峰时,外部流量与业务流量依然会存在资源抢占,不能达到流量躲避或暂停成果。
  • 磁盘上不同流量的限流互相独立,当磁盘的整体流量带宽或者 IOPS 过载时,外部流量阈值不能动静调低也会影响业务流量的服务质量。

所以须要对 QoS 组件进行肯定的改良,减少 流量监控组件,监控组件次要监控不同流量类型的带宽与 IOPS,动静 QoS 限流计划反对以下性能:

  • 通过监控组件获取流量增长率,如果呈现流量突增,则动静调低滑动窗口阈值以升高外部流量;当流量复原平缓,复原滑动窗口最后阈值以充分利用系统资源。
  • 通过监控组件获取磁盘整体流量,当整体流量大小超过设定阈值,则动静调低滑动窗口大小;当整体流量大小低于设定阈值,则复原滑动窗口至初始阈值。

上面设置磁盘整体流量阈值 2 Mbps-40iops,ec 流量的阈值为 10Mbps-600iops

当磁盘整体流量达到磁盘阈值时会动静调整其余外部流量的阈值,从测试后果能够看到 ec 的流量受动静阈值调整存在一些稳定,磁盘整体流量上来之后 ec 流量阈值又会复原到最后阈值(10Mbps-600iops),然而能够看到整体磁盘的流量并没有管制在 2 Mbps-40iops以下,而是在这个范畴高低稳定,所以咱们在初始化时须要保障设置的外部流量阈值小于磁盘的整体流量阈值,这样能力达到比较稳定的外部流量管制成果。

4.6 伪代码实现

后面提到存储 QoS 次要是限度读写的带宽跟 IOPS,具体应该如何去实现呢?IO 读写次要波及以下几个接口

Read(p []byte) (n int, err error)
ReadAt(p []byte, off int64) (n int, err error)
Write(p []byte) (written int, err error)
WriteAt(p []byte, off int64) (written int, err error)

所以这里须要对下面几个接口进行二次封装,次要是退出限流组件

带宽管制组件实现

Read 实现

// 假设 c 为限流组件
func (self *bpsReader) Read(p []byte) (n int, err error) {size := len(p)
    size = self.c.assign(size) // 申请读取文件大小

    n, err = self.underlying.Read(p[:size]) // 依据申请大小读取对应大小数据
    self.c.fill(size - n) // 如果读取的数据大小小于申请大小,将没有用掉的计数填充至限流窗口中
    return
}

Read 限流之后会呈现以下状况

  • 读取大小 n<len(p) 且 err=nil,比方须要读 4K 大小,然而以后工夫窗口只能容许读取 3K,这个是被容许的

这里兴许你会想,Read 限流的实现怎么不弄个循环呢?如直到读取指定大小数据才返回。这里的实现咱们须要参考规范的 IO 的读接口定义,其中有阐明 在读的过程中如果筹备好的数据有余 len(p)大小,这里间接返回筹备好的数据,而不是期待,也就是说规范的语义是反对只读局部筹备好的数据,因而这里的限流实现保持一致。

// Reader is the interface that wraps the basic Read method.
//
// Read reads up to len(p) bytes into p. It returns the number of bytes
// read (0 <= n <= len(p)) and any error encountered. Even if Read
// returns n < len(p), it may use all of p as scratch space during the call.
// If some data is available but not len(p) bytes, Read conventionally
// returns what is available instead of waiting for more.
// 省略
//
// Implementations must not retain p.
type Reader interface {Read(p []byte) (n int, err error)
}

ReadAt 实现

上面介绍下 ReadAt 的实现,从接口的定义来看,可能感觉 ReadAt 与 Read 相差不大,仅仅是指定了数据读取的开始地位,仔细的小伙伴可能发现咱们这里实现时多了一层循环,须要读到指定大小数据或者呈现谬误才返回,相比 Read 而言 ReadAt 是不容许呈现 \n<len(p) 且 err==nil\的状况

func (self *bpsReaderAt) ReadAt(p []byte, off int64) (n int, err error) {for n < len(p) && err == nil {
        var nn int
        nn, err = self.readAt(p[n:], off)
        off += int64(nn)
        n += nn
    }
    return
}

func (self *bpsReaderAt) readAt(p []byte, off int64) (n int, err error) {size := len(p)
    size = self.c.assign(size)
    n, err = self.underlying.ReadAt(p[:size], off)
    self.c.fill(size - n)
    return
}
// ReaderAt is the interface that wraps the basic ReadAt method.
//
// ReadAt reads len(p) bytes into p starting at offset off in the
// underlying input source. It returns the number of bytes
// read (0 <= n <= len(p)) and any error encountered.
//
// When ReadAt returns n < len(p), it returns a non-nil error
// explaining why more bytes were not returned. In this respect,
// ReadAt is stricter than Read.
//
// Even if ReadAt returns n < len(p), it may use all of p as scratch
// space during the call. If some data is available but not len(p) bytes,
// ReadAt blocks until either all the data is available or an error occurs.
// In this respect ReadAt is different from Read.
// 省略
//
// Implementations must not retain p.
type ReaderAt interface {ReadAt(p []byte, off int64) (n int, err error)
}

Write 实现

Write 接口的实现绝对比较简单,循环写直到写完数据或者呈现谬误

func (self *bpsWriter) Write(p []byte) (written int, err error) {
    size := 0
    for size != len(p) {p = p[size:]
        size = self.c.assign(len(p))

        n, err := self.underlying.Write(p[:size])
        self.c.fill(size - n)
        written += n
        if err != nil {return written, err}
    }
    return
}
// Writer is the interface that wraps the basic Write method.
//
// Write writes len(p) bytes from p to the underlying data stream.
// It returns the number of bytes written from p (0 <= n <= len(p))
// and any error encountered that caused the write to stop early.
// Write must return a non-nil error if it returns n < len(p).
// Write must not modify the slice data, even temporarily.
//
// Implementations must not retain p.
type Writer interface {Write(p []byte) (n int, err error)
}

WriteAt 实现

这里的实现跟 Write 相似

func (self *bpsWriterAt) WriteAt(p []byte, off int64) (written int, err error) {
    size := 0
    for size != len(p) {p = p[size:]
        size = self.c.assign(len(p))

        n, err := self.underlying.WriteAt(p[:size], off)
        self.c.fill(size - n)
        off += int64(n)
        written += n
        if err != nil {return written, err}
    }
    return
}
// WriterAt is the interface that wraps the basic WriteAt method.
//
// WriteAt writes len(p) bytes from p to the underlying data stream
// at offset off. It returns the number of bytes written from p (0 <= n <= len(p))
// and any error encountered that caused the write to stop early.
// WriteAt must return a non-nil error if it returns n < len(p).
//
// If WriteAt is writing to a destination with a seek offset,
// WriteAt should not affect nor be affected by the underlying
// seek offset.
//
// Clients of WriteAt can execute parallel WriteAt calls on the same
// destination if the ranges do not overlap.
//
// Implementations must not retain p.
type WriterAt interface {WriteAt(p []byte, off int64) (n int, err error)
}

IOPS 管制组件实现

IOPS 管制组件的实现跟带宽相似,这里就不具体介绍了

Read 接口实现

func (self *iopsReader) Read(p []byte) (n int, err error) {self.c.assign(1) // 这里只须要获取一个计数,如果以后窗口一个都没有,则会始终期待直到获取到一个才唤醒执行下一步
    n, err = self.underlying.Read(p)
    return
}

ReadAt 接口实现

func (self *iopsReaderAt) ReadAt(p []byte, off int64) (n int, err error) {self.c.assign(1)
    n, err = self.underlying.ReadAt(p, off)
    return
}

想想这里的 ReadAt 为啥不须要跟带宽一样循环读了呢?

Write 接口实现

func (self *iopsWriter) Write(p []byte) (written int, err error) {self.c.assign(1)
    written, err = self.underlying.Write(p)
    return
}

WriteAt

func (self *iopsWriterAt) WriteAt(p []byte, off int64) (n int, err error) {self.c.assign(1)
    n, err = self.underlying.WriteAt(p, off)
    return
}

正文完
 0