加了唯一索引,为何还有重复数据
一、现场还原
先看表结构,其中 name
、age
、city
三个字段创建一个联合唯一索引。
CREATE TABLE `test` (
`id` int NOT NULL,
`name` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
`age` int DEFAULT NULL,
`city` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `name` (`name`,`age`,`city`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
如果在这三个字段上创建一个联合唯一索引,那么就不会存在两行数据在这三个字段上的值完全相同。下面来看一组数据。
通过上图可以看到,北京的张三有多个,毕竟年龄可以不一样,其中有两个 age 为空的,同样上海的李四也有多个。是不是唯一索引没生效?那我们现在试一下唯一索引的生效情况。
可以看到已经报错,对于这个唯一索引已经出现了重复的数据,那么究竟是什么造成了唯一索引的失效呢?
二、原因分析
就是因为我们插入的数据中 age 为 Null 导致的。在 MySQL 官方文档中也有说明,Null 与任何值都不相等,包括与另一个 Null 比较。
MySQL 关于Null 描述地址:https://dev.mysql.com/doc/refman/8.4/en/problems-with-null.html
所以结论就是:当多个字段一起创建唯一索引时,需要设置每一项字段非空,如果其中一项出现 Null 值,MySQL 的唯一索引会失效。
三、逻辑删除
说到唯一索引,就要想到有逻辑删除这个东西,对于现在的系统来说,10个系统里面8个是用逻辑删除。
对于系统中记录的删除,一般是两种,一种是物理删除,也就是使用delete
语句进行删除。另一种就是使用逻辑删除,在表中增加一个删除标记字段,比如deleted
,默认0,当需要删除时改为1。
通过物理删除的数据,在表中是查询不出来的,不过可以通过 binlog
进行恢复。
通过逻辑删除的数据,在表中还是存在的,只是删除标记deleted
变成了1
。
我们还拿上面那张表举例,假如我们删除了张三、18、北京
这条记录,如果后面再次插入张三、18、北京
这条数据是无法插入的,因为我们创建的唯一索引是name
、age
、city
三个字段。
这种情况怎么解决呢?可否在上面name
、age
、city
唯一索引的基础上,增加deleted
,创建4个字段的唯一索引?那么真的可以吗?
还是那句话,用上面的表举个栗子:
四、删除状态加1
第一种方式就是删除状态加1,现在我们表中deleted
默认0
,如果删除了之后就是1
,那么我们更改为,deleted
默认0
,删除之后获取当前相同记录的最大删除状态,然后加1
。
举个栗子:
通过上面的例子可以看出来,每次删除记录,当前记录的删除标记deleted
都会获取当前相同记录的最大删除状态,然后加1
进行删除。
这样deleted
每次删除的时候都是不一样的,所以可以保证唯一索引的生效。
使用这种方案的缺点就是需要修改代码中的sql逻辑,比如查询deleted
为1
的删除数据时需要改为deleted>1
。
五、增加时间戳
除了上面这种对删除状态进行加1
的方式外,还可以增加一个时间戳字段,创建name
、age
、city
、timestamp
四个字段的联合唯一索引。
时间戳一般精确到秒
,如果并发高,还是可能生成重复数据,那么时间戳的话可以精确到毫秒
。
然后设置时间戳字段默认值为1
,当进行逻辑删除删除时,直接插入当前时间的时间戳。
这种方案的优点就是不用修改原来代码逻辑,缺点就是极限情况下还是可能会产生重复数据。
六、增加删除ID
我们还可以使用增加删除ID
的方法来进行去重。
创建唯一索引name
、age
、city
、deleted_id
。
插入数据时deleted_id
默认1
,当进行逻辑删除时修改为当前记录的主键ID。
这种方法与增加时间戳字段类似,优点就是可以解决时间戳字段的重复数据问题,并且无需修改现有系统的删除逻辑,也可以保证数据的唯一,所以如果再有逻辑删除的表中,推荐使用这种方式。
七、历史数据加唯一索引
在上面的几个方案中,都是对新表添加的唯一索引,现在有一张历史数据表,其中还有重复数据,那么该如何添加索引呢?
使用deleted_id
的方案,首先获取出相同记录的最大ID
,然后将这些记录的deleted_id
设置为1
,然后其他的记录deleted_id
就是当前记录的主键,这样我们就可以区分表中的重复数据了。
当表中的deleted_id
都有值之后,创建唯一索引name
、age
、city
、deleted_id
。
八、大字段添加唯一索引
创建索引的要求大家应该都知道,字段不可以太大,因为索引本身大了之后检索的效率也是很低的。
关于MySQL 中 InnoDB 引擎的限制可以查看这个链接。
https://dev.mysql.com/doc/refman/8.4/en/innodb-limits.html
对于大的字段添加唯一索引,可以使用hash
算法,创建一个hash
字段,将大字段进行Hash
运算之后的结果保存到 hash
中,然后创建唯一索引name
、age
、city
、hash
。
用到Hash
算法,肯定就会有Hash
冲突,所以这种方案会带来一个问题就是不同的值Hash
却相同。
所以创建多列的联合唯一索引时需要在增加一个其他的字段进行区分。
还有一种方案就是不使用唯一索引,使用唯一索引的目的就是去重,直接代码层面MQ、单线程处理等。
总结
本文通过还原唯一索引失效的场景,得出当多列唯一索引中的某一列有为Null
的值时,唯一索引会失效。
总结出在有逻辑删除的业务表中,可以通过删除状态值加1、增加时间戳字段、增加删除ID字段三种方式进行添加多列唯一索引。
最后还给出了如何对历史重复数据添加唯一索引以及使用hash
给大字段添加唯一索引的方式。
统计网站中用户在线时长的方案
在电商网站中有时候需要需要统计用户在公司的网站中在线时长,然后运营人员通过分析用户在网站中浏览的时长的数据做一些业务调整和规划工作,下面我们整理几种统计用户在网站中在线时长的方案。
1、登录登出统计方案
这种方案的实现原理很简单,当用户登录的时候我们保存用户的登录时间信息,然后用户在网站中做一些业务操作,最后用户点击退出按钮退出系统或者关闭浏览器删除session的时候,后端服务通过当前登出时间与用户登录时间相减得到用户在网站中的在线时长,更新这个在线时长数据到数据库中。此方案中如果用户是异常的退出或者长时间不操作网站等情况下就没有办法统计了。
2、借助Redis的Zset实现方案
本方案的原理是利用redis的Zset的score判断用户的在线状态,具体的流程如下所示:
(1)用户登录的时候记录用户的登录信息到数据库中,并且把用户的登录时间记录到redis的Zset的score中。
(2)用户操作业务系统的时候更新redis上Zset中score值。
(3)使用定时任务(如xxl-job)批量的扫描redis中Zset上score中前5分钟(具体的时间可以根据业务需要来定)没有做任何操作的用户,这批用户我们认为就是下线的用户。然后根据score值计算用户在线时长的数据并将这个数据更新到数据库中,最后要删除这个用户在redis中的key来释放redis的内存。
此方案类似很多中间件中的心跳机制,通过发送这种心跳来确定用户是否在线,如果用户在一定时间中没有操作业务系统,此时就认为用户下线了。
3、基于Redis的过期key的通知方案
本方案是基于Redis过期key的通知方案实现的,具体的实现流程如下所示:
(1)用户登录的时候记录用户的登录信息,然后在Redis中保存用户的信息并添加一个过期时间。
(2)用户在网站中操作相关的业务,然后延长Redis中key的过期时间。
(3)当Redis检测到某个key过期之后会通知业务系统,在业务系统中根据当前的时间减去用户登录时间得到用户的在线时长,最后将用户的在线时长数据更新到数据库中。
本方案是依赖Redis过期通知的,但是在实际中Redis的过期key不会立马删除,因为Redis采用了惰性删除和定期删除的方式来管理过期数据,这样就存在时延,此方案一般不推荐使用。
图解 Kafka 架构 | 为什么那么快?
Kafka 是一个可横向扩展,高可靠的实时消息中间件,常用于服务解耦、流量削峰。
总体架构图
- Producer:生产者,消息的产生者,是消息的入口。
-
Broker:Broker 是 kafka 一个实例,每个服务器上有一个或多个 kafka 的实例,简单的理解就是一台 kafka 服务器,kafka cluster 表示集群的意思
-
Topic:消息的主题,可以理解为消息队列,kafka的数据就保存在topic。在每个 broker 上都可以创建多个 topic 。
-
Partition:
Topic的分区
,每个 topic 可以有多个分区,分区的作用是做负载
,提高 kafka 的吞吐量。同一个 topic 在不同的分区的数据是不重复的,partition 的表现形式就是一个一个的文件夹。 -
Replication:每一个分区都有多个副本,副本的作用是做备胎,leader节点会将数据同步到follow从节点。当leader故障的时候会选择一个follower ,成为 leader,
follower和leader绝对是在不同的机器,同一机器对同一个分区也只可能存放一个副本。
- Consumer:消费者,消息的消费方,是消息的出口。
-
Consumer Group:可以将多个消费组构成一个消费者组,同一个 partition 的数据只能被消费者组中的某一个消费者消费。同一个消费者组的消费者可以消费同一个topic的不同分区的数据,这也是为了提高kafka的吞吐量。
-
Zookeeper:kafka 2.8 版本之前是依赖 zookeeper 来保存集群的的元信息,来保证系统的可用性。
-
Raft:kafka 2.8 版本之后就根据
raft
来保证系统的可用性。
为什么同一个 partition 的数据只能被消费者组中的某一个消费者消费?
-
顺序性:Kafka 保证了同一个分区内的消息是有序的,如果允许多个消费者并行消费同一个分区的消息,那么消息的
顺序性
将无法得到保证。当然由于各个分区的不同,我们顺序性还是不要靠kafka,在自己业务做判定。
-
负载均衡:通过让不同的消费者组内的消费者分摊不同的分区,Kafka 实现了
负载均衡
,确保每个消费者都有机会消费消息,同时避免了重复消费
。 -
Offset 管理:
每个消费者在消费时都会维护自己的offset
,如果多个消费者同时消费同一个分区,那么 offset 的管理将变得复杂,可能会导致重复消费或者消息丢失。
发送数据
kafka 会每次发送数据都是向 leader节点发送数据,并顺序写入到磁盘,然后 leader节点会将数据同步到各个从节点follower,即使主节点挂了,也不会影响服务的正常运行。
-
producer 生产者获取 leader 节点,将消息发送给leader节点。
-
leader节点将消息持久化到本地后,将数据同步到各个follower节点。
-
leader节点收到各个follower节点的
ack
后,发送ack给producer
消费数据
和生产者一样,消费者主动到kafka集群中拉取消息时,也是从leader节点去拉取数据
。
- 获取leader节点
- 拉去offset为0的数据进行消费
- 消费成功后发送
ack
,offset将会移动到下一位,待下次消费定位数据
kafka 为什么会那么快?
-
磁盘顺序读写
-
PageCache 页缓存技术
-
零拷贝技术
-
kafka 分区架构
磁盘顺序读写
生产者发送数据到 kafka 集群中,最终会写入到磁盘中,会采用顺序写入的方式。消费者从 kafka 集群中获取数据时,也是采用顺序读的方式。无论是机械磁盘还是固态硬盘 SSD,顺序读写的速度都是远大于随机读写的。
-
机械磁盘顺序读写省去了
磁头频繁寻址和旋转盘片
的开销 -
固态硬盘SSD以Page为单位做读写,以Block为单位做垃圾回收。写相同数据量的情况下,顺序写
制造更少的垃圾Block
,所以比随机写有更高的性能。
PageCache 页缓存技术
-
当 kafka 有写操作时,先将数据写入
PageCache
中,然后在顺序写入到磁盘中。 -
当读操作发生时,先从
PageCache
中查找,如果找不到,再去磁盘中读取。
零拷贝技术
一般性能的瓶颈都是网络io、磁盘io。我们来看下从磁盘读取数据到网卡场景下,传统 IO 的整个过程:
DMA方式,Direct Memory Access,也称为成组数据传送方式,有时也称为直接内存操作。DMA方式在数据传送过程中,没有保存现场、恢复现场之类的工作。
传统 IO 模型下,从磁盘读取数据,写到网卡设备中,经历了 4 次用户态和内核态之间的切换和数据的拷贝。那能不能让拷贝次数发送的少一点?但是kafka 采用了 sendfile 的零拷贝技术。
所谓的零拷贝技术不是指不发生拷贝,而是在用户态没有进行拷贝。
kafka 分区架构
-
分区架构:kafka 集群架构采用了多分区技术,并行度高。
Kafka ACK机制详解
Kafka 的 ACK机制是确保消息成功传递和处理的重要机制。
ACK 方式
Kafka的 ACK机制主要用于确保生产者发送的消息能够被可靠地写入到 Kafka集群的 Topic中。
ACK机制的核心思想是生产者发送消息后,需要等待 Kafka集群的确认(ACK),才认为消息发送成功。
Kafka的 ACK机制主要有三种级别:
acks=0
生产者不等待服务器的确认,消息发送后即认为成功,不管消息是否真正写入 Kafka,这种方式效率最高,但可靠性最低,数据可能存在丢失。
acks=1
生产者会等待来自 Leader 分区的确认。Leader分区接收到消息并写入本地日志后即返回确认。这种方式在 Leader分区可用时可靠,但如果 Leader分区发生故障,可能会丢失数据。从 Kafka 2.0 开始,默认值是 acks=1
acks=all(或-1)
生产者等待所有 ISR(In-Sync Replica,同步副本)分区的确认。只有当消息被写入所有同步副本后才返回确认,这种方式最可靠,但性能较低。
ISR 工作原理
ISR,全称 In-Sync Replicas,翻译为同步副本,它是指某个分区中的一组与 Leader副本保持同步的副本,即这些副本包含了 Leader副本中的所有已确认消息。ISR是 Kafka 集群中用于保证数据可靠性的一个关键概念。
-
Leader和Follower:在 Kafka中,每个分区都有一个 Leader 和若干个 Follower,Leader负责处理所有的读写请求,而 Follower 则从 Leader 那里拉取数据并进行同步。
-
同步副本(ISR):ISR是一个动态的集合,包含了 Leader 和所有与 Leader 保持同步的 Follower,只有在 ISR中的副本才被认为是可靠的,因为它们包含了与 Leader相同的数据。
-
ACK机制与ISR:当生产者发送消息并设置
acks=all
时,Kafka只有在消息被写入 ISR中的所有副本后才会返回确认,这确保了消息即使在 Leader故障的情况下也不会丢失,因为 ISR中的其他副本可以选举为新的 Leader。
ISR 维护
Kafka通过以下机制来维护ISR:
-
加入ISR:当一个 Follower 副本成功地追上了 Leader 副本的日志(即复制了 Leader 的所有新的消息),它会被加入到 ISR中。
-
移出ISR:当一个 Follower 副本落后于 Leader 超过一定的时间(由参数
replica.lag.time.max.ms
控制),它会被移出 ISR。
ISR 源码分析
以下是 Kafka中维护ISR的关键代码片段(以 Kafka 2.x版本为例):
class Partition {
private Set<Replica> isr; // 当前分区的ISR集合
public void updateISR() {
// 获取所有副本的状态
List<Replica> replicas = getReplicas();
// 计算新的ISR集合
Set<Replica> newIsr = new HashSet<>();
for (Replica replica : replicas) {
if (replica.isInSync()) {
newIsr.add(replica);
}
}
// 更新ISR
if (!newIsr.equals(this.isr)) {
this.isr = newIsr;
// 触发ISR变化的事件
onISRChanged();
}
}
}
class Replica {
public boolean isInSync() {
// 判断该副本是否与Leader同步
return this.logEndOffset >= leaderLogEndOffset - replicaLagMaxMessages;
}
}
Producer 源码分析
以 Kafka的 Producer端代码为例,下面是简化后的发送消息时处理ACK机制的关键代码片段:
public Future<RecordMetadata> send(ProducerRecord<K, V> record, Callback callback) {
// 构建请求
ProduceRequest request = new ProduceRequest(record, callback);
// 发送请求
Future<RecordMetadata> future = this.sender.send(request);
// 根据ACK配置处理确认
if (this.acks == 0) {
// 不等待确认,直接返回成功
callback.onCompletion(null, null);
} else if (this.acks == 1) {
// 等待Leader确认
RecordMetadata metadata = future.get();
callback.onCompletion(metadata, null);
} else if (this.acks == -1 || this.acks == "all") {
// 等待所有ISR确认
RecordMetadata metadata = future.get();
callback.onCompletion(metadata, null);
}
return future;
}
ACK 优缺点
acks=0
-
优点:性能最高,延迟最低。
-
缺点:消息可能丢失,可靠性最低。
acks=1
-
优点:在性能和可靠性之间取得平衡。
-
缺点:如果领导者在消息写入后但未同步给副本前崩溃,消息可能丢失。
acks=all
-
优点:最高的可靠性,确保消息被所有同步副本确认。
-
缺点:性能较低,延迟较高。
缺点
-
性能影响:更高的ACK级别会带来更高的延迟,降低吞吐量。
-
复杂性:需要根据具体应用场景选择合适的ACK配置,增加了系统设计的复杂性。
ACK 适用场景
-
acks=0:适用于对消息丢失不敏感且追求高吞吐量的场景,例如日志收集、监控数据等。
-
acks=1:适用于对消息有一定可靠性要求,但对性能要求较高的场景,例如实时数据处理。
-
acks=all:适用于对消息可靠性要求极高且可以接受较低吞吐量的场景,例如金融交易、订单处理等。
总结
从全局来看,Kafka 和 RocketMQ有着异曲同工之妙,Kafka的 ack=all
对应 RocketMQ的同步发送,ack=1
对应 RocketMQ的异步发送,ack=0
对应 RocketMQ的单向发送。
总体来说,Kafka的 ACK机制为消息的可靠传递提供了不同级别的保障,开发者可以根据具体的应用需求选择合适的 ACK配置,以在性能和可靠性之间取得平衡。
阿里面试:说说@Async实现原理?
@Async 是 Spring 3.0 提供的一个注解,用于标识某类(下的公共方法)或某方法会执行异步调用。
1.基本使用
@Async 基本使用可以分为以下 3 步:
-
项目中开启异步支持
-
创建异步方法
-
调用异步方法
1.1 开启异步支持
以 Spring Boot 项目为例,需要在 Spring Boot 的启动类,也就是带有@SpringBootApplication 注解的类上添加 @EnableAsync 注解,以开启异步方法执行的支持,如下代码所示:
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableAsync;
@SpringBootApplication
@EnableAsync
public class MyApplication {
public static void main(String[] args) {
SpringApplication.run(MyApplication.class, args);
}
}
1.2 创建异步方法
创建异步方法是在需要异步执行的方法上添加 @Async 注解,这个方法一定是要放在被 IoC 容器管理的 Bean 中,只有被 IoC 管理的类才能实现异步调用,例如在带有 @Service 注解的类中创建异步方法:
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
@Service
public class AsyncService {
@Async
public void performAsyncTask() {
// 这里放置需要异步执行的代码
System.out.println("异步任务正在执行,当前线程:" + Thread.currentThread().getName());
}
}
1.3 调用异步方法
在其他类或方法中,通过注入这个服务类的实例来调用异步方法。注意,直接在同一个类内部调用不会触发异步行为,必须通过注入的实例调用,使用 new 创建的对象也不能进行异步方法调用,具体实现代码如下:
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class MyController {
@Autowired
private AsyncService asyncService;
@GetMapping("/startAsync")
public String startAsyncTask() {
asyncService.performAsyncTask();
return "异步任务已启动";
}
}
2.实现原理
简单来说,@Async 注解是由 AOP 实现的,具体来说,它是由 AsyncAnnotationAdvisor 这个切面类来实现的。
在 AsyncAnnotationAdvisor 中,会使用 AsyncExecutionInterceptor 来处理 @Async 注解,它会在被 @Async 注解标识的方法被调用时,创建一个异步代理对象来执行方法。这个异步代理对象会在一个新的线程中调用被 @Async 注解标识的方法,从而实现方法的异步执行。
在 AsyncExecutionInterceptor 中,核心方法是 getDefaultExecutor 方法,使用此方法来获取一个线程池来执行被 @Async 注解修饰的方法,它的实现源码如下:
@Nullable
protected Executor getDefaultExecutor(@Nullable BeanFactory beanFactory) {
Executor defaultExecutor = super.getDefaultExecutor(beanFactory);
return (Executor)(defaultExecutor != null ? defaultExecutor : new SimpleAsyncTaskExecutor());
}
此方法实现比较简单,它是先尝试调用父类 AsyncExecutionAspectSupport#getDefaultExecutor 方法获取线程池,如果父类方法获取不到线程池再用创建 SimpleAsyncTaskExecutor 对象作为 Async 的线程池返回。
而 SimpleAsyncTaskExecutor 中在执行任务时是这样的:
protected void doExecute(Runnable task) {
this.newThread(task).start();
}
可以看出,在 Spring 框架中如果使用默认的 @Async 注解,它的执行比较简单粗暴,并没有使用线程池,而是每次创建线程来执行,所以在 Spring 框架中是不能直接使用 @Async 注解的,需要使用 @Async 注解搭配自定义的线程池,既实现 AsyncConfigurer 接口来提供自定义的 ThreadPoolTaskExecutor 来创建线程池,以确保 @Async 能真正的使用线程池来执行异步任务。
然而,在 Spring Boot 中,因为在框架启动时,自动注入了 ThreadPoolTaskExecutor,如下源码所示:
@ConditionalOnClass({ThreadPoolTaskExecutor.class})
@AutoConfiguration
@EnableConfigurationProperties({TaskExecutionProperties.class})
@Import({TaskExecutorConfigurations.ThreadPoolTaskExecutorBuilderConfiguration.class, TaskExecutorConfigurations.TaskExecutorBuilderConfiguration.class, TaskExecutorConfigurations.SimpleAsyncTaskExecutorBuilderConfiguration.class, TaskExecutorConfigurations.TaskExecutorConfiguration.class})
public class TaskExecutionAutoConfiguration {
public static final String APPLICATION_TASK_EXECUTOR_BEAN_NAME = "applicationTaskExecutor";
public TaskExecutionAutoConfiguration() {
}
}
具体的构建细节源码如下:
@Bean
@ConditionalOnMissingBean({TaskExecutorBuilder.class, ThreadPoolTaskExecutorBuilder.class})
ThreadPoolTaskExecutorBuilder threadPoolTaskExecutorBuilder(TaskExecutionProperties properties, ObjectProvider<ThreadPoolTaskExecutorCustomizer> threadPoolTaskExecutorCustomizers, ObjectProvider<TaskExecutorCustomizer> taskExecutorCustomizers, ObjectProvider<TaskDecorator> taskDecorator) {
TaskExecutionProperties.Pool pool = properties.getPool();
ThreadPoolTaskExecutorBuilder builder = new ThreadPoolTaskExecutorBuilder();
builder = builder.queueCapacity(pool.getQueueCapacity());
builder = builder.corePoolSize(pool.getCoreSize());
builder = builder.maxPoolSize(pool.getMaxSize());
builder = builder.allowCoreThreadTimeOut(pool.isAllowCoreThreadTimeout());
builder = builder.keepAlive(pool.getKeepAlive());
TaskExecutionProperties.Shutdown shutdown = properties.getShutdown();
builder = builder.awaitTermination(shutdown.isAwaitTermination());
builder = builder.awaitTerminationPeriod(shutdown.getAwaitTerminationPeriod());
builder = builder.threadNamePrefix(properties.getThreadNamePrefix());
Stream var10001 = threadPoolTaskExecutorCustomizers.orderedStream();
Objects.requireNonNull(var10001);
builder = builder.customizers(var10001::iterator);
builder = builder.taskDecorator((TaskDecorator)taskDecorator.getIfUnique());
builder = builder.additionalCustomizers(taskExecutorCustomizers.orderedStream().map(this::adapt).toList());
return builder;
}
因此在 Spring Boot 框架中可以直接使用 @Async 注解,无需担心它每次都会创建线程来执行的问题。
为什么使用 @Async 注解不能解决循环依赖的问题?为什么使用 @Async 注解会导致事务实现?
给一个方法加了@Async注解后,可能会导致循环依赖错误,这是因为Spring会为这个异步方法创建一个代理对象。代理对象的创建是在Spring容器初始化bean的过程中进行的。
当一个bean依赖于另一个bean时,Spring需要按照依赖关系的顺序来初始化bean。如果两个bean之间存在循环依赖关系,那么Spring容器就无法确定它们的初始化顺序,从而导致循环依赖错误。
以两个相互依赖的Service为例:
@Service public class ServiceA { @Autowired private ServiceB serviceB; @Async public void asyncMethodInA() { // ... } } @Service public class ServiceB { @Autowired private ServiceA serviceA; }
在这个例子中,ServiceA依赖于ServiceB,ServiceB依赖于ServiceA。当Spring容器尝试初始化这两个bean时,它需要先初始化ServiceA,然后再初始化ServiceB。但是,ServiceA中有一个@Async注解的方法,所以Spring需要为这个方法创建一个代理对象。在创建代理对象的过程中,Spring容器又需要先初始化ServiceB。这就导致了循环依赖错误。
初始化过程:
- Spring容器开始初始化ServiceA和ServiceB。
- ServiceA依赖于ServiceB,因此Spring容器先尝试初始化ServiceA。
- 在初始化ServiceA时,发现ServiceA中有一个@Async注解的方法。因此,Spring容器需要为这个方法创建一个代理对象。
- 在创建代理对象的过程中,Spring容器又需要先初始化ServiceB,以便将其注入到ServiceA中。
- 但是,ServiceB又依赖于ServiceA,这导致了循环依赖问题,因为Spring容器无法确定这两个bean的初始化顺序。
为了解决这个问题,可以尝试以下方法:
- 重新审查和调整bean之间的依赖关系,避免循环依赖。这可以通过重新组织代码结构、调整bean的作用域和生命周期等方式来实现。
- 如果确实存在循环依赖,可以考虑将@Async方法移动到一个新的bean中,这样可以避免循环依赖问题。
- 使用@Lazy注解来延迟依赖bean的初始化,这样可以在实际使用时再进行初始化,从而避免循环依赖问题。
总之,@Async注解可能会导致循环依赖错误,因为它会在Spring容器初始化时创建代理对象,而代理对象的创建可能依赖于其他尚未初始化的bean。为了解决这个问题,需要仔细检查bean之间的依赖关系,并采取适当的措施来避免循环依赖。
使用@Async注解会导致事务实现的问题主要是因为@Async注解用于异步执行方法,这意味着方法将在单独的线程中运行,而Spring的事务管理通常是基于同步方法执行的。当异步方法需要访问数据库或其他需要事务管理的资源时,直接在这些方法上使用@Transactional注解可能无法正确应用事务,因为事务管理通常依赖于同步调用和事务传播机制。
解决这个问题的方法是,将需要事务管理的数据库操作封装在一个单独的方法中,并在该方法上使用@Transactional注解。这样做可以确保事务的正确应用,因为Spring的事务管理机制能够正确处理同步调用中的事务边界。通过这种方式,即使方法是通过@Async异步执行的,数据库操作仍然可以在事务的上下文中正确执行,从而确保数据的一致性和完整性。
怎么解决哈希碰撞?
Hash 碰撞是指在使用哈希算法时,不同的输入数据通过哈希函数计算后,得到了相同的哈希值(即散列值)。因为哈希值相同,所以这些键会被映射到哈希表的同一个位置,从而引发“碰撞”。
常见有以下几种方式解决哈希碰撞问题:
1)拉链法(链地址法):
将哈希表中每个槽的位置变成一个链表,当多个键的哈希值相同时,将它们存储在同一个链表中。
2)开放寻址法:
如果出现碰撞,寻找哈希表中的下一个可用位置。
3)再哈希法(双重哈希):
在出现碰撞时,使用第二个哈希函数计算新的索引位置,减少碰撞的概率。
拉链法(链地址法)
使用链表来处理冲突,每个哈希表的槽(bucket)不仅存储单个元素,而是存储指向链表头部的指针。所有具有相同哈希值的元素都会被放入到同一个链表中。
优点:
-
简单易实现,扩展性好。
-
在处理大量数据时,性能更为稳定。
缺点:
-
如果碰撞频繁,链表会变长,导致查询性能下降。
-
需要额外的内存来存储链表的指针。
红黑树优化
当冲突产生的链表长度超过一定阈值时,可以将链表转换为红黑树。红黑树的查找时间复杂度为 O(log n),相较于链表 O(n) 的查找复杂度,性能更高。
这个优化可以大幅提升在极端情况下的查找性能。缺点就是实现复杂,且需要更多的内存空间。
开放寻址法
在哈希表中寻找下一个空闲的槽位以存储发生碰撞的元素,常见寻找方式有线性探查、平方探查和双重散列。
优点:
-
不需要额外的内存来存储指针或链表结构。
-
如果负载因子低,查找和插入的效率较高。
缺点:
-
随着哈希表的填充度增加,探查的次数会增加,导致性能下降。
-
删除元素时候,不能真的删除,只能打标,否则会导致查找错误。只能在下一个元素插入时,发现标记后才能替换原来的元素。
寻找方式
1)线性探查法:
在哈希表中查找下一个连续的空槽,将碰撞的键存入该槽中。
2)平方探查法:
类似于线性探查,但探查的步长是二次方,减少了聚集问题。
3)双散列法:
使用两个不同的哈希函数,第一次哈希决定初始位置,第二次哈希决定探查步长。
几种寻址方式本质原理都差不多,仅演示下线性探测过程:
再哈希法(双重哈希)
在出现碰撞时,使用第二个哈希函数计算新的索引位置,减少碰撞的概率
高性能 IO模型:Reactor vs Proactor 如何工作?
Reactor模型
定义
Reactor,中文翻译为”反应器”,它是一个被动过程,可以理解为”当接收到客户端事件后,Reactor 会根据事件类型来调用相应的代码进行处理”。Reactor 模型也叫 Dispatcher 模式,底层是 I/O 多路复用结合线程池,主要是用于服务器端处理高并发网络 IO 请求。Reactor 模型的核心思想可以总结成 2个”3种”:
-
3种事件:连接事件、读事件、写事件;
-
3种角色:reactor、acceptor、handler;
事件
-
客户端向服务器端发送连接请求,该请求对应了服务器的一个连接事件;
-
连接建立后,客户端给服务器端发送写请求,服务器端收到请求,需要从请求中读取内容,这就对应了服务器的读事件;
-
服务器经过处理后,把结果值返回给客户端,这就对应了服务器的写事件;
事件的描述可以参考下图:
角色
上述描述了 Reactor 的事件,每个事件都需要有一个专门的负责人,在 Reactor 模型中,这个负责人就是角色,其说明如下:
-
连接事件由 acceptor 来处理,只负责接收连接;acceptor 在接收连接后,会创建 handler,用于网络连接上对后续读写事件的处理;
-
读写事件由 handler 处理,处理完后响应客户端;
-
在连接事件、读写事件会同时发生的高并发场景中,需要reactor 角色专门监听和分配事件:连接事件交由 acceptor 处理;读写请求交由 handler 处理;
Reactor 线程模型
Reactor 线程模型有单 Reactor 单线程模型、单 Reactor 多线程模型、多 Reactor 多线程模型 三种。
1. 单Reactor单线程模型
单 Reactor 单线程模型,很容易理解:接受请求、业务处理、响应请求都在一个线程中处理。
模型抽象图
工作原理
-
Reactor 通过 select函数监听事件,收到事件后通过 dispatch 分发给 Acceptor 或 Handler;
-
如果监听到 client 的连接事件,则分发给 Acceptor 处理,Acceptor 通过 accept 接受连接,并创建一个 Handler 来处理连接后续的各种事件;
-
如果不是连接建立事件,则 Reactor 会调用连接对应的 Handler(步骤2创建的 Handler)来进行响应;
-
Handler 通过:read-> 业务处理 ->send 流程完成完整业务流程;
优缺点
优点是简单,不存在线程竞争,缺点是无法充分利用和发挥多核 CPU 的性能,当业务耗时很长时,容易造成阻塞。
案例
-
Redis6.0以下的版本使用的是 单 Reactor 单线程模型,因为 Redis使用的是内存,CPU不是性能瓶颈,所以单 Reactor 单线程模型可以支持 Redis 单机服务的高性能,下篇公众号文章,我会分享 Redis是如何驾驭 Reactor模型和 IO 多路复用机制。
-
Netty4 通过参数配置,可以使用单 Reactor 单线程模型;
2. 单Reactor多线程模型
鉴于单 Reactor 单线程模式无法充分利用和发挥多核 CPU 的性能,于是就诞生了单 Reactor 多线程模型。
模型抽象图
工作原理
-
在主线程中,Reactor 对象通过 select 监控事件,收到事件后通过 dispatch 分发给 Acceptor 或 Handler;
-
如果监听到 client 的连接事件,则分发给 Acceptor 处理,Acceptor 通过 accept 接受连接,并创建一个 Handler 来处理连接后续的各种事件;
-
如果不是连接建立事件,则 Reactor 会调用连接对应的 Handler(步骤2创建的 Handler)来进行响应。注意,此模型的 Handler 只负责响应事件,不进行业务处理;
-
Handler 通过 read 读取到数据后,会发给 Processor 进行业务处理;
-
Processor 会在独立的子线程中完成真正的业务处理,然后将响应结果发给主线程的 Handler 处理;Handler 收到响应后通过 send 将响应结果返回给 client;
优点
采用了线程池来处理业务逻辑,能够充分利用多 CPU 的处理能力
缺点
-
多线程数据共享和访问比较复杂。例如,子线程完成业务处理后,要把结果传递给主线程的 Reactor 进行发送,这里涉及共享数据的互斥和保护机制;
-
尽管引进了多线程处理业务逻辑,但是事件的监听和响应还是需要 Reactor 来处理,因此,瞬间高并发可能会造成 Reactor 的性能瓶颈;
案例
-
Netty4 通过参数配置,可以使用单 Reactor 多线程模型;
3. 多Reactor多线程模型
单 Reactor 多线程模型的性能瓶颈在于单个 Reactor 的处理能力,于是很自然的想到:能不能增加多个 Reactor来提升性能?于是多 Reactor 多线程模型就应孕而生。
模型抽象图
工作原理
-
父线程中 mainReactor 对象通过 select 监控连接建立事件,收到事件后通过 Acceptor 接收,将新的连接分配给某个子线程;
-
子线程的 subReactor 把 mainReactor 分配的连接加入到连接队列中并进行监听,同时创建一个 Handler 用于处理连接的各种事件;
-
当有新的事件发生时,subReactor 会调用连接对应的 Handler(步骤2创建的 Handler)来进行响应;
-
Handler 通过:read-> 业务处理 ->send 流程完成完整业务流程;
优点
-
父线程和子线程的职责明确,父线程只负责接收新连接,子线程负责完成后续的业务处理;
-
父线程和子线程的交互简单,父线程只需要把新连接传给子线程,子线程无须返回数据;
案例
-
Nginx 采用的是多 Reactor 多进程模型,但方案与标准的多 Reactor 多进程有差异;
-
开源软件 Memcache 采用的是多 Reactor 多线程模型;
-
Netty4 通过参数参数配置可以使用多 Reactor 多线程模型;
到此, Reactor模型就分析完了,需要说明的是:上文讲述的 Reactor 3种线程模型,同样可以以进程的方式部署,可能在逻辑处理上和线程有些差异。接下来再分析和 Reactor模型很类似的 Proactor 模型。
Proactor模型
定义
Proactor,中文翻译为”前摄器”,个人觉得”主动器”更符合 Proactor 模型的本意。Proactor 可以理解为“当有连接、读写等IO事件时,操作系统内核在处理完事件后主动通知我们的程序代码”。
模型抽象图
工作原理
-
Proactor Initiator 负责创建 Proactor 和 Handler,并将 Proactor 和 Handler 都通过 Asynchronous Operation Processor 注册到内核;
-
Asynchronous Operation Processor 负责处理注册请求,并完成 I/O 操作;
-
Asynchronous Operation Processor 完成 I/O 操作后通知 Proactor;
-
Proactor 根据不同的事件类型回调不同的 Handler 进行业务处理;
-
Handler 完成业务处理,Handler 也可以注册新的 Handler 到内核进程;
优缺点
-
Proactor 在处理高耗时 IO 时的性能要高于 Reactor,但对于低耗时 IO 的执行效率提升并不明显;
-
Proactor 的异步性使其并发处理能力要强于 Reactor;
-
Proactor 的实现逻辑复杂,编码成本较 Reactor 要高很多;
-
Proactor 的异步高度依赖于操作系统对于异步的支持。若操作系统对异步的支持不好,Proactor 的性能还不如 Reactor;
案例
-
Netty5, 它是采用 AIO,其网络通信模型就是 Proactor,但该版本已经被不再维护,主要原因是 Linux 目前对于异步的支持不完善,导致 Netty5 花了大代价,性能相对 Netty4 不但没有提升,甚至还会降低。
总结
-
Reactor 是同步非阻塞网络模型,Proactor 是异步非阻塞网络模型;
-
Reactor 是 I/O 多路复用和线程池的完美结合;
-
Reactor模型看似高深,其实是生活中很多真实案例的写照,比如:
-
夜市一个老板一辆推车的单人炒粉模式,从点菜,出餐,结算都是老板一人完成,这个就对应了 单 Reactor单线程模型;
-
医院叫号看病就对应了 单 Reactor多线程模型,一个叫号机负责叫号,多名医生负责接待病人;
-
大型餐饮就餐对应了 多 Reactor多线程模型,一个接待员负责接客送客,多名服务员,每名服务员负责几桌客人,然后有专门的端菜人员负责给客人端菜,比如:海底捞;
-
-
Reactor思维在日常开发中也会经常使用,最常用的是单线程处理,当并发量比较大时引进线程池,把业务细分,专门的线程处理专门的事情,这样就和 Reactor 模型的演变有异曲同工之妙;
-
Proactor 主要是采用异步的方式来处理 IO 事件(比如:叫外卖,下单支付后不需要关注,直接处理自己的事情,等外卖好了之后,外卖小哥会把主动把外卖送到你手上),不过目前 Linux 对 AIO支持的不太友好,使用该模型的 Netty5 最终也为此夭折了;
慎用 Arrays.asList
1. 不可增删的列表
首先,Arrays.asList
返回的列表是固定大小的,这意味着你不能向这个列表中增加或删除元素。尝试这么做会抛出UnsupportedOperationException
异常。
List<String> list = Arrays.asList("a", "b", "c");
// list.add("d"); // 这行代码如果取消注释,会抛出UnsupportedOperationException异常
2. 数组与列表的混淆
Arrays.asList
返回的是一个列表视图,它直接基于原始数组。因此,如果你修改了列表,原始数组也会被修改;反之亦然。
public static void main(String[] args) {
String[] array = {"a", "b", "c"};
List<String> list = Arrays.asList(array);
list.set(0, "z");
System.out.println(Arrays.toString(array)); // 输出 ["z", "b", "c"]
}
3. 基本类型数组的问题
当你尝试使用基本类型数组(如int[]
、double[]
等)调用Arrays.asList
时,会得到一个不是你预期的结果。这是因为Arrays.asList
的参数是一个泛型数组T... a
,当你传递一个基本类型数组时,它实际上被当作了一个长度为1的数组,其唯一元素就是整个基本类型数组。
public static void main(String[] args) {
int[] numbers = {1, 2, 3};
List<int[]> list = Arrays.asList(numbers);
System.out.println(list.size()); // 输出 1
System.out.println(Arrays.toString(list.get(0))); // 输出 [1, 2, 3]
}
4. 泛型数组创建的问题
由于泛型数组不能直接创建,因此你不能直接将泛型数组传递给Arrays.asList
。尝试这么做会得到编译错误。
// 下面的代码无法编译 // List<String> list = Arrays.asList(new String[] {"a", "b", "c"});
正确的做法是直接传递元素给Arrays.asList
,或者使用非泛型的数组。
public static void main(String[] args) {
List<String> list = Arrays.asList("a", "b", "c");
System.out.println(list);
}
5. 线程安全问题
Arrays.asList
返回的列表不是线程安全的。如果你在多线程环境下使用这个列表,并且至少有一个线程修改了列表,那么你必须外部同步这个列表。
List<String> list = Collections.synchronizedList(Arrays.asList("a", "b", "c"));
总结
Arrays.asList
它返回的列表是固定大小的,直接基于原始数组,且在使用基本类型数组时会有意想不到的行为。此外,它返回的列表不是线程安全的。因此,在使用Arrays.asList
时,一定要确保你了解它的这些特性,并根据需要采取相应的措施来规避潜在的问题。
聊聊Redis的AOF重写机制
AOF重写是Redis数据库中的一项机制,旨在解决随着时间推移AOF(Append Only File)文件不断增大导致的问题。随着Redis服务器的运行,AOF文件会因为不断的写操作而变得越来越大,这不仅会导致读写操作变慢,还会占用更多的磁盘空间。为了解决这个问题,AOF重写机制通过创建一个新的AOF文件来解决,这个新文件包含与原始AOF文件相同的数据,但经过优化,可以显著减小AOF文件的大小,并提高读写性能。
AOF重写的主要步骤包括:
- 读取当前数据库中的所有键值对,并根据这些键值对的当前最新状态生成对应的写入命令。
- 替换现有的AOF文件:在重写完成后,将新的AOF文件覆盖现有的AOF文件,以实现AOF文件的更新和优化。
AOF重写的目的是通过减少AOF文件中的命令数量,从而减小文件大小,提高读写性能,并优化磁盘空间的使用。这一机制通过“多变一”的功能实现,即原本旧日志文件中的多条命令在重写后的新日志中可能变成了一条命令,从而减少了不必要的磁盘和CPU开销
AOF
会将用户的指令按照RESP协议将数据持久化的物理磁盘中,由于AOF
是每条指令都会进行这周操作,所以随着时间的推移appendonly.aof
的体积会逐渐增大,于是redis就提出了aof重写这一机制来重写appendonly.aof
。
AOF重写时机
首先是用户手动执行 config set appendonly yes
,服务端就会基于此指令得到对应的指令函数configSetCommand
,该函数会解析用户传参得知用户要开启appendonly ,此时就会触发一次AOF
重写然后将文件落盘:
对应的我们给出configSetCommand
的入口,可以看到其内部判断逻辑会解析出用户的参数,在得知是AOF
开启之后就会调用stopAppendOnly
进行AOF
重写并持久化到磁盘中:
void configSetCommand(redisClient *c) {
//......
//......
else if (!strcasecmp(c->argv[2]->ptr,"appendonly")) {//如果config set 是开启appendonly则调用stopAppendOnly开启AOF并进行一次AOF重写完成文件持久化
int enable = yesnotoi(o->ptr);
if (enable == -1) goto badfmt;
if (enable == 0 && server.aof_state != REDIS_AOF_OFF) {//非关闭AOF则调用stopAppendOnly触发重写持久化文件
stopAppendOnly();
} else if (enable && server.aof_state == REDIS_AOF_OFF) {
//......
}
} //......
addReply(c,shared.ok);
return;
//......
}
我们查看startAppendOnly
的逻辑可以看到其内部会调用rewriteAppendOnlyFileBackground
,该函数就会fork
出一个子进程进行异步的AOF
重写然后进行文件落盘:
int startAppendOnly(void) {
//......
//调用rewriteAppendOnlyFileBackground执行fork子进程完成aof重写落盘
if (rewriteAppendOnlyFileBackground() == REDIS_ERR) {
close(server.aof_fd);
//......
return REDIS_ERR;
}
//......
return REDIS_OK;
}
另外一个常见的AOF
重写时机则是执行 bgrewriteaof
指令,该指令同样会执行AOF
重写落盘,对应的源码如下,可以看到其核心本质也是检查是否有其他RDB
或者AOF
子进程存在持久化操作,如果没有则调用rewriteAppendOnlyFileBackground
进行异步AOF
重写落盘,如果发现有rdb
等持久化存在则将aof_rewrite_scheduled
等待下一次redis
的事件循环得知该参数为1之后再次尝试AOF
重写:
对应的我们给出bgrewriteaofCommand
源码:
//用户手动调用bgrewriteaof进行aof重写
void bgrewriteaofCommand(redisClient *c) {
if (server.aof_child_pid != -1) {//如果存在aof子进程则不进行aof重写
addReplyError(c,"Background append only file rewriting already in progress");
} else if (server.rdb_child_pid != -1) {//如果进有rdb持久化存在,则设置aof_rewrite_scheduled后续时间时间检查允许的情况下直接进行重写
server.aof_rewrite_scheduled = 1;
addReplyStatus(c,"Background append only file rewriting scheduled");
} else if (rewriteAppendOnlyFileBackground() == REDIS_OK) {//执行aof重写
addReplyStatus(c,"Background append only file rewriting started");
} else {
addReply(c,shared.err);
}
}
最后一种则是redis
自带的事件轮询必须执行的函数serverCron
该方法就会检查上一步设置的aof_rewrite_scheduled
是否为1,若为1则进行AOF
重写。亦或者发现当前AOF
文件大小超过配置的最大值以及没有rdb
和aof
子进程也会触发AOF
异步重写落盘:
int serverCron(struct aeEventLoop *eventLoop, long long id, void *clientData) {
//......
//aof_rewrite_scheduled设置为1,且没有其他持久化子进程则进行aof重写
if (server.rdb_child_pid == -1 && server.aof_child_pid == -1 &&
server.aof_rewrite_scheduled)
{
rewriteAppendOnlyFileBackground();
}
//......
/* Trigger an AOF rewrite if needed */
//没有其他持久化子进程,且当前大小超出aof_rewrite_perc阈值,则进行aof重写
if (server.rdb_child_pid == -1 &&
server.aof_child_pid == -1 &&
server.aof_rewrite_perc && //auto-aof-rewrite-percentage aof大小超出基础大小比例,默认为1
server.aof_current_size > server.aof_rewrite_min_size)//当前大小aof_current_size大于auto-aof-rewrite-min-size为64M
{
long long base = server.aof_rewrite_base_size ?
server.aof_rewrite_base_size : 1;
long long growth = (server.aof_current_size*100/base) - 100;
if (growth >= server.aof_rewrite_perc) {
redisLog(REDIS_NOTICE,"Starting automatic rewriting of AOF on %lld%% growth",growth);
//执行AOF异步重写落盘
rewriteAppendOnlyFileBackground();
}
}
}
//......
}
AOF重写核心函数
我们了解的redis
的触发AOF
的几个时间点之后,再来聊聊AOF
重写的流程,如下图所示,在进行AOF
重写时,redis
会遍历所有数据库的键值对,然后将其生成redis
的RESP
协议规范的字符串,然后再将字符串写入写入aof物理文件中。
这里我们简单说明一下RESP
协议,因为客户端传入的指令都是基于RESP
协议的字符串,所以AOF使用这种格式的字符串就可以保证调用和redis
客户端一样的方法完成指令写入数据库,由于实现逻辑复用。以本文为例,假设我们aof
重写时遍历得到数据库0有一个键值对key
为k,value
为v,我可知这条数据是用户通过set k v
写入数据库的数据。对应AOF遍历到这个键值对之后就会基于RESP协议
得到下面这样一段字符串(附含义和注释):
# 写入数据库0的信息,后续aof恢复时就可以通过select 0定位到数据库中
*2\r\n$6\r\nSELECT\r\n$1\r\n0\r\n
# 字符串长度为3的set指令
*3\r\n$3\r\nset\r\n
# 1个字符串长度的key为k
$1\r\nk\r\n
# 1个字符串长度value为v
$1\r\nv\r\n
对应的我们给出AOF
重写的核心代码入口rewriteAppendOnlyFileBackground
,可以看到它本质就是fork
出一个子进程,然后子进程创建一个临时文件将解析到键值对字符串写入,最后通过原子重命名的方式将aof
文件重命名为appendonly.aof
:
int rewriteAppendOnlyFileBackground(void) {
pid_t childpid;
long long start;
if (server.aof_child_pid != -1) return REDIS_ERR;
if (aofCreatePipes() != REDIS_OK) return REDIS_ERR;
start = ustime();
if ((childpid = fork()) == 0) {//fork子进程进行aof重写
char tmpfile[256];
//......
//生成一个tmp文件将内存数据库的键值对写入文件
snprintf(tmpfile,256,"temp-rewriteaof-bg-%d.aof", (int) getpid());
if (rewriteAppendOnlyFile(tmpfile) == REDIS_OK) {//重写aof
size_t private_dirty = zmalloc_get_private_dirty();
//......
//结束子进程
exitFromChild(0);
} else {
exitFromChild(1);
}
} else {
//......
return REDIS_OK;
}
return REDIS_OK; /* unreached */
}
最后我们步入最核心的逻辑rewriteAppendOnlyFile
,可以看到其内部就是生成一个临时文件,然后遍历数据库中的键值对,根据键值对的类型生成相应的RESP字符串
(例如遍历到的值类型为字符串则转为set
指令的字符串,若是集合类型则声称hset
的字符串),最后写入临时文件,后续redis
的定时轮询时间时间会遍历检查先前自行任务的子进程的pid是否是aof
子进程的pid
,如果是则说明aof
重写完成直接将文件重命名为appendonly.aof
:
对应我们给出上述逻辑的核心代码入口rewriteAppendOnlyFile
,可以看到大致步骤就是生成临时文件,遍历键值对工具数据结构类型生成对应的RESP
字符串,完成后写入aof
临时文件:
int rewriteAppendOnlyFile(char *filename) {
//......
//打开临时文件
snprintf(tmpfile,256,"temp-rewriteaof-%d.aof", (int) getpid());
fp = fopen(tmpfile,"w");
if (!fp) {
redisLog(REDIS_WARNING, "Opening the temp file for AOF rewrite in rewriteAppendOnlyFile(): %s", strerror(errno));
return REDIS_ERR;
}
//......
for (j = 0; j < server.dbnum; j++) {
//根据遍历结果获得当前库生成select指令字符串
char selectcmd[] = "*2\r\n$6\r\nSELECT\r\n";
redisDb *db = server.db+j;
dict *d = db->dict;
if (dictSize(d) == 0) continue;
//获取库的字典迭代器
di = dictGetSafeIterator(d);
if (!di) {
fclose(fp);
return REDIS_ERR;
}
//写入切库select指令指令
if (rioWrite(&aof,selectcmd,sizeof(selectcmd)-1) == 0) goto werr;
if (rioWriteBulkLongLong(&aof,j) == 0) goto werr;
//遍历当前内存库
while((de = dictNext(di)) != NULL) {
sds keystr;
robj key, *o;
long long expiretime;
//获取键值对
keystr = dictGetKey(de);
o = dictGetVal(de);
initStaticStringObject(key,keystr);
expiretime = getExpire(db,&key);
//......
if (o->type == REDIS_STRING) {//如果value是字符串则记录set指令
/* Emit a SET command */
char cmd[]="*3\r\n$3\r\nSET\r\n";
if (rioWrite(&aof,cmd,sizeof(cmd)-1) == 0) goto werr;
/* Key and value */
if (rioWriteBulkObject(&aof,&key) == 0) goto werr;
if (rioWriteBulkObject(&aof,o) == 0) goto werr;
} else if (o->type == REDIS_LIST) {//如果是list则用RPUSH插入到尾部
if (rewriteListObject(&aof,&key,o) == 0) goto werr;
} else if (o->type == REDIS_SET) {//调用SADD遍历并存储
if (rewriteSetObject(&aof,&key,o) == 0) goto werr;
} else if (o->type == REDIS_ZSET) {//调用ZADD进行遍历重写
if (rewriteSortedSetObject(&aof,&key,o) == 0) goto werr;
} else if (o->type == REDIS_HASH) {//调用HMSET进行重写
if (rewriteHashObject(&aof,&key,o) == 0) goto werr;
} else {
redisPanic("Unknown object type");
}
//......
}
//刷盘结束,将数据写入到磁盘中
//......
if (fflush(fp) == EOF) goto werr;
if (fsync(fileno(fp)) == -1) goto werr;
//......
if (fflush(fp) == EOF) goto werr;
if (fsync(fileno(fp)) == -1) goto werr;
if (fclose(fp) == EOF) goto werr;
//......
return REDIS_ERR;
}
后续redis的定时任务就会检查最近执行任务的子进程是否为aof
子进程,如果是则说明aof重写
完成调用backgroundRewriteDoneHandler
将文件重命名为appendonly.aof
:
int serverCron(struct aeEventLoop *eventLoop, long long id, void *clientData) {
if (server.rdb_child_pid != -1 || server.aof_child_pid != -1) {
int statloc;
pid_t pid;
if ((pid = wait3(&statloc,WNOHANG,NULL)) != 0) {
//......
if (pid == server.rdb_child_pid) {
//......
} else if (pid == server.aof_child_pid) {//如果子进程为aof的,则说明重写完成,文件重命名为appendonly.aof
backgroundRewriteDoneHandler(exitcode,bysignal);
} else {
//......
}
//......
}
} else {
//......
}
//......
}
我们步入backgroundRewriteDoneHandler
即可看到文件重命名为appendonly.aof
的原子重命名的逻辑:
void backgroundRewriteDoneHandler(int exitcode, int bysignal) {
if (!bysignal && exitcode == 0) {
//......
//将上一步aof重写生成的tmpfile重命名为appendonly.aof
if (rename(tmpfile,server.aof_filename) == -1) {
//......
close(newfd);
if (oldfd != -1) close(oldfd);
goto cleanup;
}
//......
}
如何保证MQ消息的幂等性
现在微服务开发中为了满足限流消峰、减少系统之间的耦合等实际业务的需要,于是系统中往往会引入了MQ,加入了MQ之后如何保证消费者的消费幂等性便是需要解决的问题了。
1、幂等性问题
幂等性是数学上的一个概念,在我们开发中可以理解成就是多次执行某个方法并且得到结果是一样的,那么我们就认为这个过程就是幂等性。如下就是一些常见的幂等性的案例:
1、查询幂等性
select * from user where id = 10;
2、更新的幂等性
update user set name = 'zhangsan' where id = 20;
3、添加的幂等性(假设userId是唯一键)
insert info user(user_id, name,age) values(1, 'zhangsan',20);
4、删除幂等性
delete from user where id = 21;
查询是具备天然的幂等性(不考虑数据更新/删除的情况下,多次查询始终是一个结果)。
如上所示的更新/删除都是具备幂等性的,无论这个更新执行多少次,最终的结果都是一样的。
如上所示的添加也是具备幂等性的,因为userId字段是唯一键,重新添加数据库会提示异常的。
非幂等性的就是指多次执行的结果会不一样,如下所示是常见的一些非幂等性的案例:
1、更新的非幂等性
update user set age = age + 1 where id = 20;
2、添加的非幂等性
insert into user(name, age, nick_name) values ('zhangsan', 20, 'sanzhang')
如上所示的案例是非幂等性的,因为执行多次的结果是不一样的。所以针对这些非幂等性操作需要做单独的处理,保证一次请求只会执行一次。
2、MQ出现幂等性问题的原因
(1)生产者重复生产
由于网络原因,第一次生产者的发送的消息MQ已经接收到了,但是给服务响应的时候超时了,导致服务器再次投递了相同的消息,在消息队列中存在了两条相同的消息。下游的消费者就需要消费两次个相同的消息,消费者需要自己做幂等性的处理。
(2)MQ重试机制
消费者第一次消费id=1的消息时候,此时消费此时消费成功但是响应MQ的时候超时导致MQ认为当前的消息消费者没有成功消费,过一段时间之后重新投递给消费者消费,那么就导致同样的消息消费者消费了两次,此时消费者需要自己做幂等性的处理。
3、MQ的幂等性解决方案
分析了MQ出现幂等性问题的原因之后我们需要对消费者端做一些幂等性措施来保证实际的业务安全性。常见的方案如下所示:
(1)查询法
方案的原理:根据消息中的业务id(如订单的id)查询数据库中当前的业务是否执行过,如果业务执行过就不再处理当前消息。
方案存在缺陷,在高并发下会出现无法保证消息的幂等性问题,如下图所示的场景:
线程A和线程B同时到达查询位置,此时查询的时候没有数据,此时线程A继续持有时间片,线程B时间片用完,那么线程A执行业务并操作数据库,同时线程B又获得时间片开始执行业务处理。最终的结果就是线程A和线程B执行了两次数据库操作。所以高并发下查询法没有办法完成消息幂等性问题。
(2)加悲观锁方案
查询法中在高并发下仍然存在消息幂等性的问题,那么针对查询法的缺陷可以使用在底层加悲观锁的方案来解决,即就是查询的时候添加for update,这样可以保证在一个线程事务中加锁成功后,其他的线程就必须等待释放锁才能操作,如下图所示:
悲观锁可以很好的解决消息幂等性的问题,但是悲观锁的并发度低是一个不容忽视的性能问题。由于业务在事务中加锁了,如果业务相对复杂的情况下,消息的消费也会变长,那么在高并发下消息的消费速度会比较慢。
(3)乐观锁机制方案
通过上述的分析我们知道悲观锁的执行效率是比较低的,我们可以采用了乐观锁的机制替代悲观锁,如下所示:
在消息生产的时候携带消息的id、消息的版本号,然后在消费端根据乐观锁原理来执行,即就是根据id和version做更新操作,通过判断影响行是否大于0来判断执行是否成功;如果影响行数大于0表示当前的消息执行完成,反之就执行失败,执行失败不做抛异常。
乐观锁机制虽然可以提高系统的并发度但是它对业务有侵入,生产者不仅要携带参数id,现在还需要携带version传到下游中,这样给开发带来了一些不便。
(4)去重表方案
消费者先将消息中的唯一键(消息的id或者业务中的唯一键如订单id)获取到,然后数据插入到表message中(message表中设置msg_id为唯一键),如果数据添加成功就放行继续执行相关的业务逻辑;如果添加失败我们需要catch到异常,如果是DuplicateKeyException就直接吞掉异常并提示消息消费成功。核心的代码如下:
try {
1、添加数据到message表
messageMapper.addMessage(message);
2、执行业务逻辑
this.dealMessage(message);
3、执行业务成功后 返回消息消费成功的标识
return Boolean.TRUE;
}catch(DuplicateKeyException de){
log.warn("消息重复 messageId:{}", message.getId())
return Boolean.TRUE;
}
基于去重表方案由于依赖的只是消息表而与具体业务本身无关,所以此方案可以扩展到不同的应用场景中。
去重表方案也存在一定的局限性,如消息的消费逻辑必须是依赖于关系型数据库事务,如果消费过程中还涉及不支持事务的数据源数据的修改(如ES、Redis),那么执行过程有异常无法回滚数据。还要求数据库的数据必须是在一个库中,不支持跨库操作。
(5)非事务的去重表方案
如果现在业务需要将数据使用RPC的方式同步通知其他的系统,那么现在调整消费者消费方案,如下所示:
(1)消费者拉去MQ队列中的消息开始执行消费的逻辑,首先将消息信息携带过期时间的方式添加到数据表中,如下添加数据的sql:
insert message (msg_id, desc, exprire_time, status) values
(1,'MQ消息', 5, 0)
过期时间expire_time设置成5表示5分钟有效。
(2)根据数据库插入消息是否成功相应的逻辑处理
(a)MQ的消息添加数据库成功;首先完成消息的本地业务,然后使用RPC通知其他的系统,如果通知其他系统成功,此时将消息表数据状态修改成消费成功(status:0 -> 1),最后通知MQ本消息消费成功;如果通知其他系统失败就回滚业务、删除消息表的记录,通知MQ消息消费失败。等待下一次的推送继续业务处理。
(b)MQ消息添加数据库失败;查询已经添加到数据库中的MQ消息的的数据(如数据库中的数据:msgId=1, desc='MQ消息', expireTime=5 , status=0),根据数据status的值做判断:如果status=0(表示消息正在消费中)此时就要返回给MQ消息消费失败,等待下一次继续推送;如果status=1(表示消息消费成功),此时给MQ返回消息消费成功。
(3)开启定时任务(如XXL-Job)每隔一段时间(如3分钟执行一次),拉取数据库中过期的消息数据,然后做删除操作。这里设置过期时间的主要目的是防止出现死信的问题,如下所示:
第一条消息在处理中,由于某种原因一致处理很慢导致最终是失败了,第二条消息(其实是重复的消息)会走一遍先添加的操作,此时添加是失败的,那么会不断的走重试逻辑,重试一定次数之后就会加入到死信的队列中。添加了定时任务就是清理这些过期的数据保证下一次消息就可以执行正常的消费逻辑。
至此我们就完成利用消息的重试机制完成幂等+分布式事务的处理。
总结
(1)查询法去重解决方案是先判断再操作,但是会有并发重复消费的问题,针对并发去重问题可以借助select for update悲观锁或者乐观锁来解决。
(2)去重表方案可以很好的处理消息幂等问题,但是无法支持跨库操作以及不支持事务的数据源数据数据的修改,为此引入了非事务消息幂等性方案。
ConcurrentHashMap 的 get 方法是否需要加锁?
不需要加锁。保证 put 的时候线程安全之后,get 的时候只需要保证可见性即可,而可见性不需要加锁。具体是通过Unsafe#getXXXVolatile
和用 volatile 来修饰节点的 val 和 next 指针来实现的。
扩展 ConcurrentHashMap#get 方法源码
主要的定位逻辑在 e = tabAt(tab, (n - 1) & h)) != null
这行。而 tabAt 内部使用的就是Unsafe#getObjectVolatile
来保证可见性。
getObjectVolatile
实际是一个 native 方法,即本地方法,通过 JNI(Java Native Interface)调用底层的 C++ 实现。
它的原理就是根据对象的起始地址和字段的偏移量,直接从内存中读取字段的值。然后通过内存屏障确保该读取操作是 volatile
的,即对于其他线程是可见的。
所谓的内存屏障指的是 getObjectVolatile
方法会确保在读取操作之前插入一个读取屏障(load barrier),在读取操作之后插入一个读取屏障(load barrier)。这保证了字段的值在读取之前和之后都不会被 CPU 缓存,从而实现了 volatile 的语义。
因此,不需要加锁,利用 Unsafe
获取元素,再对比 hash 值以及 key 即可获取 value(这个流程就是普通的 map 的 get 流程)。
然后 Node 节点内的 val 和 next 指针也是被 volatile
修饰的,因此也可以保证可见性。
综上,不论是通过 hash 映射到数组中具体的 node 节点,还是因为 hash 冲突可能需要利用 next 指针遍历链表,定位到最终的 node 节点后需要获取 val 值,这几个关键点都可以保证可见性,因此不需要加锁。
扩展:Unsafe
Unsafe
类是 Java 提供的一个内部类,用于执行不安全的操作。它提供了直接操作内存和线程的能力。
列举 Unsafe 类的一些关键功能:
-
直接内存访问:允许直接分配、释放、读写内存。
-
对象操作:允许直接操作对象的字段,如设置或获取对象的字段值。
-
线程操作:包括暂停和恢复线程、管理锁等。
-
CAS 操作:提供了 compare-and-swap 操作,支持原子性更新操作。
为什么Spring不推荐使用@Autowired进行字段注入?
在Spring中,可以使用@Autowired注解来实现自动注入。然而,Spring官方文档和众多专家都不推荐使用@Autowired进行字段注入(field injection),而是推荐构造器注入(constructor injection)或设值注入(setter injection)。
字段注入的使用与弊端
字段注入是指直接在类的字段(成员变量)上使用@Autowired注解,以实现依赖的注入。示例如下:
public class MyService {
@Autowired
private MyRepository myRepository;
// class implementation
}
这种方式看似简单直接,但实际上存在诸多问题:
1. 不可见的依赖关系
字段注入将依赖关系隐藏在类的内部,使得类的依赖关系不明显。这会导致以下问题:
-
代码可读性差:其他开发者在阅读代码时,很难一眼看出该类依赖于哪些其他类。
-
代码可维护性差:在进行代码重构或维护时,开发者需要花费更多时间去理解和修改这些隐藏的依赖关系。
2. 无法使用final修饰符
由于字段注入是在对象实例化之后进行的,字段不能用final修饰。这会导致以下问题:
-
不变性(immutability)问题:无法确保依赖关系在对象生命周期内保持不变,从而可能引发难以调试的bug。
-
设计上的局限:无法利用Java语言的特性来设计出更稳固和安全的代码结构。
3. 测试不便
字段注入使得单元测试变得困难。使用字段注入时,测试类需要借助反射机制来注入依赖,这不仅繁琐,还容易出错:
public class MyServiceTest {
@InjectMocks
private MyService myService;
@Mock
private MyRepository myRepository;
@Before
public void setUp() {
MockitoAnnotations.initMocks(this);
}
@Test
public void testServiceMethod() {
// test implementation
}
}
4. 依赖注入框架的绑定
字段注入强依赖于依赖注入框架(如Spring)。一旦脱离了框架的管理,类将无法正常工作,限制了代码的可移植性和可复用性。
推荐的替代方案
为了克服上述缺点,Spring推荐使用构造器注入和设值注入。这两种方式不仅解决了字段注入的缺点,还带来了更多的优势。
1. 构造器注入
构造器注入是通过类的构造函数来注入依赖关系。示例如下:
public class MyService {
private final MyRepository myRepository;
@Autowired
public MyService(MyRepository myRepository) {
this.myRepository = myRepository;
}
// class implementation
}
构造器注入的优势包括:
-
清晰的依赖关系:所有依赖关系在类实例化时就明确了,代码可读性和可维护性大大提高。
-
不变性:可以使用final修饰符,确保依赖关系在对象生命周期内保持不变。
-
便于测试:测试类只需通过构造函数注入模拟对象,简化了单元测试的编写。
public class MyServiceTest {
private MyRepository myRepository = Mockito.mock(MyRepository.class);
private MyService myService = new MyService(myRepository);
@Test
public void testServiceMethod() {
// test implementation
}
}
2. 设值注入
设值注入是通过类的setter方法来注入依赖关系。示例如下:
public class MyService {
private MyRepository myRepository;
@Autowired
public void setMyRepository(MyRepository myRepository) {
this.myRepository = myRepository;
}
// class implementation
}
设值注入的优势包括:
-
灵活性:可以在对象实例化之后再注入依赖,适用于某些需要后期配置的场景。
-
便于测试:可以通过setter方法注入模拟对象,简化了单元测试的编写。
public class MyServiceTest {
private MyRepository myRepository = Mockito.mock(MyRepository.class);
private MyService myService = new MyService();
@Before
public void setUp() {
myService.setMyRepository(myRepository);
}
@Test
public void testServiceMethod() {
// test implementation
}
}
结论
虽然字段注入在使用上看似简单直接,但它隐藏了诸多潜在问题,不利于代码的可读性、可维护性和可测试性。Spring推荐使用构造器注入和设值注入,这两种方式不仅使依赖关系清晰明了,还提高了代码的稳固性和测试的便利性。
总之,选择合适的依赖注入方式是编写高质量、可维护的Spring应用程序的关键。通过采用构造器注入或设值注入,可以显著提高代码的健壮性和可测试性,避免字段注入带来的种种弊端。希望本文能够帮助开发者更好地理解Spring的依赖注入机制,并在实际项目中做出明智的选择。
DDD系列之商城系统
在领域驱动设计(DDD)中,系统划分是一个关键过程。在商城系统这样一个复杂的业务中,对系统进行正确的划分至关重要。DDD倡导的是根据不同的业务能力将系统分解为多个有界上下文(Bounded Contexts),每个有界上下文之间维持明确的边界,并尽可能地把模型和业务逻辑隔离。
1. 核心领域和有界上下文的划分
我们首先需要识别商城系统中的核心领域和次要领域。核心领域是企业竞争优势的集中体现,比如产品选择和订单处理。次要领域对业务重要但不是核心竞争优势,比如支付处理(通常依赖第三方服务)。
每个领域可以被定义为一个有界上下文,例如:
-
产品目录(Product Catalog):管理产品信息,分类,产品规格和库存等。
-
购物车(Shopping Cart):管理用户的选购商品,数量的增减,选择优惠等。
-
订单(Ordering):处理订单的创建,确认,状态跟踪和历史记录。
-
支付(Payment):管理支付交易,包括支付方式的选择,支付确认等。
-
物流(Logistics):负责货物的配送,追踪和配送状态更新。
-
用户账户(User Account):管理用户信息,权限和用户偏好设置。
-
营销(Marketing):处理促销活动,优惠券,积分系统等。
-
客服(Customer Service):提供用户咨询,反馈,投诉的处理。
2. 确定上下文间关系
每个有界上下文定义了自己的模型和边界,接下来需要确定这些上下文之间是如何交互的。DDD中常见的有界上下文关系包括伙伴关系(Partnership)、共享内核(Shared Kernel)、顾客-供应商(Customer-Supplier)和防腐层(Anti-corruption Layer)等。
比如,订单和支付上下文可能就是顾客-供应商关系。订单系统负责创建订单,并通知支付系统进行支付处理;而支付系统提供接口供订单系统调用。
3. 创建领域模型
对于每个有界上下文,创建其领域模型,包括定义实体、值对象、聚合和领域服务等。
-
在产品目录上下文,一个聚合根可能是
Product
,它可能包括SKU
,Name
,Description
,Price
和Inventory
等值对象。 -
在订单上下文,一个聚合根可能是
Order
,它可能关联OrderLineItems
,ShippingAddress
,BillingInformation
等。
4. 实现和集成
确保每个有界上下文通过建立清晰的通信路径,如使用REST API或消息队列等方式进行集成,并且尽可能地保持它们的自主性和松耦合性。
5. 反复迭代
设计初期的上下文划分往往并不完美,一般会随着开发的进行和对业务深入理解的过程中不断精化。每个上下文的边界也可能随着需求的变化而调整。
6. 关注领域逻辑和技术实施分离
注意不要让技术实施细节干扰领域模型的纯粹性。应用程序层、领域层、基础设施层等应该保持清晰的分离,以提供灵活性和可维护性。
综上所述,DDD在构建商城系统的过程中充当了总体规划和设计的蓝图,促使开发团队和业务专家紧密合作,以确保软件解决方案与业务策略的一致性和系统的可扩展性。
什么是 TCP 连接?
根据 RFC793 定义,TCP 的连接就是:TCP 为每个数据流初始化并维护的某些状态信息(这些信息包括 socket、序列号和窗口大小),称为连接。这些信息主要是为了实现可靠性和流量控制机制。
所以 TCP 所谓的是、面向连接的并不是真的是拉了一条线让端与端之间连起来,只是双方都维护了一个状态,通过每一次通信来维护状态的变更,使得看起来好像有一条线关联了对方。
TCP 中的 Socket、序列号和窗口大小
1)Socket:
在 TCP/IP 协议中,Socket
是通信的端点。由 IP 地址和端口号组成,如 192.168.1.1:8080
。在编程中,Socket 是用于网络通信的接口,通过它,应用程序可以发送和接收数据。
2)序列号 (Sequence Number):
TCP 序列号在传输过程中非常关键,因为它保证了数据传输的有序性和完整性。在三次握手中,双方交换初始序列号 (ISN),并在此基础上为后续的每个数据段分配序列号。
序列号有助于接收方按顺序重组数据包,并检测丢包情况。
3)窗口大小 (Window Size):
TCP 窗口大小指的是在特定时刻,接收方能够接收的最大数据量。这个大小由接收方通知发送方,表明接收方的缓冲区能处理多少数据。
它直接影响 TCP 的流量控制和拥塞控制机制。通过调整窗口大小,TCP 可以避免发送过多数据导致接收方的缓冲区溢出,也能根据网络状况调整发送速率。
什么是三元组和四元组?
1)三元组 (3-tuple):
三元组指的是 IP 地址和端口号的组合,即 IP 地址 + 端口号 + 协议类型
。例如,192.168.1.1:8080 (TCP)
就是一个三元组。在一个机器上,这样的组合唯一标识了一个网络服务或应用程序。
2)四元组 (4-tuple):
四元组即 源 IP 地址 + 源端口号 + 目的 IP 地址 + 目的端口号
。这四个要素唯一标识了一个 TCP 连接。
例如,一个客户端通过 IP 地址 192.168.1.100
和端口 50000
连接到服务器 192.168.1.1
的端口 80
,则这个连接可以表示为 192.168.1.100:50000 -> 192.168.1.1:80
。
这就是一个四元组,唯一标识了该连接。
敏感信息脱敏处理:保护用户隐私的序列化实践
场景描述
通过Mybatis与数据库交互,并使用Jackson对敏感信息进行脱敏处理。
使其HTTP接口返回用户的基本信息中身份证、手机和住址等敏感信息将被脱敏。
技术栈
-
SpringBoot:用于构建RESTful API。
-
Mybatis:用于数据库操作。
-
Jackson:用于JSON序列化和脱敏处理。
步骤
-
配置数据库:在
application.properties
中配置数据库连接。 -
创建数据库表:创建一个
user
表,包含id
、name
、identity_card
、phone
和address
字段。 -
创建实体类:创建一个
User
实体类,与数据库表对应。 -
创建Mapper接口:创建一个Mybatis Mapper接口,用于操作数据库。
-
实现数据脱敏:使用Jackson的自定义序列化器对敏感信息进行脱敏。
-
创建Service和Controller:编写业务逻辑和控制器来处理HTTP请求。
-
测试:运行应用程序并测试接口,验证数据脱敏效果。
代码示例
User实体类
@Data
public class User {
private Long id;
private String name;
private String identityCard;
private String phone;
private String address;
}
自定义序列化器
对身份证、手机、住址以及姓名进行脱敏处理,在SensitiveDataSerializer
序列化器中添加相应的脱敏逻辑。
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.SerializerProvider;
import com.fasterxml.jackson.databind.ser.std.StdSerializer;
import java.io.IOException;
public class SensitiveDataSerializer extends StdSerializer<String> {
public SensitiveDataSerializer() {
this(null);
}
public SensitiveDataSerializer(Class<String> t) {
super(t);
}
@Override
public void serialize(String value, JsonGenerator gen, SerializerProvider provider) throws IOException {
if (value != null) {
gen.writeString(maskSensitiveData(value));
}
}
private String maskSensitiveData(String data) {
// 根据数据类型应用不同的脱敏规则
if (isIdentityCard(data)) {
// 身份证脱敏:保留前6位和后4位
return data.substring(0, 6) + "********" + data.substring(data.length() - 4);
} else if (isPhoneNumber(data)) {
// 手机号码脱敏:保留前3位和后4位
return data.substring(0, 3) + "****" + data.substring(data.length() - 4);
} else if (isAddress(data)) {
// 住址脱敏:保留前两个字和后两个字,中间用*代替(根据实际需要调整)
int maskLength = data.length() - 4;
return data.substring(0, 2) + "*".repeat(Math.max(0, maskLength)) + data.substring(data.length() - 2);
} else if (isName(data)) {
// 姓名脱敏:保留第一个字,后面用*代替(或保留第一个字和最后一个字)
return data.substring(0, 1) + "*".repeat(data.length() - 2) + data.substring(data.length() - 1);
// 或者使用下面的方式,保留第一个和最后一个字
// return data.length() > 1 ? data.substring(0, 1) + "*".repeat(data.length() - 2) + data.substring(data.length() - 1) : data;
}
// 如果不是敏感数据,则原样返回
return data;
}
// 假设的方法,用于判断数据是否为身份证、手机号码、住址或姓名
// 实际应用中,你可能需要根据实际的数据格式进行判断
private boolean isIdentityCard(String data) {
return data != null && data.matches("\\d{18}"); // 简单的18位数字判断
}
private boolean isPhoneNumber(String data) {
return data != null && data.matches("\\d{11}"); // 简单的11位数字判断
}
private boolean isAddress(String data) {
// 住址的判断逻辑可能比较复杂,这里简化为非空且长度大于5
return data != null && data.length() > 5;
}
private boolean isName(String data) {
// 姓名的判断逻辑,这里简化为非空且长度大于1
return data != null && data.length() > 1;
}
}
User实体类中的注解
在User
实体类的敏感字段上使用@JsonSerialize
注解来指定使用SensitiveDataSerializer
进行序列化:
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
public class User {
// ... 其他字段和getter/setter
@JsonSerialize(using = SensitiveDataSerializer.class)
private String identityCard;
@JsonSerialize(using = SensitiveDataSerializer.class)
private String phone;
@JsonSerialize(using = SensitiveDataSerializer.class)
private String address;
@JsonSerialize(using = SensitiveDataSerializer.class)
private String name;
// ... getter/setter
}
Mapper接口
@Mapper
public interface UserMapper {
User selectUserById(Long id);
}
Service
@Service
public class UserService {
@Autowired
private UserMapper userMapper;
public User getUserById(Long id) {
return userMapper.selectUserById(id);
}
}
Controller
@RestController
@RequestMapping("/users")
public class UserController {
@Autowired
private UserService userService;
@GetMapping("/{id}")
public ResponseEntity<User> getUserById(@PathVariable Long id) {
User user = userService.getUserById(id);
return ResponseEntity.ok(user);
}
}
测试和验证
-
运行SpringBoot应用程序。
-
使用浏览器或Postman访问
http://localhost:8080/users/{id}
,其中{id}
是用户的ID。 -
观察返回结果,用户的身份证、手机和住址应该已经被脱敏处理。
请求返回结果打印示例
现在,当你通过HTTP接口获取用户信息时,身份证、手机、住址和姓名等敏感信息将被脱敏处理,例如:
{
"id": 1,
"name": "张*",
"identityCard": "123456********4567",
"phone": "138****4567",
"address": "北京**区**路**号"
}
这样,即使数据被非法获取,也无法轻易还原出原始敏感信息,从而有效保护了用户的隐私。
SpringBoot 接口性能提升方法
1. 数据库优化
-
索引优化:为数据库表中常用的查询字段添加索引,可以显著提高查询效率。通过
EXPLAIN
命令查看SQL的执行计划,确保索引被有效利用。 -
查询优化:优化SQL查询语句,减少不必要的字段选择和复杂的连接操作。尽量使用具体的字段名替代
SELECT *
,减少数据传输量。 -
批量操作:将多个数据库操作合并为批量操作,减少与数据库的交互次数,降低I/O开销。
-
事务管理:合理控制事务的大小和粒度,避免大事务造成的数据库锁定和资源消耗。
示例:假设你有一个用户表users,经常需要根据用户名username查询用户信息。
-- 创建索引
CREATE INDEX idx_username ON users(username);
-- 使用索引的查询
SELECT * FROM users WHERE username = 'exampleUser';
2. 缓存策略
-
使用缓存:对于频繁访问且数据变更不频繁的数据,可以使用缓存技术(如Redis、Memcached)来减少对数据库的访问。
-
数据预热:在系统启动或低峰时段,预先加载热点数据到缓存中,提高系统的响应速度。
示例:使用ConcurrentHashMap
作为简单的缓存示例。
import java.util.concurrent.ConcurrentHashMap;
public class CacheExample {
private static final ConcurrentHashMap<String, Object> cache = new ConcurrentHashMap<>();
public static Object getFromCache(String key) {
return cache.get(key);
}
public static void putInCache(String key, Object value) {
cache.put(key, value);
}
// 使用示例
public static void main(String[] args) {
putInCache("user1", "UserData1");
Object data = getFromCache("user1");
System.out.println(data); // 输出 UserData1
}
}
3. 异步和并行处理
-
异步处理:将耗时的操作(如远程接口调用、文件读写等)放在后台异步执行,避免阻塞主线程。
-
并行计算:利用多线程或多进程技术并行处理多个任务,提高系统的并发处理能力。
示例:使用CompletableFuture
进行异步处理。
import java.util.concurrent.CompletableFuture;
public class AsyncExample {
public static void asyncTask(String input) {
// 模拟耗时任务
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
System.out.println("Processed: " + input);
}
public static void main(String[] args) {
CompletableFuture.runAsync(() -> asyncTask("Task1"));
CompletableFuture.runAsync(() -> asyncTask("Task2"));
// 主线程可以继续执行其他任务
}
}
4. 锁粒度控制
-
减小锁粒度:在高并发的场景下,合理控制锁的粒度,避免锁的争用和死锁。
-
尽量使用行锁或间隙锁,减少表锁的使用。
示例:简单的使用synchronized
的例子
public class Counter {
private int count = 0;
// 方法级锁
public synchronized void increment() {
count++;
}
// 更细粒度的锁(使用对象锁)
private final Object lock = new Object();
public void incrementFineGrained() {
synchronized (lock) {
count++;
}
}
}
5. 代码优化
-
精简代码:移除冗余代码和不必要的逻辑判断,简化代码结构,提高执行效率。
-
算法优化:采用更高效的算法和数据结构来解决问题,减少计算量和内存使用。
-
避免重复计算:对于重复的计算结果,使用缓存机制来存储和复用,避免重复计算。
示例:使用缓存来避免重复计算。
public class Fibonacci {
private static final Map<Integer, Integer> cache = new HashMap<>();
public static int fibonacci(int n) {
if (n <= 1) return n;
if (cache.containsKey(n)) return cache.get(n);
int result = fibonacci(n - 1) + fibonacci(n - 2);
cache.put(n, result);
return result;
}
// 使用示例
public static void main(String[] args) {
System.out.println(fibonacci(10)); // 使用缓存后,重复计算会被避免
}
}
6. 并发控制
-
线程池:使用线程池来管理线程,避免频繁创建和销毁线程带来的开销。通过调整线程池的大小和参数,优化系统的并发处理能力。
-
连接池:对于数据库连接等资源,使用连接池来管理,减少连接建立和释放的开销。
示例:使用ExecutorService
来管理线程池。
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class ThreadPoolExample {
public static void task() {
// 模拟耗时任务
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
System.out.println("Task completed");
}
public static void main(String[] args) {
ExecutorService executor = Executors.newFixedThreadPool(4);
for (int i = 0; i< 10; i++) {
executor.submit(() -> task());
}
executor.shutdown();
while (!executor.isTerminated()) {
// 等待所有任务完成
}
System.out.println("All tasks completed");
}
}
7. 批处理
-
批处理:批处理通常涉及将多个请求或操作组合成一次大的操作,以减少数据库访问次数或网络请求。
-
示例:一个简单的列表批处理。
import java.util.Arrays;
import java.util.List;
public class BatchProcessing {
// 假设这是一个批量处理数据的函数
public static void processBatch(List<String> batch) {
// 批量处理逻辑
for (String item : batch) {
// 处理每个项目
System.out.println("Processing: " + item);
}
}
public static void main(String[] args) {
List<String> items = Arrays.asList("Item1", "Item2", "Item3", "Item4");
processBatch(items);
}
}
8. 读写分离
示例:这里我们仅展示如何区分读取和写入操作,并不直接连接到数据库。
public class ReadWriteSplitExample {
// 假设有方法来判断是读取还是写入操作
private boolean isReadOperation(String operation) {
// 根据操作类型返回true或false
return operation.startsWith("read");
}
// 模拟数据库操作
public void performDatabaseOperation(String operation, String data) {
if (isReadOperation(operation)) {
readFromDatabase(data);
} else {
writeToDatabase(data);
}
}
private void readFromDatabase(String data) {
// 读取数据库逻辑
System.out.println("Reading from database: " + data);
}
private void writeToDatabase(String data) {
// 写入数据库逻辑
System.out.println("Writing to database: " + data);
}
public static void main(String[] args) {
ReadWriteSplitExample example = new ReadWriteSplitExample();
example.performDatabaseOperation("read", "some_data");
example.performDatabaseOperation("write", "new_data");
}
}
当然,数据库层面也可以实现读写分离,目前一般的云数据都有这个功能。