0.前言
数据库在软件开发中必不可少,是我日常生活中的小伙伴。狭义上的传统数据库是指关系型数据库,我常用 MySQL 和 Oracle。广义上讲数据库有:关系型、内存型(redis)、搜索引擎型(elasticsearch)、消息队列(kafka),只要是能够存储和获取数据的组件都可以称为数据库。本文是对数据库的一个总结,更好的串联知识,方便复习和回顾。
MySQL
选型
MySQL 是关系型数据库的代表,简单易用,比起 Oracle 和 SQLServer 来说显得很轻量级。选择 MySQL 的原因:开源节约成本;使用广泛,技术成熟,维护成本低;性能不错。一般数据量不大,对 RTO(恢复时间目标)、RPO(恢复点目标) 要求不高,能够承受一定的数据损失时我会选用 MySQL。
部署
MySQL 一般会做高可用部署,常用有:简单的主从架构;MMM;MGR 等,更多。我们公司一开始采用简陋的主从,现在替换成了 MGR。另外,还有读写分离方式部署,专门设置读库减轻主节点压力。
MySQL 做主从复制主要是基于binlog
日志完成的。主备之间是有延迟的,造成延迟的原因主要有:主备性能差异;备库执行其他耗时统计分析任务;大事务同步。切换时有两种方式:可靠性优先策略是等到主备延迟小于阈值(比如 3 分钟)将主库改为只读,之后等主备无延迟时,将备库改为可写,然后切换浏览;可用性优先是直接切换,之后通过 binglog 继续补延迟。
架构
MySQL 可分为 service 层和存储引擎层两个部分。
service 层包括连接器、查询缓存、分析器、优化器、执行器等,涵盖 MySQL 的大多数核心服务功能,以及所有的内置函数(如日期、时间、数学和加密函数等),所有跨存储引擎的功能都在这一层实现,比如存储过程、触发器、视图等。
存储引擎层负责数据的存储和提取。其架构模式是插件式的,支持 InnoDB、MyISAM、Memory 等多个存储引擎。
SQL 语句执行过程:
连接器建立连接、获取权限 –> 查询缓存(8.0 已废弃) –> 分析器 SQL 语句解析,检查语法错误 –> 优化器确定执行方案(如用什么索引、如何进行表连接) –> 执行器在会验证是否有权限,然后执行语句
MySQL 数据类型
- 整数
- TINYINT(8), SMALLINT(16), MEDIUMINT(24), INT(32), BIGINT(64)
- 可选有符号与无符合,无符号多存储一倍范围
- 注意,INT(10)中的 10 只表示宽度
- 实数
- FLOAT(4), DOUBLE(8), DECIMAL
- 尽量只在对小数进行精确计算时,才使用 DECIMAL;在数据量较大时使用 BIGINT 代替,可提高效率
- DECIMAL(18,9)表示,一共 18 位数字,小数部分为 9 位
- 字符串
- VARCHAR, CHAR, BINARY, VARBINARY
- VARCHAR 是可变长的,更省空间;但 Update 时,字段长度变化,可能导致页空间不够存储新字段值的情况
- VARCHAR 使用场景:最大长度比平均长度大很多;列更新较少;复杂编码(如 UTF-8)每个字符使用不同的字符数
- CHAR 使用场景:很短的字符串,或长度都相近
- 日期时间
- DATETIME:保存大范围的值(1001 ~ 9999),精度到秒,与时区无关,8 字节
- TIMESTAMP:1970 ~ 2038,存 1970/1/1 开始的秒数,4 字节,依赖时区。可以自动更新,默认为 NOT NULL
- 除特殊行为外,尽量使用 timestamp,因为其更高效
- BLOB 和 TEXT
- MySQL 会把 BLOB 和 TEXT 当作独立的对象处理,当值太大时会使用专门的外部区域存储,行内只存储指针
- 排序只针对
max_sort_length
字节而不是整个列
Oracle
实际上很多 MySQL 的设计都是参照 Oracle 进行的。由于公司 Oracle 运维水平高(花钱)所以当追求数据的安全性和稳定性时我会选择 Oracle。
Oracle 语法与 MySQL 的差异
- 主键:O 主键通过序列生成;M 有自增主键
- 分页:O 分页比较复杂,没有 limit 语法,通过 row num 分页;M 有 limit 分页
- 获取当前时间:O
SYSDATE
;M now()
SQL 优化
性能下降的原因
- 没有索引
- 索引创建不当
- 数据变化,如数据量增大、特征值变化
- join 过多
- 配置不当
衡量查询的三个指标
- 响应时间:最重要,但可能是表面的值。因为它是服务时间+排队时间,可能受当时服务器情况影响
- 扫描的行数:非常有用,但不够完美。因为并不是访问所有行的代价都一致
- 返回的行数
索引
概念
- 索引类型:普通、唯一(可为空)、主键(唯一,不可为空)、组合(最左前缀原则)
- 索引模型:hash(仅等值查询)、有序数组(因为插入成本高仅适合静态存储引擎)、B+树(N 叉搜索树,减少树深度,减少磁盘读取)
- 索引作用:
- 大大减少服务器需要扫描的数据量
- 帮助服务器避免排序和临时表
- 将随机 I/O 变为顺序 I/O
- 适合索引的列
- 不适合索引的列
- 经常变更的字段
- 不作为查询条件的字段
- 重复度高的字段
- 聚集索引:
- 聚集索引:索引中键值的逻辑顺序决定了表中相应行的物理顺序(索引中的数据物理存放地址和索引的顺序是一致的)。
- 非聚集索引:索引的逻辑顺序与磁盘上的物理存储顺序不同
- MySQL 是不是一定要有聚集索引:
- 如果一个主键被定义了,那么这个主键就是作为聚集索引
- 如果没有主键被定义,那么该表的第一个唯一非空索引被作为聚集索引
- 如果没有主键也没有合适的唯一索引,那么 innodb 内部会生成一个隐藏的主键作为聚集索引,这个隐藏的主键是一个 6 个字节的列,改列的值会随着数据的插入自增。
- 覆盖索引:索引“覆盖了”我们的查询需求,我们称为覆盖索引。覆盖索引无需回表(回到主键索引树搜索的过程,我们称为回表),减少树的搜索次数。
优化
- 若函数或者表达式子作为条件,无法使用索引,要避免
- 利用好组合索引,利用最左前缀原则减少索引的创建,尽量使用覆盖索引减少回表,组合索引还需要注意字段的区分度
- 当要查询的字段较长时,可创建前缀索引减少索引的消耗,要注意前缀索引的区分度
- 主键选择:
- 最好是自增,插入时不会做过多的移动,减少页分裂、随机磁盘读写;也能使逻辑相邻的行在物理上也相邻,有利于内存缓存
- 尽量小,因为其他索引的叶子节点存的是主键,可以减小其索引大小
表结构
设计范式
- 表中的每一列都是不可分割的基本数据项,同一列不能有多个值
- 表中的每一行可以被唯一的区分
- 表中不包含其他表中一存在的非主键字段
数据字段选取原则
- 更小的更好
- 通常来说更小更快,占用的资源更少
- 需要正确评估字段大小,在 schema 增加数据类型的范围是是很耗时和痛苦的操作
- 简单就好
- 简单的数据类型操作通常需要更少的 CPU 周期
- 例如,用内建日期类型存储时间,而不是字符串;不用整型存储 ip
- 尽量避免 NULL
- NULL 列使存储、索引和比较都更为复杂
- NULL 列存储需要更多空间,需要特殊处理
- NULL 列为索引时,每个索引记录都需要记录一个额外的字节
schema 设计中的陷阱
- 太多列
- 太多的关联:如 EAV 设计,MySQL 限制了每个关联操作最多只能有 61 张表,通常情况下单个查询最好控制在 12 个表以内。
- 全能枚举:如 country enum(‘’,’0’,’1’,’2’,’3’….)
- 变相枚举:如 is_default enum(‘Y’, ‘N’)
- Not invent here 的 null:用其他形式的特定值表示 null,造成不必要的复杂运算
其他
- 特定写法:
count
:按照效率排序的话,count(字段)
< count(id)
< count(1)
=. count(*)
join
:被驱动表上有索引时会使用Index Nested-Loop Join,驱动表扫描,被驱动表走索引树。小表做驱动表。被驱动表上没有索引时会使用Block Nested-Loop Join,将驱动表存入缓存中,被驱动表依次访问判断是否可以作为返回。当缓存足够大时是一样的,当缓存不够大时小表做驱动表更好。
order by
:尽量用上索引,如果是覆盖索引更好;如果需要统计的数据量不大,尽量只使用内存临时表;也可以通过适当调大sort_buffer_size
参数,来避免用到磁盘临时表;
- 批量操作:事务提交需要写日志,批处理减小日志的性能损耗
- 绑定变量,节约解析时间
- 减少长事务
总结
可以从几个方面来说 SQL 优化,索引优化、表结构、查询时的注意点、数据库本身的注意点。
索引方面
- 查询使用索引,如果查询中有函数或者运算就不会使用索引,要避免和注意
- 注意索引的区分度
- 利用好组合索引,最左前缀原则,可以创建覆盖索引减少回表
- 对于字段较长的,可以创建前缀索引
- 主键索引选择自增主键更好,小;物理的相邻和逻辑的相邻一致,减少随机读写
表结构方面
- 注意字段类型的选择,小、简单、NULL 避免
- 表列不要过多,表关联不要过的
- 避免一些常见的反模式:全能枚举(枚举值过多)、变相枚举(bool)、日期不用日期类型用字符串
特殊写法
count
:按照效率排序的话,count(字段)
< count(id)
< count(1)
=. count(*)
join
:
- 被驱动表上有索引时会使用Index Nested-Loop Join,驱动表扫描,被驱动表走索引树。
- 小表做驱动表。被驱动表上没有索引时会使用Block Nested-Loop Join,将驱动表存入缓存中,被驱动表依次访问判断是否可以作为返回。当缓存足够大时是一样的,当缓存不够大时小表做驱动表更好。
order by
:
- 尽量用上索引,如果是覆盖索引更好;
- 如果需要统计的数据量不大,尽量只使用内存临时表;
- 也可以通过适当调大
sort_buffer_size
参数,来避免用到磁盘临时表;
group by
:
- 如果对
group by
没有排序要求,要在语句后加order by null
- 尽量让
group by
过程用上索引,用explain
确认没有使用临时表(Using temporary
or Using filesort
)
- 如果
group by
需要统计的数据量不大,尽量只使用内存临时表;也可以通过适当调大tmp_table_size
参数,来避免用到磁盘临时表;
- 如果数据量实在太大,使用
SQL_BIG_RESULT
这个提示,来告诉优化器直接使用排序算法得到group by
的结果
数据库
- 绑定变量,节约解析时间
- 批量操作:事务提交需要写日志,批处理减小日志的性能损耗
- 减少长事务
MySQL 查询计划关注的值:
id
越大越先执行,相同 id 顺序执行
select_type
查询类型:
- simple:简单查询
- primary: 使用主键
- derive:衍生表
- subquery:子查询
- union\union result:合并
table
查询表,partition
查询分区
type
连接类型(访问类型),最好到最差:
- system:表中只有一行记录
- const:只查询一次
- eq_ref:唯一性索引扫描
- ref:查找条件列使用了索引而且不为主键和 unique。语句最好能达到这种情况
- range:索引范围查询
- index:全量索引扫描
- all:全表
possible_keys
:可能用到的索引
keys
:实际用到的索引
key_len
:使用索引的长度
ref
:索引使用的具体值,常量或者是其他列的值
rows
:扫描行数
filter
:用到的行数与扫描行数的百分例,越大越好,最大 100
extra
:额外信息
- using filesort:使用文件排序
- using temporary:使用临时表
- using index:使用索引
- using where:使用查询条件
- using join buffer:使用链接缓存
- impossible value:条件永远不会达成
事务
事务特性:
- 原子(atomicity),事务最终状态只有两种,全部成功或全部不执行。
- 一致性(consistency),事务操作前后,数据满足完整性约束,数据库保持一致状态。
- 隔离性(isolation),当多个事务并发使用相同数据时,不会互相干扰。
- 持久性(durability),事务执行后数据被永久保存下来。
隔离级别:
|
脏读 |
不可重复读 |
幻读 |
read uncommitted |
Y |
Y |
Y |
read committed |
N |
Y |
Y |
repeatable read(default) |
N |
N |
Y |
serialized |
N |
N |
N |
- 脏读:一个事务能其他事务读取未提交的数据
- 不可重复读:一个事务中读取同一个数据,两次查询结果不一样
- 幻读:“幻读”是不可重复读的一种特殊场景:当事务 1 两次执行 SELECT … WHERE 检索一定范围内数据的操作中间,事务 2 在这个表中创建了(如 INSERT)了一行新数据,这条新数据正好满足事务 1 的“WHERE”子句。read committed 在另一个事务提交插入后,再次查询即可查到;repeatable read 在进行一次“当前读”后会读到另一个事务新增的数据
可重复读事务隔离的实现:每条记录在更新的时候都会同时记录一条回滚操作。同一条记录在系统中可以存在多个版本,这就是数据库的多版本并发控制(MVCC)。当系统里没有比这个回滚日志更早的 read-view 的时候,才会删除回滚,所以长事务是有害的。
锁
按不同的方式分类锁可以划分为:
- 粒度:行所、表锁、服务器锁
- 功能:共享锁(读锁)、排他锁(写锁)、意向锁(简化加行级别锁后,再加别的锁时的检查)
避免死锁:
- 如果事务涉及多个表,操作复杂,尽可能一次性锁定所有资源
- 如果一次需要更新一个表中的很多数据,可以直接加表锁
- 不同事务并发读写多张数据表,可以约定访问表的顺序,降低死锁发生的概率
- 设置合适的锁等待时间,MySQL InnoDB 中用
innodb_lock_wait_timeout
- 把最可能发生冲突的语句放在事务的最后
维护
数据迁移
迁移应该是在线的迁移,也就是在迁移的同时还会有数据的写入;数据应该保证完整性,也就是说在迁移之后需要保证新的库和旧的库的数据是一致的;迁移的过程需要做到可以回滚,这样一旦迁移的过程中出现问题,可以立刻回滚到源库不会对系统的可用性造成影响。
- 双写方案:
- 将新库作为旧库的从库,进行同步
- 改造业务代码,写入旧库同时,写入新库。可以异步写,失败做记录
- 数据校验,提前准备脚本校验,必须经过完整测试
- 灰度切换,如果遇到问题,立刻切换会旧库,减少影响
- 观察监控,对比新旧库的区别
- 级联同步:比较适合数据从自建机房向云上迁移的场景,因为迁移上云最担心云上的环境和自建机房的环境不一致,会导致数据库在云上运行时因为参数配置或者硬件环境不同出现问题。具体是自建机房准备一个备库,在云上环境上准备一个新库,通过级联同步的方式在自建机房留下一个可回滚的数据库。
- MQ 消息传递完成迁移
ElasticSearch
ElasticSearch 是一个高性能的分布式搜索引擎。它存储数据的最小单位是 Document(文档),同一种类型的文档放在一个 Index(索引)中,Index 被存储在 Shard 上,Shard 分为 Primary Shard(负责写入数据) 和 Replica Shard,它们会分布在不同的 Node 上,提高数据的高可用。
倒排索引
倒排索引是维护的单词到文档 id 的关系,分为两个部分:
- 单词词典:记录文档中的所有单词,记录单词到倒排列表的关系。可以用 B+树实现
- 倒排列表:由倒排索引项组成(文档 ID、词频、位置(Position)、偏移(Offset)
设计优点:
- API 设计的好,简洁易用
- 分布式存储,每个索引可以设置分区和备份,防止数据的丢失
- 写操作会被转发到主分区,但备份可以进行读操作的计算,增加效率
- 一个搜索会在多台机器上分布式的进行,提升搜索效率
- 删除和更新,都是先标记为逻辑删除,再新增数据接在后面,可以保证一定的顺序存储,提升读取效率
- ES 的倒排索引不可变
- 好处是:不需加锁,可以一直放在缓存中,也可以整块压缩节约 io 和 cpu
- 坏处是:修改需要重新构建索引
工作过程
- 写入:客户端发送请求给任意一个节点,它就是协调节点,由协调节点找到有主分区所在节点,将请求转发给主分区节点,主分区节点处理这个请求,并将结果同步给备份节点,同步达到设置的同步策略后,返回完成给协调节点,协调节点将结果返回给客户端。
- 读取:客户端发送请求给任意一个节点,它就是协调节点,协调节点根据 hash 算出该 doc id 所在的分区号,在负载均衡的方式选择一个主/备份分区执行查询,查询结果返回给协调节点,在返回给客户端。
- 搜索:客户端发送请求给任意一个节点,它就是协调节点,协调节点将请求发给该索引的所有分区(负载均衡方式随机发向一个主分区或副本),这打他些节点执行搜索将 doc id 返回给协调节点,协调节点汇总排序后,通过 doc id 再拉取完整数据,最终返回给客户端。
- 替换,删除内部逻辑
- 替换时会把之前的数据设置为逻辑删除
- 部分更新时,与替换一样,会将先前的记录设置为逻辑删除,创建一个新的 document。内部会自动使用乐观锁
- 删除命令也是逻辑删除,当数据越来越多时会自动清理逻辑删除过的数据
- 写入内部逻辑:数据先写入内存 buffer,然后每隔 1s,将数据 refresh 到 os cache,到了 os cache 数据就能被搜索到(所以我们才说 es 从写入到能被搜索到,中间有 1s 的延迟)。每隔 5s,将数据写入 translog 文件(这样如果机器宕机,内存数据全没,最多会有 5s 的数据丢失),translog 大到一定程度,或者默认每隔 30mins,会触发 commit 操作,将缓冲区的数据都 flush 到 segment file 磁盘文件中。数据写入 segment file 之后,同时就建立好了倒排索引。
最佳实践
- 索引创建
- 单个 document 不要过大,会带来 IO、缓存压力
- 分片数量和 data 节点数量保持一致,最好每个主分片都设置一个副本
- 对于时序数据存储,索引最好按照时间序列进行创建,可以方便删除数据、降低故障范围、减小检索粒度
- 集群中的索引不要过多,最好不要超过 200 个
- 文档维护
- 使用批量操作 bulk api
- 搜索优化:
- 不要返回很大的结果集。es 被设计为搜索引擎,非常擅长返回满足查询的前几个 document。es 并不擅长返回所有数据。如果一定要这么做需要使用
scroll api
- 使用
filter
效率更高,不需要评分只是过滤的场景,使用filter
更高效
Redis
Redis 是一个 Key-Value 类型的内存数据库,通常我们用它来做缓存。现在有一些对性能要求高的系统,也会用 Redis 作为完全的数据来源。
Redis 为什么这么快
- C 语言效率更高
- 内存数据库,避免 I/O
- 单线程避免了上下文切换资源竞争
- I/O 多路复用技术处理网络 socket 连接
- 对象压缩,如果对象小,一维结构内存小于二维结构。ziplist 紧凑型字节数组,inset 紧凑型整数数组
数据类型
类型 |
名称 |
应用 |
string |
字符串 |
分布式锁,设置 key-value-ttl,考虑超时和可重入性 |
list |
列表 |
异步消息队;利用 lrange 命令,做基于 Redis 的分页功能列 |
set |
集合 |
利用 Set 的交集、并集、差集等操作 |
zset |
有序集合 |
延迟队列,score 为延迟时间;限流器,score 为加入时间,移除加入时间超过与之的成员,zset 大小超过限流时拒绝请求 |
hash |
字典 |
|
setbit |
位图 |
位图不是特殊的数据类型,而是将字符串看出 byte 数组后按位操作。用于记录 1/0 数据节约使用空间 |
hyperloglog |
|
不精确的去重计数方案,标准误差 0.81% |
bloom filter |
布隆过滤器 |
检查一个值是否在集合里,有一定误差。当判断一个值存在时,可能不存在,但判断一个值不存在,就一定不存在 |
redis cell |
限流器 |
使用漏斗算法 |
geo hash |
地理模块 |
原理:将二维地理映射到一维 zset 中进行排序 |
高可用
- 客户端高可用:制定一些数据分片和数据读写的策略
- 集群 redis cluster,Redis 官方集群支持,去中心化的集群,每个节点管理一部分 key,共同组成一个对等集群:
- 主从同步:
- 增量同步:主节点将执行的命令存在 buffer 中,buffer 是循环记录的,然后异步的将指令同步到从节点,从节点执行命令并更新自己同步的偏移量
- 快照同步:主节点生成 rdb 快照,从节点同步 rdb 快照,完成后再同步复制 buffer。这个操作也可以不生成 rdb,主节点直接将内存中的数据通过网络传输给从节点
- 细节
- 将所有数据划分为 16384 槽位,每个节点负责一部分槽位
- 客户端也会存一份槽位映射信息,可以计算槽位应在哪个节点,直接调用
- 若客户端槽位信息和真实槽位不一致,Redis 会返回 MOVED 指令和错误信息,客户端需要重试并纠正自己的槽位映射
- 动态扩容将槽位发生变化的 key 迁移至新的槽位;当客户端请求迁移中的 key 时,Redis 会返回 ASKING 指令和错误信息,客户端需要重试
- Cluster 中的节点支持主从,若主节点故障,会将从节点提升为主节点
- 节点下线使用 Gossip 协议,一个节点发现连接不到某个节点,广播这个信息,集群中的大部分节点承认后,再广播下线信息使所有节点承认下线
- 一致性 hash:使用一致性 hash 可以保障在发生变化时(动态扩容),对数据移动更少。客户端缓存集群的槽位,当集群发生变化时,客户端请求计算的槽位就会不一致,此时会返回重新加载槽位的命令给客户端
- 代理 codis
缓存读写策略
- Cache Aside(旁路缓存)策略:在更新数据时不更新缓存,而是删除缓存中的数据,在读取数据时,发现缓存中没数据,则从数据库中读取数据,更新到缓存中。
- 问题:
- 很小几率的缓存不一致
- 数据库主从同步延迟,造成新写入的数据没有写入从库,查询不到,写不进缓存
- 解决:
- 写数据库同时,加锁写入缓存
- 写数据库同时,写缓存,设置较小的缓存过期时间
- Read/Write Through(读穿 / 写穿)策略:这个策略的核心原则是用户只与缓存打交道,由缓存和数据库通信,写入或者读取数据。
- Write Through 的策略:先查询要写入的数据在缓存中是否已经存在,如果已经存在,则更新缓存中的数据,并且由缓存组件同步更新到数据库中,如果缓存中数据不存在,我们把这种情况叫做“Write Miss(写失效)”。可以选择两种“Write Miss”方式:一个是“Write Allocate(按写分配)”,做法是写入缓存相应位置,再由缓存组件同步更新到数据库中;另一个是“No-write allocate(不按写分配)”,做法是不写入缓存中,而是直接更新到数据库中。
- Read Through 策略:先查询缓存中数据是否存在,如果存在则直接返回,如果不存在,则由缓存组件负责从数据库中同步加载数据。
- 问题:Write Through 策略中写数据库是同步的,这对于性能来说会有比较大的影响,因为相比于写缓存,同步写数据库的延迟就要高很多了。
- Write Back(写回)策略:这个策略的核心思想是在写入数据时只写入缓存,并且把缓存块儿标记为“脏”的。而脏块儿只有被再次使用时才会将其中的数据写入到后端存储中。
缓存穿透
请求穿透缓存,直接访问数据库。三种解决方案:
- 回种空值:当我们从数据库中查询到空值或者发生异常时,我们可以向缓存中回种一个空值。但是因为空值并不是准确的业务数据,并且会占用缓存的空间,所以我们会给这个空值加一个比较短的过期时间,让空值在短时间之内能够快速过期淘汰。建议在使用的时候应该评估一下缓存容量是否能够支撑。如果需要大量的缓存节点来支持,那么就无法通过通过回种空值的方式来解决,这时可以考虑使用布隆过滤器。
- 布隆过滤器:把集合中的每一个值按照提供的 Hash 算法算出对应的 Hash 值,然后将 Hash 值对数组长度取模后得到需要计入数组的索引值,并且将数组这个位置的值从 0 改成 1。在判断一个元素是否存在于这个集合中时,你只需要将这个元素按照相同的算法计算出索引值,如果这个位置的值为 1 就认为这个元素在集合中,否则则认为不在集合中。两个由于 hash 碰撞有关的缺陷:
- 它在判断元素是否在集合中时是有一定错误几率的,比如它会把不是集合中的元素判断为处在集合中
- 不支持删除元素
- 极热点缓存穿透可以通过后台加载、设置分布式锁控制穿透的数量
缓存迁移
- 利用副本,将新服务设置成现有服务的副本,应用连接这个副本,如果副本上找不到结果,会去源服务商找。
- 缓存预热,先切部分流量到新服务,等新服务足够多的命中率后切换
过期策略
- 主节点
- 维护一个设置了过期时间的 key 的字典
- 定时过期随机扫描 key 删除过期 key
- 惰性检查获取 key 的时候,如果此时 key 已经过期,就删除,不会返回任何东西
- 从节点:从节点并不会执行过期扫描,会同步主节点删除
key
时写入AOF
的del
命令
消息中间件
作用
- 解耦:多应用间通过消息队列对同一消息进行处理,避免调用接口失败导致整个过程失败;
- 消峰添谷:起到缓冲流量的作用
- 异步:多应用对消息队列中同一消息进行处理,应用间并发处理消息,相比串行处理,减少处理时间;
- 消息驱动的系统:系统分为消息队列、消息生产者、消息消费者,生产者负责产生消息,消费者(可能有多个)负责对消息进行处理;
缺点
系统可用性降低、系统复杂度提高、一致性问题
丢消息
- 丢消息定位:消息丢失定位是丢消息问题最先要解决的,如果需要让业务有感知后在解决,显然是不合理的。可以根据消息的有序性来验证是否丢消息。 在发送端和消费端添加拦截器,生成连续序号和验证序号连续。注意根据使用的中间件合理选择生成和验证方式。
- 发送阶段
- 原因:生产者发送消息时网络错误,以为发送给了 MQ,实际 MQ 没有接收到
- 解决:这种情况设置正确的消息重传次数和失败处理,另外也可以设置消息同步到多个副本才算成功。
- 存储阶段
- 原因:MQ 仅接收到消息,还未来得及写入磁盘保存;只写入到 leader 没有同步到 follower 时,leader 挂了
- 解决:设置消息同步到多个副本才算成功
- 消费阶段
- 原因:消费者已经提交了确认,但消费途中没有处理完就 down 了
- 解决:关闭自动提交确认,手动保证处理完成后提交
重复消费(仅消费一次)
避免重复消费,需要保证消息的生产、消费过程的幂等性:
- 生产幂等性有多种 MQ 都支持,MQ 做法是给每一个生产者一个唯一的 ID,并且为生产的每一条消息赋予一个唯一 ID,消息队列的服务端会存储生产者 ID,最后一条消息 ID 的映射。当某一个生产者产生新的消息时,消息队列服务端会比对消息 ID 是否与存储的最后一条 ID 一致,如果一致就认为是重复的消息,服务端会自动丢弃
- 消费者通过鉴定唯一 ID 是否已经消费过保证。可以用数据库保证,需要整个处理过程在同一个事务中。也可以用乐观锁保证,比如给数据添加版本号。
消息积压
- 监控:
- 使用消息队列提供的工具,通过监控消息的堆积来完成,如
kafka-consumer-groups.sh
、JMX
- 生成监控消息的方式来监控消息的延迟情况,具体是生成一个时间戳消息,消费者消费到此消息时判断时间是否大于阈值,发送报警
- 减少的方法:
- 优化消费性能
- 增加消费者数量(Kafka 消费者的数量和分区数一样,需要同时增加分区数和消费者数)
高可用保证
- RabbitMQ:主从。镜像集群中每一个节点可以拥有一份全量数据
- Kafka:集群模式。集群有多个 broker,topic 可以划分为多个 partition,每个 partition 分布在不同的 broker,同时可以设置 partition 的 replica 副本数,replica 会选举出 leader 负责读写数据,leader 会负责把数据同步给 follower。
Kafka 设计优点
- Kafka 使用硬盘存储,但做了很多优化的设计使存储性能很高
- 顺序存储,所有的消息在文件后面追加
- 通过维护一个 offset 来实现顺序访问
- IO 采用 0 拷贝技术,减少了从内核空间读取到用户缓存,再从用户缓存输出到网络流的时间。直接从页缓存写入网络流
- 网络带宽上的设计考虑,会对消息做压缩,减少带宽消耗。也可以设置消息批量发送,减少网络请求次数
- 分布式存储设计,有备份和主分区,保证消息不丢失。用户通过 offset 也能从宕机事故中快速恢复
选型对比
特性 |
RabbitMQ |
RocketMQ |
Kafka |
单机吞吐量 |
万级,比 RocketMQ、Kafka 低一个数量级 |
10 万级,支撑高吞吐 |
10 万级,高吞吐,一般配合大数据类的系统来进行实时数据计算、日志采集等场景 |
topic 数量对吞吐量的影响 |
|
topic 可以达到几百/几千的级别,吞吐量会有较小幅度的下降,这是 RocketMQ 的一大优势,在同等机器下,可以支撑大量的 topic |
topic 从几十到几百个时候,吞吐量会大幅度下降,在同等机器下,Kafka 尽量保证 topic 数量不要过多,如果要支撑大规模的 topic,需要增加更多的机器资源 |
时效性 |
微秒级,这是 RabbitMQ 的一大特点,延迟最低 |
ms 级 |
延迟在 ms 级以内 |
可用性 |
高,基于主从架构实现高可用 |
非常高,分布式架构 |
非常高,分布式,一个数据多个副本,少数机器宕机,不会丢失数据,不会导致不可用 |
消息可靠性 |
基本不丢 |
经过参数优化配置,可以做到 0 丢失 |
同 RocketMQ |
功能支持 |
基于 erlang 开发,并发能力很强,性能极好,延时很低 |
MQ 功能较为完善,还是分布式的,扩展性好 |
功能较为简单,主要支持简单的 MQ 功能,在大数据领域的实时计算以及日志采集被大规模使用 |
Zookeeper
ZooKeeper 作为一个分布式的协调服务框架,主要用来解决分布式集群中,应用系统需要面对的各种通用的一致性问题。它提供了一个可以保证一致性的分布式的存储系统,数据的组织方式类似于 UNIX 文件系统的树形结构。ZooKeeper 本身可以部署为一个集群,集群的各个节点之间可以通过选举来产生一个 Leader,选举遵循半数以上的原则,所以一般集群需要部署奇数个节点。
特点
- 一致性:每个节点数据一致
- 实时性:保证客户端在一定时间内获得的结果
- 原子性:Leader 在同步数据时会保证事务性,要么都成功,要么都失败
- 顺序性:所有节点收到消息都是顺序的
ZAB 协议
注意事项
- 不要在 Zookeeper 中写入大量数据,它只适合存放少量数据,当写入超过百兆,性能和稳定性会严重下降。
2.
通用实践
分布式锁
数据库
优点:直接使用数据库,直观容易理解。
缺点:会需要做额外的过期、重入、阻塞策略,另外数据库性能也需要考虑。
方式:
INSERT INTO lock_table(lock_name) VALUES ('lock1'); -- 加锁
DELETE FROM lock_table WHERE lock_name = 'lock1'; -- 解锁
问题与解决:
- 强依赖数据库;
- 锁没有失效时间;解决:隔一定时间清除一次过期的数据
- 锁是非阻塞的,因为一旦插入失败就会直接报错;解决:实现重试机制
- 非可重入的锁;解决:锁记录额外的实例和线程信息
Redis
优点:性能更好,有成熟的中间件
缺点:单节点实现的话有可用性问题
成熟实现:Redisson
方式:
- 获取锁:若 key 不存在,设置带有过期时间和特定 value 的 key;若 key 存在可以选择重试策略。
- 释放:先根据 value 判断是否是自己的锁,若是则删除 key;若不是则回滚操作。
- redlock:使用 n 个独立 redis 节点,n/2+1 个节点加锁成功才算成功加锁
问题与解决:
- 单点问题;解决:集群部署;使用 n 个 redis 保证 n/2 个获取锁成功,才算获取锁成功
- 业务并没有完成,但 key 超时了,可能导致并发问题;解决:心跳刷新 key;客户端判断超时自动回滚
- 不可重入;
Zookeeper
优点:无单节点问题,只要半数以上机器存活就可对外服务;可以持有锁任意长时间,也可自动释放锁;可阻塞,监听节点变化,然后判断自己的节点是否是最小的获取锁;可重入,直接对比节点和自己是否一样
缺点:性能不如缓存;需要对 zk 有一定了解
成熟实现:Curator
方式:
- 获取锁:指定目录下生成唯一瞬时有序节点,判断该节点是否是序号最小的
- 释放:删除节点
comments powered by