朱晔的互联网架构实践心得S2E2:写业务代码最容易掉的10种坑
我承认,本文的标题有一点标题党,特别是写业务代码,大家因为没有足够重视一些细节最容易调的坑(侧重Java,当然,本文说的这些点很多是不限制于语言的)。
1、客户端的使用
我们在使用Redis、ElasticSearch、RabbitMQ、Mongodb等中间件或存储的时候肯定都会使用客户端包来和这些系统通讯,我们也会使用Http的一些客户端来发Http请求。在使用这些客户端包的时候,非常容易犯错的一个地方就是Client的使用方式,比如有一个叫做RedisClient的类,是Redis操作的入口。你应该是每次使用new RedisClient().get(KEY)呢还是注入一个单例的RedisClient呢?
我们知道,这些组件的客户端往往需要和服务端通过TCP连接进行远程通讯,考虑到性能,客户端一般都维护连接池做长链接,如果RedisClient或MongoClient或HttpClient之类的Client在类内部维护了连接池,那么这个Client往往是线程安全的,可以在多线程环境下使用的,并且严格禁止每次都新建一个对象出来的(如果框架做的足够好一般本身就会是单例模式的,不允许实例化)。
你想,如果一个Client每次new的时候它会建立5个TCP链接给整个应用程序公用就是考虑到建连的耗时,而因为使用不当每次调用一次Redis都建5个TCP链接,那么QPS可能就会从10000一下子到10。更要命的是,有的时候这些Client不但维护用于TCP链接的连接池,还会维护用于任务处理的线程池,线程池的可能还会有比较大的默认核心线程,这个时候再去每次使用new一个Client出来,那就是双重打击了。
在使用Netty等框架的时候,本来就是基于Event Loop线程公用来做IO处理的,对于客户端来说Work Group可能只会有2~4个链接就够了,我们假设4个链接好了,如果这个时候Client框架的开发者对于Netty使用不当,对于客户端连接池再去每次new一个Bootstrap出来,客户端连接池又搞了所谓的5个,那就相当于每次20个EventLoopGroup(线程),这个时候客户端的使用者又对于框架使用不当每次再new一个Client出来,相当于做一个请求需要20个线程,这就是三重打击。
那你可能会说,是不是所有的Client都做单例使用就好了呢?并不是这样,这取决于Client的实现,很可能Client只是一个入口,那些连接池和线程池维护在另外一个类中,这个入口本身是轻量的,自带状态的(比如一些配置),是不允许作为单例的,框架的开发者就是想让大家通过这个便捷入口来使用API。这个时候如果当做单例来使用说不定会出现串配置的问题。所以Client使用最佳实践这个问题没有统一的答案。
这里我没有提到数据库的原因是,大家使用数据库一般都使用Mybatis、JPA,已经不会和数据源直接打交道了,一般而言不容易犯错。但是现在中间件太多了,客户端更是有官方的有社区的,我们在使用的时候一定要根据文档搞清楚到底应该怎么去使用客户端(或者请使用关键字XXX threadsafe或XXX singleton多搜索一下Google确认),如果搞不清楚就去看下源码,看下客户端在连接池线程池这块的处理方式,否则可能会造成巨大的性能问题。还不仅仅是性能问题,我见过很多因为对客户端使用不当导致的内存暴增、TCP链接占满等等导致的服务最终瘫痪的重大故障。
2、服务调用参数配置
现在大家都在实践微服务架构,不管是使用什么微服务框架,是基于HTTP REST还是TCP的RPC,都会设置一些参数,这些参数在设置的时候如果没有认真考虑的话可能就会有一些坑。
超时配置
客户端一般最关注的是两个参数,连接超时(ConnectionTimeout)和读取超时:(ReadTimeout),指的是建立TCP链接的超时和从Socket读取(需要的)数据的超时,后者往往不仅仅是网络的耗时,包含了服务端处理任务的耗时。在设置的时候考虑几个点:
- 连接超时相对单纯,TCP建链一般不会耗时很久,设置太大意义不大,看到有设置60秒甚至更长的,如果超过2秒都连不上还不如直接放弃,快速放弃至少还能重试,何必苦等。
- 读取超时不仅仅涉及到网络了,还涉及到远端服务的处理或执行的时间,大家可以想一下,如果客户端读取超时在5秒,远程服务的执行时间在10秒,那么客户端5秒后收到read timed out的错误,远程的服务还在继续执行,10秒后执行完毕,这个时候如果客户端重试一次的话服务端就再执行一次。一般而言,建议评估一下服务端执行时间(比如P95在3秒),客户端的读取超时参数建议比服务端执行时间设置的略长一点(比如5秒),否则可能遇到重复执行的问题。
- 之前遇到过一个问题,Job调用服务执行定时任务生成对账单,定时任务执行一次需要30分钟(完成后再更新数据状态为已生成),但是Job客户端设置的读取超时是60秒,Job每1分钟执行一次,相当于Job不断超时,不断重试,每1分钟执行一次超时了接着又执行,这个任务本应该一天处理一次,因为这个问题变为了执行了30次(请求数量放大),因为任务处理极其消耗资源,执行了还没到30次后服务端就直接挂了。大多数RPC框架在服务端执行都会在线程池中执行业务逻辑,执行本身不会设定超时时间。还是前面那个问题,对于耗时比较长的操作,要考虑一下是否需要做同步的远程服务。即使要做,也要通过锁控制好状态,或者通过限流控制好并发。
- 大家可能会觉得奇怪,为啥大多框架不关注写入超时(WriteTimeout)这个配置?其实写入操作本身就是写入Socket的缓冲,数据发往远端的过程是异步的,就写入操作本身而言往往是很快的,除非缓冲满了,我们无法知道写入操作是否成功写到远端,如果要知道的话也要等拿到了响应数据的时候才知道,这个时候就是读取阶段了,所以写入操作本身的超时配置意义不大。
自动重试
无论是Spring Cloud Ribbon还是其它的一些RPC客户端往往都有自动重试功能(MaxAutoRetries
和 MaxAutoRetriesNextServer
),考虑到Failover,有的框架会默认情况下对于节点A挂的情况下重试一次节点B。我们需要考虑一下这个功能是否是我们需要的,我们的服务端是否支持幂等,框架重试的策略是很对Get请求还是所有请求,弄的不好就会因为自动重试问题踩坑(不是所有的服务端都对幂等问题处理的足够好,或者换句话说,和之前那个问题相关的是,不是所有服务端能正确处理请求本身还没执行完成情况下的幂等处理,很多时候服务端考虑的幂等处理是基于自己的操作执行完成后提交了事务更新数据表状态下的幂等处理)。对于远程服务调用,客户端和服务端商量好幂等策略,明确超时时间不一致情况下的处理策略很重要。
3、线程池的使用
线程池配置
阿里Java开发指南中提到:
线程池不允许使用 Executors 去创建,而是通过 ThreadPoolExecutor 的方式,这样
的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险。
说明:Executors 返回的线程池对象的弊端如下:
1)FixedThreadPool 和 SingleThreadPool:
允许的请求队列长度为 Integer.MAX_VALUE,可能会堆积大量的请求,从而导致 OOM。
2)CachedThreadPool 和 ScheduledThreadPool:
允许的创建线程数量为 Integer.MAX_VALUE,可能会创建大量的线程,从而导致 OOM。
建议大家熟悉研究一下线程池基本原理,采用手动方式根据实际业务需求来配置线程数、队列类型长度、拒绝策略等参数。
我们往往会使用一定的队列来做任务缓冲(线程池也好,MQ也好),出现队列满的情况下的拒绝策略也值得一提。我们使用线程池做异步处理,就是考虑到弹性,这些任务会有补偿或任务本身丢失并不这么重要,这个时候如果轻易使用CallerRunsPolicy
策略的话可能会遇到大问题,因为队列满了后任务会由调用者线程来执行,这种做法往往是调用者最不希望出现的异步转同步问题。更严重的是这种策略配合NIO框架,比如Netty来使用线程池的时候,如果调用者是IO EventLoopGroup线程,那么这个时候业务线程池满了后就会直接把IO线程堵死。遇到任务量太大,任务怎么处理,是记录后补偿还是丢弃,还是调用者执行需要认真考虑。
线程池共享
见过一些业务代码做了Utils类型在整个项目中的各种操作共享使用一个线程池,也见过一些业务代码大量Java 8使用parallel stream特性做一些耗时操作但是没有使用自定义的线程池或是没有设置更大的线程数(没有意识到parallel stream的共享ForkJoinPool问题)。共享的问题在于会干扰,如果有一些异步操作的平均耗时是1秒,另外一些是100秒,这些操作放在一起共享一个线程池很可能会出现相互影响甚至饿死的问题。建议根据异步业务类型,合理设置隔离的线程池。
4、线程安全
对象是否单例
在使用Spring容器的时候,因为Bean默认是单例的策略,所以我们特别容易犯错的地方是让不应该是单例的类成为了单例。比如类中有一些数据字段时候类是有状态的。当我们配合Spring和其它框架一起使用的时候更容易烦这个错,比如框架内部是没有使用Spring的,会自己通过一些缓存机制或池机制来维护对象的声明周期,如果我们直接加入容器,用容器来管理框架内部一些类型的创建方式,可能就会遇到很多Bug。对于单例类型的内部数据字段,考虑使用ThreadLocal来封装,使得类型在多线程情况下内部数据基于线程隔离不至于错乱。
单例是否线程安全
前面一点我们说的是要辨别清楚,对象是否应该是单例的,这里我们说的是单例的情况下是否是线程安全的问题。在使用各种框架提供的各种类的时候,(为了性能)我们有的时候会想当然加上static或让Spring单例注入,在这么做之前务必需要确认类型是否是线程安全的(比如常见的SimpleDateFormat就不是线程安全的)。我觉得我在开发时候Google搜索的最多的关键字就是XXX threadsafe。反过来说,如果你开发框架的话有义务在注释里告知使用者类型是否线程安全。线程安全的问题在测试过程中不容易发现,毕竟测试的时候没有并发,但是到了生产可能就会有千奇百怪的问题,出现这样的Bug如果爆出了ConcurrentModificationException这种并发异常还好,在没有异常的情况要定位问题真的很难。许多Web程序员其实都没有意识到自己的项目其实是多线程环境这个问题。
锁范围和粒度
sync(object)这个object到底是什么,是类实例,还是类型,还是redis的Key(跨进程锁)值得仔细思考。我们需要确保锁能锁住需要的操作,见到过一些代码因为没有锁到正确级别导致锁失效。
同时也要尽可能减少锁的粒度,如果什么操作都方法级别分布式锁,那么这个方法永远是全局单线程。这个时候加机器就没意义,系统就无法伸缩。
最后就是要考虑锁的超时问题,特别是分布式锁,如果没有设置超时那么很可能因为代码中断导致锁永远无法释放,对于Redis锁不建议造轮子,建议使用官方推荐的红锁方案(比如Redisson的实现)。
5、异步
数据流顺序
如果数据流是异步处理的话,会遇到数据流顺序的问题。比如我们先发请求到其它服务执行异步操作(比如支付),然后再执行本地的数据库操作(比如创建支付订单),完成后提交事务可能会遇到外部服务请求处理的很快,先给我们进行了数据回调(支付成功通知),这个时候我们本地的事务都没提交呢,支付订单还没有落库,导致外部回调来的时候查不到原始数据导致出现问题。更要命的可能是这个时候我们却返回了外部回调SUCCESS的状态导致外部回调也不会进行补偿了。
在使用MQ的时候也会遇到补偿数据重新进入队列重发的问题,这个时候可能会先收到更晚的消息,后收到更早的消息,这种情况我们的消息消费处理程序是否能应对呢?如果这点没做好可能会出现逻辑处理错乱的问题。
异步非阻塞
在使用Spring WebFlux、Netty(特别是前者,Netty的开发者一般会关注这个问题)等非阻塞框架的时候,我们需要意识到我们的业务处理不能过多占用事件循环的IO线程,否则可能会导致为数不多的IO线程被阻塞的问题。任务是否在IO线程执行也不是绝对的,如果小任务都分到业务线程池执行可能会有线程切换的问题,得不偿失,一切还是要以压力测试的数据说话不能想当然。如果这点没做好可能会出现性能大幅下降的问题。有的时候NIO框架Reactor模式使用不当,其效率性能还如request-per-thread的线程模型。
6、事务
本地事务
现在大多数项目都直接使用了@Transactional注解来开启事务,但是没有过多考虑这个注解的实现原理,常见的坑有:
- 因为配置问题导致压根注解没有起作用(特别是没有使用Spring Boot的情况下)
- 虽然使用了,但是姿势不对,导致事务没有生效,比如入口没有@Transactional,然后this.method()标记的方法带有注解,类因为没代理导致无效
- 又比如rollbackFor没有配置,或是方法内部吃了所有异常并不会出现异常导致无法回滚
因为事务问题导致的代码Bug相当多,而且一般不出问题不容易发现,很多项目只是装模作样使用了@Transactional但是完全没考虑到注解压根不能生效的问题
分布式事务
不管是最终实现一致也好,两阶段提(只是思想,不是说一定要用中间件)交也好,跨进程的整体事务性需要考虑如何去实现。最难的地方在于要考虑远程资源的事务性和本地资源的事务性怎么作为整体事务。
7、引用根
这里说的是内存泄露的问题,Java程序其实如果不使用堆外直接内存分配的话不会出现狭义的内存泄露问题。坑在于,有的时候大家会使用static来声明List或Map,来存档一些数据,但是有的时候会忽略删除老数据的问题,一个劲往里面增加数据不删除,导致数据无限增多,还是有一些程序员意识不到引用根的问题的。更隐蔽的是,Spring的Bean默认是单例的,这个时候在Service内声明使用List之类结构来保存数据,虽然没有声明static,但是就是static的属性(容易让人形成对象能够自己回收的错觉)。这个问题要求我们能够明确:
- 我们数据所归属的类是否是单例或static的(生命周期)
- 我们数据所归属的类所归属的类的声明周期(探寻引用根)
- 我们数据本身是无限扩大的还是只是有限的集合
- 当我们的数据放入Map中或Set中,是否新数据会替换老的数据(见下面判等问题)
说白了,代码里见到非方法体内部声明的List、Map等数据结构(作为类成员字段)都要小心。
8、判等
判等只是代码实现细节中最容易犯错的一个点,在这里还是再次推荐一下阿里的Java开发手册以及安装IDE的检查工具,里面有很多禁止或强制项,每一个项都是一个坑,推荐大家逐一细细品味这些代码细节。
==的问题
Java程序员最容易犯的错,也是导致代码Bug非常多的一个点,这个通过代码静态检查都可以发现。出现这样的Bug非常难查,也非常可惜。其实想一下业务代码中,除了判空,有多少时候我们需要真正对两个对象的引用进行判断。
在数据库Entity中考虑到空指针问题,我们往往会使用包装类型,外部Http请求入参我们也会考虑到空指针问题用包装类型,这个时候碰在一起比较使用==就特别容易出问题,尤其需要关注。而且相等或不等处理的往往是分支逻辑,测试容易覆盖不到,真正出问题的时候就是大问题。
Map和hashCode()
也是阿里Java开发手册中提到的一点,如果自定义对象可能作为Map的Key,那么必须重写hashCode()和equals(),这是业务开发时非常容易忽略的。我也遇到过这个问题,犯错的原因不是我不知道这点,而是我不知道也意识不到我的类会被某个框架做作为Map的Key(三方框架,并非自己所写)进行缓存,然后因为这个问题导致自己定义的类的多个实例被框架当做一个实例出现无法预料的Bug。
9、中间件的使用
在使用中间件的时候,我们最好针对使用场景对中间件或存储做一次压力测试,并且研究各种配置参数做到对基本原理心中有数,否则容易因为没有按照最佳实践来使用配置而踩坑。遇到坑可以过去倒没什么,最怕的是大面积使用了某个系统比如MongoDb、ElasticSearch、InfluxDb后又遇到了伸缩性问题性能问题一时半会无法解决,这种坑就大了。
遇到过开发在使用Redis的时候把它当做数据库而不是Key-Value缓存,去用KEYS命令搜索自己需要的键进行批量操作,这种使用方式完全违背Redis的最佳实践,在巨大的Redis集群里频繁使用这样的操作可能导致Redis卡死。对于Redis的使用也遇到过因为不合理的RDB配置导致的IO性能问题,以及快照期间超量的内存占用导致的OOM问题。
比如使用InfluxDb,它的Tag是一个不错的特性,我们可以针对各种Tag来分组灵活建立各种指标,但是Tag是不能所以使用来保存组合范围过多的数据的,比如Url、Id等否则可能就会因为巨大的索引(high cardinality问题)拖慢整个InfluxDb的性能甚至OOM。
又比如有一个业务因为压力大选型Mongodb,最后Mongodb没有配置开启write-ahead log和复制,在一次断电后数据库因为存储文件损坏无法启动,研究恢复工具和数据存储结构来修复数据文件花了几天时间,整个期间所有历史数据都无法访问到。
对于极限追求稳定的项目,建议约简单约好,哪怕就是依赖MySQL不引入其它东西,在有性能问题的时候再考虑其它中间件,这种方式最不容易出问题。
10、环境和配置
因为环境问题导致的坑太多了,有的时候其实是大家意识不到环境差异问题。这里随便说几个,我相信开发和运维结合的一些环境配置的问题导致的坑或线上的事故和问题太多太多了。而本地往往因为没有容器环境、K8S环境和复杂的网络环境,本地的程序部署到生产可能会出现千奇百怪的问题。
网络环境
遇到过压测压的很好,但是到线上还是崩溃的问题,原因在于压测走的是全部都在内网部署的一套服务,生产很多服务走的是外网(或专线)链接,环境其实是不一样的,网络的消耗必然带来请求的延迟,带来线程的阻塞,带来更多的资源消耗。也遇到过因为域名错误配置(或解析错误)问题导致应该走内网的请求走了公网,在测试环境或本地往往都是配置IP不容易出现这种问题。
反过来,也遇到过,本地压测怎么都压不上去的问题,其实是因为本地有一些请求走的是公网连到了服务器上的一些服务,压根就不是完全的本地压测,如果意识不到这个问题,这个时候对于性能的优化往往很茫然。所以在压测的时候我们最好使用类似iftop这样的工具观察一下我们的压测进程对于网络流量的使用(以及连接的远端服务的地址)是否在我们的预期。
容器环境
现在大家都使用了K8S和Docker,在这种环境下,我们的业务项目不仅仅在网络上从外到内经过多层,而且对于CPU、内存、文件句柄都配置也是层层限制(Pod层面、Docker层面、OS层面)。这个时候特别容易出现某一处配置不匹配导致资源限制的问题。
之前遇到过通过K8S Ingress访问服务慢的问题,这个时候需要层层排查,毕竟K8S的网络还是挺复杂的,不同的CNI方案可能会有不同的问题,Docker里访问慢不慢,通过Service访问慢不慢,通过Ingress访问慢不慢来定位问题。
还有,在容器环境下,CPU数量可能会获取到宿主机的CPU梳理,导致很多框架的线程数配置的过大(比如有些宿主机48核+,CPU数量*2的话就是96线程),JVM的ParallelGCThreads就是一个例子,此类坑很多,不合理的配置可能会导致性能问题。
今天还遇到一位同学说,死活不知道为啥系统参数各种修改后还是无法生效增大文件描述符和进程数的限制,最后发现原来是因为java进程是supervisord(一般使用Docker都会使用)启动的,supervisor本身有限制(minfds和minprocs)。
环境隔离
互联网公司基本都会有灰度环境或Staging环境做上线前的最后测试,但是很多时候会因为这套环境和生产环境共享一些资源导致出现问题。
之前遇到一个问题是使用了七牛做CDN,灰度环境和生产环境都是使用了同样的CDN,导致在灰度测试的时候新的静态资源文件就缓存到了CDN节点上导致外部用户访问出错(访问到了新的静态资源)。出这个问题之后要马上回滚解决还是比较麻烦的,因为CDN已经被污染了。长期解决的办法很简单就是做隔离或每次发布静态资源文件名不同。
总结
总结一下,线程、线程同步、池、网络连接、网络链路、对象实例化、内存等方面的基础是最容易犯错的地方,搞清楚框架内部对于这些基础资源的的使用方式,根据最佳实践进行合理配置,这是业务开发时需要特别关注的点。有的时候一些代码在使用三方框架和中间件的时候因为不了解细节,不但没有按照最佳实践来配置反而配成了最差实践,造成了很大的问题非常可惜。
由于各种坑五花八门,本文也只是抛砖引玉,希望读者可以补充自己遇到的神坑,希望大家能在评论区留言。