数据库的读与写探讨

写于之前

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

读与写

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

数据库系统发展之路

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

分布式下的读与写

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

延伸及思考

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

数据源(连接池)的机制

上次说到为什么使用数据库连接池,因为连接池是一个系统的闸口,那么连接池本身的工作原理是什么呢,下面结合A家的开源数据库连接池druid()一起分析一下。

数据源和连接池的区别

首先连接池是一个宽泛的概念,我们都知道连接池都是有初始大小,也上限的(为什么要有上限,因为怕被拖垮),最大闲置时间等等这些只是“池”的一些配置。那既然是数据库连接池,那么必须有要跟数据库打交道,而世上数据库太多了,每个数据库都有自己的驱动(JDBC),同时还需配置数据库地址,账号密码,等等这些都扔给连接池去维护那就太不优雅了。所以把整个跟数据库驱动注册、连接、配置管理等一系列的处理封装称之为数据源,其实官方的接口只认数据源的,看一下java源码(方法注释略去):


package javax.sql;

import java.sql.Connection;
import java.sql.SQLException;
import java.sql.Wrapper;

/**
 * 

A factory for connections to the physical data source that this * {@code DataSource} object represents. An alternative to the * {@code DriverManager} facility, a {@code DataSource} object * is the preferred means of getting a connection. An object that implements * the {@code DataSource} interface will typically be * registered with a naming service based on the * Java™ Naming and Directory (JNDI) API. *

* The {@code DataSource} interface is implemented by a driver vendor. * There are three types of implementations: *

    *
  1. Basic implementation -- produces a standard {@code Connection} * object *
  2. Connection pooling implementation -- produces a {@code Connection} * object that will automatically participate in connection pooling. This * implementation works with a middle-tier connection pooling manager. *
  3. Distributed transaction implementation -- produces a * {@code Connection} object that may be used for distributed * transactions and almost always participates in connection pooling. * This implementation works with a middle-tier * transaction manager and almost always with a connection * pooling manager. *
*

* A {@code DataSource} object has properties that can be modified * when necessary. For example, if the data source is moved to a different * server, the property for the server can be changed. The benefit is that * because the data source's properties can be changed, any code accessing * that data source does not need to be changed. *

* A driver that is accessed via a {@code DataSource} object does not * register itself with the {@code DriverManager}. Rather, a * {@code DataSource} object is retrieved though a lookup operation * and then used to create a {@code Connection} object. With a basic * implementation, the connection obtained through a {@code DataSource} * object is identical to a connection obtained through the * {@code DriverManager} facility. *

* An implementation of {@code DataSource} must include a public no-arg * constructor. * * @since 1.4 */ public interface DataSource extends CommonDataSource, Wrapper { Connection getConnection() throws SQLException; Connection getConnection(String username, String password) throws SQLException; }

看到这边,可以清楚到知道:其实连接池只是数据源的一种比较常见的实现方式,具体如下:
1.基本实现、直接返回连接
2.连接池实现,自动从维护中的池里取出连接
3.分布式事务实现,但是同样也是会用到连接池,只是有中间事务管理

第1种实现比较简单,直接使用JDBC请求生成连接返回即可;第3种分布式比较复杂,不在本文主旨讨论范围内;所以重点聊一下第二种。

数据源怎么运作

首先我们看一下一般情况下怎么使用druid数据源,比如注册到spring中


<bean id="dataSource" class="com.alibaba.druid.pool.DruidDataSource"
  destroy-method="close" init-method="init">
  <property name="url" value="${dbconf.url}" />
  <property name="username" value="${dbconf.username}" />
  <property name="password" value="${dbconf.password}" />
  <property name="driverClassName" value="${dbconf.driverClass}" />
  <property name="initialSize" value="5" />
  <property name="maxActive" value="20" />
  <property name="maxWait" value="60000" />
  <property name="removeAbandoned" value="true" />
  <property name="removeAbandonedTimeout" value="180" />
  <property name="poolPreparedStatements" value="true" />
  <property name="maxPoolPreparedStatementPerConnectionSize"
    value="20" />
  <property name="connectionProperties"
    value="druid.stat.mergeSql=true;druid.stat.slowSqlMillis=5000" />
</bean>

启动类DruidDataSource,想必大家也猜到了,init初始方法传入配置参数,可以看到仍然是依赖具体驱动(各个数据库的JDBC)。同时也实现了DataSource,内部使用了线程池、并发控制等一系列处理逻辑,代码太多就不贴出来了,就放一个官方的字符图诠释吧:

screen-shot-2016-09-14-at-%e4%b8%8a%e5%8d%881-26-07

这就是整个线程池实现的一个逻辑谱图,当然除此之外druid数据源还包括监控、日志、防SQL注入等其他模块,具体问题可以继续深入研究。

数据库连接池有什么用?

我们开发连接数据库的时候,经常是使用连接池,比如常见的c3p0,那么为什么要使用连接池呢,第一反应就是可以节约创建销毁socket的成本,那节约多少呢?除了这些还有什么其他的好处呢,下面自己动手做个连接实验。

客户端

一台普通Mac笔记本
JDBC-[mysql/mysql-connector-java “6.0.4”]
C3P0-[com.mchange/c3p0 “0.9.2.1”]
JDK 1.8

服务端

Centos 6.4刀片机
Mysql5.7

测试clojure脚本如下(不想看代码 可以直接忽略往下看)


(ns dbtest.core
	(:require [dbtest.dbrepo :as r]
	  )
	(:import [java.util UUID]))

(def text "XXX本来有很长的东西")

(defn lauch
	[limit is-pool]
	(dotimes [n limit]
		(if is-pool
			(try (r/insert-book-withpool (.toString (UUID/randomUUID)) (str "use-pool" n text) ))
			(try (r/insert-book (.toString (UUID/randomUUID)) (str n text) )))))

(defn go-test
	([nthread]
		(go-test nthread 10000 false))
	([nthread limit is-pool]
		(dotimes [n nthread]
			(future (lauch limit is-pool)))))

(ns dbtest.dbrepo
	(:use [dbtest.dbconf])
	(:require [clojure.java.jdbc :as j]
	  ))


(declare insert-stat)

(defn insert-book
  [name text]
  (let [s-time (System/currentTimeMillis) 
  		result (j/insert! cur-db-spec :bookinfo {:name name :text text})]
  	(insert-stat (-> result first :generated_key) s-time (- (System/currentTimeMillis) s-time))))


(defn insert-stat
	[biz-id start-time cost-time]
	(j/insert! cur-db-spec :bookstat 
		{:biz_id biz-id :start_time start-time :cost_time cost-time}))


(defn insert-book-withpool
  [name text]
  (let [s-time (System/currentTimeMillis) 
  		result (j/insert! (db-connection) :bookinfo {:name name :text text})]
  	(insert-stat (-> result first :generated_key) s-time (- (System/currentTimeMillis) s-time))))

测试逻辑如下:
首先有个目标表bookinfo,里面有两个字段,一个是32位长的字符串字段name(有唯一键约束),一个是1000位长的大字段text。
每次只做一个动作就是插入一条记录内容基本一致,但是又区分.每次插入前后记下时间戳,并计算得出此次数据插入所花时间(无事务),存入bookstat统计表中。

几个重要参数:nThread->几个线程,limit->插入多少次, is-pool->是否使用连接池
测试用例为:5,10,20,50个线程,每个线程插入200次,使用或者不使用连接池

最后得出的结果

取所有结果的平均数
不使用线程的用时(ms) 78.9(5线程) 106.8(10线程) 164.3(20线程) 310.5(50线程)
使用了线程的用时(ms) 51.9(5线程) 56.9(10线程) 67.3(20线程) 148.7(50线程)

取所有结果top100(最耗时的100)的平均数
不使用线程的用时(ms) 126.1(5线程) 228.1(10线程) 506.4(20线程) 3863.4(50线程)
使用了线程的用时(ms) 102.4(5线程) 176.6(10线程) 314.6(20线程) 903.4(50线程)

可以看出整体平均值上,使用了连接池省去了创建销毁的成本,但时间相对而言也就快了1倍,整体的吞吐量并没有我们想的那么糟糕,可是从下面top100的数据就可以看出另外一个端倪,那就是连接池的随着并发量的上升,插入更加稳定,可以想象如果一条数据插入操作的RT是4秒左右(峰值可能还要糟糕),那么整个操作响应、体验将变得很差,甚至直接调用超时了。

最后通过测试总结一下自己的感悟:任何系统的连接操作都是有上限的,当到达危险水位的时候(并发度很高、RT很大),那么对这个系统一定要做好连接的管理,如果没有就像上面不使用线程池的结果一样,甚至会引发系统瘫痪假死,导致业务全线不可用。虽然我们很多时候不用关心连接管理,都有相应连接模块处理,但是了解连接管理是一个系统的闸门这个思想,或许会在某天帮助你完成一个更健壮的架构