Java开发面试中间件框架相关知识-持续更新中
2023-01-08 03:14:39 0 举报
AI智能生成
包含redis、dubbo、rocketMQ、kafka、sentinel等等,个人面试经验总结,还有数据库、JVM基础、方法论等模板陆续发布中。 欢迎大家交流沟通,查缺补漏,目前还在持续更新中,希望能帮助大家度过寒冬~找到满意的工作~ 某中厂程序员-丈育
作者其他创作
大纲/内容
ElasticSearch
节点分类
整体架构
写入流程
查询流程
Dubbo
各组件作用
Consumer
Producer
Registry
zookeeper为例
/dubbo
|
/com.bob.dubbo.service.CityDubboService
| | | |
/providers /consumers /configurators / routers
| |
/ip port class
所有的method
版本
例如:anyhost=true
application=dst-operate-service
dubbo=2.6.2
generic=false
interface=com.dst.modules.dataanal.CarRentalRecordService
methods=generatorData,createBatchRecord,deleteAll,selectListByCondition,updateRecordByAccount,queryRecordList,updateByBackcarReasonByCode,queryRentCarListByvinCode,queryRecordListByPickCode,queryRecordListByServiceOrderCode,updateOldServiceCode,createRecord,updateBatchCarRentalRecordById,updateBatchByRenewLease,updateRecord,queryRecordListByDeliveryCode
payload=20971520
pid=41756
side=provider
timestamp=1554781127816
|
/com.bob.dubbo.service.CityDubboService
| | | |
/providers /consumers /configurators / routers
| |
/ip port class
所有的method
版本
例如:anyhost=true
application=dst-operate-service
dubbo=2.6.2
generic=false
interface=com.dst.modules.dataanal.CarRentalRecordService
methods=generatorData,createBatchRecord,deleteAll,selectListByCondition,updateRecordByAccount,queryRecordList,updateByBackcarReasonByCode,queryRentCarListByvinCode,queryRecordListByPickCode,queryRecordListByServiceOrderCode,updateOldServiceCode,createRecord,updateBatchCarRentalRecordById,updateBatchByRenewLease,updateRecord,queryRecordListByDeliveryCode
payload=20971520
pid=41756
side=provider
timestamp=1554781127816
消费者也会注册节点便于管理、监控;
消费者在第一次调用时会获取提供方ip,并缓存到本地,同时消费者会利用zookeeper提供的watcher机制订阅提供者变更信息
zookeeper观察机制;
服务端只存储事件的信息,客户端存储事件的信息和Watcher的执行逻辑.ZooKeeper客户端是线程安全的每一个应用只需要实例化一个ZooKeeper客户端即可,同一个ZooKeeper客户端实例可以在不同的线程中使用。ZooKeeper客户端会将这个Watcher对应Path路径存储在ZKWatchManager中,同时通知ZooKeeper服务器记录该Client对应的Session中的Path下注册的事件类型。当ZooKeeper服务器发生了指定的事件后,ZooKeeper服务器将通知ZooKeeper客户端哪个节点下发生事件类型,ZooKeeper客户端再从ZKWatchManager中找到相应Path,取出相应watcher引用执行其回调函数process。
每个watcher注册一次只会触发一次,需要重新注册才能再触发
服务上线,向zk注册临时节点;
服务下线,zk本身通过发起心跳感知服务端是否存在,心跳长期无响应则删除节点
消费者在第一次调用时会获取提供方ip,并缓存到本地,同时消费者会利用zookeeper提供的watcher机制订阅提供者变更信息
zookeeper观察机制;
服务端只存储事件的信息,客户端存储事件的信息和Watcher的执行逻辑.ZooKeeper客户端是线程安全的每一个应用只需要实例化一个ZooKeeper客户端即可,同一个ZooKeeper客户端实例可以在不同的线程中使用。ZooKeeper客户端会将这个Watcher对应Path路径存储在ZKWatchManager中,同时通知ZooKeeper服务器记录该Client对应的Session中的Path下注册的事件类型。当ZooKeeper服务器发生了指定的事件后,ZooKeeper服务器将通知ZooKeeper客户端哪个节点下发生事件类型,ZooKeeper客户端再从ZKWatchManager中找到相应Path,取出相应watcher引用执行其回调函数process。
每个watcher注册一次只会触发一次,需要重新注册才能再触发
服务上线,向zk注册临时节点;
服务下线,zk本身通过发起心跳感知服务端是否存在,心跳长期无响应则删除节点
Monitor
流程
服务暴露发现过程
消费者到提供者的调用过程
1. 路由规则
2. 负载均衡
3. 集群容错策略(当调用失败时)
4.
特性
单一长连接
每个consumer默认和一个Provider只有一个长连接,减少连接成本
通过请求id区分同一个连接上的多个请求。
创建一个连接客户端同时也会创建一个心跳客户端,客户端默认基于60秒发送一次心跳来保持连接的存活
通过请求id区分同一个连接上的多个请求。
创建一个连接客户端同时也会创建一个心跳客户端,客户端默认基于60秒发送一次心跳来保持连接的存活
优雅停机
Dubbo 是通过 JDK 的 ShutdownHook 来完成优雅停机的,所以如果用户使用 kill -9 PID 等强制关闭指令,是不会执行优雅停机的,只有通过 kill PID 时,才会执行。
服务提供方:
停止时,先标记为不接收新请求,新请求过来时直接报错,让客户端重试其它机器。
然后,检测线程池中的线程是否正在运行,如果有,等待所有线程执行完成,除非超时,则强制关闭。
服务消费方:
停止时,不再发起新的调用请求,所有新的调用在客户端即报错。
然后,检测有没有请求的响应还没有返回,等待响应返回,除非超时,则强制关闭。
服务提供方:
停止时,先标记为不接收新请求,新请求过来时直接报错,让客户端重试其它机器。
然后,检测线程池中的线程是否正在运行,如果有,等待所有线程执行完成,除非超时,则强制关闭。
服务消费方:
停止时,不再发起新的调用请求,所有新的调用在客户端即报错。
然后,检测有没有请求的响应还没有返回,等待响应返回,除非超时,则强制关闭。
各种选型
序列化
方案对比
hession
跨语言、高效、二进制
json
文本形式,性能差
dubbo
尚不成熟
java
性能差
protobuf
性能好
Thrift
性能好,包含序列化之外的功能
事件派发策略
all(default)
所有消息都派发到线程池,包括请求,响应,连接事件,断开事件,心跳等。 即worker线程接收到事件后,将该事件提交到业务线程池中,自己再去处理其他事。
direct
message
execution
connection
负载均衡策略
1. (加权) 随机 (default)
2. (加权)轮询
3. 最小活跃数(活跃数低即处理慢的提供者接收到更少的请求)
4. 一致性hash
基于dubbo一致性Hash,相同参数的请求总是发到同一提供者。通过参数计算hash值,以此计算请求哪个提供者
集群容错策略(前6个属于容错,后面几个有对应的应用场景)
1. failover 失败重试其他服务器,常用于读
2. failfast 快速失败,请求失败则失败,常用于非幂等场景,如新增
3. failback 失败自动恢复,后台记录失败请求,定时重发。通常用于消息通知操作
4. failsafe 失败安全,出现异常时,直接忽略。通常用于写入审计日志等操作
5. forking 并行调用多个服务器,只要一个成功即返回。通常用于实时性要求较高的读操作,但需要浪费更多服务资源。可通过 forks="2" 来设置最大并行数
6. broadcast 广播调用所有提供者,逐个调用,任意一台报错则报错。通常用于通知所有提供者更新缓存或日志等本地资源信息。
7. mergeable 分组聚合,将集群中的调用结果聚合起来,然后再返回结果。比如菜单服务,接口一样,但有多种实现,用group区分,现在消费方需从每种group中调用一次返回结果,合并结果返回,这样就可以实现聚合菜单项
8. available 获取可用的服务方。遍历所有Invokers通过invoker.isAvalible判断服务端是否活着,只要一个有为true,直接调用返回,不管成不成功
9. mock 本地伪装,通常用于服务降级,比如某验权服务,当服务提供方全部挂掉后,客户端不抛出异常,而是通过 Mock 数据返回授权失败
10. zone-aware 注册中心集群间负载均衡
注册中心
方案对比
zookeeper
CP
etcd
CP
原理/介绍
一致性的KV存储系统
一致性:ETCD[Raft]协议保证,对比zookeeper使用Zab(类PAXOS),前者容易理解,方便工程实现
运维方面:ETCD方便运维,zookeeper难以运维;
项目活跃度:ETCD社区与开发活跃,zookeeper已经快死了;
API:ETCD提供HTTP+JSON, gRPC接口,跨平台跨语言,zookeeper需要使用其客户端;
访问安全方面:ETCD支持HTTPS访问,zookeeper在这方面缺失;
一致性:ETCD[Raft]协议保证,对比zookeeper使用Zab(类PAXOS),前者容易理解,方便工程实现
运维方面:ETCD方便运维,zookeeper难以运维;
项目活跃度:ETCD社区与开发活跃,zookeeper已经快死了;
API:ETCD提供HTTP+JSON, gRPC接口,跨平台跨语言,zookeeper需要使用其客户端;
访问安全方面:ETCD支持HTTPS访问,zookeeper在这方面缺失;
任意时刻至多存在一个有效的主节点。主节点处理所有来自客户端写操作,通过Raft协议保证写操作对状态机的改动会可靠的同步到其他节点
consul
CP
Eureka
AP
特性
如果一个服务器出问题,不需要任何类型的选举,客户端会自动连接到一个新的Eureka服务器
Eureka有一个服务心跳的概念,可以阻止过期数据:如果一个服务长时间没有发送心跳,那么Eureka将从服务注册中将其删除。但在出现网络分区、Eureka在短时间内丢失过多客户端时,它会停用这一机制,进入“自我保护模式”。网络恢复后,它又会自动退出该模式。这样,虽然它保留的数据中可能存在错误,却不会丢失任何有效数据。
Eureka在客户端会有缓存。即使所有Eureka服务器不可用,服务注册信息也不会丢失。缓存在这里是恰当的,因为它只在所有的Eureka服务器都没响应的情况下才会用到。
Eureka就是为服务发现而构建的。它提供了一个客户端库,该库提供了服务心跳、服务健康检查、自动发布及缓存刷新等功能。使用ZooKeeper,这些功能都需要自己实现。
Eureka有一个服务心跳的概念,可以阻止过期数据:如果一个服务长时间没有发送心跳,那么Eureka将从服务注册中将其删除。但在出现网络分区、Eureka在短时间内丢失过多客户端时,它会停用这一机制,进入“自我保护模式”。网络恢复后,它又会自动退出该模式。这样,虽然它保留的数据中可能存在错误,却不会丢失任何有效数据。
Eureka在客户端会有缓存。即使所有Eureka服务器不可用,服务注册信息也不会丢失。缓存在这里是恰当的,因为它只在所有的Eureka服务器都没响应的情况下才会用到。
Eureka就是为服务发现而构建的。它提供了一个客户端库,该库提供了服务心跳、服务健康检查、自动发布及缓存刷新等功能。使用ZooKeeper,这些功能都需要自己实现。
Nacos
在注册中心的场景中,注册中心不可用 对比 数据不一致
不一致:会导致客户端拿到的服务列表不一致,导致流量不均衡,但是若有最终一致的保证及failover机制,实际影响不大;
不可用:例如同机房 业务服务提供节点要注册(或更新、缩容、扩容等)到zookeeper集群,该机房和其他机房出现网络分区,由于无法连接zookeeper的leader,导致本机房业务消费房无法感知本机房的提供方的变化,导致无法调用,这是难以容忍的。
所以针对注册中心可能ap会更合适
不一致:会导致客户端拿到的服务列表不一致,导致流量不均衡,但是若有最终一致的保证及failover机制,实际影响不大;
不可用:例如同机房 业务服务提供节点要注册(或更新、缩容、扩容等)到zookeeper集群,该机房和其他机房出现网络分区,由于无法连接zookeeper的leader,导致本机房业务消费房无法感知本机房的提供方的变化,导致无法调用,这是难以容忍的。
所以针对注册中心可能ap会更合适
线程池(启动时确认)
fixed
固定大小线程池,线程,不关闭,一直持有。(缺省)
coresize:200
maxsize:200
队列:SynchronousQueue
回绝策略:AbortPolicyWithReport - 打印线程信息jstack,之后抛出异常
coresize:200
maxsize:200
队列:SynchronousQueue
回绝策略:AbortPolicyWithReport - 打印线程信息jstack,之后抛出异常
cache
缓存线程池,无核心线程数,有最大线程数,队列为SynchronousQueue,所以提交任务时,会直接创建线程,空闲一分钟自动删除,需要时重建
limit
可伸缩线程池,但池中的线程数只会增长不会收缩。只增长不收缩的目的是为了避免收缩时突然来了大流量引起的性能问题。
eager
类似tomcat中的线程池,当核心线程都在忙碌时(通过对比已提交未执行任务数量(AtomicInteger)和核心线程数量),优先保障线程数量到达最大线程数,再放队列
线程池满的问题?
dubbo优化点
2.7.5 更新
应用粒度服务注册
一个实例只向注册中心注册一条记录,不同于之前一个接口注册一条,彻底解决服务推送性能瓶颈;
同时由于这样的模型与主流微服务体系如 SpringCloud、K8S 等天然是对等的,因此为 Dubbo 解决和此类异构体系间的互联互通清除了障碍,
dubbo通过对同一接口(服务)发布不同的协议来支持异构服务互联互通
同时由于这样的模型与主流微服务体系如 SpringCloud、K8S 等天然是对等的,因此为 Dubbo 解决和此类异构体系间的互联互通清除了障碍,
dubbo通过对同一接口(服务)发布不同的协议来支持异构服务互联互通
由于注册中心只有 应用-实例 的信息,缺少RPC(如接口等)信息,那么RPC相关的数据Dubbo新版本通过
「服务自省」机制实现:
服务自省在服务消费端和提供端之间建立了一条内置的 RPC 服务信息协商机制,这也是“服务自省”这个名字的由来。服务端实例会暴露一个预定义的 MetadataService RPC 服务,消费端通过调用 MetadataService 获取每个实例 RPC 方法相关的配置信息(如重试、负载均衡策略等等)。
实际上就是提供方和消费方对于RPC数据的同步,目前有两种形式:
1)内建 MetadataService。
2)独立的元数据中心,通过中细化的元数据集群协调数据。
消费端可以在调用时查询服务方的MetadataService,或者提供方同步到元数据中心,当注册中心有变动时下发到消费方,消费方再去元数据中心查。
另外还有一个配置中心,保存了 service - app 的映射,调用变成先查配置中心,找到app后查注册中心(缓存的注册中心)找到提供方ip,然后根据提供方的RPC数据然后进行调用。
「服务自省」机制实现:
服务自省在服务消费端和提供端之间建立了一条内置的 RPC 服务信息协商机制,这也是“服务自省”这个名字的由来。服务端实例会暴露一个预定义的 MetadataService RPC 服务,消费端通过调用 MetadataService 获取每个实例 RPC 方法相关的配置信息(如重试、负载均衡策略等等)。
实际上就是提供方和消费方对于RPC数据的同步,目前有两种形式:
1)内建 MetadataService。
2)独立的元数据中心,通过中细化的元数据集群协调数据。
消费端可以在调用时查询服务方的MetadataService,或者提供方同步到元数据中心,当注册中心有变动时下发到消费方,消费方再去元数据中心查。
另外还有一个配置中心,保存了 service - app 的映射,调用变成先查配置中心,找到app后查注册中心(缓存的注册中心)找到提供方ip,然后根据提供方的RPC数据然后进行调用。
HTTP/2 (gRPC) 协议支持
通用性
Protobuf 支持
Protobuf 实现了语言中立的服务定义,不依赖于具体的语言,例如java那么只能通过interface来定义服务。
序列化上更高效,更通用
调用链路优化
在服务注册阶段提前生成 ServiceDescriptor 和 MethodDescriptor
消费端线程池模型优化
TLS 安全传输链路
Bootstrap API【beta】
多注册中心集群负载均衡
3.0 更新
和spring cloud对比
架构
基本一致
组成
spring cloud功能更全,dubbo需要第三方组件才可以达到一样的效果
spring cloud包含
spring cloud包含
通信方式
服务依赖
运行流程
社区支持及更新力度
Sentinel
关键逻辑
如何定位时间对应哪个窗口?
private int calculateTimeIdx(long timeMillis) {
// 定位数组是通过计算当前时间与单位时间窗的倍数, 例如当前时间1200ms,默认窗口500ms, timeId = 1200 / 500 = 2;
long timeId = timeMillis / windowLengthInMs;
// 再与环形数组长度取模,就可以求出落在环形数组中的实际 index,例如 2 % 2 = 0
return (int)(timeId % array.length());
}
时间窗是一个环形数组,达到复用样本窗口对象,避免频繁创建对象的作用。
// 定位数组是通过计算当前时间与单位时间窗的倍数, 例如当前时间1200ms,默认窗口500ms, timeId = 1200 / 500 = 2;
long timeId = timeMillis / windowLengthInMs;
// 再与环形数组长度取模,就可以求出落在环形数组中的实际 index,例如 2 % 2 = 0
return (int)(timeId % array.length());
}
时间窗是一个环形数组,达到复用样本窗口对象,避免频繁创建对象的作用。
//计算时间窗口开始时间戳
protected long calculateWindowStart(long timeMillis) {
// 计算出最靠近当前时间的窗口长度的倍数,例如默认单位窗口长度是500ms,当前时间戳是1200ms,那么 1200-1200%500 = 1000ms;
return timeMillis - timeMillis % windowLengthInMs;
}
另外由于是环形数组,所以需要确认定位到的数组是否是当前统计时间内的窗口,这里实现是每个窗口维护窗口的开始时间,然后和当前线程的时间做对比
protected long calculateWindowStart(long timeMillis) {
// 计算出最靠近当前时间的窗口长度的倍数,例如默认单位窗口长度是500ms,当前时间戳是1200ms,那么 1200-1200%500 = 1000ms;
return timeMillis - timeMillis % windowLengthInMs;
}
另外由于是环形数组,所以需要确认定位到的数组是否是当前统计时间内的窗口,这里实现是每个窗口维护窗口的开始时间,然后和当前线程的时间做对比
计算qps时会统计当前时间对应的单元窗口数组请求成功数量的总和
Kafka
Redis
数据结构
RedisObject
RedisObject
string
特性
1. 取字符串的长度的时间复杂度为O(1):
redis由C语言实现,不存在字符串结构,只能用字符数组和指针实现,buf[]中依然采用了C语言的以\0结尾可以直接使用C语言的部分标准C字符串库函数,redis使用sds其中记录了字符串长度
redis由C语言实现,不存在字符串结构,只能用字符数组和指针实现,buf[]中依然采用了C语言的以\0结尾可以直接使用C语言的部分标准C字符串库函数,redis使用sds其中记录了字符串长度
2. 杜绝缓冲区溢出
由于C 语言不记录字符串长度,如果增加一个字符传的长度,如果没有注意就可能溢出,覆盖了紧挨着这个字符的数据。对于SDS 而言增加字符串长度需要验证 free的长度,如果free 不够就会扩容整个 buf,防止溢出。
由于C 语言不记录字符串长度,如果增加一个字符传的长度,如果没有注意就可能溢出,覆盖了紧挨着这个字符的数据。对于SDS 而言增加字符串长度需要验证 free的长度,如果free 不够就会扩容整个 buf,防止溢出。
3. 减少修改字符串长度时造成的内存再次分配
redis 作为高性能的内存数据库,需要较高的相应速度。字符串也很大概率的频繁修改。 SDS 通过未使用空间这个参数,将字符串的长度和底层buf的长度之间的额关系解除了。buf的长度也不是字符串的长度。基于这个分设计 SDS 实现了空间的预分配和惰性释放。
redis 作为高性能的内存数据库,需要较高的相应速度。字符串也很大概率的频繁修改。 SDS 通过未使用空间这个参数,将字符串的长度和底层buf的长度之间的额关系解除了。buf的长度也不是字符串的长度。基于这个分设计 SDS 实现了空间的预分配和惰性释放。
1.「空间预分配」:当一个sds被修改成更长的 buf 时,除了会申请本身需要的内存外,还会额外申请一些空间。
如果对 SDS 修改后,如果 len 小于 1MB 那 len = 2 * len + 1byte。 这个 1 是用于保存空字节。
如果 SDS 修改后 len 大于 1MB 那么 len = 1MB + len + 1byte。
如果对 SDS 修改后,如果 len 小于 1MB 那 len = 2 * len + 1byte。 这个 1 是用于保存空字节。
如果 SDS 修改后 len 大于 1MB 那么 len = 1MB + len + 1byte。
2.「惰性空间」:如果缩短 SDS 的字符串长度,redis并不是马上减少 SDS 所占内存。只是增加 free 的长度。同时向外提供 API 。真正需要释放的时候,才去重新缩小 SDS 所占的内存
结构
struct sdshdr {
int len; //长度
int free; //剩余空间
char buf[]; //字符串数组
};
int len; //长度
int free; //剩余空间
char buf[]; //字符串数组
};
list
链表被广泛用于实现 Redis 的各种功能,比如列表键、发布与订阅、慢查询、监视器
结构
struct listNode {
struct listNode * prev; //前置节点
struct listNode * next; //后置节点
void * value;//节点的值
};
struct listNode * prev; //前置节点
struct listNode * next; //后置节点
void * value;//节点的值
};
hash
结构
struct dict {
... dictht ht[2]; //哈希表 有两个dictht,一个用于扩容复制用
rehashidx == -1 //rehash使用,没有rehash的时候为-1
}
... dictht ht[2]; //哈希表 有两个dictht,一个用于扩容复制用
rehashidx == -1 //rehash使用,没有rehash的时候为-1
}
或者 ziplist
ziplist并没有定义明确的结构体, 根据存储结构我们可以定义ziplist如下
typedef struct ziplist{
/*ziplist分配的内存大小*/
uint32_t bytes;
/*达到尾部的偏移量*/
uint32_t tail_offset;
/*存储元素实体个数*/
uint16_t length;
/*存储内容实体元素*/
unsigned char* content[];
/*尾部标识*/
unsigned char end;
}ziplist;
ziplist内存布局
|-----------|-----------|----------|---------------------------------------------------|---|
bytes offset length content {zlentry, zlentry ... ...} end
ziplist并没有定义明确的结构体, 根据存储结构我们可以定义ziplist如下
typedef struct ziplist{
/*ziplist分配的内存大小*/
uint32_t bytes;
/*达到尾部的偏移量*/
uint32_t tail_offset;
/*存储元素实体个数*/
uint16_t length;
/*存储内容实体元素*/
unsigned char* content[];
/*尾部标识*/
unsigned char end;
}ziplist;
ziplist内存布局
|-----------|-----------|----------|---------------------------------------------------|---|
bytes offset length content {zlentry, zlentry ... ...} end
是一个数组来表示链表,可想而知在插入、删除节点时间空间复杂度多大,所以只在元素很少时才会采用这个数据结构
如当哈希对象保存的所有键值对的键和值的字符串长度都小于64字节
哈希对象保存的键值对数量小于512个,会使用zipList
如当哈希对象保存的所有键值对的键和值的字符串长度都小于64字节
哈希对象保存的键值对数量小于512个,会使用zipList
后期版本都使用了quickList,因为zipList对于内存空间耗费过大,所以都使用了quickList,相当于使用双向链表,但是链表项是若干个zipList
渐进式rehash
每个字典有两个hash表,一个平时使用,一个rehash的时候使用。rehash是渐进式的
过程:dict中ht[2]中有两个hash表, 我们第一次存储数据的数据时, ht[0]会创建一个最小为4的hash表, 一旦ht[0]中的size和used相等, 则dict中会在ht[1]创建一个size*2大小的hash表;
此时并不会直接将ht[0]中的数据copy进ht[0]中, 执行的是渐进式rehash, 即在以后的操作(find, set, get等)中慢慢的迁移进去, 通过 reindexid 记录当前正在迁移的bucketId,迁移时,新增数据会直接写入h[1],而修改删除查询由于不知道属于h[0]还是[1],所以会先查找h[0]再找h[1], 因此在ht[1]被占满的时候定能确保ht[0]中所有的数据全部copy到ht[1]中.
此时并不会直接将ht[0]中的数据copy进ht[0]中, 执行的是渐进式rehash, 即在以后的操作(find, set, get等)中慢慢的迁移进去, 通过 reindexid 记录当前正在迁移的bucketId,迁移时,新增数据会直接写入h[1],而修改删除查询由于不知道属于h[0]还是[1],所以会先查找h[0]再找h[1], 因此在ht[1]被占满的时候定能确保ht[0]中所有的数据全部copy到ht[1]中.
触发:1. serveCron定时检测迁移 2. 每次kv变更的时候(新增、更新)的时候顺带rehash。
hash冲突
采用单向链表的方式解决hash冲突,新的冲突元素会被放到链表的表头,头插(依据是时间局部性原理,认为新插入的后续被访问的几率高)
zset
保存的元素少于128个且保存的所有元素大小都小于64字节则压缩列表(zipList),否则zset(字典+跳表)
typedef struct zset {
dict *dict;
zskiplist *zsl;
} zset
dict *dict;
zskiplist *zsl;
} zset
dict 中维护key->score映射,实现 O(1) 查找分数
skiplist 通过分数作为键形成,达到按照分数的(范围)查找
skiplist 通过分数作为键形成,达到按照分数的(范围)查找
skipList结构
struct zskiplistNode {
struct zskiplistLevel{
struct zskiplistNode *forward;//前进指针
unsigned int span;//跨度
}
level[];
struct zskiplistNode *backward;//后退指针
double score;//分值
robj *obj; // 成员对象
};
struct zskiplistLevel{
struct zskiplistNode *forward;//前进指针
unsigned int span;//跨度
}
level[];
struct zskiplistNode *backward;//后退指针
double score;//分值
robj *obj; // 成员对象
};
为什么跳表而不是红黑树?
方便范围查找
实现简单
插入元素方便,只需要修改相邻元素的指针
跳表原理
节点层数随机(抛硬币,每个节点每增一个层级就通过随机数决定是否再往上加层级)
set
set 的底层为了实现内存的节约,会根据集合的类型和数目而采用不同的数据结构来保存,元素都是整数时intset(整数且元素不多)、非整数时dict。无序,不重复
结构
struct intset {
uint32_t encoding;//编码方式
uint32_t length;//集合包含的元素数量
int8_t contents[];//保存元素的数组(哈希表实现)
};
uint32_t encoding;//编码方式
uint32_t length;//集合包含的元素数量
int8_t contents[];//保存元素的数组(哈希表实现)
};
快的原因?
内存型数据库
简单/特定的数据结构
预分配、跳表、渐进式rehash等
单线程
redis 的主体模式还是单线程的,除了一些持久化相关的 fork。单线程相比多线程的好处就是锁的问题,上下文切换的问题。官方也解释到:redis 的性能不在 cpu,而在内存。(注:并不是整个reids进程就一个线程,例如处理请求的是一个IO线程,执行命令也有一个线程)
IO多路复用
IO 多路复用就是多个 TCP 连接复用一个线程,redis采用reactor模型,一个IO多路复用线程监听多个socket(利用操作系统的epoll),Redis4.0开始支持多线程,主要体现在大数据的异步删除方面,例如:unlink key、flushdb async、flushall async等。而Redis6.0的多线程则增加了对IO读写的并发能力,用于更好的提升Redis的性能。
如果采用多个请求起多个进程或者多个线程的模式还是比较重的,除了要考虑到进程或者线程的切换之外,还要用户态去遍历检查事件是否到达,效率低下
通过 IO 多路复用技术,用户态不用去遍历fds集合,通过内核通知告诉事件的到达,效率比较高。
删除机制
策略:定期删除+惰性删除+淘汰策略
1. 定期删除,具体就是Redis每秒10次做的事情:
3. 淘汰策略:因为前两者都无法保证删除所有过期的key,所以需要兜底方案
- 测试随机的20个keys进行相关过期检测。
- 删除所有已经过期的keys。
- 如果有多于25%的keys过期,重复步奏1
3. 淘汰策略:因为前两者都无法保证删除所有过期的key,所以需要兜底方案
为了减少CPU消耗所以Redis不采用定时删除(对key设置过期时间的定时器,轮询到时间后删除)?希望高并发CPU都尽量用来处理请求。
淘汰策略
淘汰时机:内存不足会触发我们设置的淘汰策略
noeviction:当内存使用超过配置的时候,写入会返回错误,不会驱逐任何键(默认)
allkeys-lru:通过LRU算法驱逐最久没有使用的键(一般用这个)
volatile-lru:通过LRU算法从设置了过期时间的键集合中驱逐最久没有使用的键
allkeys-random:从所有key中随机删除
volatile-random:从过期键的集合中随机驱逐
volatile-ttl:从配置了过期时间的键中驱逐马上就要过期的键
volatile-lfu:从所有配置了过期时间的键中驱逐使用频率最少的键
allkeys-lfu:从所有键中驱逐使用频率最少的键
allkeys-lru:通过LRU算法驱逐最久没有使用的键(一般用这个)
volatile-lru:通过LRU算法从设置了过期时间的键集合中驱逐最久没有使用的键
allkeys-random:从所有key中随机删除
volatile-random:从过期键的集合中随机驱逐
volatile-ttl:从配置了过期时间的键中驱逐马上就要过期的键
volatile-lfu:从所有配置了过期时间的键中驱逐使用频率最少的键
allkeys-lfu:从所有键中驱逐使用频率最少的键
持久化
rdb
触发方式
save命令
一般不用。save是手动保存方式,它会使 redis 进程阻塞,直至 RDB 文件创建完毕,创建期间所有的命令都不能处理。
bgsave命令
与 SAVE 命令不同的是 BGSAVE,BGSAVE 可以不阻塞 redis 进程,通过 BGSAVE redis 会 fork 一个子进程去执行 rdb 的保存工作,主进程继续执行命令。
通过配置文件配置的save规则触发
save 900 1:900s 内有 1 个 key 发生变化,则触发 RDB 快照
save 60 10000:60s 内有 10000 个 key 发生变化(新增、修改、删除),则触发 RDB 快照
save "":该配置表示关闭 RDB 持久化
save 60 10000:60s 内有 10000 个 key 发生变化(新增、修改、删除),则触发 RDB 快照
save "":该配置表示关闭 RDB 持久化
原理
fork 子进程来进行数据持久化,保证 Redis 在此期间可以继续对外提供服务,但是fork一瞬间会阻塞父进程;
因为fork采用操作系统写时复制机制(COW),fork不会拷贝父进程的内存数据,但是会拷贝进程的必要的数据结构,例如拷贝内存页表(虚拟内存和物理内存的映射索引表),这个过程会消耗CPU且阻塞,内存数据越多则阻塞时间越长;
fork后父子进程实际上是指向同一个地址空间,父子进程各自有虚拟内存空间,但是对应的物理内存地址空间是同一个。此时把内存页的权限设置为read-only,如果两个进程中的一个尝试对写操作时,触发page-fault,触发kernel中断,kernel把触发异常的页复制一份,申请独立的内存地址,此后读写至此,逐渐各自独立。
在rdb场景中即父进程对已存在的key进行写操作时,会复制对应的内存页到新申请的内存地址,子进程则还是会读到fork时的内存地址数据,互不影响。
因为fork采用操作系统写时复制机制(COW),fork不会拷贝父进程的内存数据,但是会拷贝进程的必要的数据结构,例如拷贝内存页表(虚拟内存和物理内存的映射索引表),这个过程会消耗CPU且阻塞,内存数据越多则阻塞时间越长;
fork后父子进程实际上是指向同一个地址空间,父子进程各自有虚拟内存空间,但是对应的物理内存地址空间是同一个。此时把内存页的权限设置为read-only,如果两个进程中的一个尝试对写操作时,触发page-fault,触发kernel中断,kernel把触发异常的页复制一份,申请独立的内存地址,此后读写至此,逐渐各自独立。
在rdb场景中即父进程对已存在的key进行写操作时,会复制对应的内存页到新申请的内存地址,子进程则还是会读到fork时的内存地址数据,互不影响。
恢复数据
redis 没有专门的用户导入的命令,redis 在启动的时候会检测是否有 RDB 文件,有的话,就自动导入。
优、缺点
恢复大数据集时较快,缺点是备份时间较长的话,发生宕机,容易丢失部分数据
aof
aof 先是写到aof_buf的缓冲区中,redis 提供三种方案将 buf 的缓冲区的数据刷盘,当然也是 serverCron 来根据策略处理的。
1. appendfsync always
2. appendfsync everysec
3. appendfsync no
1. appendfsync always
2. appendfsync everysec
3. appendfsync no
aof重写
描述:aof由于是类似日志追加的形式保存,其数据会越来越大,所以进行压缩存储,如多条命令合并为一条,这样存储就节省了很多。重写也不是分析现有 aof,重写就是从数据库读取现有的 key,然后尽量用一条命令替代;
另外重写是通过fork子进程重写,重写时机由文件大小来控制
另外重写是通过fork子进程重写,重写时机由文件大小来控制
混合持久化(4.0之后)
将AOF和RDB的数据放到同一个文件,在 bgrewriteaof 时生成,使用RDB作为AOF文件的前半段,
恢复时比较快,虽然还是会丢数据,所以这个方案主要还是针对afo恢复速度慢和rdb数据不全的问题
恢复时比较快,虽然还是会丢数据,所以这个方案主要还是针对afo恢复速度慢和rdb数据不全的问题
pipeline
实现
服务端和客户端一起完成(客户端缓冲命令,服务端顺序处理后一次响应)
但是client缓冲区不是无限的,满了就会发送,所以会出现多次请求的情况,但是在请求结束前,并不处理服务端的响应
但是client缓冲区不是无限的,满了就会发送,所以会出现多次请求的情况,但是在请求结束前,并不处理服务端的响应
优点
1. 针对批处理命令,减少IO次数,通过管道我们可以把多条命令合并发送,只需要建立一次连接,但pipeline非原子性的
2. 提高请求效率,适用于多key读写场景,比如同时读取多个key的value,或者更新多个key的value,但非原子
2. 提高请求效率,适用于多key读写场景,比如同时读取多个key的value,或者更新多个key的value,但非原子
缺点
1. 一次组装的命令不能太多,占用客户端、服务端内存和连接
2. 命令按顺序执行但不保证原子性,中间有失败的命令会继续往后执行,并且也会穿插执行其他客户端发来的命令,所以不适合强依赖结果的场景
3. 由于依赖连接,所以redis cluster并不支持pipeline,不过可以通过客户端计算key对应slot,按slot分组执行pipeline,或者使用hashtag
2. 命令按顺序执行但不保证原子性,中间有失败的命令会继续往后执行,并且也会穿插执行其他客户端发来的命令,所以不适合强依赖结果的场景
3. 由于依赖连接,所以redis cluster并不支持pipeline,不过可以通过客户端计算key对应slot,按slot分组执行pipeline,或者使用hashtag
对比脚本
1. 脚本同样可以实现一次I/O处理多个命令,但是脚本可以支持判断,所以可以实现请求间的互相依赖
2. 脚本同样不支持cluster,需要采用分组或hashtag方式
2. 脚本同样不支持cluster,需要采用分组或hashtag方式
缓存一致
本质上是个业务问题,不是技术问题。技术上有标准答案,就不存在这个问题了
所以需要结合业务场景看业务容忍度来确定方案,强一致的方案建议弃用缓存,避免造成业务损失
例如,针对营销的场景:
在商品详情页/确认订单页的优惠计算时使用缓存,而在下单时不使用缓存。
这可以让极端情况发生时,不产生过大的业务损失。
针对库存的场景:
读取到旧版本的数据只是会在商品已售罄的情况下让多余的流量进入到下单而已,下单时的库存扣减是操作数据库的,所以不会有业务上的损失。
所以需要结合业务场景看业务容忍度来确定方案,强一致的方案建议弃用缓存,避免造成业务损失
例如,针对营销的场景:
在商品详情页/确认订单页的优惠计算时使用缓存,而在下单时不使用缓存。
这可以让极端情况发生时,不产生过大的业务损失。
针对库存的场景:
读取到旧版本的数据只是会在商品已售罄的情况下让多余的流量进入到下单而已,下单时的库存扣减是操作数据库的,所以不会有业务上的损失。
方案
1. 一般的更新数据库+更新缓存 / 更新缓存更新数据库 并发更新和非原子的问题
X 不采纳
X 不采纳
2. 先删缓存 脏读问题 X 不采纳
3. 更新数据库+删缓存
删缓存方案
1. 直接删缓存
2. 删缓存动作放到MQ(可以通过事务消息)
3. 监听Master 的 binlog,然后删缓存
存在的问题
① 删除后,读slave库,slave复制延迟的问题,
1. 可以通过延迟双删(发mq,设定估计的salve复制延迟时间的double)
2. 可以起个定时任务扫表兜底同步(表可以是数据库操作记录表,写数据和操作记录同一个事务)或每周全量同步
3. 起个JOB进行数据校验对比redis和mysql
1. 可以通过延迟双删(发mq,设定估计的salve复制延迟时间的double)
2. 可以起个定时任务扫表兜底同步(表可以是数据库操作记录表,写数据和操作记录同一个事务)或每周全量同步
3. 起个JOB进行数据校验对比redis和mysql
②删缓存后缓存穿透db的问题
1)利用本地或分布式锁,读请求串行化(吞吐量影响较大)
2)或者通过下诉更新数据库+带版本号更新缓存方案解决
1)利用本地或分布式锁,读请求串行化(吞吐量影响较大)
2)或者通过下诉更新数据库+带版本号更新缓存方案解决
采用删缓存而不是更新缓存还体现了懒加载,需要的时候才更新缓存
4. 更新数据库 + 带版本号更新缓存
和方案3. 更新数据库+删缓存区别在于
数据库数据要加版本号信息
缓存组件要判断版本号较新的进行更新,旧的忽略
避免缓存穿透
数据库数据要加版本号信息
缓存组件要判断版本号较新的进行更新,旧的忽略
避免缓存穿透
高可用
分布式锁
特性
- 互斥性:任意一个时刻,只能有一个客户端获取到锁
- 安全性:锁只能被持久的客户端删除,不能被其他人删除
- 高可用,高性能:加锁和解锁消耗的性能少,时间短
- 锁超时:当客户端获取锁后出现故障,没有立即释放锁,该锁要能够在一定时间内释放,否则其他客户端无法获取到锁
- 可重入性:客户端获取到锁后,在持久锁期间可以再次获取到该锁
方案
redis
存在的问题
锁时间到了,业务还没执行完?
解决方案:
1. 提前给足时间
2. 设置监控线程,每1/3时间来check一次,若业务还没处理完(业务执行),则延长锁时间,可以通过在设置redis key时,把key维护在内存如通过map维护,然后由另外的线程去监控这个map,自动去续期;锁删除前删除map中key,防止删除redis的key失败,若map不删,则监控线程无限续期。
3. 异常回滚,解锁时检查当前线程是否持有,否则回滚业务。
解决方案:
1. 提前给足时间
2. 设置监控线程,每1/3时间来check一次,若业务还没处理完(业务执行),则延长锁时间,可以通过在设置redis key时,把key维护在内存如通过map维护,然后由另外的线程去监控这个map,自动去续期;锁删除前删除map中key,防止删除redis的key失败,若map不删,则监控线程无限续期。
3. 异常回滚,解锁时检查当前线程是否持有,否则回滚业务。
redLock存在的问题
强依赖时钟,存在时钟回拨
redisson
优点
watch dog自动续期
可重入(通过hash结构,filed为线程id,value为重入数量,通过Lua脚本)
原理
加锁
通过Lua脚本
if (redis.call('exists', KEYS[1]) == 0) then " +
"redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end; " +
"if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
"redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end; " +
"return redis.call('pttl', KEYS[1]);"
"redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end; " +
"if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
"redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end; " +
"return redis.call('pttl', KEYS[1]);"
注释:
先判断加锁key存不存在,不存在就新建hash结构(hincrby命令如果不存在hash则会新建一个hash)
通过hincrby给指定的filed+1,
KEYS[1] 是加锁key,
KEYS[2]是客户端id(锁实例的UUID属性(每个锁实例会生成一个uuid)+线程id)
hash的value是重入次数
加锁成功返回null
先判断加锁key存不存在,不存在就新建hash结构(hincrby命令如果不存在hash则会新建一个hash)
通过hincrby给指定的filed+1,
KEYS[1] 是加锁key,
KEYS[2]是客户端id(锁实例的UUID属性(每个锁实例会生成一个uuid)+线程id)
hash的value是重入次数
加锁成功返回null
例子:
127.0.0.1:6379> HGETALL myLock
1) "285475da-9152-4c83-822a-67ee2f116a79:52"
2) "1"
127.0.0.1:6379> HGETALL myLock
1) "285475da-9152-4c83-822a-67ee2f116a79:52"
2) "1"
为什么redis中Lua可以保证原子性?
Redis使用同一个Lua解释器来执行所有命令,同时,Redis保证以一种原子性的方式来执行脚本:当lua脚本在执行的时候,不会有其他脚本和命令同时执行,这种语义类似于 MULTI/EXEC。
另外Redis执行Lua是单线程,所以不会有ABA问题。
另外Redis执行Lua是单线程,所以不会有ABA问题。
Lua缺点
redis使用lua可以保证指令依次执行而不受其他指令干扰,可以简单的认为仅仅只是把命令打包执行,但是不能保证指令最终必定是原子性的。
所以redis并没有严格的保证了数据的原子性操作,仅保证语句排他,不保证数据一致,这样的好处在于redis不需要像数据库一样还要保存回滚日志等,可以让redis执行的更快。
不能保证原子性的场景主要有以下这些(即多个命令中,存在某个命令语法错误,执行失败,前面已执行的命令不会回滚)
1. 指令语法错误,如上述执行redis.call(‘het’,‘k1’,‘1’),正确的应该是redis.call(‘het’,‘k1’,‘1’,‘2’)
2. 语法是正确的,但是类型不对,比如对已经存在的string类型的key,执行hset等
3. 服务器挂掉了,比如lua脚本执行了一半,但是服务器挂掉了
所以redis并没有严格的保证了数据的原子性操作,仅保证语句排他,不保证数据一致,这样的好处在于redis不需要像数据库一样还要保存回滚日志等,可以让redis执行的更快。
不能保证原子性的场景主要有以下这些(即多个命令中,存在某个命令语法错误,执行失败,前面已执行的命令不会回滚)
1. 指令语法错误,如上述执行redis.call(‘het’,‘k1’,‘1’),正确的应该是redis.call(‘het’,‘k1’,‘1’,‘2’)
2. 语法是正确的,但是类型不对,比如对已经存在的string类型的key,执行hset等
3. 服务器挂掉了,比如lua脚本执行了一半,但是服务器挂掉了
锁互斥
当第二个线程来尝试加锁,会判断
"if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then "
即Key是否存在且当前线程是否是重入的,否则会通过pttl返回当前锁的剩余时间
当锁正在被占用时,等待获取锁的进程并不是通过一个 while(true) 死循环去获取锁,而是利用了 Redis 的发布订阅机制,通过 await 方法阻塞等待锁的进程,有效的解决了无效的锁申请浪费资源的问题。等待结束后会重新通过之前的方式获取锁
"if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then "
即Key是否存在且当前线程是否是重入的,否则会通过pttl返回当前锁的剩余时间
当锁正在被占用时,等待获取锁的进程并不是通过一个 while(true) 死循环去获取锁,而是利用了 Redis 的发布订阅机制,通过 await 方法阻塞等待锁的进程,有效的解决了无效的锁申请浪费资源的问题。等待结束后会重新通过之前的方式获取锁
锁续期
Watch Dog 机制其实就是一个后台定时任务线程,获取锁成功之后,会将持有锁的线程放入到一个 RedissonLock.EXPIRATION_RENEWAL_MAP里面,然后每隔 10 秒 (internalLockLeaseTime / 3) 检查一下,如果客户端 1 还持有锁 key(判断客户端是否还持有 key,其实就是遍历 EXPIRATION_RENEWAL_MAP 里面线程 id 然后根据线程 id 去 Redis 中查(if (redis.call('hexists', KEYS[1], ARGV[2]) == 1)),如果存在就会延长 key 的时间),那么就会不断的延长锁 key 的生存时间。
注意:这里有一个细节问题,如果服务宕机了,Watch Dog 机制线程也就没有了,此时就不会延长 key 的过期时间,到了 30s 之后就会自动过期了,其他线程就可以获取到锁。
注意:这里有一个细节问题,如果服务宕机了,Watch Dog 机制线程也就没有了,此时就不会延长 key 的过期时间,到了 30s 之后就会自动过期了,其他线程就可以获取到锁。
锁释放
第一步:删除锁,对field值-1
第二步:若减到0,则del删除key,并广播释放锁的消息,通知阻塞等待的进程(向通道名为 redisson_lock__channel publish 一条 UNLOCK_MESSAGE 信息)。
第三部:取消 Watch Dog 机制,即将 RedissonLock.EXPIRATION_RENEWAL_MAP 里面的线程 id 删除,并且 cancel 掉 Netty 的那个定时任务线程。
第二步:若减到0,则del删除key,并广播释放锁的消息,通知阻塞等待的进程(向通道名为 redisson_lock__channel publish 一条 UNLOCK_MESSAGE 信息)。
第三部:取消 Watch Dog 机制,即将 RedissonLock.EXPIRATION_RENEWAL_MAP 里面的线程 id 删除,并且 cancel 掉 Netty 的那个定时任务线程。
参考 https://blog.csdn.net/loushuiyifan/article/details/82497455
缺点
1. Redis Master-Slave 架构的主从异步复制,master宕机的情况若没有把锁及时同步到slave,会导致锁状态丢失,多个客户端获取到锁
2. 有个别观点说使用 Watch Dog 机制开启一个定时线程去不断延长锁的时间对系统有所损耗(这里只是网络上的一种说法,仅供参考)。
2. 有个别观点说使用 Watch Dog 机制开启一个定时线程去不断延长锁的时间对系统有所损耗(这里只是网络上的一种说法,仅供参考)。
数据库
唯一键
zookeeper
问题
羊群效应
由于zookeeper是阻塞锁,等待线程会监听锁定线程的释放命令,若等待的JVM很多,那么锁释放时就会有可能会造成我们ZkServer端阻塞
性能不好
选举leader的时间太长,30 ~ 120s, 且选举期间整个zookeeper集群都是不可用的
由于master才能写,所以tps有限(W级别),无法水平拓展来增加性能,增加节点反而增加了同步的成本
由于master才能写,所以tps有限(W级别),无法水平拓展来增加性能,增加节点反而增加了同步的成本
问题
KV设计
Key设计
业务名:表名:id 例子 o2o:order:1
Value设计
拒绝big key
打散过期时间(加上随机数)
热Key问题
定义
所谓热key问题就是,突然有几十万的请求去访问redis上的某个特定key。那么,这样会造成流量过于集中,达到物理网卡上限,从而导致这台redis的服务器宕机。那接下来这个key的请求,就会直接怼到你的数据库上,导致你的服务不可用
如何发现Hot Key?
1. 凭借业务经验,进行预估哪些是热key其实这个方法还是挺有可行性的。比如某商品在做秒杀,那这个商品的key就可以判断出是热key。缺点很明显,并非所有业务都能预估出哪些key是热key。
2. 在客户端进行收集这个方式就是在操作redis之前,加入一行代码进行数据统计。那么这个数据统计的方式有很多种,也可以是给外部的通讯系统发送一个通知信息。缺点就是对客户端代码造成入侵。
3. 在Proxy层做收集有些集群架构是下面这样的,Proxy可以是Twemproxy,是统一的入口。可以在Proxy层做收集上报,但是缺点很明显,并非所有的redis集群架构都有proxy。 client -> proxy ->redis cluster
4. 用redis自带命令(1)monitor命令,该命令可以实时抓取出redis服务器接收到的命令,然后写代码统计出热key是啥。当然,也有现成的分析工具可以给你使用,比如redis-faina。但是该命令在高并发的条件下,有内存增暴增的隐患,还会降低redis的性能。(2)hotkeys参数,redis 4.0.3提供了redis-cli的热点key发现功能,执行redis-cli时加上–hotkeys选项即可。但是该参数在执行的时候,如果key比较多,执行起来比较慢。
5. 自己抓包评估Redis客户端使用TCP协议与服务端进行交互,通信协议采用的是RESP。自己写程序监听端口,按照RESP协议规则解析数据,进行分析。缺点就是开发成本高,维护困难,有丢包可能性。
解决方案
1. 预热,首先热 key 肯定是要缓存的,提前把热数据加载到缓存中,一上线就直接读取缓存。
预热手段
1. 手动刷新缓存:直接写个缓存刷新页面,上线时手工操作下。
2. 应用启动时刷新缓存:数据量不大,可以在项目启动的时候自动进行加载。
3. 通过定时任务 异步刷新缓存。
1. 手动刷新缓存:直接写个缓存刷新页面,上线时手工操作下。
2. 应用启动时刷新缓存:数据量不大,可以在项目启动的时候自动进行加载。
3. 通过定时任务 异步刷新缓存。
2. 备份,缓存至少集群架构,保证多个从,这样就算一个从挂了,还有备份。
3. 二级缓存(热点发现,本地缓存),使用机器的内存再做一道拦截。比如像秒杀的商品基本信息可以直接使用机器的内存,可以通过增加proxy层,topK统计高频访问key,达到阈值后自动通过集中式配置中心例如zookeeper下发客户端,客户端本地缓存对应kv。
4. 限流,预估支持的 qps,拦截多余的请求。
5. 冗余到多个节点,首先生成hotKey_l, hotKey_2, hotKey_3…, hotKey100,换句话说,热键被人为复制了100次。它们会映射到多个哈希槽,并存储在许多不同的 Redis 实例上。然后在查询时,每当这个热键hotKey由客户端请求,我们在热键后添加一个 [1, 100] 范围内的随机数
BigKey问题
定义
1. 字符串类型 超过10KB 2. 集合类型 元素数量超过5000?
危害
1. redis阻塞 redis单线程,bigkey删除耗时长,也消耗cpu,bigkey序列化反序列也消耗应用的cpu
2. 网络阻塞 假设我们的交换机,千兆网络(小b),那么 实际带宽 1024 / 8 = 128M . 假设你的这个key的大小 500KB, 客户端并发 1000获取这个key, 那么就意味着 1000 * 500KB = 500M ,那就是每秒产生500M的流量。先不说你的Redis能不能处理的过来这个并发下的bigKey,单说你的这个千兆网络, 你说你这个网络I/O能扛得住吗? 一般服务器会采用单机多实例的方式来部署,也就是说一个bigkey可能会对其他实例也造成影响,其后果不堪设想
如何定位?
--bigkeys 命令
离线方式 对rdb文件进行分析,不够实时
如何优化bigkey?
分片:big list变为 list_1、list_2、…list_N
比如一个大的key,假设存了1百万的用户数据,可以拆分成 200个key,根据用户id分片,newHashKey = hashKey + (哈希(字段) % 200);
比如一个大的key,假设存了1百万的用户数据,可以拆分成 200个key,根据用户id分片,newHashKey = hashKey + (哈希(字段) % 200);
查少量:若大key不可避免 那么不要一下子全查出来,例如有时候仅仅需要hmget,而不是hgetall
过期时间设置为非业务高峰期,否则过期触发del,造成阻塞,或者用redis 4.0的lazy free特性,但是默认不开启
* Redis 4.0提供了过期异步删除(lazyfree-lazyexpire yes)lazy free 惰性删除或延迟释放: 当删除键的时候,redis提供异步延时释放key内存的功能,把key释放操作放在Background I/O单独的子线程处理中,减少删除big key对redis主线程的阻塞,有效地避免删除big key带来的性能和可用性问题。
删除BigKey注意:对于非字符串的bigkey,比如 hash list set zset , 不要使用del 删除, 请使用 hscan 、sscan、zscan方式渐进式删除。
hash key:通过hscan命令,每次获取500个字段,再用hdel命令;
set key:使用sscan命令,每次扫描集合中500个元素,再用srem命令每次删除一个元素;
list key:删除大的List键,未使用scan命令; 通过ltrim命令每次删除少量元素。
sorted set key:删除大的有序集合键,和List类似,使用sortedset自带的zremrangebyrank命令,每次删除top 100个元素。
hash key:通过hscan命令,每次获取500个字段,再用hdel命令;
set key:使用sscan命令,每次扫描集合中500个元素,再用srem命令每次删除一个元素;
list key:删除大的List键,未使用scan命令; 通过ltrim命令每次删除少量元素。
sorted set key:删除大的有序集合键,和List类似,使用sortedset自带的zremrangebyrank命令,每次删除top 100个元素。
缓存穿透、击穿、雪崩
1. 缓存穿透
现象:访问一个非法的数据(数据库和缓存中都不存在)出现这种情况,每次必然是要去数据库请求一次不存在的数据,这时候因为没有数据,所以也不会写入缓存,下一次同样的请求还是会重蹈覆辙。
解决方案
1. 前端校验: 例如根据用户id查询数据,针对id如负数等可以直接拦截
2. 后端校验: 在接口的开始处,校验一些常规的正负数,比如负数的user_id直接返回报错。
3. 空值缓存: 有时候我们也对于数据库查不到的数据,也做个缓存,这个缓存的时间可以短一些如60秒。
4. hash 拦截: hash 校验使用一些数据量不多的场景,比如店铺的商品信息,上架一个商品的时候,我们商品做下hash标记(map[“商品ID”]=1),这样如果请求的商品 id 都不在 hash 表里,直接返回了。
5. 位图标记: 类似 hash,但是使用比特位来标记
6. 布隆过滤器: 当我们关心的数据量非常大的时候 hash和位图那得多大,不现实,这时可以用布隆过滤器,布隆过滤器不像hash和位图那样可以做到百分百的拦截,但是可以做到绝大部分的非法的拦截。布隆过滤器的思想就是在有限的空间里,通过多个hash函数来定位一条数据,当只要有一个hash没中,那么一定是不存在的,但是当多个hash全中的话,也不一定是存在的,这一点是需要注意的。
2. 后端校验: 在接口的开始处,校验一些常规的正负数,比如负数的user_id直接返回报错。
3. 空值缓存: 有时候我们也对于数据库查不到的数据,也做个缓存,这个缓存的时间可以短一些如60秒。
4. hash 拦截: hash 校验使用一些数据量不多的场景,比如店铺的商品信息,上架一个商品的时候,我们商品做下hash标记(map[“商品ID”]=1),这样如果请求的商品 id 都不在 hash 表里,直接返回了。
5. 位图标记: 类似 hash,但是使用比特位来标记
6. 布隆过滤器: 当我们关心的数据量非常大的时候 hash和位图那得多大,不现实,这时可以用布隆过滤器,布隆过滤器不像hash和位图那样可以做到百分百的拦截,但是可以做到绝大部分的非法的拦截。布隆过滤器的思想就是在有限的空间里,通过多个hash函数来定位一条数据,当只要有一个hash没中,那么一定是不存在的,但是当多个hash全中的话,也不一定是存在的,这一点是需要注意的。
2. 缓存击穿
现象:热点数据在某一时刻缓存过期,然后突然大量请求打到 db 中,这时如果 db 扛不住,可能就挂了,引起线上连锁反应。
解决方案
1. 分布式锁:分布式系统中,并发请求的问题,第一时间想到的就是分布式锁,只放一个请求进去(可以用redis setnx、zookeeper等等)
2. 单机锁:也并不一定非得需要分布式锁,单机锁在集群节点不多的情况下也是ok的(golang 可以用 synx.mutex、java 可以用 JVM 锁),保证一台机器上的所有请求中只有一个能进去。假设你有 10 台机器,那么最多也就同时 10 个并发打到db,对数据库来说影响也不大。相比分布式锁来说开销要小点,但是如果你的机器多达上千,还是慎重考虑。
3. 二级缓存:当我们的第一级缓存失效后,也可以设置一个二级缓存,二级缓存也可以拦截下,二级缓存可以是内存缓存也可以是其他缓存数据库。
针对本地缓存的不一致问题(集群某个节点数据修改只影响了本地的,导致和其他节点的数据不一样,可以通过gossip协议通知其他节点)
针对本地缓存的不一致问题(集群某个节点数据修改只影响了本地的,导致和其他节点的数据不一样,可以通过gossip协议通知其他节点)
4. 热点数据不过期:某些时候,热点数据就不要过期。或者通过定时任务去刷新
3. 缓存雪崩
现象:缓存不可用或者大量缓存由于超时时间相同在同一时间段失效,所有的请求都打到了 db,与缓存击穿不同的是,雪崩是大量的 key,击穿是一个 key,这时 db 的压力也不言而喻。
解决方案
事前:Redis 高可用方案(Redis Cluster + 主从),避免缓存全面崩溃。
事中:
(一)采用多级缓存方案,本地缓存(Ehcache/Caffine/Guava Cache) + 分布式缓存(Redis/ Memcached)。
(二)调整限流 + 熔断 + 降级(Hystrix),避免极端情况下,数据库被打死。
(三)热点数据分散在集群不同节点
(一)采用多级缓存方案,本地缓存(Ehcache/Caffine/Guava Cache) + 分布式缓存(Redis/ Memcached)。
(二)调整限流 + 熔断 + 降级(Hystrix),避免极端情况下,数据库被打死。
(三)热点数据分散在集群不同节点
事后:Redis 持久化(RDB+AOF),一旦重启,自动从磁盘上加载数据,快速恢复缓存数据。
辅助手段:
1. 监控缓存,弹性扩容。
2. 缓存的过期时间可以取个随机值。这么做是为避免缓存同时失效,使得数据库 IO 骤升。比如:以前是设置 10 分钟的超时时间,那每个 Key 都可以随机 8-13 分钟过期,尽量让不同 Key 的过期时间不同。
1. 监控缓存,弹性扩容。
2. 缓存的过期时间可以取个随机值。这么做是为避免缓存同时失效,使得数据库 IO 骤升。比如:以前是设置 10 分钟的超时时间,那每个 Key 都可以随机 8-13 分钟过期,尽量让不同 Key 的过期时间不同。
1. 缓存时间随机些:对于所有的缓存,尽量让每个 key 的过期时间随机些,降低同时失效的概率
2. 上锁:根据场景上锁,保护 db
3. 二级缓存:同缓存击穿
4. 热点数据不过期:同缓存击穿
常用排查命令
redis阻塞,使用info commandstats命令分析,展示每个命令的次数,总时间,平均时间
慢日志查看 slowlog get 128
info 状态
bigkey排查 redis-cli提供了--bigkeys来查找bigkey,会给出每种数据类型的最大key,原理是用scan扫描所有key,所以有影响性能,建议在从库执行
集群方案
redis sentinel (redis 2.8)
解决的问题
在主从的架构下,解决人工将从节点晋升为主节点,但是没有解决数据分片,每台节点数据都一样
Redis Sentinel是一个分布式架构,其中包含若干个Sentinel节点和Redis数据节点,每个Sentinel节点会对数据节点和其余Sentinel节点进行监控,当它发现节点不可达时,会对节点做下线标识。如果被标识的是主节点,它还会和其他Sentinel节点进行“协商”,当大多数Sentinel节点都认为主节点不可达时,它们会选举出一个Sentinel节点来完成自动故障转移的工作,同时会将这个变化实时通知给Redis应用方。
codis
proxy-based 是官方发布redis cluster之前的集群方案,由于依赖zk,是个中心化方案。
架构
1. Codis Proxy (codis-proxy)
- 实现了Redis协议,表现的和原生Redis差不多,集群部署,无状态,分布式逻辑写在proxy上,逻辑上将key分成1024个slot(hash算法是crc32(key)%1024),会从zk同步节点的槽位分布路由信息
2. Codis Manager (codis-config)
- 管理工具,节点的增删改查
3. Codis Redis (codis-server)
4. ZooKeeper
- 存放数据路由表,proxy元数据,config通过zookeeper下发配置到每个proxy
- 实现了Redis协议,表现的和原生Redis差不多,集群部署,无状态,分布式逻辑写在proxy上,逻辑上将key分成1024个slot(hash算法是crc32(key)%1024),会从zk同步节点的槽位分布路由信息
2. Codis Manager (codis-config)
- 管理工具,节点的增删改查
3. Codis Redis (codis-server)
4. ZooKeeper
- 存放数据路由表,proxy元数据,config通过zookeeper下发配置到每个proxy
缺点
1. 中心化,codis强依赖zookeeper,proxy通过zookeeper来监听的redis集群变化等,会出现zk死机或选举阶段服务不可用或者通信问题,zk没有及时摘除故障proxy,导致请求按比例丢失的风险
2. codis本身并不负责主从切换,主从复制依赖redis本身的replication,手动切换;另外也提供一个codis-ha在master挂掉后,提升slave为master
3. 部署结构复杂,缩扩容需要手动干预
2. codis本身并不负责主从切换,主从复制依赖redis本身的replication,手动切换;另外也提供一个codis-ha在master挂掉后,提升slave为master
3. 部署结构复杂,缩扩容需要手动干预
redis cluster
去中心化,客户端上可以缓存路由表,并且Redis Cluster的节点之间会共享消息,每个节点都会知道是哪个节点负责哪个范围内的数据槽(16384个槽,2^14),通过Gossip协议实现各节点最终一致,相比集中式config如zk,时效性差但不存在单点压力。
采用虚拟槽(slot)分区算法,分成16384个槽,客户端根据key计算,CRC16(key)%16383 计算出 slot 的值,根据缓存的路由表查到该slot属于哪个节点,如果请求的节点不存在对应的slot,会返回给客户端重定向,确定所属槽后客户端会更新路由缓存。
原生集群通常可以支持 ~300 - 400 个实例。每个实例可以处理 80K 读取 QPS,集群总共可以处理 20-3000 万 QPS
采用虚拟槽(slot)分区算法,分成16384个槽,客户端根据key计算,CRC16(key)%16383 计算出 slot 的值,根据缓存的路由表查到该slot属于哪个节点,如果请求的节点不存在对应的slot,会返回给客户端重定向,确定所属槽后客户端会更新路由缓存。
原生集群通常可以支持 ~300 - 400 个实例。每个实例可以处理 80K 读取 QPS,集群总共可以处理 20-3000 万 QPS
为什么slot大小为16384而不是65536(CRC16产生的 Hash 值有 16bit,即65536个值)?
答:三个方面 心跳包大小、节点数量、心跳包压缩比
1. 节点间交换信息中包含一个bitmap用于表示当前节点负责的槽信息,大小为16384/8/1024 = 2KB,当slot为65536时,大小相应变为8KB
unsigned char myslots[CLUSTER_SLOT/8];
2. 另外也会携带其他节点的信息,比例约为集群总结点数的1/10,至少为3个节点,导致节点越多,心跳包消息头越大,所以作者建议节点数不大于1000,
这样slot为16384时,每个节点的slot分配不会过少。
3. bitmap传输中会进行压缩,bitmap填充率(slot/N N为节点数)很高的情况下,压缩率变低。
综上作者的16384是一个权衡下的最合适的选择。
答:三个方面 心跳包大小、节点数量、心跳包压缩比
1. 节点间交换信息中包含一个bitmap用于表示当前节点负责的槽信息,大小为16384/8/1024 = 2KB,当slot为65536时,大小相应变为8KB
unsigned char myslots[CLUSTER_SLOT/8];
2. 另外也会携带其他节点的信息,比例约为集群总结点数的1/10,至少为3个节点,导致节点越多,心跳包消息头越大,所以作者建议节点数不大于1000,
这样slot为16384时,每个节点的slot分配不会过少。
3. bitmap传输中会进行压缩,bitmap填充率(slot/N N为节点数)很高的情况下,压缩率变低。
综上作者的16384是一个权衡下的最合适的选择。
为什么不用一致性hash,而采用虚拟槽?
答:首先一致性hash是将节点分布在hash环上,请求计算hash值对应节点位置后,顺时针寻找环上的最近节点,
那么相对于一致性hash的key的分布是自动的,而hash槽的 key-node 映射是确定的,方便运维介入控制,比如节点的增删、热点key迁移等等
答:首先一致性hash是将节点分布在hash环上,请求计算hash值对应节点位置后,顺时针寻找环上的最近节点,
那么相对于一致性hash的key的分布是自动的,而hash槽的 key-node 映射是确定的,方便运维介入控制,比如节点的增删、热点key迁移等等
gossip通信机制
每个节点维护一份自己视角下整个集群的状态,主要包含:
1. 当前集群状态
2. 集群中各节点所负责的slot及migrate状态
3. 各节点master-slave状态
4. 各节点存活及怀疑Fail状态
节点之间会发送多种消息,最主要的是节点间会通过发送PING/PONG互相探活和交换信息。
1. 当前集群状态
2. 集群中各节点所负责的slot及migrate状态
3. 各节点master-slave状态
4. 各节点存活及怀疑Fail状态
节点之间会发送多种消息,最主要的是节点间会通过发送PING/PONG互相探活和交换信息。
不可用条件
1. 集群半数master宕机
2. 某一个节点主从全部宕机
集群缩/扩容过程
扩容:
1. CLUSTER MEET命令或使用redis-trib.rb工具让新节点加入集群
2. rehash指定节点的数据(目的是迁移到新节点)
3. 添加从节点
缩容类似:
1. 迁移数据
2. 下线节点
1. CLUSTER MEET命令或使用redis-trib.rb工具让新节点加入集群
2. rehash指定节点的数据(目的是迁移到新节点)
3. 添加从节点
缩容类似:
1. 迁移数据
2. 下线节点
故障发现(raft协议)
Redis Cluster通过ping/pong消息实现故障发现:不需要sentinel
ping/pong不仅能传递节点与槽的对应消息,也能传递其他状态,比如:节点主从状态,节点故障等
故障发现就是通过这种模式来实现,分为主观下线和客观下线:
1. 主观下线(偏见)
某个节点认为另一个节点不可用,'偏见',只代表一个节点对另一个节点的判断,不代表所有节点的认知
流程:
1.节点1定期发送ping消息给节点2
2.如果发送成功,代表节点2正常运行,节点2会响应PONG消息给节点1,节点1更新与节点2的最后通信时间
3.如果发送失败,则节点1与节点2之间的通信异常判断连接,在下一个定时任务周期时,仍然会与节点2发送ping消息
4.如果节点1发现与节点2最后通信时间超过node-timeout,则把节点2标识为pfail状态
2. 客观下线(半数存在偏见则客观下线)
当半数以上持有槽的主节点都标记某节点主观下线时,可以保证判断的公平性,
集群模式下,只有主节点(master)才有读写权限和集群槽的维护权限,从节点(slave)只有复制的权限
1.某个节点接收到其他节点发送的ping消息,如果接收到的ping消息中包含了其他pfail节点,这个节点会将主观下线的消息内容添加到自身的故障列表中,故障列表中包含了当前节点接收到的每一个节点对其他节点的状态信息
2.当前节点把主观下线的消息内容添加到自身的故障列表之后,会尝试对故障节点进行客观下线操作
ping/pong不仅能传递节点与槽的对应消息,也能传递其他状态,比如:节点主从状态,节点故障等
故障发现就是通过这种模式来实现,分为主观下线和客观下线:
1. 主观下线(偏见)
某个节点认为另一个节点不可用,'偏见',只代表一个节点对另一个节点的判断,不代表所有节点的认知
流程:
1.节点1定期发送ping消息给节点2
2.如果发送成功,代表节点2正常运行,节点2会响应PONG消息给节点1,节点1更新与节点2的最后通信时间
3.如果发送失败,则节点1与节点2之间的通信异常判断连接,在下一个定时任务周期时,仍然会与节点2发送ping消息
4.如果节点1发现与节点2最后通信时间超过node-timeout,则把节点2标识为pfail状态
2. 客观下线(半数存在偏见则客观下线)
当半数以上持有槽的主节点都标记某节点主观下线时,可以保证判断的公平性,
集群模式下,只有主节点(master)才有读写权限和集群槽的维护权限,从节点(slave)只有复制的权限
1.某个节点接收到其他节点发送的ping消息,如果接收到的ping消息中包含了其他pfail节点,这个节点会将主观下线的消息内容添加到自身的故障列表中,故障列表中包含了当前节点接收到的每一个节点对其他节点的状态信息
2.当前节点把主观下线的消息内容添加到自身的故障列表之后,会尝试对故障节点进行客观下线操作
故障恢复(raft协议)
1. 资格检查
对从节点的资格进行检查,只有经过过检查的从节点才可以开始进行故障恢复
每个从节点检查与故障主节点的断线时间
超过cluster-node-timeout * cluster-slave-validity-factor数字,则取消资格
cluster-node-timeout默认为15秒,cluster-slave-validity-factor默认值为10
如果这两个参数都使用默认值,则每个节点都检查与故障主节点的断线时间,如果超过150秒,则这个节点就没有成为替换主节点的可能性
2. 投票、替换故障的主
由正常的master节点进行投票,slave复制的offset大的权重大,获得的投票多,超过半数成为master
当一个从节点发现自己正在复制的主节点进入了已下线状态时,从节点将开始对下线主节点进行故障转移,以下是故障转移执行的步骤:
1. 从节点会执行SLAVEOF no one命令,成为新的主节点;
2. 新的主节点会撤销所有对已下线主节点的槽指派,并将这些槽全部指派给自己;
3.新的主节点向集群广播一条PONG消息,这条PONG消息可以让集群中的其他节点立即知道这个节点已经由从节点变成了主节点,并且这个主节点已经接管了原本由已下线节点负责处理的槽。
4. 新的主节点开始接收和自己负责处理的槽有关的命令请求,故障转移完成。
对从节点的资格进行检查,只有经过过检查的从节点才可以开始进行故障恢复
每个从节点检查与故障主节点的断线时间
超过cluster-node-timeout * cluster-slave-validity-factor数字,则取消资格
cluster-node-timeout默认为15秒,cluster-slave-validity-factor默认值为10
如果这两个参数都使用默认值,则每个节点都检查与故障主节点的断线时间,如果超过150秒,则这个节点就没有成为替换主节点的可能性
2. 投票、替换故障的主
由正常的master节点进行投票,slave复制的offset大的权重大,获得的投票多,超过半数成为master
当一个从节点发现自己正在复制的主节点进入了已下线状态时,从节点将开始对下线主节点进行故障转移,以下是故障转移执行的步骤:
1. 从节点会执行SLAVEOF no one命令,成为新的主节点;
2. 新的主节点会撤销所有对已下线主节点的槽指派,并将这些槽全部指派给自己;
3.新的主节点向集群广播一条PONG消息,这条PONG消息可以让集群中的其他节点立即知道这个节点已经由从节点变成了主节点,并且这个主节点已经接管了原本由已下线节点负责处理的槽。
4. 新的主节点开始接收和自己负责处理的槽有关的命令请求,故障转移完成。
redis变慢排查
首先要定义慢,要对你的生产redis进行基准测试
使用复杂度过高命令 方式:SLOWLOG命令排查,可能是消耗cpu的命令,如sort,或O(n)命令且N很大,导致IO瓶颈
BIG KEY 申请、释放内存慢
集中过期 变慢的时间点很有规律,例如某个整点,或者每间隔多久就会发生一波延迟,由于redis主进程中存在定时任务去删除过期的key,会导致同时删除大量的KEY,若有BIG KEY 那么删除就会更耗时,并且这个删除不会出现在SLOW LOG
内存达到上限 也会导致频繁删除KEY,延时原因同上
fork 耗时
当 Redis 开启了后台 RDB 和 AOF rewrite 后,在执行时,它们都需要主进程创建出一个子进程进行数据的持久化。
主进程创建子进程,会调用操作系统提供的 fork 函数。
而 fork 在执行过程中,主进程需要拷贝自己的内存页表给子进程,如果这个实例很大,那么这个拷贝的过程也会比较耗时。
当 Redis 开启了后台 RDB 和 AOF rewrite 后,在执行时,它们都需要主进程创建出一个子进程进行数据的持久化。
主进程创建子进程,会调用操作系统提供的 fork 函数。
而 fork 在执行过程中,主进程需要拷贝自己的内存页表给子进程,如果这个实例很大,那么这个拷贝的过程也会比较耗时。
书籍
redis使用手册 http://redisguide.com/
RocketMQ
各组件作用
Broker
NameSever
Producer
Consumer
消费策略
queue数量=consumer数量时,queue与consumer一对一指定。queue数量>consumer数量时,其中一些消费者会消费多个队列。queue数量<consumer数量,queue与consumer一对一指定,多出来的消费者空闲。
特性
见 https://juejin.cn/post/6844903511235231757
单机高队列数
数据可靠
Producer
发送重试
重试次数、超时时间、重试不同的broker等机制
Broker
单机的同步、异步刷盘机制
同步刷盘模式下,只有写入到commit.log文件才返回发送成功。而异步刷盘只须追加到映射文件就返回发送成功。异步刷盘相对于同步刷盘效率更高,但存在消息丢失的风险,一旦broker宕机(切换电源),映射文件中部分未及时flush到磁盘中的消息可能会丢失(注意:JVM异常退出,不会受到影响,因为消息还存在物理内存中,重启应用后还会继续flush消息)
主从之间的数据复制
Consumer
ACK机制
支持消费失败重试
分布式事务
支持消息轨迹
消息消费端幂等处理
以订单场景为例
如订单状态变化的消息,每种状态只处理一次,那么可以以订单号+状态作为幂等key,可以先用redis作为前置校验,通过后再插入数据库唯一索引,保证幂等
消息顺序问题
如订单有支付、收货、完成、退款等消息,程序中的顺序并不一定符合预期
方案 1 宽表
每个状态单独分出一个或多个独立的字段。消息来时只更新其中一个字段,会有短暂的不一致,但是最终会一致。
方案 2 消息补偿机制
方案3 利用同一个分区实现顺序消息
为什么快?
零拷贝
RocketMQ主要通过MappedByteBuffer对文件进行读写操作。其中,利用了NIO中的FileChannel模型将磁盘上的物理文件直接映射到用户态的内存地址中
(这种mmap的方式减少了传统IO将磁盘文件数据在 操作系统内核地址空间的缓冲区 和 用户应用程序地址空间的缓冲区 之间来回进行拷贝的性能开销),
将对文件的操作转化为直接对内存地址进行操作,从而极大地提高了文件的读写效率(正因为需要使用内存映射机制,故RocketMQ的文件存储都使用定长结构来存储,方便一次将整个文件映射至内存)
(这种mmap的方式减少了传统IO将磁盘文件数据在 操作系统内核地址空间的缓冲区 和 用户应用程序地址空间的缓冲区 之间来回进行拷贝的性能开销),
将对文件的操作转化为直接对内存地址进行操作,从而极大地提高了文件的读写效率(正因为需要使用内存映射机制,故RocketMQ的文件存储都使用定长结构来存储,方便一次将整个文件映射至内存)
页缓存
OS使用PageCache机制对读写访问操作进行了性能优化,将一部分的内存用作PageCache。对于数据的写入,OS会先写入至Cache内,随后通过异步的方式由pdflush内核线程将Cache内的数据刷盘至物理磁盘上。对于数据的读取,如果一次读取文件时出现未命中PageCache的情况,OS从物理磁盘上访问读取文件的同时,会顺序对其他相邻块的数据文件进行预读取。
Kafka对比
为什么kafka需要zookeeper而rocket不需要?
rocketmq的master/slave角色是固定的,没有选举,NameServer只需要提供topic/queue的路由。而kafka的需要依赖zookeeper选主
Kafka 和 RocketMQ 之性能对比 https://mp.weixin.qq.com/s/KzMPPZ0NNHJkHiqbx6v1sw
文件布局
Kafka
topic+分区进行组织,多副本机制,且flower和leader不会在同一个机器
这样的组织虽然单文件是顺序追加写,但是当topic很多时,消息高并发写入的情况下,IO就会显得零散,因为要同时写入多个文件,就相当于随机写,即Kafka的写入性能在IO增加时性能会先上升后下降;并且扩容比较复杂,涉及老数据迁移
RocketMQ
消息和消费进度分开,消息都放在commitLog,消费进度在consumeQueue且按topic/queue的形式组织,副本是以commitLog复制
追求极致顺序写,只写一个commitLog文件,但这样也比较浪费,无法充分发挥磁盘IO性能,但是扩容比较简单,只影响新消息,运维成本低
把kafka的partition分成commitLog和consumeQueue,消息文件和队列文件分离,
所以RocketMQ的ConsumeQueue也是随机IO,却比kafka能支持更多的partition,原因是RocketMQ通过MappedFile的方式读写ConsumeQueue,操作系统对内存映射文件有page cache而ConsumeQueue中的数据都非常小(只有20bytes),读写几乎都是page cache的操作,因此虽然是随机IO但效率也非常高。
所以RocketMQ的ConsumeQueue也是随机IO,却比kafka能支持更多的partition,原因是RocketMQ通过MappedFile的方式读写ConsumeQueue,操作系统对内存映射文件有page cache而ConsumeQueue中的数据都非常小(只有20bytes),读写几乎都是page cache的操作,因此虽然是随机IO但效率也非常高。
数据写入方式
Kafka
基于sendfile
sendfile 系统调用相比内存映射多了一次从用户缓存区拷贝到内核缓存区,但对于超过64K的内存写入时往往 sendfile 的性能更高,可能是由于 sendfile 是基于块内存的
RocketMQ
基于mmap
消息发送方式
Kafka
消息在客户端进行组织并插入一个双端队列,按批次发送,由另外的线程去获取队列中的批次,会增加响应时间但是提高了吞吐量
RocketMQ
在客户端路由到某个队列,发送到服务端进行组织、持久化
Kafka 在性能上综合表现确实要比 RocketMQ 更加的优秀,但在消息选型过程中,我们不仅仅要参考其性能,还有从功能性上来考虑,例如 RocketMQ 提供了丰富的消息检索功能、事务消息、消息消费重试、定时消息等。
通常在大数据、流式处理场景基本选用 Kafka,业务处理相关选择 RocketMQ。
通常在大数据、流式处理场景基本选用 Kafka,业务处理相关选择 RocketMQ。
kafka消费速度不均问题
RabbitMQ 对比
消息积压问题?
0 条评论
下一页