思考记录:JdbcTemplate、线程池队列、Event、Redis初始化Map、WatchDog、IO模型

1 JdbcTemplate

        做了个大批量插入功能,一开始是想着用Mybatis的<foreach>标签动态拼,1000条1000条的执行SQL,又觉得这个方法笨笨的,不太优雅。因为MySQL本身的并发插入性能是不差的,所以用稍微底层一点的方法应该也不错。

        说到底层,稍微底一点的肯定是手动获取Connection构建PreparedStatement发送请求最后关闭连接了(注意一下,凡是手动获取连接的地方,最好都在finally里手动关闭一下;尽管大部分工具封装时都会帮你做这件事,但难免遗漏,养成良好习惯)。但这也太底了,自己挨个手写行是行,可是没必要——那就可以用Spring封装的JDBC工具JdbcTemplate

        JdbcTemplate为我们封装了一个batchUpdate方法用于批量插入(update泛指一切修改操作,包括增删改),其实看源码底层还是我们刚说的老一套,他美丽就美丽在可以指定批处理条数;我们希望每1000条提交一次,就只需要美美传入1000即可。

        我幸福地构建数据、重写PreparedStatement方法、指定单次处理1000条,然后发送请求,40000条数据插了10分钟......

        失望地点开源码开始逐行debug,发现一个异常的东西:

    if (batchSupported) {
        ps.addBatch();
        if (n % batchSize == 0 || n == batchArgs.size()) {
            if (this.logger.isTraceEnabled()) {
                batchIdx = n % batchSize == 0 ? n / batchSize : n / batchSize + 1;
                int items = n - (n % batchSize == 0 ? n / batchSize - 1 : n / batchSize) * batchSize;
                this.logger.trace("Sending SQL batch update #" + batchIdx + " with " + items + " items");
            }

            rowsAffected.add(ps.executeBatch());
        }
    } else {
        batchIdx = ps.executeUpdate();
        rowsAffected.add(new int[]{batchIdx});
    }

        if里面是我们所熟悉的addBatchexecuteBatch,但else就有点不尽人意了,居然使用得是executeUpdate,合着这么慢是你一条一条给我执行呢呗?那问题就是为什么我会走进这个else条件里,这个“batchSupported”到底是啥?

        查了一下资料再看了看源码,发现PreparedStatement里有个“getRewriteBatchedStatements

方法,从Connection的MetaData中判断这个属性,因此我们需要在连接URL的后面指定“rewriteBatchedStatements=true”,否则驱动就不会帮你将插入语句重写成batch形式。

        但项目里有些人用Connection手写batch、且连接URL后面没加这个属性,好像也没出现不批量执行的问题,奇了怪了。但我反正是解决了,之前插10分钟,加入之后速度来到了欣慰的十来秒。

2 线程池队列

        线程池属性的设置是个老生常谈的话题了,之前的文章也有提及过,大体分为IO密集型和CPU密集型两大阵营。

        IO密集型由于大部分任务落在网络IO、磁盘IO上,CPU常处于空轮询状态,因此可以将核心线程数和最大线程数都稍微设置大一些,一般为核心*2;线程忙着写磁盘挂起后,就切换到别的线程。CPU密集型则为大部分计算任务,CPU基本不会闲下来,这时就将核心线程数和最大线程数都设置为核心数+1,只留一个线程闲着及时支援,尽量减少线程上下文切换带来的损耗。

        今天主要想说说线程池的队列该如何选择,队列长度怎么设置合适,先介绍一下都有哪些队列:

  • ArrayBlockingQueue 有界数组队列,底层基于数组实现,因此必须传入队列长度来指定数组长度。
  • LinkedBlockingQueue 无界链表队列,底层基于链表实现,不传入队列长度时为无界链表,传入长度时为有界链表。
  • PriorityBlockingQueue 无界优先级队列,传入的线程需要实现Comparable接口,自定义优先级计算逻辑,越靠近尾部的线程优先级越高,则会优先执行。
  • SynchronousQueue 阻塞队列,不会存放任何线程任务,拿到任务就会直接提交给线程,达到最大线程数后再提交会触发拒绝策略。
  • DelayQueue 无界延时队列,传入的线程需要实现Delayed接口,自定义延时触发逻辑。

        这里比较让人困惑的是PriorityBlockingQueue,在查阅源码后,发现存储任务底层还是依靠数组,有参构造器可以传入队列长度,且无参构造器默认传入DEFAULT_INITIAL_CAPACITY = 11。但当我自信满满提交了1w个线程准备看看报错时,队列长度极速增长到了1w,而核心线程数仍然是1没动......

        首先要有一个认知:不要用无界队列!不要用无界队列!不要用无界队列!如果队列无限增长,OOM是迟早的事。不涉及特殊需求的情况下,我们都会使用有界的ArrayBlockingQueue或LinkedBlockingQueue,那这俩我们选哪个?

        区别有二,最明显的是Array和LinkedList的区别:Array是定长数组,而LinkedList是动态列表,且变动时需要维护头尾两个节点;两者比较之下,LinkedList显然操作更麻烦一些、占用内存更多一些——不要小瞧Node消耗的内存,在链表长度较大时Node占用的内存是十分可观的,但是链表操作的效率还是高于列表一些的。其次是内部的并发控制机制,ArrayBlockingQueue采用一把锁同时控制放入和取出任务,而LinkedBlockingQueue使用两把锁分别控制,那LinkedBlockingQueue在并发情况下的锁竞争明显会小于ArrayBlockingQueue,也就有着更高的并发性能。

        Spring封装的ThreadPoolTaskExecutor里创建队列的createQueue方法,也是默认使用了LinkedBlockingQueue,因此还是比较推荐的。

        那队列长度应该设置成多少呢?要我说这个问题没有银弹,多方面思考一下,设置太大会导致线程数永远没法超过核心线程数,因为队列始终没满,还有可能占用过多内存频繁触发GC甚至OOM;设置太小吞吐量又会变低,队列满后使用不合理的拒绝策略也会有很大隐患。所以在我看来,最好的答案是评估业务并发量、业务复杂度与耗时、实时性要求,综合给出一套合理的设计。

        比如这个业务就是个异步发邮件,网络IO又慢又不要求立马能看到,那就把线程数拉低、队列长度拉大让他慢慢执行去;但如果这是个支付的异步回调通知,顾客付了5分钟了一点儿响没有,那就比较可怕了,这种就最好把核心线程数拉高、队列长度拉小、再加上CallerRunsPolicy队列满时让调用者自己处理,让用户及时收到响应。

3 Event与EventListener

        Event和EventListener,可以说除了好用还是好用。了解事件驱动模型的朋友们应该也知道“消息驱动”,在我理解中,事件驱动算是对消息驱动的二次封装(因为EventListener和RabbitMQ的源码有点异曲同工的意思),只是事件用于应用程序内部,消息用于组件间的交互。

        本篇主要是记录一下事件的最佳实践,首先事件监听器只能接收事件,所以所有Event对象都需要继承ApplicationEvent;而事件一般都有一些共同点,我们可以封装一个符合我们需求的基类。因此最好是创建一个泛型类BaseEvent<T>继承ApplicaitonEvent,在这个基类中可以放一些一定会用到的元素,例如状态/类型、以及一定会用到的事件主体。那么大概会是下面这样:

public class BaseEvent<T> extends ApplicationEvent {

    private static final long serialVersionUID = 6370223317981500282L;

    //事件类型,可扩展
    private Integer type;

    private T obj;
    
    //Todo:构造器等省略

}

         type可以用于Condition条件判断时使用,比如订单事件传入时,可以根据type判断是秒杀订单、退款订单、普通订单让不同的事件处理;而obj很明显就是事件传入的对象了,可以指定不同的泛型类。之后所有的事件对象都可以继承这个BaseEvent,指定事件需要处理的类,通过构造器传入必需的参数。

        事件默认是同步的,即主方法发送事件后,会先等待事件监听方法执行完毕再向下执行;我们也可以加上@Async来使其成为异步事件,不过要记住自行捕获异常并立马处理,因为多线程的异常抛出主线程是感知不到的,当时就应该做好异常处理和设计补偿机制。

        至于一个监听器监听多个事件(不推荐),一个事件被多个监听器处理(记得加上@Order指定执行顺序),有返回值和无返回值的监听方法(返回一个事件并再次发布)就不啰嗦了,大家自行了解一下吧。

4 Redis初始化Map出现的问题

        最近做了个东西,需要每天初始化一个HashKey并指定0点过期,心里一想那不挺简单吗?直接大手一挥,初始化个HashMap然后hmset,最后expire到明天0点,然后死活找不到这个key。心里急啊,怎么会这样呢?

redisTemplate.opsForHash().putAll(key, map);

        工具类里是这样调用的,看着也没什么问题,看完RedisTemplate源码想抽自己一巴掌:

 public void putAll(K key, Map<? extends HK, ? extends HV> m) {
        if (!m.isEmpty()) {
            byte[] rawKey = this.rawKey(key);
            Map<byte[], byte[]> hashes = new LinkedHashMap(m.size());
            Iterator var5 = m.entrySet().iterator();

            while(var5.hasNext()) {
                Entry<? extends HK, ? extends HV> entry = (Entry)var5.next();
                hashes.put(this.rawHashKey(entry.getKey()), this.rawHashValue(entry.getValue()));
            }

            this.execute((connection) -> {
                connection.hMSet(rawKey, hashes);
                return null;
            }, true);
        }
    }

        人第一行就判断传入的map是否为空了,最后初始化map的时候随便放了个键值对,问题就修复了。看来以后遇到问题还是得先看源码......

5 Redisson WatchDog

        分布式锁是一个老生常谈的话题了,有经验的大佬都会建议给分布式锁加上一个合理的过期时间,以免程序逻辑异常或释放锁失败造成死锁。过期时间如何评估就又成了一个大难题,设久了吧程序异常其他进程就死等这个锁,设短了吧万一业务异常耗时久,还没执行完呢释放了,又会有并发问题。

        直到我遇到了Redisson,使用了他提供的WatchDog看门狗,一切都明朗了。WatchDog这个概念其实在很多地方都存在,比如单片机的硬件WatchDog Timer,是一个拥有独立供电的定时器电路;如果超过时间没有接收到从主电路传来的讯息,即代表主电路已经失控,强行关闭或Reset主控程序。

        Redisson的看门狗也差不多,不设置锁过期时间便会激活,Redisson会为锁设置默认30秒过期,在客户端没有释放锁之前,每过 锁续期时间/3 秒(默认为10秒),便会为锁续上设定的锁续期时间(默认为30秒);这样一来业务执行过程中锁释放的问题,就被完美解决了。

        这其中有几个问题需要注意:

  • 开门狗开启的条件是“不能设置过期时间”。
  • 如果客户端不释放锁,会导致看门狗无限续期,所以不管业务异常与否,在try和catch里都应该释放锁,或者把释放锁操作放在finally里。总之一定要保证显式释放锁!
  • 客户端宕机服务崩溃锁不会被续期,因为看门狗线程是在客户端开启的,客户端宕机看门狗也会去世,就不会续期锁啦。
  • 客户端释放锁操作失败锁不会被续期,Redisson内部维护了一个Map,里面放着看门狗看守的锁;而释放锁操作回调的第一行就是将操作的锁从Map里移除,这个回调函数是无论如何都会被执行的,因此顶多就是锁移除失败但不会续期,到了过期时间锁依然会释放。但这一点也印证了上文说的“设久了程序异常其他进程就死等这个锁”,过期时间不要设置太长哦。

6 网络IO模型

        仅记录一下自己对于阻塞/非阻塞,同步/异步IO的一点理解。

        传统的BIO(Blocking IO,阻塞IO)下,Client发送请求Server在接收到后,首先会分配一个线程用于处理本次请求,之后该线程就会阻塞等待网络IO传输请求报文,直至Client的请求报文完全发送至Server,开始处理请求。这种情况会造成传输1分钟、处理1毫秒,CPU的运算速度是IO设备远远没法相比的,大部分时间CPU就在那坐着等你。而且来一个请求分配一个线程,线程又都阻塞在IO上,线程数远大于核心数时就会产生频繁的线程切换,性能急剧降低。

        而NIO(Non-Blocking IO,非阻塞)下,Server在接收请求后的网络IO传输期间,可以去忙别的,轮询到IO完成的响应再处理请求。很显然NIO能够大大提高系统的响应速度,可还是怪怪的,虽然操作系统提供的read操作不阻塞了,但线程需要一直轮询IO结果,直至IO响应完成再继续处理——等于线程不阻塞在IO上、又阻塞在轮询上了......

        所以这个不阻塞的read操作,肯定不是这样用的。

        最核心的“多路复用”出现了,原本一个线程轮询一个IO状态,等于还是一个请求一个线程,但现在我们可以用一个线程轮询多个IO状态。原先BIO情况下,你想进行IO操作就必须立正站好在那等着;现在有了NIO,只需要问问操作系统IO操作完成了没,甚至可以一个线程问多个IO操作状态,这才是不阻塞read的真正奥义!

        不同于BIO的是,Java的NIO为我们提供了Buffer/Channel/Selector三大利器,BIO面向流(Stream)读取,而NIO面向缓冲区(Buffer)读取。流的操作是线性的,要不往里流要不往外流(InputStream/OutputStream),且在流没有结束以前需要一直读取;而Buffer则是以“块”为单位一批一批地读取,当内核完成接收后就通知应用程序的Channel可以读取了,Channel就会往Buffer里读,此时应用程序就拿到了IO的数据。而且Channel和Buffer都是可读可写的,调用Buffer的flip方法,即可将其读写功能翻转。

        Selector用于将Channel注册进其中并加以管理,Selector会不断轮询他所管理的Channel,每当有可读的Channel就让他出列并将数据读取到Buffer;类似于组长管组员,一个Selector是一个线程小组长,一群注册进这个Selector的Channel就是组员,组长不间断地问:“你好了没啊?你好了没啊?你好了没啊?”当这次询问得到了某些组员的肯定回复,就开始读取这些数据。

        经典的模型就是线程A注册为“SelectionKey.OP_ACCEPT”,监听新连接的接入;再注册个线程B为“SelectionKey.OP_READ”,用于轮询所有接入连接的IO进度。线程A监听到连接接入,立马回复收到并把连接注册到线程B,让线程B去管理IO进度并处理程序逻辑。这样可以做到仅用两个线程就管理无数个连接,但一般也不会这样做,我们会创建很多个类似线程B的线程,用几个线程小组长管理一大堆连接,以此来提高效率。

        至于AIO(Async IO,异步IO)和NIO最大的区别就是:NIO需要线程不断轮询,而AIO采用了回调或者事件驱动,异步任务完成了主动通知,性能应该要强于NIO,具体不太了解就不误人子弟了。

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值