面试八股-分布式系统
mysql
索引
- 什么是索引
- 索引是一种能实现对表中某一列或多列的值快速查找的数据结构,常见的是 B+树
- B+树是一种多叉搜索树,叶节点是数据节点,存放着指向行数据的指针,叶节点之间顺序链接
- 没有索引的时候,数据的物理存储是按插入顺序存储的,查找数据时要逐行查找
- 索引的优缺点
- 优点:
- 加快查询速度
- 缩小锁的范围(比如 gap lock 和 next key lock)
- 缺点:维护索引要耗费时间和空间,会降低写入速度
- mysql 索引分类
- 普通索引、唯一索引、主键索引
- 普通索引允许重复值和空值
- 唯一索引不允许重复值,但允许空值
- 主键索引既不允许重复值,又不允许空值
- 单列索引和组合索引
- 组合索引是在多列上构建的索引,查询条件必须满足两个条件:(1)符合组合索引的最左前缀原则(2)用 and 连接时才会触发组合索引
- 什么是最左前缀原则呢?就是查询时,必须按创建索引时字段的顺序去用and连接,比如对三个字段创建了一个组合索引,
Index index1 (name,age,city),查询的时候只能以name="swj" and age=30或者name="swj" and age=30 and city='tianjin'这两种方式去查询,不能跳过age,直接以name和city去查询,相当于创建了两个索引(name,age)和(name,age,city)
- 全文索引
- 可以在列上进行全文搜索
- 怎么判断要不要加索引
- 加
- 在频繁进行条件判断、分组、排序的列上创建索引
- 多读少写或者对读操作耗时要求高时加索引
- 不加
- 写多读少时不要加所索引
- 不做条件判断的列不要加索引
- 数据大量重复时也不应该加索引
- 索引命中和失效
- 不符合左前缀原则会导致组合索引失效
- where 条件不用 and 连接会导致组合索引失效
- 在索引列上做计算、函数调用、不等于比较会导致索引失效
- 可以用 explain 语句查看是否命中索引,explain 语句是用来查看 sql 执行计划的
- 索引实现上分为聚集索引和非聚集索引
- 一个表只能有一个聚集索引,之所以叫聚集索引,是因为数据的物理存储顺序就是按聚集索引的排序存储的,也是因为这个原因,一个表只能有一个聚集索引。InnoDB 的聚集索引叶节点存储完整的数据记录。
- 其他索引都属于非聚集索引,非聚集索引也叫二级索引或者辅助索引,非聚集索引有两种情况
- 数据节点不是直接指向数据记录,而是保存一个聚集索引的 key,每次对非聚集索引的查找都要到聚集索引上查一次数据,这叫“回表”。(mysql 的 InnoDB 引擎)
- 叶节点直接指向对应的数据块。(mysql 的 MyISAM)
- mysql InnoDB 引擎一定会构建一个聚集索引,默认会将主键索引作为聚集索引,如果没有主键索引,会将第一个没有空值的唯一索引作为聚集索引,否则会在一个隐藏的 ROWID 上构建聚集索引,ROWID 是行的唯一标识
- 组合索引是以多个列的组合作为 key,例如"(2,3)"
2kw 是数据量上限这个说法从何而来
- 我们通常认为 B+树超过三层性能会下降
- mysql innodb 一个 B+树节点 16KB,可用空间 15KB
- 假设 key 是 bigint 类型(8B),key 加指针(4B)一共 12B
- 因此第二层有 1280 个节点,第三层有 1280*1280 个节点
- 假设单行数据 1KB,那三层 B+树支持的数据量是 1280*1280*15KB/1KB=24576000,也就是 2kw+
- 2kw 只是个参考值,单行数据量如果不是 1KB,数据量上限就不是 2kw 了。机器的硬件配置也会影响 mysql 的性能,比如用 ssd 代替机械硬盘、使用大内存都会提高 mysql 的性能。
事务
事务(transaction)的目的是要保证一连串数据操作的原子性,并能够支持操作回滚
- ACID 特性(原子性、一致性、隔离性、持久性)
- 原子性:一连串操作要么全完成,要么全不完成
- 一致性:事务执行前后,数据处于一致的状态,数据不会被破坏
- 隔离性:并发执行的事务之间互不干扰
- 持久性:一旦事务提交,数据就会被永久保存到数据库中
- InnoDB 如何实现事务的 ACID 特性
- 原子性,通过 undo log (回滚日志)回滚,事务执行前会记录操作前的数据到 undo 日志,若事务失败或回滚,可以利用 undo 日志恢复到原始状态。
- 一致性
- 隔离性
- 通过锁保证隔离性,事务在修改数据之前先对数据上锁。锁按照粒度分为表锁和行锁。
- 使用 MVCC(多版本并发控制)保证隔离性
- mysql支持四种隔离级别
- 持久性,数据落盘之前先要放到 buffer pool,buffer pool 的数据会定期刷到磁盘上去,写入 buffer pool 之前先写到 redo log 中,保证数据不会因宕机丢失
事务的并发问题
- 脏读:当前事务可以读取到其他事务未提交的数据
- 不可重复读:当前事务前后两次使用相同的查询语句查到了不同的数据,原因是在事务执行过程中,有其他事务对这批数据做了增删改操作。如果当前事务的两次读操作分别发生在其他事务开始前和提交后,读到的数据不一致,这就不算脏读,属于不可重复读问题。
- 幻读:事务进行读操作发现数据不存在,试图插入数据,但是插入失败,或者事务发现数据存在,试图更新数据,但更新失败,原因是事务读数据后,有其他事务对数据做了增删操作。即使数据库能够保证不发生脏读和不可重复读,仍有可能发生幻读,现象是每次读数据结果都是不存在,但就是不能插入数据,或者是每次读数据结果都是存在,但就是更新不了。
不可重复读和幻读是类似的问题,都是由于事务执行过程中其他事务把数据改了造成的。
InnoDB 的锁
- InnoDB 的锁按粒度分为
- 行锁和表锁
- 意向锁和非意向锁
- 按并发控制分为共享锁和排他锁
- 共享锁:可以对数据上多个共享锁,但不能同时上共享锁和排他锁(有点像并发编程的读锁)
- 排他锁:只能对数据上一个排他锁(有点像并发编程的写锁)
- 意向锁只能是表级,非意向锁可以是表级的也可以是行级的,因此一共有 6 种组合
- 表级的排他锁
- 表级的共享锁
- 行级的排他锁
- 行级的共享锁
- 表级的排他意向锁
- 表级的共享意向锁
- 申请行级锁之前要先申请表级的意向锁(意向锁相当于是行锁在表这个级别的代表)
- 表级锁互斥性(X-排他锁,S-共享锁,I-意向锁) ||X|IX|S|IS| |---|---|---|---|---| |X|×|×|×|×| |IX|×|√|×|√| |S|×|×|√|√| |IS|×|√|√|√|
- 行锁有三种
- record lock,单行锁
- gap lock,锁住两个索引值之间的空隙(空隙是指还没有插入值),目的是为了防止幻读,注意,如果没有命中索引,gap 锁会锁全表。
- next-key lock,以上两种锁的结合,也是为了防止幻读。
如何加锁
在 mysql 中,有些SQL语句会自动加锁,有些锁需要手动指定
- 修改表会自动加锁
- 可以手动为表级操作加表锁,表锁分为读写锁,
- 读锁(共享锁):
LOCK TABLES table_name READ,UNLOCK TABLES - 写锁(排他锁):
LOCK TABLES table_name WRITE,UNLOCK TABLES - 写入数据的操作(Insert,Update,Delete)会自动加排他锁
- 普通的select语句不会加锁
SELECT ... FOR UPDATE会加排他锁SELECT ... LOCK IN SHARE MODE会加共享锁- 在事务中使用锁,锁定时间会持续到事务结束
InnoDB MVCC
MVCC 的全称是多版本并发控制,目的是提高数据库的并发性能,实现读-写冲突不加锁
数据库并发场景 - 读-读:不需要并发控制 - 读-写:存在脏读、不可重复读、幻读问题 - 写-写:存在写入脏数据问题
版本链
MySQL 为每一行数据维护一个版本链,其中包含了该行数据的多个版本。每个版本都有一个创建时间和一个删除时间
当一个事务对某行数据进行修改时,数据库系统会创建一个新的版本,并将其插入到版本链中,并记录事务的 ID 和修改时间。
事务可见性判断
当一个事务读取某行数据时,数据库系统会根据该事务的开始时间和版本链中的版本信息,确定该事务能够看到的版本
一致性视图
在 REPEATABLE READ 和 SERIALIZABLE 隔离级别下,InnoDB 会为每个事务创建一个一致性读视图,确保事务执行期间,多次读操作看到的数据是一致的。
MySQL的隔离级别
- 读未提交(Read Uncommitted):最低级别,不做任何保证,会出现脏读、不可重复读和幻读问题
- 读已提交(Read Committed):解决脏读问题
- 可重复读(Repeatable Read):默认级别,通过一致性读视图解决不可重复读问题,但还是会幻读
- 串行化(Serializable):最高级别,通过加锁的方式来保证事务的串行执行,避免了所有的并发问题,但会严重影响数据库的性能。
优化
- 表优化
- 建索引、去掉不该有的索引
- 拆分表,拆出频率低的字段,冗余多的字段
- 分表分库
- 优化字段类型
- 用整型代替字符串类型
- SQL 优化
- 不用 select *,减少“回表”,减少网络传输
- 修改不命中索引的写法
- explain 命令查看 sql 的执行计划,可以看到是否命中索引
- 不等于比较,字段上进行函数调用,对字段做计算,使用like匹配字符串
- 组合索引不符合最左前缀原则,使用or连接条件
- 用联结查询代替子查询
- 用命中索引的范围查询代替偏移量非常大的limit语句
- 利用慢查询日志查看执行的比较慢的语句
log
- bin log,记录所有对数据的修改操作
- redo log,用于数据的持久化,防止数据因宕机丢失
- undo log,用于回滚数据
其他
mysql 的主从同步,利用主服务的 binlog 和从服务的 relay log 实现
redis
redis的功能
- redis 是什么
- redis 是一种内存型 kv 数据库,性能高,它的高性能来源于内存存储和 kv 型存储,数据结构简单
- 通常用来做缓存
- redis 的 5 种主要数据类型
- string,
set key value,get key,mget,mset - hash,key对应一个value映射表,适合存储对象,
hset hashkey field value,hget hashkey field,hmset,hmget,hgetall - list,支持队列和栈操作,
lpush key element1 element2,插入到列表头部 - set,无序集合,支持集合运算(交、并、差),查找时间O(1)
- zset,有序集合,查找时间O(1),每个元素关联一个分数,靠分数排序
- redis的单线程模型
- redis的命令执行是单线程的,不存在并发问题
- redis6.0对网络io引入了多线程,但是核心的命令执行仍然是单线程的
- redis利用队列把网络连接和数据处理做了解耦
- redis为什么高性能
- 高性能来自内存存储和key-value数据结构
- 高并发来自基于epoll的I/O多路复用
redis中的原子性 - redis中经常会提到“原子性”这个词,redis中的原子性是指两条命令之间不会插入其他命令,可以保证严格的前后相邻顺序,但是并不保证全部执行成功,也不会回滚,会出现指令全部执行完毕后,一部分执行成功,另一部分失败 - 哪些功能能保证原子性 - 单条命令 - 事务 - lua脚本 - 哪些保证不了 - pipeline,pipeline是客户端的行为,对redis是透明的 - redis的事务 - redis的事务实际上就是一组顺序执行的命令集合。 - redis保证事务执行的原子性。 - 由于redis的单线程模型,redis不存在命令的并发,也不存在隔离级别。 - redis的lua脚本会以事务的方式执行 - redis的watch命令 - 和事务配合使用 - watch命令用来监控一个或多个指定的key,如果在事务执行之前,被监控的key被修改,则事务取消执行。 - 类似一种乐观锁机制,防止多个客户端之间出现并发冲突。 - redis的setnx命令 - key不存在时才会写入,写入成功返回0,写入失败返回1
redis的缓存淘汰策略
- redis中分为两个数据字典,一个字典存所有的key-value,一个字典存key和对应的expire(称为expire字典,只有配置了过期时间的key才会在这里面)
- redis的缓存淘汰策略
- 不淘汰任何键,直接返回错误
- 在所有key中随机删除
- 在所有key中使用LRU算法删除(最近最少使用)
- 在所有key中使用LFU算法删除(使用频率最低)
- 在expire字典中随机删除key
- 在expire字典中删除马上快过期的
- 在expire字典中使用LRU算法删除
- 在expire字典中使用LFU算法删除
- redis的key过期策略
- 惰性删除,当客户访问key的时候判断key是否过期,过期就删除
- 定期删除,在expire字典中随机采样,取20个key,删掉里面过期的key,如果过期key占比超过1/4,就重复这个过程
kafka
mq的作用 - 削峰填谷 - 解耦 - 异步
kafka的特点
- 高吞吐量
- 消息可持久性存储
- 分布式,支持横向扩展
- 稳定性低,容易丢数据,容易出现数据重复
kafka原理
- kafka是pull模型,不是push模型,消费者要主动拉取数据。
- kafka分为queue模式和topic模式
- queue模式,消息是按顺序消费的,消费者主动拉取消息,每个消息只能被一个消费者消费,并且只能消费一次。
- topic模式,发布订阅模式,一个topic分多个partition,消费者组成消费者组,topic由消费者组订阅。
- 同一个topic中,同一个partition内的消息是有序的,不同paitition之间的消息是无序的。
- 无论消不消费,消息都不会被删除,直到过期。
- 一个消费者组订阅一个topic,消息被均衡地送给每个消费者,分配给各消费者的partition互不相同(不存在两个消费者消费同一个partition的情况)。消费者数量大于partition数量时,会有消费者空闲。
- 生产者和消费者都可以指定固定的订阅分区
- rebalance,重平衡,消费者组内消费者数量发生变化时,分区会重新分配,这叫rebalance,消费者定期向partition发送心跳来证明自己存活。
- 一个topic可以由多个消费者组订阅,并且这些消费者组消费到的消息是完全相同的。如果想让不同的消费者消费同一个partition,就要把它们分到不同的组中。为了支持同一个消息能被多个消费者消费,kafka会记录partition中不同消费者各自的消费位置。
- 每个partition又有多个副本,其中一个是leader副本,其余的是follower副本,leader负责收发消息,follower是备份,leader和follower分布在不同的节点上。
- kafka的节点称为broker
- 生产者同步发送和异步发送模式
producer.type参数指定了生产者是同步发送还是异步发送,值为asnyc时为异步发送,值为sync时为同步发送。用户调用send()方法时,kafka会根据producer.type参数决定消息的发送方式,如果是同步发送,就会直接发送消息,如果是异步发送,会先把消息放入一个本地缓存,然后启动一个发送线程,当缓存中的消息满足一定条件时,发送线程批量发送消息。- 不管同步还是异步模式,消息都是按序发送的,只有发送失败时,才可能破坏发送消息的顺序。
- send函数本身的调用方式是异步调用,也就是说,不管发送模式是同步还是异步,send都会立即返回。
send().get()只是将函数的调用方式从异步改成同步,并不会改变producer的发送模式。- kafka如何实现三种消息传递语义
- at most once,最多一次,消息不会重复,但有可能丢
- ack=0就是at most once
- at least once,至少一次,消息不会丢,但有可能重复
- ack=1或者ack=all,配合retries
- exaclty once,不多不少正好一次
- ack=all配合retries,保证不丢数据
- 启用kafka的幂等配置,
enable.idempotence,保证重试不会导致消息重复 - 消费者处理完消息之后再手动提交offset,保证消费
- 消费者自动、同步、异步提交
- 消费者可以配置自动提交offset(
auto.commit.enable参数),自动提交和消费消息是异步的,消费者会定期向kafka提交offset。自动提交可能会造成消息丢失,即提交成功但是消费失败 - 手动同步提交,除了会阻塞,没什么问题
- 手动异步提交,异步提交不会发生阻塞,但是kafka没给异步提交提供重试机制(防止重试导致旧的提交覆盖新的提交)。(但是提交失败本身也会导致重复消费)
- kafka事务
- 事务场景
- 发送多条消息到一个topic
- 发送到多个topic、多个partition(典型的分布式事务)
- 消费-处理-发送
- 保证多个消息、多个操作的原子性,即同时成功,同时失败,失败会回滚。
kafka如何保证顺序消费
- topic端,让生产者和消费者指定同一个partition,并且保证分区有足够多的副本,防止partition挂掉。
- 生产端,单线程发送消息
- 消息丢失会破坏消息顺序,所以要配置ack=1或ack=all并配置重试
- 同时发出多次消息时可能会破坏消息的顺序性(异步发送的一批数据算一次),因此,要将
max.in.flight.requests.per.connection参数设置为1(这个参数规定了生产者能同时发送多少条未经确认的消息),还要配置ack=1或ack=all能让broker确认消息。 - 消费端,单线程消费消息
kafka如何防止消息丢失
- 生产者丢消息
- kafka客户端支持异步发送消息,生产者丢失消息会发生在这个过程中,可能会由于网络原因或者数据太大等原因发送失败
- 解决方法
- 配置retries,消息发送失败就重试
- 给发送方法加回调函数,消息发送失败时进行特殊处理(例如重试、记录日志并根据日志异步重试)
- broker丢消息
- leader分区每收到一条消息都要同步到follower,如果数据还没落盘或者没等同步完leader就挂了,某一个follower成为了新的leader,就会导致消息丢失。
- 解决办法,利用ack机制解决
- ack机制是指kafka接收到消息后会向生产者发送确认,ack参数影响了kafka的消息持久性,ack参数有三种(
request.required.acks参数) - ack = 0, 生产者不会等待broker发送ack,这种情况无法保证不丢数据。(生产者的retries参数这时也是失效的)
- ack = 1,leader分区接收到数据后,向生产者返回ack,如果消息没有同步到follower中,就会导致数据丢失。(kafka默认的是ack=1)
- ack = all,等leader向所有follower同步完数据,才会返回ack。
- 可以利用ack=all配合retries参数让生产者失败重试,保证数据不丢失。(
message.send.max.retries参数用来设置发送端重试次数)
- ack机制是指kafka接收到消息后会向生产者发送确认,ack参数影响了kafka的消息持久性,ack参数有三种(
- 消费者丢消息
- 消费者可以配置自动提交offset(
auto.commit.enable参数),自动提交和消费消息是异步的,消费者会定期向kafka提交offset,如果offset已经提交,而消息消费失败,就会丢失消息 - 解决办法,将自动提交offset改为手动提交,消息消费完成后再提交offset
kafka如何防止消息重复
- 消息重复的情况
- 生产者失败重试导致的消息重复
- 消费者未提交offset导致的消息重复
- 消息处理完成,消费者突然挂了,未能成功提交offset
- 消息正在处理,partition发生了重平衡
- 上游业务发送了重复的消息
- 解决办法
- 对于第一种情况,启用kafka幂等配置,重试不会导致消息重复
- 对于后两种情况,需要在业务层面实现接口的幂等性
分布式系统
概念
CAP原理
一致性、可用性、分区容错性三者不可兼得
- 一致性(Consistency),是指数据一致性,任何时刻,所有节点的数据都是一致的
- 可用性(Availability),是指系统能在有限时间内响应每个请求
- 分区容错性(Partition Tolerance),是指系统能在网络分区的情况下,继续提供服务(网络分区是指某些节点之间的网络连接中断,导致系统的网络分成好几个子分区)
BASE原理
BASE原理,是在满足分区容错性的前提下,在一致性和可用性之间做的权衡,是指分布式系统可以同时满足基本可用性、软状态和最终一致性。
- 基本可用性(Basically Available),系统在出现故障时,允许损失部分可用性(如响应时间延长、部分功能降级),但整体服务仍可用。
- 软状态(Soft State),系统中的数据状态可以有一段时间的不一致,允许存在中间状态,数据同步和一致性可以延后。
- 最终一致性(Eventual Consistency),系统在经过一段时间后,所有节点的数据会达到一致状态。
脑裂问题
分布式系统发生分区错误后,各分区选举出各自新的master导致分布式系统中存在多个master的情况
RAFT算法和Paxos算法
分布式锁
分布式锁要解决以下问题
- 互斥,只有一个实例能获取到锁
- 死锁,实例崩溃或者因为网络原因导致没人释放锁,产生死锁
- 锁失效
- 锁的超时时间过短,锁提前释放,导致其他实例获取到锁
- 实例请求处理时间过长,锁提前释放,导致其他实例获取到锁
- 锁被其他实例误释放
- 网络分区导致多个实例同时获取到锁
- 可重入问题,同一个实例,要能反复获取同一个锁
如何解决以上问题
- 互斥
- 使用一个唯一标识表示锁,例如一个UUID,只要这个锁存在,就表示锁被获取了
- 保证加解锁操作的原子性
- 死锁,设置锁的过期时间,过期后自动释放锁
- 锁失效
- 设置合理的锁过期时间,过期时间要大于实例处理请求的最大时间
- 获取到锁的实例自动续约锁超时时间
- 给锁带上实例的唯一标识,确保不会被其他实例误释放
- redlock算法,避免网络分区问题
- 可重入问题
- 给锁带上实例的唯一标识,加锁前先检查当前实例是否已经持有锁,如果持有则直接返回成功
利用redis实现分布式锁
方案1
往redis里存一个key,key是锁的标识,value是实例标识,并设置过期时间,当key不存在时,表示可以获取锁,存在时,表示锁正在被其他实例持有。
设置key value的两种方法
- setnx + expire,用事务或lua脚本保证原子性
- setnx是个命令,含义是“set if not exists”
- lua/事务保证setnx+expire成为原子操作
set key value nx ex ...,set命令自带nx和ex,单条命令实现setnx + expire的功能
方案2
redlock算法,redis官方提出的分布式锁算法,在redis集群部署的情况下可以保证高稳定性。
假设redis有n个节点,该算法认为,只要超过一半的节点加锁成功,就认为加锁成功,如果加锁的时间超过了超时时间,认为加锁失败。解锁时对全部节点解锁。
分布式id
为一个资源生成一个全局的唯一id
方法
- 利用,数据库自增id,每次创建分布式锁之前,先到数据库生成一个自增id
- 优点,简单,容易实现
- 缺点,性能低,存在数据库单点问题,数据库访问压力大
- UUID、hash
- 优点,可以本地生成,无网络消耗
- 缺点,无序,无法建立索引
- 从数据库批量申请自增id,业务服务预先从数据库批量申请一段自增id,缓存在本地使用,不够了再去到数据库申请
- 优点,简单,容易实现
- 缺点,性能低,存在数据库单点问题,数据库访问压力大
- 利用redis的incr命令实现id的原子性自增
- 雪花算法(snowflake)
- 优点,可以本地生成,不需要一个中心化的分布式id服务
- 原理其实就是通过一些特定标识的拼接,让生成的id不重复的同时还能做到有序
缓存击穿、穿透、雪崩问题
- 缓存击穿问题,高并发场景下,缓存过期的一瞬间,大量请求直接落到数据库的问题
- 限流,例如,对于一个值,只允许一个请求访问数据库,可以为这个key设置分布式锁,其他获取数据的请求等待锁释放
- 设置热点key的缓存时间为一个较长的时间
- 缓存穿透问题,高并发场景下,大量请求查询一个在数据库和缓存中都不存在的值,导致大量请求不停地落到数据库的问题
- 允许数据库存储null值,或者在缓存中存储空值
- 缓存雪崩问题,高并发下场景下,缓存中很多值在同一时间过期,导致大量请求直接落到数据库的问题
- 设置随机过期时间
缓存和数据库一致性问题
- 分布式系统要保证分区容错性和可用性,就没办法保证数据的强一致性,最多保证最终一致性,甚至是弱一致性。
- 方案1:写入命中缓存时,既更新数据库,又更新缓存。
- 问题1:其中一个操作失败导致数据不一致
- 问题2:不管是先写缓存还是后写缓存,都会存在写并发问题,例如“A写缓存X=1,B写缓存X=2,B写数据库X=2,A写数据库X=1”,造成了数据不一致。
- 方案2:写入时删除缓存,写数据库。由读请求写缓存。
- 问题1:其中一个操作失败导致数据不一致
- 问题2:不存在写并发问题,但存在读写并发问题。
- 方案1和方案2都会遇到操作失败和并发问题,解决方案
- 对于并发问题,使用分布式锁
- 对于操作失败问题
- 重试,缺陷是会占用更多资源,并且即使是无限重试也不一定100%成功
- 异步重试,利用消息队列重试,缺陷是会提高系统复杂度,要解决消息队列的失败问题。
- 利用数据库的变更日志更新缓存(例如mysql的binlog)
- 从整体复杂度来讲,方案2更好一些,对于方案2,还可以采用写DB后延迟一段时间删除缓存的策略,尽量让缓存不会被写回旧数据
- 此外,数据不一致还可能由redis和数据库内部数据不同步导致。
- 总之,实现数据的强一致性是不可能的,最终一致性也不能百分百保证,因此,最后,引入手动补偿机制,手动修复极端情况下的不一致数据
如何实现接口的幂等性
- 接口的幂等性是指多次请求同一个资源和只请求一次产生同样的效果
- 接口需要满足幂等的原因是很多操作我们只希望发生一次,比如付款、创建订单等等
- 读操作天然具备幂等性
- 如何实现写幂等性
- 插入和删除
- 在数据库层面,使用唯一key标识一个资源,避免重复插入同一个key
- 更新
- 为每个资源记录一个状态,根据状态判断是否能执行操作
- 使用乐观锁或者悲观锁防止操作时出现的幻读问题
- 高并发下可以使用分布式锁防止请求直接落到数据库上
高并发
- 数据库一主多从,读写分离
- 降低单台数据库负载
- 避免写请求阻塞
- 提高系统稳定性(高可用)
- 冗余(分布式部署,做集群,读写分离)
- 超时+重试
- 异步
- 缓存
- 限流
- 降级/熔断
- qps,每秒请求数
- 并发和qps换算:并发数 = qps*latency (单位:s)
系统并发量和什么因素有关
- 环境因素
- 硬件资源,cpu核心数,内存,网络带宽
- 请求处理耗时
- 请求处理的计算量
- 请求的IO量
- 下游服务的耗时,包括数据库、缓存、消息队列的耗时以及其他服务的耗时
- 网络延迟
- 负载均衡
如何提高并发量
- 提高硬件资源/服务扩容
- 减少请求IO量
- 优化请求计算量
- 使用高性能的网络通信库
- 尽量使用无锁数据结构
- 优化下游服务的耗时
- 减少网络延迟
- 优化负载均衡
如何排查并发问题
- 日志分析:通过分析系统日志,找出并发请求的执行路径和耗时,定位性能瓶颈。
- 性能监控:利用监控和告警系统,实时监控系统性能,绘制性能指标图表。
- 压力测试:通过压力测试模拟高并发场景,观察系统表现,找出潜在问题。
- 性能分析:使用性能分析工具分析程序的cpu和内存占用等。
- 代码审查:对关键代码进行审查,检查是否存在并发安全问题。
并发编程机制
悲观锁和乐观锁
悲观锁和乐观锁是广泛存在的概念,是两种解决并发冲突问题的策略,不只在数据库领域中存在
悲观锁是指对数据并发持悲观态度,认为一定会冲突,悲观锁是在操作数据之前对数据上锁
乐观锁是指对数据并发持乐观态度,认为不一定会冲突,不会提前对数据上锁,乐观锁一般会在更新数据前做一个校验,校验通过了才会更新,否则重试,乐观锁一般会使用版本号机制或 CAS (Compare And Swap)算法实现,乐观锁适合读多写少的场景,如果写多的话,会导致大量的重试,反而更浪费资源
版本号机制
定义一个版本号字段,修改数据前,先查询版本号,更新数据之前检查版本号变没变,如果没变才能更新,如果变了,说明被其他并发任务改了,需要重试。关键点在于,更新+更新前检查必须是原子操作,可以使用update xxx where xxx来实现这一操作,这个语句会自动对数据行上排他锁
CAS
Compare And Swap,是一种依赖硬件的乐观锁实现,CAS需要三个值参与:
- 变量的内存地址
- 变量的预期值
- 要更新的新值
CAS会先判断变量内存地址中的值和预期值是否一致,如果变量的值等于预期值,则将变量的值更新为新值,并返回 true
CAS通过硬件的原子操作来保证更新的原子性,CAS操作是非阻塞的,会避免线程阻塞和上下文切换的开销
C++中的CAS机制:atomic的两个CAS操作,compare_exchange_weak和compare_exchange_strong,atomic不保证跨进程原子性
windows的CAS机制:InterlockedCompareExchange,这个可以跨进程使用
COW(Copy On Write)
COW 是一种延迟拷贝的优化机制,常用于内存管理和资源共享。其核心思想是:多个进程或线程共享同一份数据,只有在有写操作时才进行真正的拷贝。
- 原理:
- 初始时,多个对象(如进程、线程)共享同一块内存区域,引用计数+1,内存为只读。
- 当有对象需要写入时,操作系统会为该对象单独分配一份新的内存(拷贝原数据),然后写入,其他对象仍然指向原来的内存。
-
这样,只有在写操作发生时才会真正分配和拷贝内存,节省了资源,提高了效率。
-
应用场景:
- Linux 的 fork 系统调用,父子进程初始共享内存,只有写时才分配新页。
- 某些高级语言的string实现
RCU(Read Copy Update,读-复制-更新)
RCU 是一种高效的并发读优化机制,常用于内核、数据库等读多写少的场景。其核心思想是:读操作无需加锁,写操作通过复制和延迟更新来保证一致性。
- 原理:
- 读操作:直接访问共享数据,不加锁,保证极高的并发性能。
- 写操作:先复制一份数据,在副本上修改,修改完成后用原子操作将指针切换到新数据。
-
旧数据不会立即释放,等所有正在进行的读操作结束后再释放(通过“宽限期”机制判断)。
-
优点:
- 读操作无锁,极大提升读性能。
-
写操作不会阻塞读操作,适合读多写少的场景。
-
本质上是一种double buffer机制
无锁编程
参考https://brpc.apache.org/zh/docs/rpc-in-depth/atomic-instructions/
核心思想是尽量避免竞争
- 读写分离,使用多个读写buffer,例如double buffer、triple buffer、环形buffer
- 使用原子操作避免锁带来的线程上下文切换开销
- 使用线程局部变量避免多线程竞争,进一步减少原子变量和锁的使用
锁的性能问题
锁会导致线程进入阻塞状态,导致cpu需要进行线程上下文切换,而上下文切换是微秒级的
原子操作能避免锁的线程上下文切换开销
两类内存可见性问题
- 乱序执行问题,乱序执行问题是实现原子操作必须要解决的问题
- 缓存一致性问题,缓存一致性问题告诉我们,原子操作也是存在性能问题的,如果追求高性能,最好想办法避免线程竞争
内存可见性问题1——乱序执行问题
编译器指令重排和cpu乱序执行都会导致多线程的内存操作执行顺序不一致,导致并发竞争问题。
内存屏障就是为了解决这个问题的。
内存可见性问题2——缓存一致性问题
缓存一致性问题是指在现代多核CPU中,一般都采用SMP(Symmetrical Multi-Processing)架构,即每个核心都有自己的缓存,对CPU核心来说,代码中的内存操作其实都是对缓存的操作。
由于各个核心的缓存数据可能不一致,需要缓存一致性协议来保证各核心的数据一致性,但缓存一致性协议只能保证最终一致性,并不能保证强一致性。
因此,当多个核心竞争同一个cacheline时,虽然这时读写是原子的,但由于需要等待CPU完成一致性同步,会导致读写变慢(竞争激烈时,可能会使一个写入操作从几ns变成几百ns),这就是原子操作的瓶颈。
内存屏障
内存屏障是为了解决 CPU 的乱序执行问题,保证特定操作的执行顺序。
原子操作一般会使用内存屏障来实现,C++11 的atomic提供了几种内存屏障的语义。
| memory order | 作用说明 |
|---|---|
| memory_order_relaxed | 没有fencing作用 |
| memory_order_consume | 后面依赖此原子变量的访存指令勿重排至此条指令之前 |
| memory_order_acquire | 后面访存指令勿重排至此条指令之前 |
| memory_order_release | 前面访存指令勿重排至此条指令之后。当此条指令的结果对其他线程可见后,之前的所有指令都可见 |
| memory_order_acq_rel | acquire + release语义 |
| memory_order_seq_cst | acq_rel语义外加所有使用seq_cst的指令有严格地全序关系 |
原子操作
原子操作的原子性由硬件保证,并且利用内存屏障来保证访存指令的执行顺序。
原子操作相比锁的好处是不会阻塞线程,避免了线程上下文切换的开销。
但原子操作仍无法避免竞争问题,如cas操作虽然不会让线程阻塞,但是会让线程原地空转,导致cpu资源浪费。并且原子操作会因为缓存一致性问题导致性能下降。
读多写少场景
使用 double buffer,读buffer和写buffer分离
方案1,使用原子操作保护读写指针,切换读写指针的时候会存在读写竞争
方案2(brpc方案),每个读线程持有一个局部读写指针,写线程写入完成后会去依次修改所有线程的局部读写指针 - 一次只阻塞一个读线程,避免了全局锁的开销。 - 写延迟会变高 - 读线程会将局部读写指针注册到全局,这样写线程才能依次修改读写指针,注册线程局部变量的成本不高,只发生在线程创建时
RCU(Read Copy Update)机制就是一种典型的double buffer机制,用于解决读多写少场景下的读写冲突问题。
写多读少场景
参考brpc bvar实现,写线程只写自己的线程局部变量,由读线程对线程局部变量做汇总,写操作不会互相阻塞,读线程汇总数据时只会阻塞一个写线程,读线程的读取会变慢一些
brpc-高性能rpc框架
- 高性能的无锁数据结构,例如iobuf、bvar、DoublyBufferedData等
- 高效线程模型,bthread用户态线程m:n调度,事件等待、io、请求处理全并发
- 负载均衡算法,Locality-aware load balancing, 规避按cpu idle分配流量的悖论,LALB将当前qps也加入了权值计算,形成了一种负反馈机制
- W = QPS/L,W为权值,L为请求耗时,综合节点当前QPS和请求处理耗时作为权重
- 基于QPS和Latency做权重不需要依赖对下游节点的负载统计
- 对流量做分流时,使用加权随机选择算法,用加权前缀树查找权重
iobuf
iobuf是brpc的一个高性能内存缓冲区实现,主要用于网络通信和数据传输。
iobuf互操作时可以做到零拷贝,本质上通过预分配内存和引用计数实现
iobuf分为三层,iobuf、blockRef和block
- iobuf是一个内存缓冲区,包含了一个或多个blockRef
- 每个blockRef引用某个block中的一部分内存(blockRef内部会保存所引用block的内存偏移量)
- 每个block是一个固定8KB大小的内存块,内部包含一个引用计数,记录有多少blockRef引用自己
- block之间通过next指针连接成一个单向链表
DoublyBufferedData
是一种double buffer实现,适用于读多写少的场景,brpc对读写指针的切换做了优化,利用thread local机制降低了读写指针切换时对读线程的阻塞
这个结构使用非常广泛,负载均衡器就采用了这个实现,负载均衡器需要定期更新下游服务的可用节点列表,这是一个典型的多读少写的场景
bvar
用于brpc内部的数据统计,针对数据统计这种多读少写的场景进行了针对性优化,具体来说,是让每个写线程只写自己的线程局部变量,由读线程对线程局部变量做汇总,写操作不会互相阻塞,读线程汇总数据时只会阻塞一个写线程,读线程的读取会变慢一些
bthread
m:n协程调度模型,类似golang的协程,有独立的协程栈,有work stealing机制
bthread属于用户态线程,上下文切换开销小
brpc的线程模型完全基于bthread实现,可以实现event loop、io、请求处理等全并发
一些传统的rpc框架会使用有限数量的内核线程作为处理线程,把不同的请求散列到这些线程上,一旦出现一个长尾请求,就会将其所在线程上的所有任务全部阻塞,而一个tcp连接对应一个内核线程又不太实际,非常浪费系统资源
bthread的work stealing机制可以让一个线程从其他线程偷取任务来执行,避免了长尾请求阻塞整个线程的问题
TLS(Thread Local Storage)
和C++的thread_local不太一样
首先,bthread里面不能用thread_local关键字,因为thread_local是C++标准库的特性,运行在内核线程上,而bthread会在不同的内核线程上调度,原生的thread_local值会发生变化
其次,bthread的TLS支持注册到全局,其他线程也能访问到线程的TLS变量,这给很多性能优化方案提供了可能
负载均衡
Locality-aware load balancing, 规避按cpu idle分配流量的悖论,LALB将当前qps也加入了权值计算,形成了一种负反馈机制 - W = QPS/L,W为权值,L为请求耗时,综合节点当前QPS和请求处理耗时作为权重 - 基于QPS和Latency做权重不需要依赖对下游节点的负载统计 - 对流量做分流时,使用加权随机选择算法,用加权前缀树查找权重
docker和k8s
- docker是一种容器技术,容器是一种轻量的虚拟化技术,其最大的好处是能让程序在不同平台上有一致的执行环境。
- docker可以用来创建一个容器。容器是根据配置文件将程序依赖的库等各种环境打包在一起,形成一个独立的运行环境。
- docker基于 Linux 内核的 cgroup,namespace,以及 Union FS 技术实现了容器之间的隔离
- docker的三个概念,镜像、容器、仓库
- 镜像是基于UnionFS实现的个特殊的文件系统,里面包含了程序所需要的所有环境。
- 容器是镜像运行的实体(这个有点像进程和程序的关系),容器的本质就是进程,他有自己的namespace,只能访问自己的命名空间里的文件,靠这个实现了文件系统之间的隔离
- 仓库是一个用来分发镜像的系统。
- k8s
- 容器编排服务
- 通常称作k8s集群,主要是管理容器生命周期,对服务做扩缩容