原文地址
1. Session 一致性
什么是session?
服务器为每个用户创建一个会话,存储用户的相关信息,以便多次请求能够定位到同一个上下文。
Web开发中,web-server可以自动为同一个浏览器的访问用户自动创建session,提供数据存储功能。最常见的,会把用户的登录信息、用户信息存储在session中,以保持登录状态。
什么是session一致性问题?
只要用户不重启浏览器,每次http短连接请求,理论上服务端都能定位到session,保持会话。
当只有一台web-server提供服务时,每次http短连接请求,都能够正确路由到存储session的对应web-server(废话,因为只有一台)。
此时的web-server是无法保证高可用的,采用“冗余+故障转移”的多台web-server来保证高可用时,每次http短连接请求就不一定能路由到正确的session了。
如上图,假设用户包含登录信息的session都记录在第一台web-server上,反向代理如果将请求路由到另一台web-server上,可能就找不到相关信息,而导致用户需要重新登录。
在web-server高可用时,如何保证session路由的一致性,是今天将要讨论的问题。
二、session同步法
思路:多个web-server之间相互同步session,这样每个web-server之间都包含全部的session
优点:web-server支持的功能,应用程序不需要修改代码
不足:
session的同步需要数据传输,占内网带宽,有时延
所有web-server都包含所有session数据,数据量受内存限制,无法水平扩展
有更多web-server时要歇菜
三、客户端存储法
思路:服务端存储所有用户的session,内存占用较大,可以将session存储到浏览器cookie中,每个端只要存储一个用户的数据了
优点:服务端不需要存储
缺点:
每次http请求都携带session,占外网带宽
数据存储在端上,并在网络传输,存在泄漏、篡改、窃取等安全隐患
session存储的数据大小受cookie限制
“端存储”的方案虽然不常用,但确实是一种思路。
三、反向代理hash一致性
思路:web-server为了保证高可用,有多台冗余,反向代理层能不能做一些事情,让同一个用户的请求保证落在一台web-server上呢?
方案一:四层代理hash
反向代理层使用用户ip来做hash,以保证同一个ip的请求落在同一个web-server上
方案二:七层代理hash
反向代理使用http协议中的某些业务属性来做hash,例如sid,city_id,user_id等,能够更加灵活的实施hash策略,以保证同一个浏览器用户的请求落在同一个web-server上
优点:
只需要改nginx配置,不需要修改应用代码
负载均衡,只要hash属性是均匀的,多台web-server的负载是均衡的
可以支持web-server水平扩展(session同步法是不行的,受内存限制)
不足:
如果web-server重启,一部分session会丢失,产生业务影响,例如部分用户重新登录
如果web-server水平扩展,rehash后session重新分布,也会有一部分用户路由不到正确的session
session一般是有有效期的,所有不足中的两点,可以认为等同于部分session失效,一般问题不大。
对于四层hash还是七层hash,个人推荐前者:让专业的软件做专业的事情,反向代理就负责转发,尽量不要引入应用层业务属性,除非不得不这么做(例如,有时候多机房多活需要按照业务属性路由到不同机房的web-server)。
四、后端统一存储
思路:将session存储在web-server后端的存储层,数据库或者缓存
优点:
没有安全隐患
可以水平扩展,数据库/缓存水平切分即可
web-server重启或者扩容都不会有session丢失
不足:增加了一次网络调用,并且需要修改应用代码
对于db存储还是cache,个人推荐后者:session读取的频率会很高,数据库压力会比较大。如果有session高可用需求,cache可以做高可用,但大部分情况下session可以丢失,一般也不需要考虑高可用。
五、总结
保证session一致性的架构设计常见方法:
session同步法:多台web-server相互同步数据
客户端存储法:一个用户只存储自己的数据
反向代理hash一致性:四层hash和七层hash都可以做,保证一个用户的请求落在一台web-server上
后端统一存储:web-server重启和扩容,session也不会丢失
我的思考与启发
- 专业的软件做专业的事情,不要让业务涉及到太多层面。
- 每台机器都同步SESSION,当机器数量多的时候,不仅是网络的灾难,数据也很冗余。
- 如果后台用一个DB 去存SESSION 会有单点故障问题。不过SESSION的持久化属性不是很强。而且采用REDIS CACHE层去做 响应速度会更快。
- 把SESSION状态留在客户端也不失为一种思路,这就是TOKEN吧。
2. 数据库主从一致性
大部分互联网的业务都是“读多写少”的场景,数据库层面,读性能往往成为瓶颈。如下图:业界通常采用“一主多从,读写分离,冗余多个读库”的数据库架构来提升数据库的读性能。
这种架构的一个潜在缺点是,业务方有可能读取到并不是最新的旧数据:
(1)系统先对DB-master进行了一个写操作,写主库
(2)很短的时间内并发进行了一个读操作,读从库,此时主从同步没有完成,故读取到了一个旧数据
(3)主从同步完成
有没有办法解决或者缓解这类“由于主从延时导致读取到旧数据”的问题呢,这是本文要集中讨论的问题。
方案一(半同步复制)
不一致是因为写完成后,主从同步有一个时间差,假设是500ms,这个时间差有读请求落到从库上产生的。有没有办法做到,等主从同步完成之后,主库上的写请求再返回呢?答案是肯定的,就是大家常说的“半同步复制”semi-sync:
(1)系统先对DB-master进行了一个写操作,写主库
(2)等主从同步完成,写主库的请求才返回
(3)读从库,读到最新的数据(如果读请求先完成,写请求后完成,读取到的是“当时”最新的数据)
方案优点:利用数据库原生功能,比较简单
方案缺点:主库的写请求时延会增长,吞吐量会降低
方案二(强制读主库)
如果不使用“增加从库”的方式来增加提升系统的读性能,完全可以读写都落到主库,这样就不会出现不一致了:
方案优点:“一致性”上不需要进行系统改造
方案缺点:只能通过cache来提升系统的读性能,这里要进行系统改造
方案三(数据库中间件)
如果有了数据库中间件,所有的数据库请求都走中间件,这个主从不一致的问题可以这么解决:
(1)所有的读写都走数据库中间件,通常情况下,写请求路由到主库,读请求路由到从库
(2)记录所有路由到写库的key,在经验主从同步时间窗口内(假设是500ms),如果有读请求访问中间件,此时有可能从库还是旧数据,就把这个key上的读请求路由到主库
(3)经验主从同步时间过完后,对应key的读请求继续路由到从库
方案优点:能保证绝对一致
方案缺点:数据库中间件的成本比较高
方案四(缓存记录写key法)
既然数据库中间件的成本比较高,有没有更低成本的方案来记录某一个库的某一个key上发生了写请求呢?很容易想到使用缓存,当写请求发生的时候:
(1)将某个库上的某个key要发生写操作,记录在cache里,并设置“经验主从同步时间”的cache超时时间,例如500ms
(2)修改数据库
而读请求发生的时候:
(1)先到cache里查看,对应库的对应key有没有相关数据
(2)如果cache hit,有相关数据,说明这个key上刚发生过写操作,此时需要将请求路由到主库读最新的数据
(3)如果cache miss,说明这个key上近期没有发生过写操作,此时将请求路由到从库,继续读写分离
方案优点:相对数据库中间件,成本较低
方案缺点:为了保证“一致性”,引入了一个cache组件,并且读写数据库时都多了一步cache操作
总结
为了解决主从数据库读取旧数据的问题,常用的方案有四种:
(1)半同步复制
(2)强制读主
(3)数据库中间件
(4)缓存记录写key
我的思考与启发
- 解决问题的方案可以是如何让从库拿到新值(方案1,4),或者找到能拿到新值的地方(方案2,3)2方面来考虑。
3. 数据库双主一致性
一、双主保证高可用
MySQL数据库集群常使用一主多从,主从同步,读写分离的方式来扩充数据库的读性能,保证读库的高可用,但此时写库仍然是单点。
在一个MySQL数据库集群中可以设置两个主库,并设置双向同步,以冗余写库的方式来保证写库的高可用。
二、并发引发不一致
数据冗余会引发数据的一致性问题,因为数据的同步有一个时间差,并发的写入可能导致数据同步失败,引起数据丢失:
如上图所述,假设主库使用了auto increment来作为自增主键:
两个MySQL-master设置双向同步可以用来保证主库的高可用
数据库中现存的记录主键是1,2,3
主库1插入了一条记录,主键为4,并向主库2同步数据
数据同步成功之前,主库2也插入了一条记录,由于数据还没有同步成功,插入记录生成的主键也为4,并向主库1也同步数据
主库1和主库2都插入了主键为4的记录,双主同步失败,数据不一致
三、相同步长免冲突
能否保证两个主库生成的主键一定不冲突呢?
回答:
设置不同的初始值
设置相同的增长步长
就能够做到。
如上图所示:
两个MySQL-master设置双向同步可以用来保证主库的高可用
库1的自增初始值是1,库2的自增初始值是2,增长步长都为2
库1中插入数据主键为1/3/5/7,库2中插入数据主键为2/4/6/8,不冲突
数据双向同步后,两个主库会包含全部数据
如上图所示,两个主库最终都将包含1/2/3/4/5/6/7/8所有数据,即使有一个主库挂了,另一个主库也能够保证写库的高可用。
四、上游生成ID避冲突
换一个思路,为何要依赖于数据库的自增ID,来保证数据的一致性呢?
完全可以由业务上游,使用统一的ID生成器,来保证ID的生成不冲突:
如上图所示,调用方插入数据时,带入全局唯一ID,而不依赖于数据库的auto increment,也能解决这个问题。
至于如何生成全局唯一,趋势递增的ID,参见文章《分布式ID生成算法》。
五、消除双写不治本
使用auto increment两个主库并发写可能导致数据不一致,只使用一个主库提供服务,另一个主库作为shadow-master,只用来保证高可用,能否避免一致性问题呢?
如上图所示:
两个MySQL-master设置双向同步可以用来保证主库的高可用
只有主库1对外提供写入服务
两个主库设置相同的虚IP,在主库1挂掉或者网络异常的时候,虚IP自动漂移,shadow master顶上,保证主库的高可用
这个切换由于虚IP没有变化,所以切换过程对调用方是透明的,但在极限的情况下,也可能引发数据的不一致:
如上图所示:
两个MySQL-master设置双向同步可以用来保证主库的高可用,并设置了相同的虚IP
网络抖动前,主库1对上游提供写入服务,插入了一条记录,主键为4,并向shadow master主库2同步数据
突然主库1网络异常,keepalived检测出异常后,实施虚IP漂移,主库2开始提供服务
在主键4的数据同步成功之前,主库2插入了一条记录,也生成了主键为4的记录,结果导致数据不一致
六、内网DNS探测
虚IP漂移,双主同步延时导致的数据不一致,本质上,需要在双主同步完数据之后,再实施虚IP偏移,使用内网DNS探测,可以实现shadow master延时高可用:
使用内网域名连接数据库,例如:db.58daojia.org
主库1和主库2设置双主同步,不使用相同虚IP,而是分别使用ip1和ip2
一开始db.58daojia.org指向ip1
用一个小脚本轮询探测ip1主库的连通性
当ip1主库发生异常时,小脚本delay一个x秒的延时,等待主库2同步完数据之后,再将db.58daojia.org解析到ip2
程序以内网域名进行重连,即可自动连接到ip2主库,并保证了数据的一致性
七、总结
主库高可用,主库一致性,一些小技巧:
双主同步是一种常见的保证写库高可用的方式
设置相同步长,不同初始值,可以避免auto increment生成冲突主键
不依赖数据库,业务调用方自己生成全局唯一ID是一个好方法
shadow master保证写库高可用,只有一个写库提供服务,并不能完全保证一致性
内网DNS探测,可以实现在主库1出现问题后,延时一个时间,再进行主库切换,以保证数据一致性
4. 主从DB与cache一致性
一、需求缘起
上一篇《缓存架构设计细节二三事》中有一个小优化点,在只有主库时,通过“串行化”的思路可以解决缓存与数据库中数据不一致。引发大家热烈讨论的点是“在主从同步,读写分离的数据库架构下,有可能出现脏数据入缓存的情况,此时串行化方案不再适用了”,这就是本文要讨论的主题。
二、为什么数据会不一致
为什么会读到脏数据,有这么几种情况:
(1)单库情况下,服务层的并发读写,缓存与数据库的操作交叉进行
虽然只有一个DB,在上述诡异异常时序下,也可能脏数据入缓存:
1)请求A发起一个写操作,第一步淘汰了cache,然后这个请求因为各种原因在服务层卡住了(进行大量的业务逻辑计算,例如计算了1秒钟),如上图步骤1
2)请求B发起一个读操作,读cache,cache miss,如上图步骤2
3)请求B继续读DB,读出来一个脏数据,然后脏数据入cache,如上图步骤3
4)请求A卡了很久后终于写数据库了,写入了最新的数据,如上图步骤4
这种情况虽然少见,但理论上是存在的, 后发起的请求B在先发起的请求A中间完成了。
(2)主从同步,读写分离的情况下,读从库读到旧数据
在数据库架构做了一主多从,读写分离时,更多的脏数据入缓存是下面这种情况:
1)请求A发起一个写操作,第一步淘汰了cache,如上图步骤1
2)请求A写数据库了,写入了最新的数据,如上图步骤2
3)请求B发起一个读操作,读cache,cache miss,如上图步骤3
4)请求B继续读DB,读的是从库,此时主从同步还没有完成,读出来一个脏数据,然后脏数据入cache,如上图步4
5)最后数据库的主从同步完成了,如上图步骤5
这种情况请求A和请求B的时序是完全没有问题的,是主动同步的时延(假设延时1秒钟)中间有读请求读从库读到脏数据导致的不一致。
那怎么来进行优化呢?
三、不一致优化思路
有同学说“那能不能先操作数据库,再淘汰缓存”,这个是不行的,在《缓存和数据库先操作谁》的文章中介绍过。
出现不一致的根本原因:
(1)单库情况下,服务层在进行1s的逻辑计算过程中,可能读到旧数据入缓存
(2)主从库+读写分离情况下,在1s钟主从同步延时过程中,可能读到旧数据入缓存
既然旧数据就是在那1s的间隙中入缓存的,是不是可以在写请求完成后,再休眠1s,再次淘汰缓存,就能将这1s内写入的脏数据再次淘汰掉呢?
答案是可以的。
写请求的步骤由2步升级为3步:
(1)先淘汰缓存
(2)再写数据库(这两步和原来一样)
(3)休眠1秒,再次淘汰缓存
这样的话,1秒内有脏数据如缓存,也会被再次淘汰掉,但带来的问题是:
(1)所有的写请求都阻塞了1秒,大大降低了写请求的吞吐量,增长了处理时间,业务上是接受不了的
再次分析,其实第二次淘汰缓存是“为了保证缓存一致”而做的操作,而不是“业务要求”,所以其实无需等待,用一个异步的timer,或者利用消息总线异步的来做这个事情即可:
写请求由2步升级为2.5步:
(1)先淘汰缓存
(2)再写数据库(这两步和原来一样)
(2.5)不再休眠1s,而是往消息总线esb发送一个消息,发送完成之后马上就能返回
这样的话,写请求的处理时间几乎没有增加,这个方法淘汰了缓存两次,因此被称为“缓存双淘汰”法。这个方法付出的代价是,缓存会增加1次cache miss(代价几乎可以忽略)。
而在下游,有一个异步淘汰缓存的消费者,在接收到消息之后,asy-expire在1s之后淘汰缓存。这样,即使1s内有脏数据入缓存,也有机会再次被淘汰掉。
上述方案有一个缺点,需要业务线的写操作增加一个步骤,有没有方案对业务线的代码没有任何入侵呢,是有的,这个方案在《细聊冗余表数据一致性》中也提到过,通过分析线下的binlog来异步淘汰缓存:
业务线的代码就不需要动了,新增一个线下的读binlog的异步淘汰模块,读取到binlog中的数据,异步的淘汰缓存。
提问:为什么上文总是说1s****,这个1s****是怎么来的?
回答:1s只是一个举例,需要根据业务的数据量与并发量,观察主从同步的时延来设定这个值。例如主从同步的时延为200ms,这个异步淘汰cache设置为258ms就是OK的。
四、总结
在“异常时序”或者“读从库”导致脏数据入缓存时,可以用二次异步淘汰的“缓存双淘汰”法来解决缓存与数据库中数据不一致的问题,具体实施至少有三种方案:
(1)timer异步淘汰(本文没有细讲,本质就是起个线程专门异步二次淘汰缓存)
(2)总线异步淘汰
(3)读binlog异步淘汰
我的启发和思考
- 上述这个问题只是用了不同手段来达到先更新了数据库后淘汰缓存。我们通过《缓存和数据库先操作谁》这篇文章可以了解到,要先操作数据库再淘汰缓存的最大风险是操作了数据库,之后缓存淘汰失败了。 造成数据不一致。 其实在这篇文章这个2次淘汰缓存的策略也有这个风险。 我的结论是缓存挂掉的概率远低于数据库,所以先设置数据库再淘汰缓存是可以接受的。在我的这篇文章里也思考过这个问题6.设计用户系统