微服务篇
SpringCloud常见组件有那些?
问题说明:这个题目主要考察对SpringCloud的组件基本了解
难易程度:⭐⭐
参考话术:
SpringCloud包含的组件很多,有很多功能是重复的。其中最常用组件包括:
- 服务注册与发现(注册中心组件):Eureka、Nacos、Consul、Zookeeper
- 客户端负载均衡 (负载均衡组件):Ribbon、Feign
- 服务熔断与降级 (服务保护组件):Sentinel、Hystrix、Resilience4j
- 远程调用组件:OpenFeign,Dubbo、gRPC、Apache 、OkHttp、RestTemplate
- 网关组件:Zuul、Gateway
- 配置中心(服务配置管理组件):Spring Cloud Config、Consul、Nacos、Zookeeper
- 消息总线:Spring Cloud Bus、Kafka
- 链路追踪:Spring Cloud Sleuth、Zipkin
- 数据访问:Spring Data、Spring JDBC
- 消息队列:Spring AMQP、Spring Kafka、Spring Cloud Stream
- 分布式缓存:Spring Data Redis、CacheManager
- 分布式事务:Spring Cloud Alibaba Seata
## Nacos的服务注册表结构是怎样的? 要了解Nacos的服务注册表结构,需要从两方面入手:
一是Nacos的分级存储模型
二是Nacos的服务端源码
**问题说明**:考察对Nacos数据分级结构的了解,以及Nacos源码的掌握情况
**难易程度**:⭐⭐⭐
**参考话术**:
Nacos采用了数据的分级存储模型,最外层是Namespace,用来隔离环境。然后是Group,用来对服务分组。接下来就是服务(Service)了,一个服务包含多个实例,但是可能处于不同机房,因此Service下有多个集群(Cluster),Cluster下是不同的实例(Instance)。
对应到Java代码中,Nacos采用了一个多层的Map来表示。结构为Map
Nacos的服务注册表结构如下:
- Namespace(命名空间):命名空间是一组逻辑隔离的资源集合,用于实现多租户的SaaS场景。
- Group(分组):可以根据业务进行服务分组,对不同服务进行分别管理。
- Service(服务):服务是一组具有相同接口定义的实例集合。
- Cluster(集群):服务集群是指实例的一个逻辑分组,服务提供者可以按照自己的需要对服务进行集群划分。
- Instance(实例):服务实例是指实际提供服务的进程。每个服务可能有多个实例,每个实例有一个唯一的IP地址和端口号。
- Configuration(配置):在Nacos中,配置数据也是注册到服务注册表中的。每个配置项都有一个名称和一个值,应用程序可以通过配置名称来获取配置值。
Nacos如何支撑阿里内部数十万服务注册压力?
问题说明:考察对Nacos源码的掌握情况
难易程度:⭐⭐⭐⭐⭐
参考话术:
Nacos内部接收到注册的请求时,不会立即写数据,而是将服务注册的任务放入一个阻塞队列就立即响应给客户端。然后利用线程池读取阻塞队列中的任务,异步来完成实例更新,从而提高并发写能力。
Nacos 能够支撑阿里内部数十万服务注册压力的原因有以下几点:
- 使用分布式存储:Nacos 提供了分布式存储服务来存储服务注册信息。分布式存储通过将注册信息分布在多个节点上,减少了单点故障的可能,同时提高了可用性和灵活性,能够支撑更高的并发服务注册和发现。
- 水平扩展:Nacos 采用基于 Raft 协议的多主节点架构,支持水平扩展,可以根据实际需求灵活扩容来支撑更多的服务注册和发现负载。
- 高可用性:Nacos 提供了自动选举和数据同步机制,在节点出现故障或网络异常的情况下,自动选举新的主节点,并将数据同步到新的主节点上,保证服务注册和发现的高可用性。
- 快速注册:Nacos 实现了快速注册机制,可以在短时间内注册大量服务实例。同时,Nacos 提供了健康检查和自动摘除机制,可以在服务实例发生故障时,自动将其摘除,保证服务注册信息的及时更新。
- 高效存储:Nacos 使用基于 RocksDB 的存储引擎来存储服务注册信息,具有高效、稳定、可靠的特点,能够支持高并发、高吞吐的服务注册和发现负载。
从集群角度来看,Nacos 的高可用性和水平扩展能力是得益于其实现方式。Nacos 使用了基于 Raft 协议的多主架构实现,使得每个实例都具有相同的能力和权重,在任何一个实例故障的情况下,集群依旧可以正常工作。同时,实例之间的通信采用多协议支持,如 TCP、HTTP、DNS 等,使得 Nacos 在不同场景下都能够具备高可用、高并发、低延迟、高可扩展的能力。
从 Nacos 的内部实现来看,Nacos 的高效率和高可靠性得益于其使用的多种技术手段:
- 使用高性能存储引擎:Nacos 使用基于 RocksDB 的存储引擎进行数据存储,能够提供高性能、高可靠性、高并发的读写能力,保证了服务的快速响应和高效处理。
- 采用异步存储:Nacos 同时采用了异步存储和批量写入的方式,有效地降低了操作系统的 IO 开销,减少了系统调用次数,提高了数据的存取性能。
- 使用快速注销机制:Nacos 在服务注销方面采用了快速注销机制,使得服务注销能够更加迅速、高效,避免了因注销操作阻塞导致的单点故障。
- 采用异步通信:Nacos 在实例之间的通信采用了异步非阻塞的方式,使得通信能够更加高效、低延时,提高了注册中心的处理能力和响应速度。
- 支持多种协议:Nacos 支持 HTTP、TCP、DNS 等多种协议,能够满足不同场景下的需要,提供更多业务场景和接入方案。
@CanDistro
@PostMapping
@Secured(parser = NamingResourceParser.class, action = ActionTypes.WRITE)
public String register(HttpServletRequest request) throws Exception {
// 从request, 获取namespaceId
final String namespaceId = WebUtils
.optional(request, CommonParams.NAMESPACE_ID, Constants.DEFAULT_NAMESPACE_ID);
// 获取服务名称 groupName@@serviceName
final String serviceName = WebUtils.required(request, CommonParams.SERVICE_NAME);
NamingUtils.checkServiceNameFormat(serviceName);
// 把request中的参数封装成 Instance(实例) 对象
final Instance instance = parseInstance(request);
// 注册实例
serviceManager.registerInstance(namespaceId, serviceName, instance);
return "ok";
}
public void registerInstance(String namespaceId, String serviceName, Instance instance) throws NacosException {
// 第一次就创建空的服务
createEmptyService(namespaceId, serviceName, instance.isEphemeral());
// 从注册表中,拿到service
Service service = getService(namespaceId, serviceName);
// 判断服务是否为null
if (service == null) {
throw new NacosException(NacosException.INVALID_PARAM,
"service not found, namespace: " + namespaceId + ", service: " + serviceName);
}
// 添加实例到 Service 当中
addInstance(namespaceId, serviceName, instance.isEphemeral(), instance);
}
// 初始化服务 createEmptyService
public void createEmptyService(String namespaceId, String serviceName, boolean local) throws NacosException {
createServiceIfAbsent(namespaceId, serviceName, local, null);
}
public void createServiceIfAbsent(String namespaceId, String serviceName, boolean local, Cluster cluster)
throws NacosException {
// 尝试从注册表中获取服务
Service service = getService(namespaceId, serviceName);
if (service == null) {
// 如果service为null, 说明该服务是第一次注册,创建新的service
Loggers.SRV_LOG.info("creating empty service {}:{}", namespaceId, serviceName);
service = new Service();
service.setName(serviceName);
service.setNamespaceId(namespaceId);
service.setGroupName(NamingUtils.getGroupName(serviceName));
// 现在验证服务。如果失败,将抛出异常
// 记录服务最后一次更新的时间
service.setLastModifiedMillis(System.currentTimeMillis());
service.recalculateChecksum();
if (cluster != null) {
cluster.setService(service);
service.getClusterMap().put(cluster.getName(), cluster);
}
service.validate();
// 创建服务完成之后,放入Map集合
putServiceAndInit(service);
if (!local) {
addOrReplaceService(service);
}
}
}
// 创建服务完成之后,把Service放入注册表
private void putServiceAndInit(Service service) throws NacosException {
// 把Service放入注册表
putService(service);
service = getService(service.getNamespaceId(), service.getName());
// 对Service初始化, 对Service进行健康检测
service.init();
// 服务状态变更的监听
consistencyService
.listen(KeyBuilder.buildInstanceListKey(service.getNamespaceId(), service.getName(), true), service);
consistencyService
.listen(KeyBuilder.buildInstanceListKey(service.getNamespaceId(), service.getName(), false), service);
Loggers.SRV_LOG.info("[NEW-SERVICE] {}", service.toJson());
}
public void addInstance(String namespaceId, String serviceName, boolean ephemeral, Instance... ips)
throws NacosException {
// 给当前服务生成一个唯一标识serviceId
String key = KeyBuilder.buildInstanceListKey(namespaceId, serviceName, ephemeral);
// 从注册表中获取 Service
Service service = getService(namespaceId, serviceName);
// 以service为锁对象,同一个服务的多个实例只能串行来完成注册
synchronized (service) {
// 拷贝注册表中旧的实例列表,然后结合新的注册实例,得到最终的实例列表
List<Instance> instanceList = addIpAddresses(service, ephemeral, ips);
// 封装实例列表到 Instances 对象当中
Instances instances = new Instances();
instances.setInstanceList(instanceList);
// 更新注册表 (更新本地注册表,同步到Nacos集群中的其它节点)
consistencyService.put(key, instances);
}
}
@Override
public void put(String key, Record value) throws NacosException {
mapConsistencyService(key).put(key, value);
}
private ConsistencyService mapConsistencyService(String key) {
return KeyBuilder.matchEphemeralKey(key) ? ephemeralConsistencyService : persistentConsistencyService;
}
@PostConstruct
public void init() {
// 利用线程池,执行 notifier
GlobalExecutor.submitDistroNotifyTask(notifier);
}
@Override
public void put(String key, Record value) throws NacosException {
// 异步,更新本地注册表
onPut(key, value);
// 异步,将数据同步给Nacos集群当中的其它节点
distroProtocol.sync(new DistroKey(key, KeyBuilder.INSTANCE_LIST_KEY_PREFIX), DataOperation.CHANGE,
globalConfig.getTaskDispatchPeriod() / 2);
}
public void onPut(String key, Record value) {
// 判断是否是临时实例
if (KeyBuilder.matchEphemeralInstanceListKey(key)) {
// 封装实例列表到Datum
Datum<Instances> datum = new Datum<>();
// value是服务中的实例列表Instances
datum.value = (Instances) value;
// key是服务中的唯一标识
datum.key = key;
// 记录最近一次更新
datum.timestamp.incrementAndGet();
// 以Instances为key,以Datum为值,缓存起来
dataStore.put(key, datum);
}
if (!listeners.containsKey(key)) {
return;
}
// 把serviceId 和 当前的操作列表存入notifier
notifier.addTask(key, DataOperation.CHANGE);
}
public void addTask(String datumKey, DataOperation action) {
if (services.containsKey(datumKey) && action == DataOperation.CHANGE) {
return;
}
if (action == DataOperation.CHANGE) {
services.put(datumKey, StringUtils.EMPTY);
}
// 把serviceId 和 CHANGE事件 放入阻塞队列
tasks.offer(Pair.with(datumKey, action));
}
@Override
public void run() {
Loggers.DISTRO.info("distro notifier started");
for (; ; ) {
try {
// 从阻塞队列中获取任务
Pair<String, DataOperation> pair = tasks.take();
// 执行更新服务列表
handle(pair);
} catch (Throwable e) {
Loggers.DISTRO.error("[NACOS-DISTRO] Error while handling notifying task", e);
}
}
}
public void sync(DistroKey distroKey, DataOperation action, long delay) {
// 获取Nacos集群中的所有成员,除了我自己
for (Member each : memberManager.allMembersWithoutSelf()) {
DistroKey distroKeyWithTarget = new DistroKey(distroKey.getResourceKey(), distroKey.getResourceType(),
each.getAddress());
DistroDelayTask distroDelayTask = new DistroDelayTask(distroKeyWithTarget, action, delay);
distroTaskEngineHolder.getDelayTaskExecuteEngine().addTask(distroKeyWithTarget, distroDelayTask);
if (Loggers.DISTRO.isDebugEnabled()) {
Loggers.DISTRO.debug("[DISTRO-SCHEDULE] {} to {}", distroKey, each.getAddress());
}
}
}
Nacos如何避免并发读写冲突问题?
问题说明:考察对Nacos源码的掌握情况
难易程度:⭐⭐⭐⭐⭐
参考话术:
Nacos在更新实例列表时,会采用CopyOnWrite技术,首先将旧的实例列表拷贝一份,然后更新拷贝的实例列表,再用更新后的实例列表来覆盖旧的实例列表。
这样在更新的过程中,更新的是拷贝出来的实例列表,就不会对读实例列表的请求产生影响,也不会出现脏读问题了。
为了避免并发读写冲突问题,Nacos实现了如下几个方面的机制:
- Nacos使用了高性能的分布式存储RocksDB,它具备多版本并发控制(MVCC)和事务机制,能够有效地避免并发读写冲突,保证数据的一致性和完整性。
- Nacos的分布式锁机制,能够防止多个客户端同时修改同一个数据,避免数据的并发写入问题。Nacos采用的是ZooKeeper或者Redis作为分布式锁的实现。
- Nacos的实例注册和注销过程中,使用了乐观锁机制。当多个客户端对同一个服务实例做注册、注销或者心跳操作时,Nacos会使用版本号来控制并发操作冲突,保证多个操作不会互相影响。
- 集群内部的通信采用了基于Netty实现的异步非阻塞通信方式,避免了由于过多的阻塞导致的并发问题,使得并发读写能够更高效地完成。
@Override
public void run() {
Loggers.DISTRO.info("distro notifier started");
for (; ; ) {
try {
// 从阻塞队列中获取任务
Pair<String, DataOperation> pair = tasks.take();
// 执行更新服务列表
handle(pair);
} catch (Throwable e) {
Loggers.DISTRO.error("[NACOS-DISTRO] Error while handling notifying task", e);
}
}
}
private void handle(Pair<String, DataOperation> pair) {
try {
// 获取serviceId
String datumKey = pair.getValue0();
// 事件类型, CHANGE类型
DataOperation action = pair.getValue1();
services.remove(datumKey);
int count = 0;
if (!listeners.containsKey(datumKey)) {
return;
}
for (RecordListener listener : listeners.get(datumKey)) {
count++;
try {
if (action == DataOperation.CHANGE) {
// 这里的 listener 就是 service, 当服务触发时,就触发了 onChange 事件,处理变更
listener.onChange(datumKey, dataStore.get(datumKey).value);
continue;
}
if (action == DataOperation.DELETE) {
listener.onDelete(datumKey);
continue;
}
} catch (Throwable e) {
Loggers.DISTRO.error("[NACOS-DISTRO] error while notifying listener of key: {}", datumKey, e);
}
}
if (Loggers.DISTRO.isDebugEnabled()) {
Loggers.DISTRO
.debug("[NACOS-DISTRO] datum change notified, key: {}, listener count: {}, action: {}",
datumKey, count, action.name());
}
} catch (Throwable e) {
Loggers.DISTRO.error("[NACOS-DISTRO] Error while handling notifying task", e);
}
}
@Override
public void onChange(String key, Instances value) throws Exception {
Loggers.SRV_LOG.info("[NACOS-RAFT] datum is changed, key: {}, value: {}", key, value);
for (Instance instance : value.getInstanceList()) {
if (instance == null) {
// Reject this abnormal instance list:
throw new RuntimeException("got null instance " + key);
}
if (instance.getWeight() > 10000.0D) {
instance.setWeight(10000.0D);
}
if (instance.getWeight() < 0.01D && instance.getWeight() > 0.0D) {
instance.setWeight(0.01D);
}
}
// 更新实例列表
updateIPs(value.getInstanceList(), KeyBuilder.matchEphemeralInstanceListKey(key));
recalculateChecksum();
}
public void addInstance(String namespaceId, String serviceName, boolean ephemeral, Instance... ips)
throws NacosException {
// 给当前服务生成一个唯一标识serviceId
String key = KeyBuilder.buildInstanceListKey(namespaceId, serviceName, ephemeral);
// 从注册表中获取 Service
Service service = getService(namespaceId, serviceName);
// 以service为锁对象,同一个服务的多个实例只能串行来完成注册
synchronized (service) {
// 拷贝注册表中旧的实例列表,然后结合新的注册实例,得到最终的实例列表
List<Instance> instanceList = addIpAddresses(service, ephemeral, ips);
// 封装实例列表到 Instances 对象当中
Instances instances = new Instances();
instances.setInstanceList(instanceList);
// 更新注册表 (更新本地注册表,同步到Nacos集群中的其它节点)
consistencyService.put(key, instances);
}
}
private List<Instance> addIpAddresses(Service service, boolean ephemeral, Instance... ips) throws NacosException {
return updateIpAddresses(service, UtilsAndCommons.UPDATE_INSTANCE_ACTION_ADD, ephemeral, ips);
}
public List<Instance> updateIpAddresses(Service service, String action, boolean ephemeral, Instance... ips)
throws NacosException {
// 从 DataStore 获取实例列表 (从 nacos 集群同步来的实例列表)
Datum datum = consistencyService
.get(KeyBuilder.buildInstanceListKey(service.getNamespaceId(), service.getName(), ephemeral));
// 从本地注册表中,获取实例列表
List<Instance> currentIPs = service.allIPs(ephemeral);
Map<String, Instance> currentInstances = new HashMap<>(currentIPs.size());
Set<String> currentInstanceIds = Sets.newHashSet();
for (Instance instance : currentIPs) {
currentInstances.put(instance.toIpAddr(), instance);
currentInstanceIds.add(instance.getInstanceId());
}
// 合并并拷贝 旧实例列表
Map<String, Instance> instanceMap;
if (datum != null && null != datum.value) {
// 如果集群列表中有数据,则将本地注册表与datum中的列表进行合并
instanceMap = setValid(((Instances) datum.value).getInstanceList(), currentInstances);
} else {
instanceMap = new HashMap<>(ips.length);
}
// 遍历新实例列表
for (Instance instance : ips) {
if (!service.getClusterMap().containsKey(instance.getClusterName())) {
Cluster cluster = new Cluster(instance.getClusterName(), service);
cluster.init();
service.getClusterMap().put(instance.getClusterName(), cluster);
Loggers.SRV_LOG
.warn("cluster: {} not found, ip: {}, will create new cluster with default configuration.",
instance.getClusterName(), instance.toJson());
}
if (UtilsAndCommons.UPDATE_INSTANCE_ACTION_REMOVE.equals(action)) {
instanceMap.remove(instance.getDatumKey());
} else {
// 尝试获取获取与当前ip、端口一致的旧实例
Instance oldInstance = instanceMap.get(instance.getDatumKey());
if (oldInstance != null) {
// 如果存在,则用旧的 instanceId 赋值 新的 instanceId
instance.setInstanceId(oldInstance.getInstanceId());
} else {
// 如果不存在,证明是一个全新实例,重新生成id
instance.setInstanceId(instance.generateInstanceId(currentInstanceIds));
}
instanceMap.put(instance.getDatumKey(), instance);
}
}
if (instanceMap.size() <= 0 && UtilsAndCommons.UPDATE_INSTANCE_ACTION_ADD.equals(action)) {
throw new IllegalArgumentException(
"ip list can not be empty, service: " + service.getName() + ", ip list: " + JacksonUtils
.toJson(instanceMap.values()));
}
// 返回实例列表
return new ArrayList<>(instanceMap.values());
}
public void onChange(String key, Instances value) throws Exception {
Loggers.SRV_LOG.info("[NACOS-RAFT] datum is changed, key: {}, value: {}", key, value);
// 对权重做初始化
for (Instance instance : value.getInstanceList()) {
if (instance == null) {
// Reject this abnormal instance list:
throw new RuntimeException("got null instance " + key);
}
if (instance.getWeight() > 10000.0D) {
instance.setWeight(10000.0D);
}
if (instance.getWeight() < 0.01D && instance.getWeight() > 0.0D) {
instance.setWeight(0.01D);
}
}
// 更新实例列表
updateIPs(value.getInstanceList(), KeyBuilder.matchEphemeralInstanceListKey(key));
recalculateChecksum();
}
public void updateIps(List<Instance> ips, boolean ephemeral) {
// 先等到旧的实例列表
Set<Instance> toUpdateInstances = ephemeral ? ephemeralInstances : persistentInstances;
HashMap<String, Instance> oldIpMap = new HashMap<>(toUpdateInstances.size());
for (Instance ip : toUpdateInstances) {
oldIpMap.put(ip.getDatumKey(), ip);
}
// ips中包含两部分:新增实例,待更新的实例
// 等到待更新的实例
List<Instance> updatedIPs = updatedIps(ips, oldIpMap.values());
if (updatedIPs.size() > 0) {
for (Instance ip : updatedIPs) {
Instance oldIP = oldIpMap.get(ip.getDatumKey());
// do not update the ip validation status of updated ips
// because the checker has the most precise result
// Only when ip is not marked, don't we update the health status of IP:
if (!ip.isMarked()) {
// 将实例的health保持为oldInstance的health
ip.setHealthy(oldIP.isHealthy());
}
if (ip.isHealthy() != oldIP.isHealthy()) {
// ip validation status updated
Loggers.EVT_LOG.info("{} {SYNC} IP-{} {}:{}@{}", getService().getName(),
(ip.isHealthy() ? "ENABLED" : "DISABLED"), ip.getIp(), ip.getPort(), getName());
}
if (ip.getWeight() != oldIP.getWeight()) {
// ip validation status updated
Loggers.EVT_LOG.info("{} {SYNC} {IP-UPDATED} {}->{}", getService().getName(), oldIP.toString(),
ip.toString());
}
}
}
List<Instance> newIPs = subtract(ips, oldIpMap.values());
if (newIPs.size() > 0) {
Loggers.EVT_LOG
.info("{} {SYNC} {IP-NEW} cluster: {}, new ips size: {}, content: {}", getService().getName(),
getName(), newIPs.size(), newIPs.toString());
for (Instance ip : newIPs) {
HealthCheckStatus.reset(ip);
}
}
List<Instance> deadIPs = subtract(oldIpMap.values(), ips);
if (deadIPs.size() > 0) {
Loggers.EVT_LOG
.info("{} {SYNC} {IP-DEAD} cluster: {}, dead ips size: {}, content: {}", getService().getName(),
getName(), deadIPs.size(), deadIPs.toString());
for (Instance ip : deadIPs) {
HealthCheckStatus.remv(ip);
}
}
toUpdateInstances = new HashSet<>(ips);
// 用新实例列表覆盖了Cluster 中旧的实例列表
if (ephemeral) {
ephemeralInstances = toUpdateInstances;
} else {
persistentInstances = toUpdateInstances;
}
}
Nacos与Eureka的区别有那些?
问题说明:考察对Nacos、Eureka的底层实现的掌握情况
难易程度:⭐⭐⭐⭐⭐
参考话术:
Nacos与Eureka有相同点,也有不同之处,可以从以下几点来描述:
- 接口方式:Nacos与Eureka都对外暴露了Rest风格的API接口,用来实现服务注册、发现等功能
- 实例类型:Nacos的实例有永久和临时实例之分;而Eureka只支持临时实例
- 健康检测:Nacos对临时实例采用心跳模式检测,对永久实例采用主动请求来检测;Eureka只支持心跳模式
- 服务发现:Nacos支持定时拉取和订阅推送两种模式;Eureka只支持定时拉取模式
- 数据存储方式:Nacos支持多种数据存储模式,包括MySQL、Oracle等多种关系型数据库和本地文件等多种方式,这样可以满足不同场景下的需求。而Eureka只支持内存存储,所以在大规模集群中,数据的存储能力会受到限制。
- 服务注册和发现:Nacos通过客户端和服务端的相互注册和发现实现服务的管理。在客户端上,通过向Nacos注册中心上报服务IP、端口、实例ID、健康状态等信息实现注册。在服务端上,通过Nacos注册中心对所有服务实例进行管理。而Eureka只是一种服务发现机制,服务都是注册在Eureka Server上,客户端只需从Eureka Server获取服务实例的列表,并与服务实例进行通信。
- 高可用和负载均衡的实现:Nacos支持集群高可用,可以通过多个Nacos Server节点实现。同时,Nacos还支持多种方式的负载均衡算法,可以实现流量的均衡分配。而Eureka没有内置的负载均衡算法,需要在客户端自己实现。
- 动态配置管理:Nacos支持动态配置管理,可以动态管理不同环境下的配置和规则信息。而Eureka并不提供配置中心的功能,需要结合其他组件来实现。
Sentinel的线程隔离与Hystix的线程隔离有什么差别?
问题说明:考察对线程隔离方案的掌握情况
难易程度:⭐⭐⭐⭐⭐
参考话术:
Hystix默认是基于线程池实现的线程隔离,每一个被隔离的业务都要创建一个独立的线程池,线程过多会带来额外的CPU开销,性能一般,但是隔离性更强。
Sentinel是基于信号量(计数器)实现的线程隔离,不用创建线程池,性能较好,但是隔离性一般。
Sentinel和Hystrix都使用了基于线程隔离的机制来实现服务降级、隔离和限流等功能。但是,在具体实现方式上,两者存在一些差别。
首先,Hystrix的线程隔离是通过使用不同的线程池来实现的,即为每个HystrixCommand创建一个独立的线程池来执行。这样可以避免由于某个服务的执行时间过长而阻塞了整个线程池的问题。Hystrix的线程隔离机制还支持按照请求上下文对请求进行分组,从而达到隔离和限流的目的。
而Sentinel则使用了一种名为SphU.entry()的机制来实现线程隔离。在这种机制下,每个服务都有一个对应的SphU对象,负责对该服务的请求进行隔离和限流控制。SphU通过获取当前线程的上下文信息来识别该服务的请求,并使用信号量来控制该服务请求的并发量。这种机制的好处是极大地简化了线程隔离的实现,并且更利于扩展和管理。
另外,Hystrix的线程隔离机制支持超时控制、熔断器机制等额外的特性,而Sentinel则提供了更多的运行时监控和管理功能,例如实时流量统计、热点参数限流、黑白名单控制等。
总的来说,虽然Sentinel和Hystrix的线程隔离机制存在一些差别,但都可以用于实现服务隔离、降级和限流等功能,在不同的场景下选择适合自己的框架可以更好地保障服务质量和可靠性。
Sentinel的限流与Gateway的限流有什么差别?
问题说明:考察对限流算法的掌握情况
难易程度:⭐⭐⭐⭐⭐
参考话术:
限流算法常见的有三种实现:滑动时间窗口、令牌桶算法、漏桶算法。Gateway则采用了基于Redis实现的令牌桶算法。
而Sentinel内部却比较复杂:
- 默认限流模式是基于滑动时间窗口算法
- 排队等待的限流模式则基于漏桶算法
- 而热点参数限流则是基于令牌桶算法
Sentinel内部采用的限流算法有以下几种:
- 基于固定窗口的计数器限流算法
这种算法很简单,就是基于时间窗口内的请求数量来判断是否达到限流阈值。如果超过设定的阈值,则对超过数量的请求进行拒绝或其他指定的动作。
- 基于滑动窗口的计数器限流算法
与斐波那契数列中斐波那契数的定义很相似,滑动窗口大小取决于时间序列之前的若干个窗口大小之和。它可以有效平滑突发流量,并更准确地预测窗口内的平均QPS。
- 基于令牌桶的限流算法
令牌桶算法是比较常见的一种限流算法,其本质就是一个桶,请求需要从桶中拿取令牌才能通过,当令牌不够时该请求会被拦截或等待。每个时间片会向桶中注入固定数量的令牌,如果桶满了则不再注入,当一次请求到来时,如果桶中没有可用的令牌则被拦截或等待。桶的大小和注入速率可以根据业务需求调整。
- 基于漏桶的限流算法
漏桶算法与令牌桶算法的基本思想相反,它用一个固定容量的漏桶来存储请求,每个请求到来时都要往漏桶中倒入一个请求的容量,如果漏桶容量满了,则新请求会被拒绝。漏桶算法的好处在于可以平滑处理短期突发流量,并保证了请求处理的稳定性。
Sentinel的限流算法实现了多种不同策略的算法,并可以自主选择适合自己业务场景的算法来实现限流和流量控制,从而更好地保护服务的稳定性和可靠性。
MQ篇
你们为什么选择了RabbitMQ而不是其它的MQ?
如图:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-qp5KYJjm-1684488108921)(assets/image-20210925220034702.png#id=KO1Fs&originalType=binary&ratio=1&rotation=0&showTitle=false&status=done&style=none&title=)]话术:
kafka是以吞吐量高而闻名,不过其数据稳定性一般,而且无法保证消息有序性。我们公司的日志收集也有使用,业务模块中则使用的RabbitMQ。
阿里巴巴的RocketMQ基于Kafka的原理,弥补了Kafka的缺点,继承了其高吞吐的优势,其客户端目前以Java为主。但是我们担心阿里巴巴开源产品的稳定性,所以就没有使用。
RabbitMQ基于面向并发的语言Erlang开发,吞吐量不如Kafka,但是对我们公司来讲够用了。而且消息可靠性较好,并且消息延迟极低,集群搭建比较方便。支持多种协议,并且有各种语言的客户端,比较灵活。Spring对RabbitMQ的支持也比较好,使用起来比较方便,比较符合我们公司的需求。
综合考虑我们公司的并发需求以及稳定性需求,我们选择了RabbitMQ。
- 开源免费,社区活跃:RabbitMQ 采用 Erlang 语言开发,支持多种开发语言,并且遵循 AMQP(高级消息队列协议)标准。它在企业和开源社区都受到广泛关注,具有强大的社区以及可靠的技术支持。
- 可靠性高:RabbitMQ 提供了多种机制来确保消息传递的可靠性,例如持久化、确认机制、事务等。这些机制可以帮助开发者在消息的传递过程中保证消息不会丢失,保证消息传递的可靠性。
- 扩展性好:RabbitMQ 支持集群和分布式部署,支持多种消息传输方式(点对点、发布订阅等),并且可以通过插件扩展新的功能。
- 生态丰富:RabbitMQ 在消息队列领域拥有广泛的应用场景,例如微服务、实时数据处理、消息中间件等。针对不同的需要,RabbitMQ 提供了丰富的生态工具和插件,例如 Spring AMQP、Celery 等。
RabbitMQ如何确保消息的不丢失?
话术:
RabbitMQ针对消息传递过程中可能发生问题的各个地方,给出了针对性的解决方案:
- 生产者发送消息时可能因为网络问题导致消息没有到达交换机:
- RabbitMQ提供了publisher confirm机制
- 生产者发送消息后,可以编写ConfirmCallback函数
- 消息成功到达交换机后,RabbitMQ会调用ConfirmCallback通知消息的发送者,返回ACK
- 消息如果未到达交换机,RabbitMQ也会调用ConfirmCallback通知消息的发送者,返回NACK
- 消息超时未发送成功也会抛出异常
- RabbitMQ提供了publisher confirm机制
- 消息到达交换机后,如果未能到达队列,也会导致消息丢失:
- RabbitMQ提供了publisher return机制
- 生产者可以定义ReturnCallback函数
- 消息到达交换机,未到达队列,RabbitMQ会调用ReturnCallback通知发送者,告知失败原因
- RabbitMQ提供了publisher return机制
- 消息到达队列后,MQ宕机也可能导致丢失消息:
- RabbitMQ提供了持久化功能,集群的主从备份功能
- 消息持久化,RabbitMQ会将交换机、队列、消息持久化到磁盘,宕机重启可以恢复消息
- 镜像集群,仲裁队列,都可以提供主从备份功能,主节点宕机,从节点会自动切换为主,数据依然在
- RabbitMQ提供了持久化功能,集群的主从备份功能
- 消息投递给消费者后,如果消费者处理不当,也可能导致消息丢失
- SpringAMQP基于RabbitMQ提供了消费者确认机制、消费者重试机制,消费者失败处理策略:
- 消费者的确认机制:
- 消费者处理消息成功,未出现异常时,Spring返回ACK给RabbitMQ,消息才被移除
- 消费者处理消息失败,抛出异常,宕机,Spring返回NACK或者不返回结果,消息不被异常
- 消费者重试机制:
- 默认情况下,消费者处理失败时,消息会再次回到MQ队列,然后投递给其它消费者。Spring提供的消费者重试机制,则是在处理失败后不返回NACK,而是直接在消费者本地重试。多次重试都失败后,则按照消费者失败处理策略来处理消息。避免了消息频繁入队带来的额外压力。
- 消费者失败策略:
- 当消费者多次本地重试失败时,消息默认会丢弃。
- Spring提供了Republish策略,在多次重试都失败,耗尽重试次数后,将消息重新投递给指定的异常交换机,并且会携带上异常栈信息,帮助定位问题。
- 消费者的确认机制:
- SpringAMQP基于RabbitMQ提供了消费者确认机制、消费者重试机制,消费者失败处理策略:
- 持久化:RabbitMQ 支持将消息持久化到磁盘中,以保证在 RabbitMQ 服务器启动或者出现其他故障时,消息不会丢失。可以通过将队列和消息都设置成持久化的方式来确保消息不会丢失。
- 确认机制:RabbitMQ 支持两种确认方式,分别为 publisher 确认和 consumer 确认。publisher 确认指的是消息发送者在消息发送到 RabbitMQ 之后,RabbitMQ 会返回一个确认消息,告诉发送者消息已正常接收。consumer 确认则是指消息接收者处理完消息之后,向 RabbitMQ 发送一个确认消息,告诉 RabbitMQ 消息已经被成功处理。通过确认机制,可以确保消息传递过程中不会丢失。
- 事务机制:RabbitMQ 支持基于 AMQP 事务机制,将多个消息发送操作组合成一个事务进行提交或回滚。使用事务机制可以确保在发送和接收消息过程中,若出现意外情况,可以进行消息回滚,避免消息丢失。
- 备份队列:通过备份队列机制,将消息同时发送到备份队列中,以备消息在主队列中丢失时可以从备份队列中还原。
RabbitMQ如何避免消息堆积?
话术:
消息堆积问题产生的原因往往是因为消息发送的速度超过了消费者消息处理的速度。因此解决方案无外乎以下三点:
- 提高消费者处理速度
- 增加更多消费者
- 增加队列消息存储上限
1)提高消费者处理速度
消费者处理速度是由业务代码决定的,所以我们能做的事情包括:
- 尽可能优化业务代码,提高业务性能
- 接收到消息后,开启线程池,并发处理多个消息
优点:成本低,改改代码即可
缺点:开启线程池会带来额外的性能开销,对于高频、低时延的任务不合适。推荐任务执行周期较长的业务。
2)增加更多消费者
一个队列绑定多个消费者,共同争抢任务,自然可以提供消息处理的速度。
优点:能用钱解决的问题都不是问题。实现简单粗暴
缺点:问题是没有钱。成本太高
3)增加队列消息存储上限
在RabbitMQ的1.8版本后,加入了新的队列模式:Lazy Queue
这种队列不会将消息保存在内存中,而是在收到消息后直接写入磁盘中,理论上没有存储上限。可以解决消息堆积问题。
优点:磁盘存储更安全;存储无上限;避免内存存储带来的Page Out问题,性能更稳定;
缺点:磁盘存储受到IO性能的限制,消息时效性不如内存模式,但影响不大。
RabbitMQ在消息堆积的情况下,可能会出现消费者无法正常消费消息的问题,这可能会导致RabbitMQ服务器宕机甚至数据丢失等问题。为了避免消息堆积问题,RabbitMQ提供了一些解决方案。
第一种方案 是设置message TTL(Time To Live),这是一种消息超时机制,设置消息的生命周期,在该时间之后如果消息还未被消费,就会被删除。这可以保证不会无限制地积累消息,避免消息堆积问题。另外,RabbitMQ还提供了队列的过期时间,如果队列在指定的时间内没有被消费或没有新消息产生,就会自动删除。
第二种方案 是设置队列的长度限制。当队列达到一定的长度限制时,新的消息就会被拒绝,防止队列无限增长。可以配置队列最大可容纳消息数量,或者设置为限流模式,即在队列达到限制时拒绝新消息并返回异常信息。
第三种方案 是使用优先级队列,设置消息的优先级。高优先级的消息会被优先处理,避免低优先级的消息长时间阻塞队列。这对于实时性要求高的系统可以有效地避免消息堆积问题。
RabbitMQ如何保证消息的有序性?
话术:
其实RabbitMQ是队列存储,天然具备先进先出的特点,只要消息的发送是有序的,那么理论上接收也是有序的。不过当一个队列绑定了多个消费者时,可能出现消息轮询投递给消费者的情况,而消费者的处理顺序就无法保证了。
因此,要保证消息的有序性,需要做的下面几点:
- 保证消息发送的有序性
- 保证一组有序的消息都发送到同一个队列
- 保证一个队列只包含一个消费者
在 RabbitMQ 中,消息是以队列的形式存储在 Broker 中的,因此,可以使用一个队列来实现有序性。也就是说,如果想要确保消息的有序性,可以将相关的消息放入同一个队列中,在此过程中需要注意以下几点:
- Producer 编写消息时,可以使用单线程模型,避免并发操作,保证消息的顺序性。
- 避免使用不同速率的 Consumer。如果多个 Consumer 从同一个队列订阅消息,那么必须确保它们的速率是一致的,这样才能保证消息的顺序性。
- 在 Consumer 端消费消息时,可以通过手动 Acknowledgment 的方式,即等待上一个消息被 Acknowledged 后再 Acknowledged 下一个消息,以确保消息按照顺序进行处理。
如何防止MQ消息被重复消费?
话术:
消息重复消费的原因多种多样,不可避免。所以只能从消费者端入手,只要能保证消息处理的幂等性就可以确保消息不被重复消费。
而幂等性的保证又有很多方案:
- 给每一条消息都添加一个唯一id,在本地记录消息表及消息状态,处理消息时基于数据库表的id唯一性做判断
- 同样是记录消息表,利用消息状态字段实现基于乐观锁的判断,保证幂等
- 基于业务本身的幂等性。比如根据id的删除、查询业务天生幂等;新增、修改等业务可以考虑基于数据库id唯一性、或者乐观锁机制确保幂等。本质与消息表方案类似。
- 消息去重
可以通过业务数据的唯一性,对已经处理过的消息进行去重。在消费消息时,将消息对应的唯一标识与已经处理的消息进行比对,如果已经处理,则直接跳过,不再重复消费。常见的去重方式有使用数据库或Redis等存储服务,记录消息的处理状态。 - 使用幂等性
幂等性是指多次执行相同操作所产生的影响与一次执行的影响相同。在消息处理过程中,可以设计幂等性检查并保证处理幂等,避免同一条消息被重复处理。常见的幂等性实现方式有使用数据库的唯一性约束或者通过在消息体中添加唯一标识的方式。 - 使用消息消费确认机制
RabbitMQ 提供了消息确认机制,即每当消费者消费到一条消息时,会返回一个确认信号,告诉服务器该消息已经被消费。如果消费异常或者未能确认消息,那么 RabbitMQ 就会重新将消息推送给其他消费者进行消费。这样做可以保证消息不会被重复消费。
如何保证RabbitMQ的高可用?
话术:
要实现RabbitMQ的高可用无外乎下面两点:
- 做好交换机、队列、消息的持久化
- 搭建RabbitMQ的镜像集群,做好主从备份。当然也可以使用仲裁队列代替镜像集群。
要保证 RabbitMQ 的高可用,可以通过以下几个方面来实现:
- 集群化:RabbitMQ 提供了集群化的解决方案,可以将多个 RabbitMQ 实例组成一个集群,实现负载均衡和高可用。在集群模式下,RabbitMQ 的队列和交换器会在多个实例之间共享,如果某个实例出现故障,其他实例可以接管其服务。
- 持久化:RabbitMQ 可以将消息持久化到磁盘中,使得即使在出现故障的情况下,消息也能够得到保存,不会丢失。
- 备份队列:RabbitMQ 提供了备份队列的功能,可以将队列的消息同步到备份队列中,在主队列出现故障时,备份队列可以接管其服务,避免消息丢失。
- 心跳机制:RabbitMQ 提供了心跳机制,当客户端与 RabbitMQ 的连接处于空闲状态时,会发送心跳包,以保证连接的有效性。
- 负载均衡:通过负载均衡的方式,可以将请求均匀分配到多个 RabbitMQ 实例上,避免单点故障。
- 备份节点:可以在集群中添加备份节点,在主节点出现故障时,备份节点可以接管其服务,确保 RabbitMQ 的高可用。
使用MQ可以解决那些问题?
话术:
RabbitMQ能解决的问题很多,例如:
- 解耦合:将几个业务关联的微服务调用修改为基于MQ的异步通知,可以解除微服务之间的业务耦合。同时还提高了业务性能。
- 流量削峰:将突发的业务请求放入MQ中,作为缓冲区。后端的业务根据自己的处理能力从MQ中获取消息,逐个处理任务。流量曲线变的平滑很多
- 延迟队列:基于RabbitMQ的死信队列或者DelayExchange插件,可以实现消息发送后,延迟接收的效果。
使用 MQ(Message Queue)可以解决以下几个问题:
- 异步通信:MQ 可以实现消息发送和接收的异步通信,发送端无需等待接收端的响应就可以继续处理其他任务,从而提高系统的性能和并发能力。
- 异构系统集成:MQ 可以作为一个中间件,实现不同系统之间的数据通信,类似于解耦器的作用。这样即使系统改变,也不会影响整个集成进程的正常运行。
- 流量控制和负载均衡:当系统处理的请求增加,处理速度跟不上的时候,MQ 可以用来做流量控制和负载均衡,让请求均匀分发到多个消费者节点上,避免单点故障。
- 反压机制: 如果消费端的处理速度跟不上生产端的消息发送速度,MQ 可以提供反压机制,即让生产者在发送消息的时候等待消费者处理完之后再发送下一步消息,从而避免消息积压导致系统崩溃。
- 可靠性保证:MQ 可以提供消息的可靠传输和处理,通过消息的持久化、确认机制、事务机制等方式,确保消息不会不丢失、不重复和不乱序。
- 业务解耦:MQ 可以将不同业务之间的逻辑解耦,从而实现消息的独立处理,提高系统的可扩展性和可维护性。
Redis篇
Redis与Memcache的区别?
redis支持更丰富的数据类型
(支持更复杂的应用场景):Redis不仅仅支持简单的k/v类型的数据,同时还提供list,set,zset,hash等数据结构的存储。memcache支持简单的数据类型,String。Redis支持数据的持久化
,可以将内存中的数据保持在磁盘中,重启的时候可以再次加载进行使用,而Memecache把数据全部存在内存之中。集群模式
:memcached没有原生的集群模式,需要依靠客户端来实现往集群中分片写入数据;但是 redis 目前是原生支持 cluster 模式的Redis使用单线程
:Memcached是多线程,非阻塞IO复用的网络模型;Redis使用单线程的多路 IO 复用模型。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-14rJGTMD-1684488108922)(assets/1574821356723.png#id=rpZpf&originalType=binary&ratio=1&rotation=0&showTitle=false&status=done&style=none&title=)]Redis 和 Memcache 都是内存缓存系统,可以加速应用程序的访问速度,但是它们之间还是有些明显的区别:
- 数据类型支持:Redis 支持更多的数据类型(string(字符串)、hash(哈希)、list(列表)、set(集合)、sorted set(有序集合)),而 Memcache 只支持字符串(string)类型数据。
- 存储数据位置: Redis 可以将数据存储到磁盘上,可以持久化保存数据,而 Memcache 只将数据保存在内存中,需要定期备份数据到磁盘上,否则重启服务器后数据会丢失。
- 最大对象存储大小(object size): Redis 最大对象存储大小为 512MB,而 Memcache 最大对象存储大小为 1MB。
- 性能: Memcache 专注于高性能,其原理是使用尽可能少的代码来提取数据,而 Redis 则更加注重的是功能的丰富和实现的灵活性。
- 支持分布式部署:两者都能够进行分布式部署,但 Redis 提供的分布式方案更多而灵活,可以支持 sharding 和 replication 等多种方式。
Redis的单线程问题
Redis采用单线程,如何保证高并发?
面试话术:
Redis快的主要原因是:
- 完全基于内存
- 数据结构简单,对数据操作也简单
- 使用多路 I/O 复用模型,充分利用CPU资源
虽然 Redis 是单线程的,但它通过 IO 多路复用技术以及内存高速访问等优化方式,使得 Redis 在高并发场景下依然能够表现良好。这些优化方式包括:
- 将数据存储在内存中,读写速度快,不需要频繁的磁盘 IO 操作,这大大提高了 Redis 的性能。
- 使用异步 I/O,Redis 采用 epoll 实现了网络模型,使得 I/O 处理效率非常高,使得单线程能够处理大量并发请求。
- Redis 采用了非阻塞的网络 IO 操作,客户端发来的请求都先存放到内存中,然后由 Redis 的主线程依次进行处理,这样就避免了因为阻塞 IO 操作而导致的线程切换和上下文切换开销,提高了并发处理能力。
- Redis 通过实现了多种数据结构(如 Hash、List、Set、Sorted set 等)的一系列高效操作指令,进一步提高了 Redis 的查询效率。
这样做的好处是什么?
面试话术:
单线程优势有下面几点:
- 代码更清晰,处理逻辑更简单
- 不用去考虑各种锁的问题,不存在加锁释放锁操作,没有因为锁而导致的性能消耗
- 不存在多进程或者多线程导致的CPU切换,充分利用CPU资源
Redis 之所以采用单线程的架构,主要是为了解决多线程并发问题带来的复杂性、性能损失和可靠性问题。采用单线程架构,可以带来以下好处:
- 节省了线程切换和上下文切换带来的开销,避免了线程间的竞争和锁定等问题,从而大幅提高了 Redis 的处理能力和效率。
- Redis 的单线程能够处理数百万的并发请求,而且还能保持稳定的响应速度,使得 Redis 在高并发场景下拥有出色的性能表现。
- 采用单线程架构可以使 Redis 的代码更清晰简洁,更容易维护和扩展,同时可以大幅减少程序中的并发问题和死锁问题,这使得 Redis 更加可靠和稳定。
- 采用单线程的架构也可以带来更广泛的兼容性,因为单线程的程序可以在任何操作系统和硬件平台上运行,几乎不存在兼容性和依赖问题,这可以让 Redis 更加具有通用性。
Redis的持久化方案由哪些?
相关资料:
1)RDB 持久化
RDB持久化可以使用save或bgsave,为了不阻塞主进程业务,一般都使用bgsave,流程:
- Redis 进程会 fork 出一个子进程(与父进程内存数据一致)。
- 父进程继续处理客户端请求命令
- 由子进程将内存中的所有数据写入到一个临时的 RDB 文件中。
- 完成写入操作之后,旧的 RDB 文件会被新的 RDB 文件替换掉。
下面是一些和 RDB 持久化相关的配置:
save 60 10000
:如果在 60 秒内有 10000 个 key 发生改变,那就执行 RDB 持久化。stop-writes-on-bgsave-error yes
:如果 Redis 执行 RDB 持久化失败(常见于操作系统内存不足),那么 Redis 将不再接受 client 写入数据的请求。rdbcompression yes
:当生成 RDB 文件时,同时进行压缩。dbfilename dump.rdb
:将 RDB 文件命名为 dump.rdb。dir /var/lib/redis
:将 RDB 文件保存在/var/lib/redis
目录下。
当然在实践中,我们通常会将stop-writes-on-bgsave-error
设置为false
,同时让监控系统在 Redis 执行 RDB 持久化失败时发送告警,以便人工介入解决,而不是粗暴地拒绝 client 的写入请求。
RDB持久化的优点:
- RDB持久化文件小,Redis数据恢复时速度快
- 子进程不影响父进程,父进程可以持续处理客户端命令
- 子进程fork时采用copy-on-write方式,大多数情况下,没有太多的内存消耗,效率比较好。
RDB 持久化的缺点:
- 子进程fork时采用copy-on-write方式,如果Redis此时写操作较多,可能导致额外的内存占用,甚至内存溢出
- RDB文件压缩会减小文件体积,但通过时会对CPU有额外的消耗
- 如果业务场景很看重数据的持久性 (durability),那么不应该采用 RDB 持久化。譬如说,如果 Redis 每 5 分钟执行一次 RDB 持久化,要是 Redis 意外奔溃了,那么最多会丢失 5 分钟的数据。
2)AOF 持久化
可以使用appendonly yes
配置项来开启 AOF 持久化。Redis 执行 AOF 持久化时,会将接收到的写命令追加到 AOF 文件的末尾,因此 Redis 只要对 AOF 文件中的命令进行回放,就可以将数据库还原到原先的状态。
与 RDB 持久化相比,AOF 持久化的一个明显优势就是,它可以提高数据的持久性 (durability)。因为在 AOF 模式下,Redis 每次接收到 client 的写命令,就会将命令write()
到 AOF 文件末尾。
然而,在 Linux 中,将数据write()
到文件后,数据并不会立即刷新到磁盘,而会先暂存在 OS 的文件系统缓冲区。在合适的时机,OS 才会将缓冲区的数据刷新到磁盘(如果需要将文件内容刷新到磁盘,可以调用fsync()
或fdatasync()
)。
通过appendfsync
配置项,可以控制 Redis 将命令同步到磁盘的频率:
always
:每次 Redis 将命令write()
到 AOF 文件时,都会调用fsync()
,将命令刷新到磁盘。这可以保证最好的数据持久性,但却会给系统带来极大的开销。no
:Redis 只将命令write()
到 AOF 文件。这会让 OS 决定何时将命令刷新到磁盘。everysec
:除了将命令write()
到 AOF 文件,Redis 还会每秒执行一次fsync()
。在实践中,推荐使用这种设置,一定程度上可以保证数据持久性,又不会明显降低 Redis 性能。
然而,AOF 持久化并不是没有缺点的:Redis 会不断将接收到的写命令追加到 AOF 文件中,导致 AOF 文件越来越大。过大的 AOF 文件会消耗磁盘空间,并且导致 Redis 重启时更加缓慢。为了解决这个问题,在适当情况下,Redis 会对 AOF 文件进行重写,去除文件中冗余的命令,以减小 AOF 文件的体积。在重写 AOF 文件期间, Redis 会启动一个子进程,由子进程负责对 AOF 文件进行重写。
可以通过下面两个配置项,控制 Redis 重写 AOF 文件的频率:
auto-aof-rewrite-min-size 64mb
auto-aof-rewrite-percentage 100
上面两个配置的作用:当 AOF 文件的体积大于 64MB,并且 AOF 文件的体积比上一次重写之后的体积大了至少一倍,那么 Redis 就会执行 AOF 重写。
优点:
- 持久化频率高,数据可靠性高
- 没有额外的内存或CPU消耗
缺点:
- 文件体积大
- 文件大导致服务数据恢复时效率较低
面试话术:
Redis 提供了两种数据持久化的方式,一种是 RDB,另一种是 AOF。默认情况下,Redis 使用的是 RDB 持久化。
RDB持久化文件体积较小,但是保存数据的频率一般较低,可靠性差,容易丢失数据。另外RDB写数据时会采用Fork函数拷贝主进程,可能有额外的内存消耗,文件压缩也会有额外的CPU消耗。
ROF持久化可以做到每秒钟持久化一次,可靠性高。但是持久化文件体积较大,导致数据恢复时读取文件时间较长,效率略低
Redis 提供了两种基本的持久化方案:
- RDB 持久化:RDB 持久化可以将 Redis 在内存中的数据集快照保存到磁盘上的一个 RDB 文件中,在内存快照成功创建之后,Redis 还可以对 RDB 文件进行备份、压缩和归档等工作,提高了 Redis 的数据可靠性和稳定性。可以手动执行 SAVE 或者 BGSAVE 命令,在指定目录下生成一个 RDB 文件,保存 Redis 当前时刻的数据。
- AOF 持久化:AOF 持久化可以将 Redis 的所有写操作序列化保存到磁盘上的一个 AOF 文件中,在 Redis 重启时,Redis 会读取 AOF 文件重建原始的数据集。AOF 持久化提供了更加精细的持久化控制,可以设置多种同步策略和持久化模式来控制数据的持久化时机和效率。可以通过在 redis.conf 文件中设置 appendonly yes 来启用 AOF 持久化。
这两种持久化方案各有优劣,并且可以混合使用,以实现更可靠、更稳定、更高效的 Redis 数据持久化。
Redis的集群方式有哪些?
面试话术:
Redis集群可以分为主从集群和分片集群两类。
主从集群一般一主多从,主库用来写数据,从库用来读数据。结合哨兵,可以在主库宕机时从新选主,目的是保证Redis的高可用。
分片集群是数据分片,我们会让多个Redis节点组成集群,并将16383个插槽分到不同的节点上。存储数据时利用对key做hash运算,得到插槽值后存储到对应的节点即可。因为存储数据面向的是插槽而非节点本身,因此可以做到集群动态伸缩。目的是让Redis能存储更多数据。
1)主从集群
主从集群,也是读写分离集群。一般都是一主多从方式。
Redis 的复制(replication)功能允许用户根据一个 Redis 服务器来创建任意多个该服务器的复制品,其中被复制的服务器为主服务器(master),而通过复制创建出来的服务器复制品则为从服务器(slave)。
只要主从服务器之间的网络连接正常,主从服务器两者会具有相同的数据,主服务器就会一直将发生在自己身上的数据更新同步 给从服务器,从而一直保证主从服务器的数据相同。
- 写数据时只能通过主节点完成
- 读数据可以从任何节点完成
- 如果配置了
哨兵节点
,当master宕机时,哨兵会从salve节点选出一个新的主。
主从集群分两种:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-k3Ge91SB-1684488108924)(assets/1574821993599.png#id=PIdlT&originalType=binary&ratio=1&rotation=0&showTitle=false&status=done&style=none&title=)] [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-2nYeZiLR-1684488108924)(assets/1574822026037.png#id=oWQSm&originalType=binary&ratio=1&rotation=0&showTitle=false&status=done&style=none&title=)]带有哨兵的集群:
2)分片集群
主从集群中,每个节点都要保存所有信息,容易形成木桶效应。并且当数据量较大时,单个机器无法满足需求。此时我们就要使用分片集群了。
集群特征:
- 每个节点都保存不同数据
- 所有的redis节点彼此互联(PING-PONG机制),内部使用二进制协议优化传输速度和带宽.
- 节点的fail是通过集群中超过半数的节点检测失效时才生效.
- 客户端与redis节点直连,不需要中间proxy层连接集群中任何一个可用节点都可以访问到数据
- redis-cluster把所有的物理节点映射到[0-16383] slot(插槽)上,实现动态伸缩
为了保证Redis中每个节点的高可用,我们还可以给每个节点创建replication(slave节点),如图:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-s1WsEkQN-1684488108927)(assets/1574822584357.png#id=U0q7y&originalType=binary&ratio=1&rotation=0&showTitle=false&status=done&style=none&title=)]出现故障时,主从可以及时切换:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-03bz8mPM-1684488108928)(assets/1574822602109.png#id=A2kl6&originalType=binary&ratio=1&rotation=0&showTitle=false&status=done&style=none&title=)]
Redis的集群方式主要有以下两种:
- Redis Cluster:Redis官方提供的分布式集群方案,用于在多个节点间分布数据。Redis Cluster可以自动将节点分片,并将数据分配到不同的节点中,从而实现了高可用性和横向扩展。同时Redis Cluster还支持自动故障转移,可以在出现故障的情况下自动将失效节点的数据迁移至其他节点。
- Redis Sentinel:Redis Sentinel是Redis的高可用性解决方案,可用于监控Redis节点和实现自动故障转移。Redis Sentinel可以监控多个Redis节点的状态,并在节点故障时自动将主节点的角色转移至备用节点,从而实现了长时间运行和高可用性。相比于Redis Cluster,Redis Sentinel的部署和维护要简单。
Redis的常用数据类型有哪些?
支持多种类型的数据结构,主要区别是value存储的数据格式不同:
- string:最基本的数据类型,二进制安全的字符串,最大512M。
- list:按照添加顺序保持顺序的字符串列表。
- set:无序的字符串集合,不存在重复的元素。
- sorted set:已排序的字符串集合。
- hash:key-value对格式
Redis支持以下5种常用数据类型:
- 字符串(String):Redis中的字符串是二进制安全的,可以存储任何数据类型,例如图片、序列化的对象等。字符串数据类型是Redis中最基本的数据类型,可以使用SET和GET命令分别进行存储和获取。
- 列表(List):Redis列表即为链表结构,可以利用列表存储多个字符串值。常用的命令有LPUSH(从列表左侧添加元素)、RPUSH(从列表右侧添加元素)、LPOP(弹出列表的左侧元素)和RPOP(弹出列表的右侧元素)等。
- 集合(Set):Redis中的集合可以存储不重复的字符串元素。常用的命令有SADD(向集合中添加元素)、SREM(从集合中移除元素)和SMEMBERS(获取所有元素)等。
- 散列表(Hash):Redis散列表即为键值对存储结构,可以存储多个键值对。常用的命令有HSET(设置键值对)、HGET(获取键值对)、HDEL(删除键值对)等。
- 有序集合(Sorted Set):Redis有序集合可以存储多个带有权重值的字符串元素。常用的命令有ZADD(添加带有权重的元素)、ZRANGE(获取元素)和ZREMRANGEBYSCORE(根据权重值删除元素)等。
聊一下Redis事务机制
相关资料:
参考:
Redis事务功能是通过MULTI、EXEC、DISCARD和WATCH 四个原语实现的。Redis会将一个事务中的所有命令序列化,然后按顺序执行。但是Redis事务不支持回滚操作,命令运行出错后,正确的命令会继续执行。
MULTI
: 用于开启一个事务,它总是返回OK。 MULTI执行之后,客户端可以继续向服务器发送任意多条命令,这些命令不会立即被执行,而是被放到一个待执行命令队列中EXEC
:按顺序执行命令队列内的所有命令。返回所有命令的返回值。事务执行过程中,Redis不会执行其它事务的命令。DISCARD
:清空命令队列,并放弃执行事务, 并且客户端会从事务状态中退出WATCH
:Redis的乐观锁机制,利用compare-and-set(CAS)原理,可以监控一个或多个键,一旦其中有一个键被修改,之后的事务就不会执
使用事务时可能会遇上以下两种错误:
- 执行 EXEC 之前,入队的命令可能会出错。比如说,命令可能会产生语法错误(参数数量错误,参数名错误,等等),或者其他更严重的错误,比如内存不足(如果服务器使用
maxmemory
设置了最大内存限制的话)。- Redis 2.6.5 开始,服务器会对命令入队失败的情况进行记录,并在客户端调用 EXEC 命令时,拒绝执行并自动放弃这个事务。
- 命令可能在 EXEC 调用之后失败。举个例子,事务中的命令可能处理了错误类型的键,比如将列表命令用在了字符串键上面,诸如此类。
- 即使事务中有某个/某些命令在执行时产生了错误, 事务中的其他命令仍然会继续执行,不会回滚。
为什么 Redis 不支持回滚(roll back)?
以下是这种做法的优点:
- Redis 命令只会因为错误的语法而失败(并且这些问题不能在入队时发现),或是命令用在了错误类型的键上面:这也就是说,从实用性的角度来说,失败的命令是由编程错误造成的,而这些错误应该在开发的过程中被发现,而不应该出现在生产环境中。
- 因为不需要对回滚进行支持,所以 Redis 的内部可以保持简单且快速。
鉴于没有任何机制能避免程序员自己造成的错误, 并且这类错误通常不会在生产环境中出现, 所以 Redis 选择了更简单、更快速的无回滚方式来处理事务。
面试话术:
Redis事务其实是把一系列Redis命令放入队列,然后批量执行,执行过程中不会有其它事务来打断。不过与关系型数据库的事务不同,Redis事务不支持回滚操作,事务中某个命令执行失败,其它命令依然会执行。
为了弥补不能回滚的问题,Redis会在事务入队时就检查命令,如果命令异常则会放弃整个事务。
因此,只要程序员编程是正确的,理论上说Redis会正确执行所有事务,无需回滚。
Redis的事务机制是指在多个操作被打包成一个单独的操作序列,然后一次性执行这个序列。在执行事务期间,所有的命令都不会被其他客户端的请求打断。
Redis事务机制的实现主要有以下两种方式:
- MULTI/EXEC/DISCARD 模式
MULTI和EXEC指令配对使用来表示一个事务的开始和结束,期间的所有指令将被打包成一个事务,并等待执行。如果EXEC在执行事务之前被取消,那么整个事务将被取消。DISCARD指令可以用来取消正在进行的事务。
- WATCH/MULTI/EXEC 模式
WATCH指令可以监控一个或多个键,一旦这些键的值被其他客户端修改,事务就会被取消。这种方式可以保证事务的原子性。与MULTI/EXEC相似,WATCH也需要与MULTI/EXEC配对使用来表示一个事务的开始和结束。
如果事务执行一半的时候Redis宕机怎么办?
Redis有持久化机制,因为可靠性问题,我们一般使用AOF持久化。事务的所有命令也会写入AOF文件,但是如果在执行EXEC命令之前,Redis已经宕机,则AOF文件中事务不完整。使用 redis-check-aof
程序可以移除 AOF 文件中不完整事务的信息,确保服务器可以顺利启动。
如果Redis在执行事务期间宕机了,那么事务中的操作将被回滚。这是因为Redis使用了类似于锁的机制来保证事务的原子性,如果Redis在事务执行期间崩溃,就会被视为事务执行失败,从而触发回滚。
当Redis重新启动时,会检查是否有未完成的事务,并尝试回滚这些事务。但是需要注意的是,这种回滚仅适用于命令已经被存储在内存中的情况,如果事务中的某些操作已被持久化到磁盘上,那么Redis重启时无法回滚这些操作,因为这些操作已经被写入到磁盘上的快照中。
因此,为了最大程度地避免这种情况的发生,在进行关键的事务操作之前,最好先使用 Redis 的快照、AOF 持久化或是 Redis 集群等方法来对数据进行备份,以保证在 Redis 宕机的情况下数据不会丢失。
Redis的Key过期策略
参考资料:
为什么需要内存回收?
- 1、在Redis中,set指令可以指定key的过期时间,当过期时间到达以后,key就失效了;
- 2、Redis是基于内存操作的,所有的数据都是保存在内存中,一台机器的内存是有限且很宝贵的。
基于以上两点,为了保证Redis能继续提供可靠的服务,Redis需要一种机制清理掉不常用的、无效的、多余的数据,失效后的数据需要及时清理,这就需要内存回收了。
Redis的内存回收主要分为过期删除策略和内存淘汰策略两部分。
过期删除策略
删除达到过期时间的key。
- 1)定时删除
对于每一个设置了过期时间的key都会创建一个定时器,一旦到达过期时间就立即删除。该策略可以立即清除过期的数据,对内存较友好,但是缺点是占用了大量的CPU资源去处理过期的数据,会影响Redis的吞吐量和响应时间。
- 2)惰性删除
当访问一个key时,才判断该key是否过期,过期则删除。该策略能最大限度地节省CPU资源,但是对内存却十分不友好。有一种极端的情况是可能出现大量的过期key没有被再次访问,因此不会被清除,导致占用了大量的内存。
在计算机科学中,懒惰删除(英文:lazy deletion)指的是从一个散列表(也称哈希表)中删除元素的一种方法。在这个方法中,删除仅仅是指标记一个元素被删除,而不是整个清除它。被删除的位点在插入时被当作空元素,在搜索之时被当作已占据。
- 3)定期删除
每隔一段时间,扫描Redis中过期key字典,并清除部分过期的key。该策略是前两者的一个折中方案,还可以通过调整定时扫描的时间间隔和每次扫描的限定耗时,在不同情况下使得CPU和内存资源达到最优的平衡效果。
在Redis中,同时使用了定期删除和惰性删除
。不过Redis定期删除采用的是随机抽取的方式删除部分Key,因此不能保证过期key 100%的删除。
Redis结合了定期删除和惰性删除,基本上能很好的处理过期数据的清理,但是实际上还是有点问题的,如果过期key较多,定期删除漏掉了一部分,而且也没有及时去查,即没有走惰性删除,那么就会有大量的过期key堆积在内存中,导致redis内存耗尽,当内存耗尽之后,有新的key到来会发生什么事呢?是直接抛弃还是其他措施呢?有什么办法可以接受更多的key?
内存淘汰策略
Redis的内存淘汰策略,是指内存达到maxmemory极限时,使用某种算法来决定清理掉哪些数据,以保证新数据的存入。
Redis的内存淘汰机制包括:
- noeviction: 当内存不足以容纳新写入数据时,新写入操作会报错。
- allkeys-lru:当内存不足以容纳新写入数据时,在键空间(
server.db[i].dict
)中,移除最近最少使用的 key(这个是最常用的)。 - allkeys-random:当内存不足以容纳新写入数据时,在键空间(
server.db[i].dict
)中,随机移除某个 key。 - volatile-lru:当内存不足以容纳新写入数据时,在设置了过期时间的键空间(
server.db[i].expires
)中,移除最近最少使用的 key。 - volatile-random:当内存不足以容纳新写入数据时,在设置了过期时间的键空间(
server.db[i].expires
)中,随机移除某个 key。 - volatile-ttl:当内存不足以容纳新写入数据时,在设置了过期时间的键空间(
server.db[i].expires
)中,有更早过期时间的 key 优先移除。
在配置文件中,通过maxmemory-policy可以配置要使用哪一个淘汰机制。
什么时候会进行淘汰?
Redis会在每一次处理命令的时候(processCommand函数调用freeMemoryIfNeeded)判断当前redis是否达到了内存的最大限制,如果达到限制,则使用对应的算法去处理需要删除的key。
在淘汰key时,Redis默认最常用的是LRU算法(Latest Recently Used)。Redis通过在每一个redisObject保存lru属性来保存key最近的访问时间,在实现LRU算法时直接读取key的lru属性。
具体实现时,Redis遍历每一个db,从每一个db中随机抽取一批样本key,默认是3个key,再从这3个key中,删除最近最少使用的key。
面试话术:
Redis过期策略包含定期删除和惰性删除两部分。定期删除是在Redis内部有一个定时任务,会定期删除一些过期的key。惰性删除是当用户查询某个Key时,会检查这个Key是否已经过期,如果没过期则返回用户,如果过期则删除。
但是这两个策略都无法保证过期key一定删除,漏网之鱼越来越多,还可能导致内存溢出。当发生内存不足问题时,Redis还会做内存回收。内存回收采用LRU策略,就是最近最少使用。其原理就是记录每个Key的最近使用时间,内存回收时,随机抽取一些Key,比较其使用时间,把最老的几个删除。
Redis的逻辑是:最近使用过的,很可能再次被使用
Redis的Key过期策略有两种:
- 定时过期策略(TTL)
Redis的定时过期策略是指在向Redis存储数据时,为每一个键值对设置一个过期时间(TTL)。Redis会周期性地检查键值对是否已经过期并将过期的键值对删除。这个过程是通过Redis服务端内部的定时器(expires)来实现的。
- 惰性过期策略
Redis的惰性过期策略是指在Redis中,键值对过期检查实际上是在获取键值对时进行的。因此,即使一个键已经到了过期时间,Redis也不会立即将这个键删除,而是等到这个键被访问时才进行删除。
两种过期策略的作用不同。定时过期策略主要适用于需要精确控制数据过期时间的应用场景;而惰性过期策略则更适用于一些对数据实时性要求不高的应用场景,可以避免一些不必要的计算和消耗,提高数据库效率。
需要注意的是,惰性过期策略可能会导致某些过期键一直存在Redis中,因为这些键从创建到过期一直没有被访问过,没有触发Redis的键被删除行为。
Redis在项目中的哪些地方有用到?
(1)共享session
在分布式系统下,服务会部署在不同的tomcat,因此多个tomcat的session无法共享,以前存储在session中的数据无法实现共享,可以用redis代替session,解决分布式系统间数据共享问题。
(2)数据缓存
Redis采用内存存储,读写效率较高。我们可以把数据库的访问频率高的热点数据存储到redis中,这样用户请求时优先从redis中读取,减少数据库压力,提高并发能力。
(3)异步队列
Reids在内存存储引擎领域的一大优点是提供 list 和 set 操作,这使得Redis能作为一个很好的消息队列平台来使用。而且Redis中还有pub/sub这样的专用结构,用于1对N的消息通信模式。
(4)分布式锁
Redis中的乐观锁机制,可以帮助我们实现分布式锁的效果,用于解决分布式系统下的多线程安全问题
Redis在项目中通常有以下几个方面的用途:
- 缓存:使用Redis缓存热点数据,减轻数据库的压力,提高系统的性能。
- 分布式锁:多个服务节点需要同步访问共享资源时,可以通过Redis实现分布式锁,保证数据一致性。
- 计数器和限流:使用Redis的计数器和限流功能,可以对请求进行限制,防止因高并发导致系统崩溃。
- 会话管理:在集群环境下,使用Redis存储会话信息,避免了单节点崩溃导致会话丢失的问题。
- 消息队列:使用Redis作为消息队列,可以实现简单的消息发布/订阅功能,支持消息的持久化和过期自动删除等特性。
Redis的缓存击穿、缓存雪崩、缓存穿透
1)缓存穿透
参考资料:
- 什么是缓存穿透
- 正常情况下,我们去查询数据都是存在。那么请求去查询一条压根儿数据库中根本就不存在的数据,也就是缓存和数据库都查询不到这条数据,但是请求每次都会打到数据库上面去。这种查询不存在数据的现象我们称为缓存穿透。
- 穿透带来的问题
- 试想一下,如果有黑客会对你的系统进行攻击,拿一个不存在的id 去查询数据,会产生大量的请求到数据库去查询。可能会导致你的数据库由于压力过大而宕掉。
- 解决办法
- 缓存空值:之所以会发生穿透,就是因为缓存中没有存储这些空数据的key。从而导致每次查询都到数据库去了。那么我们就可以为这些key对应的值设置为null 丢到缓存里面去。后面再出现查询这个key 的请求的时候,直接返回null 。这样,就不用在到数据库中去走一圈了,但是别忘了设置过期时间。
- BloomFilter(布隆过滤):将所有可能存在的数据哈希到一个足够大的bitmap中,一个一定不存在的数据会被 这个bitmap拦截掉,从而避免了对底层存储系统的查询压力。在缓存之前在加一层 BloomFilter ,在查询的时候先去 BloomFilter 去查询 key 是否存在,如果不存在就直接返回,存在再走查缓存 -> 查 DB。
话术:
缓存穿透有两种解决方案:其一是把不存在的key设置null值到缓存中。其二是使用布隆过滤器,在查询缓存前先通过布隆过滤器判断key是否存在,存在再去查询缓存。
设置null值可能被恶意针对,攻击者使用大量不存在的不重复key ,那么方案一就会缓存大量不存在key数据。此时我们还可以对Key规定格式模板,然后对不存在的key做正则规范匹配,如果完全不符合就不用存null值到redis,而是直接返回错误。
2)缓存击穿
相关资料:
- 什么是缓存击穿?
key可能会在某些时间点被超高并发地访问,是一种非常“热点”的数据。这个时候,需要考虑一个问题:缓存被“击穿”的问题。
当这个key在失效的瞬间,redis查询失败,持续的大并发就穿破缓存,直接请求数据库,就像在一个屏障上凿开了一个洞。
- 解决方案:
- 使用互斥锁(mutex key):mutex,就是互斥。简单地来说,就是在缓存失效的时候(判断拿出来的值为空),不是立即去load db,而是先使用Redis的SETNX去set一个互斥key,当操作返回成功时,再进行load db的操作并回设缓存;否则,就重试整个get缓存的方法。SETNX,是「SET if Not eXists」的缩写,也就是只有不存在的时候才设置,可以利用它来实现互斥的效果。
- 软过期:也就是逻辑过期,不使用redis提供的过期时间,而是业务层在数据中存储过期时间信息。查询时由业务程序判断是否过期,如果数据即将过期时,将缓存的时效延长,程序可以派遣一个线程去数据库中获取最新的数据,其他线程这时看到延长了的过期时间,就会继续使用旧数据,等派遣的线程获取最新数据后再更新缓存。
推荐使用互斥锁,因为软过期会有业务逻辑侵入和额外的判断。
面试话术:
缓存击穿主要担心的是某个Key过期,更新缓存时引起对数据库的突发高并发访问。因此我们可以在更新缓存时采用互斥锁控制,只允许一个线程去更新缓存,其它线程等待并重新读取缓存。例如Redis的setnx命令就能实现互斥效果。
3)缓存雪崩
相关资料:
缓存雪崩,是指在某一个时间段,缓存集中过期失效。对这批数据的访问查询,都落到了数据库上,对于数据库而言,就会产生周期性的压力波峰。
解决方案:
- 数据分类分批处理:采取不同分类数据,缓存不同周期
- 相同分类数据:采用固定时长加随机数方式设置缓存
- 热点数据缓存时间长一些,冷门数据缓存时间短一些
- 避免redis节点宕机引起雪崩,搭建主从集群,保证高可用
面试话术:
解决缓存雪崩问题的关键是让缓存Key的过期时间分散。因此我们可以把数据按照业务分类,然后设置不同过期时间。相同业务类型的key,设置固定时长加随机数。尽可能保证每个Key的过期时间都不相同。
另外,Redis宕机也可能导致缓存雪崩,因此我们还要搭建Redis主从集群及哨兵监控,保证Redis的高可用。
Redis的缓存击穿、缓存雪崩、缓存穿透是开发者需要注意的一些问题,下面是它们的详细说明:
- 缓存击穿:指一个key非常热点,在不停的扛着大并发,当这个key在失效的瞬间,持续的大并发就穿透缓存,直接请求数据库,导致数据库压力骤增,严重的会挂掉。
解决方案:使用互斥锁(mutex key),设置一个key的失效时间为短暂的时间(例如1-5分钟),在此期间不再去查询数据库,防止缓存同时失效导致大量请求直接打到数据库上。
- 缓存雪崩:指缓存在同一时间大量的失效,快速的导致数据库瞬时压力增大。
解决方案:设置缓存失效时间的随机性,让缓存失效的时间点尽量分散,同时也可以在缓存时间即将到期之前,自动延长其失效时间,避免大面积同时失效。
- 缓存穿透:指查询一个一定不存在的数据,由于缓存不命中,就会直接去查询数据库,这样如果有恶意攻击或者大量查询不存在的数据,都会直接打到数据库上,导致数据库压力过大。
解决方案:在查询的时候,对于不存在的数据也进行缓存,但是缓存时间设置比较短暂,当然这样会带来空间的问题,所以也可以采用布隆过滤器等技术,从而提升效率和空间利用率。
缓存冷热数据分离
背景资料:
Redis使用的是内存存储,当需要海量数据存储时,成本非常高。
经过调研发现,当前主流DDR3内存和主流SATA SSD的单位成本价格差距大概在20倍左右,为了优化redis机器综合成本,我们考虑实现基于热度统计 的数据分级存储及数据在RAM/FLASH之间的动态交换,从而大幅度降低成本,达到性能与成本的高平衡。
基本思路:基于key访问次数(LFU)的热度统计算法识别出热点数据,并将热点数据保留在redis中,对于无访问/访问次数少的数据则转存到SSD上,如果SSD上的key再次变热,则重新将其加载到redis内存中。
目前流行的高性能磁盘存储,并且遵循Redis协议的方案包括:
- SSDB:http://ssdb.io/zh_cn/
- RocksDB:https://rocksdb.org.cn/
因此,我们就需要在应用程序与缓存服务之间引入代理,实现Redis和SSD之间的切换,如图:
这样的代理方案阿里云提供的就有。当然也有一些开源方案,例如:https://github.com/JingchengLi/swapdb
缓存冷热数据分离指将访问频率低的数据存储在冷数据缓存中,访问频率高的数据存储在热数据缓存中。这样可以有效地利用缓存资源,提高系统的性能和响应速度。
具体实现方式有以下几种:
- 按照访问频率分配缓存空间:将缓存空间划分为多个级别,根据数据的访问频率将数据存储在对应的级别中,访问频率高的数据存放在高级别缓存,访问频率低的数据存放在低级别缓存。
- 按照数据的重要性分配缓存空间:将缓存空间划分为多个级别,根据数据的重要性将数据分配到对应的级别,重要的数据存放在高级别缓存,不重要的数据存放在低级别缓存。
缓存冷热数据分离的好处是可以充分利用缓存资源,提高系统性能,同时降低缓存空间的浪费和成本,减轻缓存维护的工作量。
但是需要注意的是,数据的访问频率和重要性是随时变化的,需要对系统的访问频率和数据重要性进行动态监控和调整,保持缓存的优化效果。
Redis实现分布式锁
分布式锁要满足的条件:
- 多进程互斥:同一时刻,只有一个进程可以获取锁
- 保证锁可以释放:任务结束或出现异常,锁一定要释放,避免死锁
- 阻塞锁(可选):获取锁失败时可否重试
- 重入锁(可选):获取锁的代码递归调用时,依然可以获取锁
1)最基本的分布式锁:
利用Redis的setnx命令,这个命令的特征时如果多次执行,只有第一次执行会成功,可以实现互斥
的效果。但是为了保证服务宕机时也可以释放锁,需要利用expire命令给锁设置一个有效期
setnx lock thread-01 # 尝试获取锁
expire lock 10 # 设置有效期
如果expire之前服务宕机怎么办?
要保证setnx和expire命令的原子性。redis的set命令可以满足:
set key value [NX] [EX time]
需要添加nx和ex的选项:
- NX:与setnx一致,第一次执行成功
- EX:设置过期时间
释放锁的时候,如果自己的锁已经过期了,此时会出现安全漏洞,如何解决?
在锁中存储当前进程和线程标识,释放锁时对锁的标识判断,如果是自己的则删除,不是则放弃操作。
但是这两步操作要保证原子性,需要通过Lua脚本来实现。
if redis.call("get",KEYS[1]) == ARGV[1] then
redis.call("del",KEYS[1])
end
2)可重入分布式锁
如果有重入的需求,则除了在锁中记录进程标识,还要记录重试次数,流程如下:
下面我们假设锁的key为“lock
”,hashKey是当前线程的id:“threadId
”,锁自动释放时间假设为20
获取锁的步骤:
- 1、判断lock是否存在
EXISTS lock
- 存在,说明有人获取锁了,下面判断是不是自己的锁
- 判断当前线程id作为hashKey是否存在:
HEXISTS lock threadId
- 不存在,说明锁已经有了,且不是自己获取的,锁获取失败,end
- 存在,说明是自己获取的锁,重入次数+1:
HINCRBY lock threadId 1
,去到步骤3
- 判断当前线程id作为hashKey是否存在:
- 2、不存在,说明可以获取锁,
HSET key threadId 1
- 3、设置锁自动释放时间,
EXPIRE lock 20
- 存在,说明有人获取锁了,下面判断是不是自己的锁
释放锁的步骤:
- 1、判断当前线程id作为hashKey是否存在:
HEXISTS lock threadId
- 不存在,说明锁已经失效,不用管了
- 存在,说明锁还在,重入次数减1:
HINCRBY lock threadId -1
,获取新的重入次数
- 2、判断重入次数是否为0:
- 为0,说明锁全部释放,删除key:
DEL lock
- 大于0,说明锁还在使用,重置有效时间:
EXPIRE lock 20
- 为0,说明锁全部释放,删除key:
对应的Lua脚本如下:
首先是获取锁:
local key = KEYS[1]; -- 锁的key
local threadId = ARGV[1]; -- 线程唯一标识
local releaseTime = ARGV[2]; -- 锁的自动释放时间
if(redis.call('exists', key) == 0) then -- 判断是否存在
redis.call('hset', key, threadId, '1'); -- 不存在, 获取锁
redis.call('expire', key, releaseTime); -- 设置有效期
return 1; -- 返回结果
end;
if(redis.call('hexists', key, threadId) == 1) then -- 锁已经存在,判断threadId是否是自己
redis.call('hincrby', key, threadId, '1'); -- 不存在, 获取锁,重入次数+1
redis.call('expire', key, releaseTime); -- 设置有效期
return 1; -- 返回结果
end;
return 0; -- 代码走到这里,说明获取锁的不是自己,获取锁失败
然后是释放锁:
local key = KEYS[1]; -- 锁的key
local threadId = ARGV[1]; -- 线程唯一标识
local releaseTime = ARGV[2]; -- 锁的自动释放时间
if (redis.call('HEXISTS', key, threadId) == 0) then -- 判断当前锁是否还是被自己持有
return nil; -- 如果已经不是自己,则直接返回
end;
local count = redis.call('HINCRBY', key, threadId, -1); -- 是自己的锁,则重入次数-1
if (count > 0) then -- 判断是否重入次数是否已经为0
redis.call('EXPIRE', key, releaseTime); -- 大于0说明不能释放锁,重置有效期然后返回
return nil;
else
redis.call('DEL', key); -- 等于0说明可以释放锁,直接删除
return nil;
end;
3)高可用的锁
redis分布式锁依赖与redis,如果redis宕机则锁失效。如何解决?
此时大多数同学会回答说:搭建主从集群,做数据备份。
这样就进入了陷阱,因为面试官的下一个问题就来了:
如果搭建主从集群做数据备份时,进程A获取锁,master还没有把数据备份到slave,master宕机,slave升级为master,此时原来锁失效,其它进程也可以获取锁,出现安全问题。如何解决?
关于这个问题,Redis官网给出了解决方案,使用RedLock思路可以解决:
在Redis的分布式环境中,我们假设有N个Redis master。这些节点完全互相独立,不存在主从复制或者其他集群协调机制。之前我们已经描述了在Redis单实例下怎么安全地获取和释放锁。我们确保将在每(N)个实例上使用此方法获取和释放锁。在这个样例中,我们假设有5个Redis master节点,这是一个比较合理的设置,所以我们需要在5台机器上面或者5台虚拟机上面运行这些实例,这样保证他们不会同时都宕掉。
为了取到锁,客户端应该执行以下操作:
- 获取当前Unix时间,以毫秒为单位。
- 依次尝试从N个实例,使用相同的key和随机值获取锁。在步骤2,当向Redis设置锁时,客户端应该设置一个网络连接和响应超时时间,这个超时时间应该小于锁的失效时间。例如你的锁自动失效时间为10秒,则超时时间应该在5-50毫秒之间。这样可以避免服务器端Redis已经挂掉的情况下,客户端还在死死地等待响应结果。如果服务器端没有在规定时间内响应,客户端应该尽快尝试另外一个Redis实例。
- 客户端使用当前时间减去开始获取锁时间(步骤1记录的时间)就得到获取锁使用的时间。当且仅当从大多数(这里是3个节点)的Redis节点都取到锁,并且使用的时间小于锁失效时间时,锁才算获取成功。
- 如果取到了锁,key的真正有效时间等于有效时间减去获取锁所使用的时间(步骤3计算的结果)。
- 如果因为某些原因,获取锁失败(_没有_在至少N/2+1个Redis实例取到锁或者取锁时间已经超过了有效时间),客户端应该在所有的Redis实例上进行解锁(即便某些Redis实例根本就没有加锁成功)。
如何实现数据库与缓存数据一致?
面试话术:
实现方案有下面几种:
- 本地缓存同步:当前微服务的数据库数据与缓存数据同步,可以直接在数据库修改时加入对Redis的修改逻辑,保证一致。
- 跨服务缓存同步:服务A调用了服务B,并对查询结果缓存。服务B数据库修改,可以通过MQ通知服务A,服务A修改Redis缓存数据
- 通用方案:使用Canal框架,伪装成MySQL的salve节点,监听MySQL的binLog变化,然后修改Redis缓存数据
数据库与缓存数据的一致性是一个非常关键的问题,因为如果缓存数据与数据库数据不一致,则可能会导致数据错误或系统故障。以下是一些实现数据库与缓存数据一致性的常用方法:
- 缓存失效策略:在数据库更新数据时,缓存数据可能已经过时。因此,使用缓存失效策略可以确保在数据库更新数据时,缓存中的数据也会被更新。缓存失效策略可以采用基于时间的策略或基于事件的策略。例如,当数据库中的数据被更新时,可以通过设置缓存的超时时间来强制缓存中的数据过期,以确保下一次访问时,从数据库中重新获取最新的数据。
- 双写一致性策略:在更新数据库数据时,同时更新缓存数据以确保它们的一致性。这种策略可以保证数据的一致性,但在访问量较高的情况下,会增加服务器的负载。
- 缓存穿透策略:当请求的数据并不在缓存中时,为了避免大量请求直接打到数据库上,可以采用缓存穿透策略。例如在缓存中设置一个空值,表示查找的数据不在缓存中,然后将这些请求拦截并快速返回,以减少对数据库的直接访问。
- 读写分离策略:读操作和写操作分开,数据的写操作在主库进行,而读操作则在从库进行,这种策略可以减轻主库的压力,提升系统性能。对于读请求,先从缓存中获取,如果缓存中没有数据,再从从库中获取,然后将数据缓存在缓存中。对于写请求,先更新主库数据,再删除缓存中的数据,以确保下次请求时从数据库中重新获取最新的数据。