两年编程杂谈-如何写出可读性高、不易崩溃的代码

工作已经两年了,简单总结一下这一年来的思考和感悟吧,包括:

  • 业务逻辑的本质复杂度无法改变的情况下,如何基于编程范式编写可读性高的代码?
  • 机器资源一定的情况下,写代码的时候如何考虑资源的使用?如何更好的利用资源;以及一些因为线上资源被打死导致的问题实例分析;

如何写好代码?

考虑使用的编程范式

想要写好代码,我们首先要想清楚所谓代码(程序)的本质是什么?

程序 = 算法(控制+逻辑) + 数据结构

对于业务开发而言,算法即为业务逻辑,而我们通过不同的编程范式实现算法时,又将算法分为了控制与逻辑

  • 逻辑:逻辑即为我们真实的业务逻辑,这部分的复杂度是不可避免的;
  • 控制:不同的语言实现了各自支持的编程范式,提供了不同的控制方式;比如命令式编程引入了if/else/for/while、过程式编程引入了过程(方法)、面向对象式编程引入了对象、函数式编程引入了函数

不同的编程范式,有不同的方法论让我们使用控制来组织代码,比如面向对象-设计模式;编程范式之间是可以演化的,比如在命令式中增加过程的概念便演变为面向过程的范式,在面向过程的范式中增加对象的概念,便演化为对象式编程;

而最核心的、相互正交的两个范式是命令式、函数式

  • 命令式是对冯·诺伊曼机器的执行命令过程的抽象-命令从磁盘加载到内存、寄存器,CPU取指执行,并将结果写到寄存器;
  • 函数式是对人脑思考问题过程的抽象-分解、描述问题;

使用什么语言与使用范式并不是强相关的,比如通过Java可以通过面向过程组织代码(事务脚本模式),也可以通过面向对象来组织代码(通过对象与对象的信息传递);又比如Java通过Stream支持了函数式编程,自身又支持命令式编程;那么对于一段相同的逻辑,我们可以通过fliter/map/reduce的方式处理数据,也可以通过for循环的方式处理数据:

/**
 * 实现输入列表中正整数翻倍的逻辑
 */
List<Integer> numberList = Arrays.asList(-9, -1, 2, 3, 10, 16);
List<Integer> positiveNumberDouble;

// 函数式、声明式编程范式,编写代码时关注做什么,通过描述问题的思维来编写代码
positiveNumberDouble = numberList.stream().filter(number -> number > 0)
                                          .map(number -> number * 2)
                                          .collect(Collectors.toList());

// 过程式、命令式编程,编写代码时关注如何实现
for (Integer number : numberList) {
    if (number > 0) {
        Integer doubleNumber = number * 2;
        positiveNumberDouble.add(doubleNumber);
    }
}

在数据流的处理上,明显基于函数式编程范式提供的控制编写代码的可读性更好;因为Java支持的函数式编程的提供的fliter/map/reduce等程序流控制方式在实现算法上(即业务逻辑-正整数加倍)更加简洁;

但是一个业务系统中存在很多复杂的系统概念,对于这些概念的建模,则更多的会使用面向对象的范式,基于提供接口而不是实现,组合优于继承的原则来编写代码;因此对于不同的业务场景,选择合适的编程范式、语言是很重要的。

在Java中,因为支持函数式与面向对象两种编程范式,因此往往可以在系统的不同层面进行组合使用

  • 在模块化的系统中,一般通过函数式编程来控制数据的流动,而模块内部则由面向对象的范式来实现;对于这个原则,类似于Java8的Stream,每一条业务数据,在不同的处理模块上流动,而模块内部在处理数据时,依据数据自身的描述信息,来决定走模块内部的何种逻辑;
  • 在非模块化的系统中,一般通过面向对象的思维来建模系统,对象内部的数据则交由函数式编程来处理;对于这个原则,类似于Tomcat的设计,Tomcat作为一个有状态的服务器,通过状态机模式建模,在状态流转的过程中通过观察者模式监听事件,在请求处理的过程中通过pipeline模式进行处理逻辑构建...

考虑业务核心代码的复杂度-将"厚"代码写“薄”

对于一个代码实现良好的系统,其系统中核心代码的"熵"一定是很低的(程序的可能性越多,各种可能性的概率越均等,熵越高),而我去年总结中关注的 函数的正交性、模块化、透明性,设计模式等方法实质上都是通过隔离代码中的变化用以使得程序的核心逻辑清晰可见;这样的价值在于虽然业务逻辑的复杂度无法降低,但是可以使得代码的复杂度接近业务核心复杂度;

举一个简单的Spring事务的例子,对于Spring上层而言,之于一个数据库事务链接,本质上分为三个操作-getTransaction()commit()rollback(),这三个步骤是事务概念的核心逻辑,而Spring实现的事务执行的代码如下:

public class TransactionTemplate extends DefaultTransactionDefinition
      implements TransactionOperations, InitializingBean {

   private PlatformTransactionManager transactionManager;

   @Override
   public <T> T execute(TransactionCallback<T> action) throws TransactionException {
       // 1\. 获取事务链接
       TransactionStatus status = this.transactionManager.getTransaction(this);
       T result;
       try {
          // 2\. 执行数据库查询
          result = action.doInTransaction(status);
       }
       catch (RuntimeException | Error ex) {
          // 3\. 报错事务回滚
          this.transactionManager.rollback(status);
          throw ex;
       }
       // 4\. 无报错则提交事务
       this.transactionManager.commit(status);
       return result;
    }
}   

可以看到在事务模板类中,执行数据库的核心代码的整体逻辑是非常清晰的,其通过注入的PlatformTransactionManager来隔离不同的数据库连接方式中不同的事务连接的获取、提交、回滚的实现方式;比如对于原生JDBC的连接的方式,则注入DataSourceTransactionManager,对于JPA的实现方式,则注入JpaTransactionManager...这样很厚的一层代码,Spring通过接口与策略类的方式组织了良好的代码结构,使得在事务的上层代码中,事务的核心逻辑是非常清晰的。

Spring将"厚"代码写"薄",便是基于面向对象范式的语言Java,使用面向接口编程的思维通过合理地组织类与类的结构,控制代码的整体复杂度

概念抽象与分层

上文提到,Spring将事务这个概念的核心逻辑在代码的最上层清晰的暴露出来,而设计良好的原因在于事务这个概念是很独立且清晰明了的,但是日常编码中,我们的业务系统内部存在非常多的概念,这种时候系统代码的复杂度升高,很可能是因为系统中存在一些隐式且重要的概念没有被定义、命名、抽离隐式的概念与被定义的概念交叉、耦合在一起,使得程序的核心逻辑变得臃肿。 (概念与概念之间为一对一、一对多、多对多的关系,往往一对多、多对多的概念不能放在同一个抽象层次上)。

考虑代码运行占据的系统资源

系统资源一般为CPU、内存、IO(磁盘与网卡),而CPU通过执行线程上下文中的指令序列来使用本机系统资源,而对于外界系统资源的使用,要借助网络连接,因此本文主要分析为什么要关心线程、网络连接、内存的资源代码中使用又需要注意什么

线程资源

创建一个线程的开销分为内存与CPU开销:

  • 内存开销-栈与线程对象,HotSpot JVM采用的是Java线程与操作系统核心线程一对一的线程模型,因此创建一个Java线程会对应一个内核栈与用户态栈(HotSpot中对应JVM栈+本地方法栈,两栈合一),在JVM中通过-Xss进行配置用户态栈大小,默认为1M;线程对象-JavaThread.class的内存空间则可以忽略;
  • CPU开销,以时间片轮转算法为例,线程数越多,可被分配时间片的线程大概率也会增多,那么线程切换的成本会变高;

使用时依据系统资源、代码特性创建合适的线程池大小: 创建线程的内存与CPU开销,需要我们考虑JVM进程中最多可以创建多少个线程;需要我们区分线程中执行的代码块是CPU密集型还是IO密集型,并依据密集类型使用线程池对线程进行统一的资源管理;因为CPU的核数不会变,机器的IO速度不会变,所以与其创建更多的阻塞线程浪费资源,不如放到线程池的等待队列中去

使用时避免项目核心线程池中的线程阻塞: 因为项目中可能引入不同的协议框架以对外提供服务,比如TomcatDubbo,而框架自身维护了线程池,比如Tomcat中每个Context对应一个线程池,核心线程数量为200,DubboClient线程亦是如此;因此如果将过多IO等耗时操作放到协议框架线程池中进行处理,连接线程资源会释放的比较慢,最终整个系统的吞吐量降低;因此Tomcat Servlet3.0Dubbo2.7+接口协议支持CompletableFuture返回,都是通过异步处理请求,更快释放线程资源,从而提高系统整体的吞吐量;

资源与资源之间是会相互影响,一个资源的耗尽是现象,另一个资源的耗尽才是根因: 比如线上dubbo线程池满了,如果已经进行了异步化请求,那么需要考虑系统中的其他资源,比如是否系统是否因为处理请求时内存占用比较高(比如Json的反序列化),导致频繁进行FullGC并STW,而在STW的期间,上游还在发起请求,最终导致线程池被打满。

2. 网络资源

构建一个网络连接的开销: 基于TCP而言,连接是双端的-客户端与服务器,开销也是基于双方的,连接的建立成本:一次网络连接需要构建五元组套接字、经过三次握手,如果是长连接则需要额外的保活KeepAlive开销;

2.1 短连接

使用短连接需要防止套接字耗尽: 因为在服务使用短连接的情况下,客户端每次请求都会占用一个临时端口,

  • 如果客户端自身短连接构建很快,客户端会频繁的主动进行FIN,并等待2MSL以关闭连接、释放端口,而这个端口在2MSL间是没有办法再次使用的,因此整个服务对下游的并发请求数最大为64000 (可用端口) / 120 (2分钟,2MSL) = 533.333
  • 如果服务器端挂了,那么客户端会进行tcp_retries2=15次重试,这个时间段内请求积压,而每个请求会先占用一个客户端端口,端口无法释放,最终导致客户端端口耗尽;

2.2 长连接

因此一般使用长连接进行网络资源的使用,长连接一般维护在连接池中;这里基于数据库连接的一些共性进行讨论;客户端连接池:需要关心池中连接的数量与连接的有效性

连接池连接的有效性

需要进行连接保活和有效性测试,避免线程获取到失效连接;

TCP层

  • TCP连接有自身的保活机制-TCP Keepalive,连接超过net.ipv4.tcp_keepalive_time=7200s(2h)时间 没有流量后,会发送Keepalive(心跳)包到对端,并对Keepalive包进行指数退避重试,如果重试net.ipv4.tcp_keepalive_probes次后心跳包仍然没有收到回应,就会判断连接已经失效;显然默认的时间很难发现连接的问题,因为2h还没有流量的连接很少,而且一般项目都是通过容器部署,如果直接修改参数会影响到同一个物理机上的其他服务,所以一般需要在应用层进行保活

  • TCP连接有效性测试:在保活期间2h内发送了网络包到对端,如果网络包进行指数退避重传,并且超过tcp_retries2=15次仍然没有回复,则TCP会认为连接已经失效,整个周期默认为15min;

应用层连接框架

而生产中我们的应用中的连接一般不会直连其他服务,一般都会经过网关层进行转发,因此客户端连接失效的原因一般分为1)网关反向代理服务器主动断开,2)网关反向代理服务器重启(触发Connection Reset)或集群中部分机器挂了

  • 服务器主动断开应用层服务器端一般会有无流量连接的清理机制,用于防止无效连接过多占据资源;连接空闲超过一定时间后,服务端会主动发送FIN包给客户端,并关闭连接进程,后续客户端的请求,因为连接进程已经关闭,服务端会回复RST包给客户端;比如Mysqlwait_timeout配置,连接无流量超过8h后,就会关闭连接;因此需要合理配置连接保活参数,如Druid中配置testWhileIdle+timeBetweenEvictionRunsMillis参数,用于固定在timeBetweenEvictionRunsMillis间隔下测试连接是否有效,可以同时起到连接保活与测试连接有效性的作用;
  • 网关服务器重启:这种情况下因为重启后,服务器上原本建立的连接都被关闭了,如果客户端使用网络连接时不进行连接测试,则需要等待下一次保活再重新建连,而保活间隔期间,第一次使用到失效连接的线程会报错,因此客户端需要进行连接测试;如在Druid中配置testOnBorrow,每次获取连接时,都会先进行连接可用的校验,但是对性能的损耗比较高,因此可以定制化开发,在连接异常报错数量大于阈值后,程序自动打开连接池testOnBorrow配置;
  • 集群中部分机器挂了:这种情况需要重置连接,与集群中状态正常的其他服务器建连;但是因为这种场景和重启不一样,服务器重启成功后,接收到客户端失效连接发送的包时,会返回RST使得客户端重置连接;而机器挂了,服务器无响应,客户端发送的网络包会重试tcp_retries2次后才能知道连接失效后再重置连接,这种情况一般通过配置TCP_USER_TIMEOUT来解决,第一次网络包超时后直接重置连接;

连接池连接的数量

网络连接池为IO密集型,因此连接数量可以设置可以参考:

// `core_count`是CPU核心数, `effective_spindle_count` 是有效主轴数
connections = ((core_count * 2) + effective_spindle_count)

如果你的服务器使用的是带有16个磁盘的RAID,那么valid_spindle_count=16。它实质上是服务器可以管理多少个并行I/O请求的度量。旋转硬盘一次(通常)一次只能处理一个I/O请求,如果你有16个,则系统可以同时处理16个I/O请求。

使用连接需要避免长时间的读写与较大事务范围

  • sql返回时间较长:如果代码中存在一些查询,查询的数据量很大,会导致查询使用的这个连接无法释放,如果这部分代码调用量比较高,会导致直接把连接池占满,导致后续线程获取连接失败,服务只能降级;因此如果服务的数据库使用特性是sql查询比较复杂, 返回时间较长,这种时候可以适当扩大数据库连接池的大小,而不必遵循上面的表达式;
  • 较大的事务范围:现在的数据库一般是读主写从的,但是如果是事务读写,为了保证事务的ACID,只能读写主库,而不能更好的利用从库的读性能,因此不需要增加事务的流程可以不增加事务,而需要事务的代码可以考虑通过代码指定事务的边界,而不是通过@Transactional注解 ;
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值