朱晔的互联网架构实践心得S2E2:写业务代码最容易掉的10种坑

我认可,本文的标题有一点标题党,特别是写业务代码,你们由于没有足够重视一些细节最容易调的坑(侧重Java,固然,本文说的这些点不少是不限制于语言的)。java

一、客户端的使用

咱们在使用Redis、ElasticSearch、RabbitMQ、Mongodb等中间件或存储的时候确定都会使用客户端包来和这些系统通信,咱们也会使用Http的一些客户端来发Http请求。在使用这些客户端包的时候,很是容易犯错的一个地方就是Client的使用方式,好比有一个叫作RedisClient的类,是Redis操做的入口。你应该是每次使用new RedisClient().get(KEY)呢仍是注入一个单例的RedisClient呢?程序员

咱们知道,这些组件的客户端每每须要和服务端经过TCP链接进行远程通信,考虑到性能,客户端通常都维护链接池作长连接,若是RedisClient或MongoClient或HttpClient之类的Client在类内部维护了链接池,那么这个Client每每是线程安全的,能够在多线程环境下使用的,而且严格禁止每次都新建一个对象出来的(若是框架作的足够好通常自己就会是单例模式的,不容许实例化)。redis

你想,若是一个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连接占满等等致使的服务最终瘫痪的重大故障。服务器

二、服务调用参数配置

如今你们都在实践微服务架构,无论是使用什么微服务框架,是基于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客户端每每都有自动重试功能(MaxAutoRetriesMaxAutoRetriesNextServer),考虑到Failover,有的框架会默认状况下对于节点A挂的状况下重试一次节点B。咱们须要考虑一下这个功能是不是咱们须要的,咱们的服务端是否支持幂等,框架重试的策略是很对Get请求仍是全部请求,弄的很差就会由于自动重试问题踩坑(不是全部的服务端都对幂等问题处理的足够好,或者换句话说,和以前那个问题相关的是,不是全部服务端能正确处理请求自己还没执行完成状况下的幂等处理,不少时候服务端考虑的幂等处理是基于本身的操做执行完成后提交了事务更新数据表状态下的幂等处理)。对于远程服务调用,客户端和服务端商量好幂等策略,明确超时时间不一致状况下的处理策略很重要。多线程

三、线程池的使用

线程池配置

阿里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秒,这些操做放在一块儿共享一个线程池极可能会出现相互影响甚至饿死的问题。建议根据异步业务类型,合理设置隔离的线程池。

四、线程安全

对象是否单例

在使用Spring容器的时候,由于Bean默认是单例的策略,因此咱们特别容易犯错的地方是让不该该是单例的类成为了单例。好比类中有一些数据字段时候类是有状态的。当咱们配合Spring和其它框架一块儿使用的时候更容易烦这个错,好比框架内部是没有使用Spring的,会本身经过一些缓存机制或池机制来维护对象的声明周期,若是咱们直接加入容器,用容器来管理框架内部一些类型的建立方式,可能就会遇到不少Bug。对于单例类型的内部数据字段,考虑使用ThreadLocal来封装,使得类型在多线程状况下内部数据基于线程隔离不至于错乱。

单例是否线程安全

前面一点咱们说的是要辨别清楚,对象是否应该是单例的,这里咱们说的是单例的状况下是不是线程安全的问题。在使用各类框架提供的各类类的时候,(为了性能)咱们有的时候会想固然加上static或让Spring单例注入,在这么作以前务必须要确认类型是不是线程安全的(好比常见的SimpleDateFormat就不是线程安全的)。我以为我在开发时候Google搜索的最多的关键字就是XXX threadsafe。反过来讲,若是你开发框架的话有义务在注释里告知使用者类型是否线程安全。线程安全的问题在测试过程当中不容易发现,毕竟测试的时候没有并发,可是到了生产可能就会有千奇百怪的问题,出现这样的Bug若是爆出了ConcurrentModificationException这种并发异常还好,在没有异常的状况要定位问题真的很难。许多Web程序员其实都没有意识到本身的项目实际上是多线程环境这个问题。

锁范围和粒度

sync(object)这个object究竟是什么,是类实例,仍是类型,仍是redis的Key(跨进程锁)值得仔细思考。咱们须要确保锁能锁住须要的操做,见到过一些代码由于没有锁到正确级别致使锁失效。

同时也要尽量减小锁的粒度,若是什么操做都方法级别分布式锁,那么这个方法永远是全局单线程。这个时候加机器就没意义,系统就没法伸缩。

最后就是要考虑锁的超时问题,特别是分布式锁,若是没有设置超时那么极可能由于代码中断致使锁永远没法释放,对于Redis锁不建议造轮子,建议使用官方推荐的红锁方案(好比Redisson的实现)。

五、异步

数据流顺序

若是数据流是异步处理的话,会遇到数据流顺序的问题。好比咱们先发请求到其它服务执行异步操做(好比支付),而后再执行本地的数据库操做(好比建立支付订单),完成后提交事务可能会遇到外部服务请求处理的很快,先给咱们进行了数据回调(支付成功通知),这个时候咱们本地的事务都没提交呢,支付订单尚未落库,致使外部回调来的时候查不到原始数据致使出现问题。更要命的多是这个时候咱们却返回了外部回调SUCCESS的状态致使外部回调也不会进行补偿了。

在使用MQ的时候也会遇到补偿数据从新进入队列重发的问题,这个时候可能会先收到更晚的消息,后收到更早的消息,这种状况咱们的消息消费处理程序是否能应对呢?若是这点没作好可能会出现逻辑处理错乱的问题。

异步非阻塞

在使用Spring WebFlux、Netty(特别是前者,Netty的开发者通常会关注这个问题)等非阻塞框架的时候,咱们须要意识到咱们的业务处理不能过多占用事件循环的IO线程,不然可能会致使为数很少的IO线程被阻塞的问题。任务是否在IO线程执行也不是绝对的,若是小任务都分到业务线程池执行可能会有线程切换的问题,得不偿失,一切仍是要以压力测试的数听说话不能想固然。若是这点没作好可能会出现性能大幅降低的问题。有的时候NIO框架Reactor模式使用不当,其效率性能还如request-per-thread的线程模型。

六、事务

本地事务

如今大多数项目都直接使用了@Transactional注解来开启事务,可是没有过多考虑这个注解的实现原理,常见的坑有:

  • 由于配置问题致使压根注解没有起做用(特别是没有使用Spring Boot的状况下)
  • 虽然使用了,可是姿式不对,致使事务没有生效,好比入口没有@Transactional,而后this.method()标记的方法带有注解,类由于没代理致使无效
  • 又好比rollbackFor没有配置,或是方法内部吃了全部异常并不会出现异常致使没法回滚

由于事务问题致使的代码Bug至关多,并且通常不出问题不容易发现,不少项目只是装模做样使用了@Transactional可是彻底没考虑到注解压根不能生效的问题

分布式事务

无论是最终实现一致也好,两阶段提(只是思想,不是说必定要用中间件)交也好,跨进程的总体事务性须要考虑如何去实现。最难的地方在于要考虑远程资源的事务性和本地资源的事务性怎么做为总体事务。

七、引用根

这里说的是内存泄露的问题,Java程序其实若是不使用堆外直接内存分配的话不会出现狭义的内存泄露问题。坑在于,有的时候你们会使用static来声明List或Map,来存档一些数据,可是有的时候会忽略删除老数据的问题,一个劲往里面增长数据不删除,致使数据无限增多,仍是有一些程序员意识不到引用根的问题的。更隐蔽的是,Spring的Bean默认是单例的,这个时候在Service内声明使用List之类结构来保存数据,虽然没有声明static,可是就是static的属性(容易让人造成对象可以本身回收的错觉)。这个问题要求咱们可以明确:

  • 咱们数据所归属的类是不是单例或static的(生命周期)
  • 咱们数据所归属的类所归属的类的声明周期(探寻引用根)
  • 咱们数据自己是无限扩大的仍是只是有限的集合
  • 当咱们的数据放入Map中或Set中,是否新数据会替换老的数据(见下面判等问题)

说白了,代码里见到非方法体内部声明的List、Map等数据结构(做为类成员字段)都要当心。

八、判等

判等只是代码实现细节中最容易犯错的一个点,在这里仍是再次推荐一下阿里的Java开发手册以及安装IDE的检查工具,里面有不少禁止或强制项,每个项都是一个坑,推荐你们逐一细细品味这些代码细节。

==的问题

Java程序员最容易犯的错,也是致使代码Bug很是多的一个点,这个经过代码静态检查均可以发现。出现这样的Bug很是难查,也很是惋惜。其实想一下业务代码中,除了判空,有多少时候咱们须要真正对两个对象的引用进行判断。

在数据库Entity中考虑到空指针问题,咱们每每会使用包装类型,外部Http请求入参咱们也会考虑到空指针问题用包装类型,这个时候碰在一块儿比较使用==就特别容易出问题,尤为须要关注。并且相等或不等处理的每每是分支逻辑,测试容易覆盖不到,真正出问题的时候就是大问题。

Map和hashCode()

也是阿里Java开发手册中提到的一点,若是自定义对象可能做为Map的Key,那么必须重写hashCode()和equals(),这是业务开发时很是容易忽略的。我也遇到过这个问题,犯错的缘由不是我不知道这点,而是我不知道也意识不到个人类会被某个框架作做为Map的Key(三方框架,并不是本身所写)进行缓存,而后由于这个问题致使本身定义的类的多个实例被框架当作一个实例出现没法预料的Bug。

九、中间件的使用

在使用中间件的时候,咱们最好针对使用场景对中间件或存储作一次压力测试,而且研究各类配置参数作到对基本原理心中有数,不然容易由于没有按照最佳实践来使用配置而踩坑。遇到坑能够过去倒没什么,最怕的是大面积使用了某个系统好比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不引入其它东西,在有性能问题的时候再考虑其它中间件,这种方式最不容易出问题。

十、环境和配置

由于环境问题致使的坑太多了,有的时候实际上是你们意识不到环境差别问题。这里随便说几个,我相信开发和运维结合的一些环境配置的问题致使的坑或线上的事故和问题太多太多了。而本地每每由于没有容器环境、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已经被污染了。长期解决的办法很简单就是作隔离或每次发布静态资源文件名不一样。

总结

总结一下,线程、线程同步、池、网络链接、网络链路、对象实例化、内存等方面的基础是最容易犯错的地方,搞清楚框架内部对于这些基础资源的的使用方式,根据最佳实践进行合理配置,这是业务开发时须要特别关注的点。有的时候一些代码在使用三方框架和中间件的时候由于不了解细节,不但没有按照最佳实践来配置反而配成了最差实践,形成了很大的问题很是惋惜。

因为各类坑五花八门,本文也只是抛砖引玉,但愿读者能够补充本身遇到的神坑,但愿你们能在评论区留言。

相关文章
相关标签/搜索