数据库的读与写探讨

写于之前

本篇文章,主要旨在记录这些时间对数据库(系统)读与写,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很大),那么对这个系统一定要做好连接的管理,如果没有就像上面不使用线程池的结果一样,甚至会引发系统瘫痪假死,导致业务全线不可用。虽然我们很多时候不用关心连接管理,都有相应连接模块处理,但是了解连接管理是一个系统的闸门这个思想,或许会在某天帮助你完成一个更健壮的架构

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

简要业务上下文

  • 后台会定期导出单据核对,导出调用的是分页查询接口(按照创建时间倒序)
  • 运营又会在某时某刻新建一个单据
    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可以是最新的一条数据的时间戳,也可以第一次查询时的当前时间,总之这个时间戳每次查询不能变,也可保证查出来数据的一致性。

总结

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

Threejs(Demo)

Three.js 是JavaScript编写的第三方3D引擎,支持Canvas,WebGL,CSS3。这个是官网
文档相对比较欠缺,开发还是要按图索骥,从官网看一些例子然后改写相对快一些,当然还是要依赖 google,stackoverflow。

下面是自己写的一个WebGL的地图展示页面,其中视窗的模糊为了效率、效果好,改写了shader着色器(这个效果也捣腾了蛮久的)。
Threejs-demo,一般的电脑都是可以流畅转动的。
以后可以直接拿来借鉴,各个创建场景,渲染初始化,事件绑定的模块也抽象出来了。

why token 2(参见微信,使用签名)

之前讲了一篇Token的文章,服务器需要每个用户分配一个唯一的Token,并且要在有效期内一直持有和维护。如果分布式的话,还要考虑到Token在服务器之间的共享。
但是有个问题来了,如果token暴露了,那么该用户(甚至平台)就被劫持了。
研究了下微信第三方接入的验证方式,有以下几种情况。
1.公众平台服务器端发起,获取该公众号下用户是上面那个方式,APPSecret+APPid 获取access_token,后面直接通过access_token,全程HTTPS保护无烦恼,秘钥等关键信息不过客户端。
2.开放平台,用户微信登陆,使用的是OAUTH认证并且回调服务器,服务器通过Code+APPSecret+APPid获取该用户基本信息,全程HTTPS保护无烦恼,秘钥等关键信息不过客户端。
3.微信JS-SDK,如果要使用到分享,扫一扫语音等功能,那么就需要微信JS-SDK了。JS-SDK不是随便用的(TMD,腾讯的东西,没钱玩你麻痹),要初始化验证,但是又不能赤裸裸的把APPSecret,access_token放到页面里面初始化(如果这家伙一连电脑调试,就能拿到了,拿了会怎么样? 看情况1,直接把该公众平台下用户数据拉下来随便玩),这也是此种情况不同上两种的原因。

下面讲下针对第三种情况的解决办法,不光光是微信,以后碰到类似场景也可以借鉴。虽然微信不能把access_token返回客户端,但是微信让服务器通过access_token换取一个api_ticket(局部风险保护,以后直接用api_ticket做下面操作),之后服务器使用api_ticket+timestamp+nonce_str 等一些按照字母排序做SHA1算法返回客户端作为签名signature。
之后客户端初始化就用signature到微信平台那验证,过了就OK了,就算signature暴露了,也不会对该公众平台有什么危险。

总结一下就是,如果单纯使用token,必须要保证token的安全(HTTPS)。如果有暴露风险,那么就应该加密/签名。

why token?

最近的一个项目里,客户端与服务器做业务逻辑请求时,都会发生UserID 来识别该用户,并进行相应业务处理。为了防止信息被盗取,加了SSL,后来突然想到一个问题,如果客户端的代码被反编译破解之后,其调用的服务器接口都会暴露出来,包括一些支付的敏感接口。那么这个时候,陌生人就可以直接请求HTTPS接口,因为UserID是相对有规则的,而且是没用防备的,而且可以直接绕开登陆流程。
因此,参考类似于OAUTH协议,应该是首先登陆完成,用户分配一串token,后面的接口都以token为准,这样陌生人直接请求接口也会因为没有合法的token而被拒。token相对于userID最大的特点就是无序,位数长,有失效时间,因此在敏感业务必须用上此机制。
(有人问这个token和一般App Sever的sessionID有什么区别? 其实也没有什么区别(我没有用sessionID- -#,其实是当初大意疏漏了,用了也没这个事了。。。),就是维护一个彼此之间的会话不被第三方陌生人知道。token相比于sessionID 应用环境应该更大一点,系统跨度更广,因为将用户登录授权单独做成了一个模块。

总结:用户验证机制还是要做成一个流程,绕前执行过滤,Mark,否则以后肯定会有坑。

数据库隔离级别

细细梳理一下DB的隔离级别。

1.Read Uncomitted(未提交读)

在此级别中,事务中的级别即便是没有提交,对其他事务也都是可见的。也就是说事务可以读取到未提交的数据,这也是俗称的“脏读”。这个是比较危险的,而且性能上不比其他严格级别的好多少。

2.Read Committed(提交读)

顾名思义,该事务下,一个事务只有当提交之后,其所做的修改才能被另外的事务看见。这里又会产生另外一个问题,事务前后查询的数据是不一样的,(即“不可重复读”),大多数数据库默认是这个级别。因为在一个事务中前后两次查询同一数据,并且对其结果有一致性要求的需求极少,所以一般情况下是安全的。

3.Repeatalbe Read(可重复读)

该级别保证了同一事务中多次读取同一记录的结果是一致的。但是理论上,该级别无法解决另外一个”幻读“问题。当一个事务查询ID 0~100的记录,此时另外一个事务插入ID为99的记录,那边在插入事务前后的查询会不一致。(此问题和不可重复读类似,前者是因为新增或删除,后者是因为修改)

4.Serializable(可串行化)

最高的隔离级别。所有事务串行执行,会对操作的每一行数据都加上锁,会导致性能耗费在超时和锁争抢上。只有在非常需要确保数据的一致性并且可以接受没有并发的情况下,才应该选择此级别。

Mysql-查看事务级别

<pre><code>SELECT @@global.tx_isolation;
SELECT @@session.tx_isolation;
SELECT @@tx_isolation;</pre></code>

默认的行为(不带session和global)是为下一个(未开始)事务设置隔离级别。如果使用Global关键字,语句在全局对从那点开始创建的所有新连接(除了不存在的连接)设置默认事务级别。需要Super权限来做这个。使用Session 关键字为将来在当前连接上执行的事务设置默认事务级别。 任何客户端都能自由改变会话隔离级别(甚至在事务的中间),或者为下一个事务设置隔离级别。

Https 工作原理浅析

最近,碰到个需求:服务器与移动端交互,保护敏感数据。原理之前的HTTP协议,如果自己搞加密,总会有点不安全,所以考虑到了直接用HTTPS。以前知道HTTPS干嘛,但是还没深入研究过,正好可以捣腾下。
如下图
https
1.浏览器与服务器握个手,告知服务器自己支持的加密方式并且拿到服务器CA证书和一个RSA、公钥S_PuKey,然后去校验(CA证书是由一些指定机构颁发的),如果有安全隐患就会提示危险(那么基本就是自己签的,像12306)。
2.接着,浏览器随机生成一个数作为之后交互数据的对称加密秘钥C_Key,把生成的这个数用S_PuKey加密给服务端。这个时候,第三方窃取者可以知道RSA,和S_PuKey,以及密文,但是却无法知道数据内容的明文(因为是非对称的加密方式,而这也是整个加密过程的核心)。服务器用私钥把S_PuKey加密的数据给解开,得到C_Key。
3.最后,服务器和浏览器通信就由这个C_Key对称加密数据。

可能还有双向认证,服务器也要验浏览器这边的证书,方式也差不多,核心也就是RSA阻隔窃取者。
至于自签的HTTPS证书,和机构颁发的HTTPS证书有什么区别,加密功能上看 没有什么区别,自签的照样可以有加密效果。主要是机构颁发的浏览器上可以看到认证的绿色标志,表示不是钓鱼网站,包括IOS item协议下载App的时候也要HTTPS协议(自签要双向认证,你总不可能让别人下个APP还要装个证书吧)

ring form 上传文件

最近需要form表单提交文件,text复合内容(即multipart/form-data),首先添加其中间件wrap-multipart-params函数,github Wiki上有写,加入之后会发现request 多了个multipart-params的K-V对
例如 :multipart-params {“user” “kimmy”, “up” {:size 261556, :tempfile #, :content-type “image/jpeg”, :filename “A{@D}%])VN6)QCF0}}QM(CI.jpg”}}。
——————————-说点别的————————-
1.如果用的是Compojure,这样获取request

(POST "/upload" request
		(println request))

2.用我写的这个csp-compiler调试很方便,可以直接像jsp调试java代码一样 实时调试输出clj改动响应结果(得瑟一下)
—————————————————————

回到正题,如果用官方的这样得到的:multipart-params,是使用默认的临时文件保存方法,其功能就是把文件们写到临时文件下,并且开启后台线程检测超过某个过期时间,就会删除其。
但是我现在的需求是永久的保存,并且不想浪费这个资源去开线程检测它有没有过期,默认方法是做不到的(它只能传入过期时间的参数,线程照开),所以找到代码

(defn multipart-params-request
  "Adds :multipart-params and :params keys to request.
  See: wrap-multipart-params."
  {:arglists '([request] [request options])
   :added "1.2"}
  [request & [options]]
  (let [store    (or (:store options) @default-store)
        encoding (or (:encoding options)
                     (req/character-encoding request)
                     "UTF-8")
        params   (if (multipart-form? request)
                   (parse-multipart-params request encoding store)
                   {})]
    (merge-with merge request
                {:multipart-params params}
                {:params params})))

核心代码如上,如果想自己写的话,就把函数作为:store的值塞到一个option的map里面,然后改下middleware的参数,这里的upload/file-store参照默认函数temp_file.clj改写的

(-> routes
      wrap-keyword-params
      wrap-nested-params
      wrap-params
      (wrap-multipart-params {:store (upload/file-store)} ))

最后注意,改写的函数是个闭包函数,执行后才返回一个干正事的函数。