数据库的读与写探讨

写于之前

本篇文章,主要旨在记录这些时间对数据库(系统)读与写,HA、HP上的一些思考,做一些沉淀。可能会有地方有缺陷,也是为了将来可以更好得提高打下一些思想基础。

读与写

说到系统上的读与写,特别是数据库,不知道大家会首先冒出什么概念,我把自己对这两种操作的关键点理解列一下。
写:事务、落盘
读:缓存、高效
我觉得对于写操作,数据一致性、稳定性毫无以为的关心重点,虽然有点废话,其实我的意思是,写操作主要关心这两点就够了。为什么写不需要做性能优化?因为写操作的性能优化点实在屈指可数,一般而言,我们会做合并批量处理,或者是更新删除的where条件有索引即可。如果是大型系统的写优化,有另外的方式,下面会说到。
对于读操作,这一块,是慢SQL泛滥的地方,因为业务需求,可能会有各种各样的连接嵌套分组查询,而并不是所有的条件都有索引覆盖到,或者是走正确预料之中的索引,需要读SQL做很多的优化。此外,就算有索引,在几百万行记录,高并发查询面前,数据库依然响应会很慢,这时候会把一些数据上浮作缓存,这里不是特定指数据库本身的缓存,可能是系统的缓存Redis、或者是搜索引擎Solr之类的,虽然这可能会牺牲一部分一致性(看需求场景)

数据库系统发展之路

这里先讲一下,我们数据库系统随着公司业务发展的过程。
第一阶段:一台Mysql服务器,业务都在上面,读读写写变化快,定期备份;优点:响应快 缺点:挂得也快
第二阶段:一台Mysql主服务器,多台从服务器,读写分离,异步binlog同步;优点:性能提高 有高可用性了 缺点:写有瓶颈,治标不治本
第三阶段:N台Mysql服务器组,每个组有主从架构,服务器组按照某个维度分库,并且分表;优点:高可用 高性能 缺点:系统处于分布式下,一些分布式的问题就会暴露

分布式下的读与写

从之前的阶段可以看到,我们的数据库以后必然会随着业务发展转向分布式。分布式的确给我们带来了很多好处,但同时也带来了麻烦,现在回头看一下之前读写的特点。
先说写,原来10条数据往一个库一个表写,那分布式下我就可以往10个库10个表各写一条,那么压力就会小很多,而且以后业务量上去了,加机器,加库就行了。但是注意到写一般是具有事务的,现在跨库了,是没有办法保证涉及多库操作的事务一致性的,这时候就需要引入分布式事务了(要么就是记录一些异常,后续做数据补偿)
再说读,原来一条查询语句在一个库查询,那分布式下就可能需要查询10个库(当然如果有分库的Hint,可以直接查),然后做数据的整合返回客户端;这其实就是一个数据访问层的中间件了,想想如果查询语句当中含有“limit,group by,join”等,那做数据整合的工作会很复杂,并且同时还需要保证效率和准确性。

延伸及思考

把数据库抽象成一个系统看,任何一个系统肯定也会面临读与写请求,大部分也会需要考虑相对应的特点,问题及解决方案。最后,我的思考就是任何系统肯定都会碰到问题,任何问题肯定都会有对应的解决方案,任何解决方案都会带来额外的优缺,任何额外的优缺都会去推向业务;所以,某种程度上,我们并不是在解决问题,我们只是在权衡,在取舍符合当前业务利益最大化的那个选择。

分页导出引发的数据隔离问题思考

简要业务上下文

  • 后台会定期导出单据核对,导出调用的是分页查询接口(按照创建时间倒序)
  • 运营又会在某时某刻新建一个单据
    p11

出现的问题

  • 当导出开始时,不管是串行查询,还是并行查询;期间如果有人新建一条数据,那么整个数据将有错乱的问题。如下图,Query1 执行完成后,同时插入一条新数据,那么之后的Query2将会又查询到id为831的数据,导致整个导出查询会有2条id为831的行。p12

思考

  • 解决当前这个问题,最快最高效的方法可能就是新增一个支持全量查询的接口服务,一次查询搞定;或者分页接口不按时间倒序,按照时间、ID升序
  • 抛开这个特定的业务场景,把问题抽象出来,可以发现这就像是数据库事务隔离的问题。如下图p13

Mysql 实践

启2个session,一个分批查询,一个在期间插入(当前表中没有记录)。

1. 查询不加事务,隔离机制的保护必然有一致性问题。
p14

2.加事务(使用默认隔离级别-‘REPEATABLE-READ’),可以发现插入被阻塞了,整个查询过程中,数据都没有被插入。此外,如果查询条件变成select count(id) from expense where id < 0,那么插入也不会阻塞,说明加锁是针对行级的。整个过程大致的机制是通过MVCC方式,对CRUD的操作做数据版本控制,比对版本,Redo等等。
p15

3.加事务,并把隔离级别改成‘READ-COMMITTED’,效果和1是一样的,因为其是不可重复读的。

MVCC

从数据库处理事务的机制,可以发现、借鉴这种MVCC思想,MVCC即Multiversion Concurrency Control。举个我们平时会用到的例子,AtomicInteger.compareAndSet(expect, update),Java的原子类CAS操作,其实就是把expect看成版本号;此外我们用的Tair分布式缓存,对某个Key肯定会有多并发,也是通过操作version来控制一致性。

对于像数据库,STM内部实现此机制,也同样会利用乐观、事务来处理并发控制,只不过数据结构、流程逻辑更加复杂。

回到最初的问题

在不新增全量接口,或者改变排序方式的情况下。可以把MVCC思想拿过来,业务场景中费用单记录只会新增、修改不会删除。那么在原接口不改的情况下,可以将查询结果的总量作为版本号来做比较(这里会有一个修改导致并发冲突,如期间某个小二修改某行,因为是基于导出之后修改之前的时间点,所以是可以容忍的),每次查询都与上一次查询返回的总量比对,如果不一致那么说明有数据插入,此时整个查询全部Redo。

当然了,业务场景不同,需求也不同。如果按上面的逻辑,导出如果一直遇到新增,那么也不知道等到猴年马月了。再往下优化的话(遐想),异步处理,dump缓存等等,其实都不如导出时拒绝新增请求,毕竟费用单导出核对,到这个点就冻结,拉出所有流水也很合理,有点像GC的“stop the world”,简单粗暴也挺好。

后来实践发现整个查询事务都加上一个时间戳去查询,比如创建时间小于XX,这个XX可以是最新的一条数据的时间戳,也可以第一次查询时的当前时间,总之这个时间戳每次查询不能变,也可保证查出来数据的一致性。

总结

兵无常势,水无常形,有些思想在某些问题上是触类旁通的,多发现,多总结,因地制宜,因需制效!