目录
核心思想
找到性能瓶颈、单点故障,使用集群、缓存、异步处理等方式,处理掉这些并发。
本文主要总结一些常见的应对并发的设计思想及业内常用的解决方案,同时分析这些方案的解决方式及引入后会造成的问题。
后续文章会分析相关组件的实现原理,敬请期待。
池化技术
主要解决了频繁创建数据库连接的时间开销
Tips: 这部分开销有多大?
用 “tcpdump -i bond0 -nn -tttt port 3306” 命令抓取 MySQL 建立连接的网络包来做分析,从抓包结果来看,整个 MySQL 的连接过程可以分为两部分:
**第一部分是前三个数据包。**第一个数据包是客户端向服务端发送的一个 “SYN” 包,第二个包是服务端回给客户端的 “ACK” 包以及一个 “SYN” 包,第三个包是客户端回给服务端的 “ACK” 包,这是一个 TCP 的三次握手过程。
**第二部分是 MySQL 服务端校验客户端密码的过程。**其中第一个包是服务端发给客户端要求认证的报文,第二和第三个包是客户端将加密后的密码发送给服务端的包,最后两个包是服务端回给客户端认证 OK 的报文。从图中,你可以看到整个连接过程大概消耗了 4ms(969012-964904)。
是什么
核心思想是空间换时间,期望使用预先创建好的对象来减少频繁创建对象的性能开销,同时还可以对对象进行统一的管理,降低了对象的使用的成本。
大致步骤:
- 程序启动时,预先创建数据库连接
- 如果连接池中有空闲连接则复用空闲连接
- 如果空闲池中没有连接并且当前连接数小于
最大连接数
(创建时候指定),则创建新的连接处理请求 - 如果当前连接数已经大于等于最大连接数,则按照配置中设定的时间等待旧的连接可用
- 如果等待超过了这个设定时间则向用户抛出错误。
eg: Python Connection Pooling 9.1
可能引入问题
问题一:MySQL 有一个参数 wait_timeout
控制着当数据库连接闲置多长时间后,数据库会主动的关闭这条连接。
这个机制对数据库调用方来说是无感知的,如果使用了被关闭连接,会报错。
问题二: 多余的内存消耗,增加系统启动时间。(可忽略,微不足道。)
解决方法
问题一:
- (推荐)启动一个线程定期检查连接是否可用。比如接发送 “select 1” 的命令给数据库看是否会抛出异常,如果抛出异常则将这个连接从连接池中移除,并且尝试关闭。(有些封装库以使用这种方式实现)
- (不推荐)获取到连接后,先检验可用,再执行想执行的 SQL。引入多余开销,不推荐
其它应用
线程池、redis 连接池
主从分离
大部分系统是读多写少,可能读写请求量的差距几个数量级。
数据库抗住更高的查询请求,我们一般将查询和写入分离,主库只用作写,从库用来作为查询,这样当流量突增导致数据库负载过高,可优先做增加从库扩容,扛住请求,然后考虑将流量拦截在查询数据前。
两个技术上的关键点:
- 主从复制, 从数据库获取主数据库更新的数据;
- 在主从分离的情况下,如何屏蔽主从分离带来的访问数据库方式的变化,像是在使用单一数据库。
主从复制
步骤:
假设主库已经开启了 binlog,并正常的记录 binlog。
首先从库启动 I/O 线程,跟主库建立客户端连接。
主库启动 binlog dump 线程,读取主库上的 binlog event 发送给从库的 I/O 线程,I/O 线程获取到 binlog event 之后将其写入到自己的 Relay Log 中。
然后从库启动 SQL 线程,将 Relay 中的数据进行重放,完成从库的数据更新。
其它复制方式: 同步复制、半同步复制
可能引入的问题
- 主从数据因意外不一致。主库 binlog 还没来得及刷新到磁盘,出现断电或者机器损坏导致 binlog 丢失。(出现概率较小可以忽略
- 主从同步的延迟带来的业务影响。 比如用户发帖场景,写完主库之后,需要有一个送到审核的环节,为了不影响主流程,帖子 ID 发送到消息队列,由相应程序从队列取出ID,在从库中获取帖子内容,然后发给审核系统,这时候如果存在主从延迟,就会获取不到对应帖子内容,出现问题。
解决方法
单从这个业务场景来说,思路核心是不读从库,这几个方法均可
- 数据冗余,在发消息队列的时候,不仅仅发送帖子 ID,还带上处理所需其它内容。
- 使用缓存,在写库的同时,写入缓存,这样可以优先从缓存读取。(比较适用于新增场景,更新场景需要考虑到多个线程更新缓存不一致问题)
- 读主库,不推荐,查询量不大也尽量不要这么用。
踩过的坑
线上 MongoDB 有一个 Collection,已经有 一亿五千万的数据了,做了主从分离。
有一次对主库加索引,使用的是 background 模式,几小时后,主库安全,从库上由于存在一些不规范的查询,常年机器 cpu 占用较高,加上索引的同步,直接把数据库进程 oom 了,造成服务不可用半小时
教训
- 数据表已经这个量级了,需要考虑分库分表了,不应该再往上加索引了
- 设立报警群,对单条语句查询时间、数据库机器负载进行报警,尽早优化掉不规范的查询
- 尽可能的消除单点故障,主库和从库尽量不要在一台物理机上
其它应用
不止是数据库,其实缓存中也有类似的操作 https://redis.io/topics/cluster-tutorial
分库分表
数据库分库分表的方式有两种:一种是垂直拆分,另一种是水平拆分。
垂直拆分,顾名思义就是对数据库竖着拆分,也就是将数据库的表拆分到多个不同的数据库中。一般是按照业务类型来拆分,核心思想是专库专用。
举个形象的例子就是在整理衣服的时候,将羽绒服、毛衣、T 恤分别放在不同的格子里。这样可以把不同的业务的数据分拆到不同的数据库节点上,这样一旦数据库发生故障时只会影响到某一个模块的功能,不会影响到整体功能,从而实现了数据层面的故障隔离。
水平拆分指的是将单一数据表按照某一种规则拆分到多个数据库和多个数据表中,关注点在数据的特点。
常见拆分规则
- 按照某一个字段的哈希值做拆分。比如要把用户表拆分成 16 个库,64 张表,那么可以先对用户 ID 做哈希,哈希的目的是将 ID 尽量打散,然后再对 16 取余,这样就得到了分库后的索引值;对 64 取余,就得到了分表后的索引值。
- 另一种比较常用的是按照某一个字段的区间来拆分。比如时间字段。
可能引入的问题
分库分表引入的一个最大的问题就是**引入了分库分表键,也叫做分区键,**也就是我们对数据库做分库分表所依据的字段。
带来一个问题是:我们之后所有的查询都需要带上这个字段,才能找到数据所在的库和表
问题1. 使用非拆分字段查询
比如,在用户库中我们使用 ID 作为分区键,这时如果需要按照昵称来查询用户
最合适的思路是要建立一个昵称和 ID 的映射表,在查询的时候要先通过昵称查询到 ID,再通过 ID 查询完整的数据
问题2:一些数据库的特性(如 join)在实现时可能变得很困难
比如说多表的 join 在单库时是可以通过一个 SQL 语句完成的,但是拆分到多个数据库之后就无法跨库执行 SQL 了
解决方法: 取出来,业务筛选
问题3:查询总数
在未分库分表之前查询数据总数时只需要在 SQL 中执行 count() 即可
**解决方法:**比方说将计数的数据单独存储在一张表中或者记录在 Redis 里面。
问题4:查询总数如何保证分库分表后ID的全局唯一性
主键的选择:
倾向于使用生成的 ID 作为数据库的主键
理由及注意事项
- 生成的 ID 最好具有单调递增性(UUID不好)
- ID 有可能成为排序的字段
- ID 有序也会提升数据的写入性能。 B+ 树/索引
- uuid 不具备业务含义
Snowflake 算法
Snowflake 的核心思想是将 64bit 的二进制数字分成若干部分,每一部分都存储有特定含义的数据,比如说时间戳、机器 ID、序列号等等,最终生成全局唯一的有序 ID。它的标准算法是这样的:
41 位的时间戳大概可以支撑 pow (2,41)/1000/60/60/24/365 年,约等于 69 年
如果系统部署在多个机房,那么 10 位的机器 ID 可以继续划分为 2~3 位的 IDC 标示(可以支撑 4 个或者 8 个 IDC 机房)和 7~8 位的机器 ID(支持 128-256 台机器)
12 位的序列号代表着每个节点每毫秒最多可以生成 4096 的 ID
不同公司也会依据自身业务的特点对 Snowflake 算法做一些改造,比如说减少序列号的位数增加机器 ID 的位数以支持单 IDC 更多的机器,也可以在其中加入业务 ID 字段来区分不同的业务。
比方: 1 位兼容位恒为 0 + 41 位时间信息 + 6 位 IDC 信息(支持 64 个 IDC)+ 6 位业务信息(支持 64 个业务)+ 10 位自增信息(每毫秒支持 1024 个号)
缺点:
- 它依赖于系统的时间戳,一旦系统时间不准,就有可能生成重复的 ID。 所以如果我们发现系统时钟不准,就可以让发号器暂时拒绝发号,直到时¬钟准确为止。
- 如果请求发号器的 QPS 不高,比如说发号器每毫秒只发一个 ID,就会造成生成 ID 的末位永远是 1,那么在分库分表时如果使用 ID
作为分区键就会造成库表分配的不均匀。
而解决办法主要有两个:
- 时间戳不记录毫秒而是记录秒,这样在一个时间区间里可以多生成几个id,避免出现分库分表时数据分配不均。
- 生成的序列号的起始号可以做一下随机,这一秒是 21,下一秒是 30,这样就会尽量的均衡了。