楔子
有时候因为业务和功能的要求可能会出现一段简短的时间之内频繁的写入数据库,而写入数据库的这个时机有不能确定,可能是是由请求提交来确定;比如,前端一秒之内发起多个请求,而这多个请求又是写入数据库,这样一来短时间之内会产生多个持久化操作;那么针对这种情况又该如何优化呢?下面我就综上结合我在实际开发中遇到的类似问题,并说一说是如何解决的。
实际开发
在实际开发中遇到过这样一个功能,一个类似于多人聊天室的模块,用户与用户之间相互在房间里一起对话,支持发送图片、文字、语音,消息这一块需要使用非对称加密,同时序列化这里使用的Google protobuf3,;由于前端是浏览器,所以通讯这一块使用的是Web Socket来支撑;持久化这一块采用的是MySQL,需要将用户的聊天记录持久化起来,我们平时也接触过这一类这一类的聊天软件,一般的消息的发送频率是根据用户而定的,我们也无法预估用户会在哪一段时间内发送大量密集的消息;但是,我们可以预估的是用户可能会出现在很短的时间内发送大量的消息,那么这个“很短的时间”可能为一秒或者半秒甚至更短;在惯性思维的情况下消息早期的持久化逻辑我是这样实现的
问题分析
流程分析
1、用户在极短暂的时间内发送大量的消息
2、通过Web Socket将消息传递至后端
3、后端解密消息之后再将消息持久化至数据库
事务脚本模式
分析以上流程发现这一个非常传统并呆板的实现方式,前端提交数据->后端解析数据->持久化到数据库,跟Martin flower提出的事务小脚本模式别无二致,是非常纯粹的面向过程实现流程,关于事务脚本模式这个看这里(业务逻辑模式——事务脚本模式-CSDN博客)
模块开发初期我也像绝大多数人一样将大量的时间和精力放在整体的功能模块实现上,从而忽略了一些功能实现的细节,虽然说这个功能实现的细节在实际的开发阶段时大同小异,并不会体现出多大差异,但是,在实际的生产环境中时就会发现存在一些性能上的问题,并且,会随着数据量的增大,情况会愈发严重。
在这里会出现性能的问题就是第3点,如果用户在极短暂的时间内发送大量的消息,那么每一条消息都会进行持久化的操作并且还会频繁的提交事务;了解MySQL持久化的原理的都知道,MySQL本身是通过缓冲池来维护数据的,通过redo log来保证数据丢失的问题,那么,当事务提交时MySQL会将缓冲池中的脏页刷新至磁盘,而磁盘的I/O性能肯定是远远小于DDR的速度,哪怕是目前NVM.2的3D储存也无法相比,所以就意味着此时的事务提交性能开销是非常大的;那么如何解决这一问题呢?让它尽可能的降低频繁持久化带来的开销呢?
解决方式
如下图所示
队列
我在后端持久化时增加了一个队列,该队列用来存储用户提交的消息,然后,通过额外的线程以均速重试的方式将消息批量写入到数据库并统一提交事务
当然,看到这里可能有人要想了,如果,写入数据库停顿的时间过长怎么办?在持久化数据库之前服务器宕机又如何?
如果,持久化停顿的时间过长的话就可能导致积压消息过多并且队列中数据与数据库中的数据不一致的情况,用户在查询消息时会出现消息丢失,那么如何保证了?
重试机制
之前提到过主要是解决用户“在极短暂的时间”内发送大量的消息,避免,这个大量的消息频繁持久化到数据中而降低数据库性能,所以,这里可以将持久化停顿的时间间隔设定为非常短的一个间隔,目的只为了解决将短时间之内频发持久化大量消息,只需要在短暂的时间内批量持久化这一范围的消息即可,并且这里引入了重试机制解决临近消息持久化问题;那么定义的消息处理模型如下
MySQL缓冲池
该思路是在MySQL数据缓冲池将脏页数据刷入到磁盘中启发的,利用MySQL实现的这一特性这里也可以通过缓冲池+定时刷入的方式来持久化数据(Mysql-缓冲池 buffer pool_mysql bufferpool-CSDN博客)
流程分析
1、用户在极短暂的时间内发送大量的消息
2、通过Web Socket将消息传递至后端
3、后端使用阻塞队列来存储消息
4、用户请求的线程会唤醒守护线程,该线程维护消息的持久化
5、守护线程会将队列中所有的消息取出并持久化到数据库
6、每次唤醒线程后都会重试,其目的是为了将后续临近的消息持久化到数据库
这样一来就可以解决短时间大量消息频繁持久化导致的数据库性能开销问题了
Codes
/**
* 阻塞缓冲队列实现工具类
* 目前用来提高开标大厅中消息持久化的IO性能
* @Author: Song L.Lu
* @Since: 2023-08-07 11:24
**/
@Slf4j
public class BlockingQueueBuffer<E> extends LinkedBlockingQueue<E> implements Queue<E> {
/**
* 缓冲区线程首次启动
*/
private volatile boolean firstBoots = true;
/**
* 缓冲区复写间隔,默认1000毫秒
*/
private long delay = 1000L;
/**
* 缓冲区每次取数
*/
private int newElement = 100;
/**
* 当前复写重试次数,当pos>=ROUND_POS_TIMES时监控线程会挂起
*/
private int pos = 0;
/**
* 复写重试的总次数,默认为5
*/
private final int ROUND_POS_TIMES = 5;
/**
* 缓冲区复写间隔时间单位,默认为毫秒级
*/
private TimeUnit timeUnit = TimeUnit.MILLISECONDS;
/**
* JUC下的可重入锁
*/
private ReadWriteLock lock = new ReentrantReadWriteLock();
public BlockingQueueBuffer() {
}
public BlockingQueueBuffer(long delay, int newElement, TimeUnit timeUnit, boolean fair) {
this.delay = delay;
this.newElement = newElement;
this.timeUnit = timeUnit;
this.lock = new ReentrantReadWriteLock(fair);
}
public boolean isFirstBoots() {
boolean result;
try{
this.lock.readLock().lock();
result = firstBoots;
}finally {
this.lock.readLock().unlock();
}
return result;
}
public void setFirstBoots(boolean firstBoots) {
try{
this.lock.writeLock().lock();;
this.firstBoots = firstBoots;
}finally {
this.lock.writeLock().unlock();
}
}
/**
* 将消息推入缓冲区,推入缓冲区之后并不会立即写入数据库,而是,根据监控线程的频率定时写入数据库
* @param e
* @return
*/
public boolean push(E e){
checkNotNull(e);
boolean result = this.add(e);
return result;
}
/**
* 将缓冲区中的数据全部取出并返回给Consumer函数,这个函数是一个函数接口支持回调;该方法只需要触发一次,后续会自动挂起
* 通过push方法可以唤醒该方法中的挂起的线程;每次唤醒时会获取队列中的所有元素,并延迟delay的间隔,每次唤醒都会持续重试5次,然后自动挂起,周而复始
* @param c
* @throws InterruptedException
*/
private void drainToTail(Consumer<Set<E>> c) throws InterruptedException {
Set<E> runnerTask = new HashSet<>();
for(;;) {
Queues.drain(this, runnerTask, this.newElement,this.delay, this.timeUnit);
c.accept(runnerTask);
log.info("缓冲区消息数量:{}",runnerTask.size());
runnerTask.clear();
this.pos++;
if(this.pos >= ROUND_POS_TIMES){
this.pos = 0;
this.setFirstBoots(true);
return;
}
}
}
public void accept(Consumer<Set<E>> c) throws InterruptedException {
drainToTail(c);
}
private void checkNotNull(E e){
Preconditions.checkNotNull(e);
}
}