基于支付场景下的微服务改造与性能优化(二)
四、从代码层面提升微服务架构的性能
很多架构变迁或演进方面的文章大多是针对架构方面的介绍,很少有针对代码级别的性能优化介绍,这就好比盖楼一样,楼房的基础架子搭得很好,但是盖房的工人不够专业,有很多需要注意的地方忽略了,在往里面添砖加瓦的时候出了问题,后果就是房子经常漏雨、墙上有裂缝等各种问题出现,虽然不至于楼房塌陷,但楼房已经变成了危楼。
判断一个项目是否具有良好的设计需要从优秀的代码和高可用架构两个方面来衡量,如图11-6所示。
图11-6
优秀的代码是要看程序的结构是否合理,程序中是否存在性能问题,依赖的第三方组件是否被正确使用等。而高可用架构是要看项目的可用性、扩展型,以及能够支持的并发能力。可以说一个良好的项目设计是由两部分组成的,缺一不可。
4.1 从代码和设计的角度看
在实战的过程中,不同的公司所研发的项目和场景也不一样,下面主要以支付场景为出发点,从代码和设计的角度总结一些常见的问题。
1)数据库经常发生死锁现象
以MySQL数据库为例,select......for update语句是手工加锁(悲观锁)语句,是一种行级锁。通常情况下单独使用select语句不会对数据库数据加锁,而使用for update语句则可以在程序层面实现对数据的加锁保护,如果for update语句使用不当,则非常容易造成数据库死锁现象的发生,如表11-1所示。
在上述事例中,会话B会抛出死锁异常,死锁的原因就是A和B两个会话互相等待,出现这种问题其实就是我们在项目中混杂了大量的事务+for update语句并且使用不当所造成的。
MySQL数据库锁主要有三种基本锁。
Record Lock:单个行记录的锁。
Gap Lock:间隙锁,锁定一个范围,但不包括记录本身。
Gap Lock+Record Lock(next-key lock):锁定一个范围,并且也锁定记录本身。当for update语句和gap lock、next-key lock锁相混合使用,又没有注意用法的时候,就非常容易出现死锁的情况。
2)数据库事务占用时间过长
先看一段伪代码:
public void test() {
Transaction.begin //事务开启
try {
dao.insert //插入一行记录
httpClient.queryRemoteResult() //请求访问
dao.update //更新一行记录
Transaction.commit()//事务提交
} catch(Exception e) {
Transaction.rollFor//事务回滚
}
}
项目中类似这样的程序有很多,经常把类似httpClient,或者有可能造成长时间超时的操作混在事务代码中,不仅会造成事务执行时间超长,而且会严重降低并发能力。
我们在使用事务的时候,遵循的原则是快进快出,事务代码要尽量小。针对以上伪代码,我们要把httpClient这一行拆分出来,避免同事务性的代码混在一起。
3)滥用线程池,造成堆和栈溢出
Java通过Executors提供了四种线程池可供我们直接使用。
newCachedThreadPool:创建一个可缓存线程池,这个线程池会根据实际需要创建新的线程,如果有空闲的线程,则空闲的线程也会被重复利用。
newFixedThreadPool:创建一个定长线程池,可控制线程最大并发数,超出的线程会在队列中等待。
newScheduledThreadPool:创建一个定长线程池,支持定时及周期性任务执行。
newSingleThreadExecutor:创建一个单线程化的线程池,它只会用唯一的工作线程来执行任务,保证所有任务按照指定顺序(FIFO,LIFO,优先级)执行。
JDK提供的线程池从功能上替我们做了一些封装,也节省了很多参数设置的过程。如果使用不当则很容易造成堆和栈溢出的情况,示例代码如下所示。
private staticfinal ExecutorService executorService = Executors.newCachedThreadPool();
/**
* 异步执行短频快的任务
* @param task
*/
public static voidasynShortTask(Runnable task){
executorService.submit(task);
//task.run();
}
CommonUtils.asynShortTask(newRunnable() {
@Override
public void run() {
String sms =sr.getSmsContent();
sms = sms.replaceAll(finalCode, AES.encryptToBase64(finalCode,ConstantUtils.getDB_AES_KEY()));
sr.setSmsContent(sms);
smsManageService.addSmsRecord(sr);
}
});
以上代码的场景是每次请求过来都会创建一个线程,将DUMP日志导出进行分析,发现项目中启动了一万多个线程,而且每个线程都显示为忙碌状态,已经将资源耗尽。我们仔细查看代码会发现,代码中使用的线程池是使用以下代码来申请的。
private static final ExecutorServiceexecutorService = Executors.newCachedThreadPool();
在高并发的情况下,无限制地申请线程资源会造成性能严重下降,采用这种方式最大可以产生多少个线程呢?答案是Integer的最大值!查看如下源码:
public static ExecutorServicenewCachedThreadPool() {
return newThreadPoolExecutor(0, Integer.MAX_VALUE,
60L, TimeUnit.SECONDS,
newSynchronousQueue<Runnable>());
}
既然使用newCachedThreadPool可能带来栈溢出和性能下降,如果使用newFixedThreadPool设置固定长度是不是可以解决问题呢?使用方式如以下代码所示,设置固定线程数为50:
private static final ExecutorServiceexecutorService = Executors.newFixedThreadPool(50);
修改完成以后,并发量重新上升到100TPS以上,但是当并发量非常大的时候,项目GC(垃圾回收能力下降),分析原因还是因为Executors.newFixedThreadPool(50)这一行,虽然解决了产生无限线程的问题,但采用newFixedThreadPool这种方式会造成大量对象堆积到队列中无法及时消费,源码如下:
public static ExecutorService newFixedThreadPool(int nThreads,ThreadFactory threadFactory) {
return newThreadPoolExecutor(nThreads, nThreads,
0L, TimeUnit.MILLISECONDS,
newLinkedBlockingQueue<Runnable>(),
threadFactory);
}
可以看到采用的是无界队列,也就是说队列可以无限地存放可执行的线程,造成大量对象无法释放和回收。
其实JDK还提供了原生的线程池ThreadPoolExecutor,这个线程池基本上把控制的权力交给了使用者,使用者设置线程池的大小、任务队列、拒绝策略、线程空闲时间等,不管使用哪种线程池,都是建立在我们对其精准把握的前提下才能真正使用好。
4)常用配置信息依然从数据库中读取
不管是什么业务场景的项目,只要是老项目,我们经常会遇到一个非常头疼的问题就是项目的配置信息是在本地项目的properties文件中存放的,或者是将常用的配置信息存放到数据库中,这样造成的问题是:
如果使用本地properties文件,每次修改文件都需要一台一台地在线上环境中修改,在服务器数量非常多的情况下非常容易出错,如果修改错了则会造成生产事故。
如果是用采集数据库来统一存放配置信息,在并发量非常大的情况下,每一次请求都要读取数据库配置则会造成大量的I/O操作,会对数据库造成较大的压力,严重的话对项目也会产生性能影响。
比较合理的解决方案之一:使用统一配置中心利用缓存对配置信息进行统一管理,具体的实现方案可以参考《深入分布式缓存》这本书。
5)从库中查询数据,每次全部取出
我们在代码中经常会看到如下SQL语句:
select * from order where status = 'init'
这句SQL从语法上确实看不出什么问题,但是放在不同的环境上却会产生不同的效果,如果此时我们的数据库中状态为init的数据只有100条,那么这条SQL会非常快地查询出来并返回给调用端,在这种情况下对项目没有任何影响。如果此时我们的数据库中状态为init的数据有10万条,那么这条SQL语句的执行结果将是一次性把10万记录全部返回给调用端,这样做不仅会给数据库查询造成沉重的压力,还会给调用端的内存造成极大的影响,带来非常不好的用户体验。
比较合理的解决方案之一:使用limit关键字控制返回记录的数量。
6)业务代码研发不考虑幂等操作
幂等就是用户对于同一操作发起的一次请求或多次请求所产生的结果是一致的,不会因为多次点击而产生多种结果。
以支付场景为例,用户在网上购物选择完商品后进行支付,因为网络的原因银行卡上面的钱已经扣了,但是网站的支付系统返回的结果却是支付失败,这时用户再次对这笔订单发起支付请求,此时会进行第二次扣款,返回结果成功,用户查询余额返发现多扣钱了,流水记录也变成了两条,这种场景就不是幂等。
实际工作中的幂等其实就是对订单进行防重,防重措施是通过在某条记录上加锁的方式进行的。
针对以上问题,完全没有必要使用悲观锁的方式来进行防重,否则不仅对数据库本身造成极大的压力,对于项目扩展性来说也是很大的扩展瓶颈,我们采用了三种方法来解决以上问题:
使用第三方组件来做控制,比如ZooKeeper、Redis都可以实现分布式锁。
使用主键防重法,在方法的入口处使用防重表,能够拦截所有重复的订单,当重复插入时数据库会报一个重复错,程序直接返回。
使用版本号(version)的机制来防重。
注意:以上三种方式都必须设置过期时间,当锁定某一资源超时的时候,能够释放资源让竞争重新开始。
7)使用缓存不合理,存在惊群效应、缓存穿透等情况
缓存穿透
我们在项目中使用缓存通常先检查缓存中数据是否存在,如果存在则直接返回缓存内容,如果不存在就直接查询数据库,然后进行缓存并将查询结果返回。如果我们查询的某一数据在缓存中一直不存在,就会造成每一次请求都查询DB,这样缓存就失去了意义,在流量大时,可能DB就“挂掉”了,这就是缓存穿透,如图11-7所示。
图11-7
要是有黑客利用不存在的缓存key频繁攻击应用,就会对数据库造成非常大的压力,严重的话会影响线上业务的正常进行。一个比较巧妙的做法是,可以将这个不存在的key预先设定一个值,比如“key”“NULL”。在返回这个NULL值的时候,应用就可以认为这是不存在的key,应用就可以决定是继续等待访问,还是放弃掉这次操作。如果继续等待访问,则过一个时间轮询点后,再次请求这个key,如果取到的值不再是NULL,则可以认为这时候key有值了,从而避免透传到数据库,把大量的类似请求挡在了缓存之中。
缓存并发
看完上面的缓存穿透方案后,可能会有读者提出疑问,如果第一次使用缓存或缓存中暂时没有需要的数据,那么又该如何处理呢?
在这种场景下,客户端从缓存中根据key读取数据,如果读到了数据则流程结束,如果没有读到数据(可能会有多个并发都没有读到数据),则使用缓存系统中的setNX方法设置一个值(这种方法类似加锁),没有设置成功的请求则“sleep”一段时间,设置成功的请求则读取数据库获取值,如果获取到则更新缓存,流程结束,之前sleep的请求唤醒后直接从缓存中读取数据,此时流程结束,如图11-8所示。
图11-8
这个流程里面有一个漏洞,如果数据库中没有我们需要的数据该怎么处理?如果不处理请求则会造成死循环,不断地在缓存和数据库中查询,这时就可以结合缓存穿透的思路,这样其他请求就可以根据“NULL”直接进行处理,直到后台系统在数据库成功插入数据后同步更新清理NULL数据和更新缓存。
缓存过期导致惊群效应
我们在使用缓存组件的时候,经常会使用缓存过期这一功能,这样可以不定期地释放使用频率很低的缓存,节省出缓存空间。如果很多缓存设置的过期时间是一样的,就会导致在一段时间内同时生成大量的缓存,然后在另外一段时间内又有大量的缓存失效,大量请求就直接穿透到数据库中,导致后端数据库的压力陡增,这就是“缓存过期导致的惊群效应”!
比较合理的解决方案之一:为每个缓存的key设置的过期时间再加一个随机值,可以避免缓存同时失效。
最终一致性
缓存的最终一致性是指当后端的程序在更新数据库数据完成之后,同步更新缓存失败,后续利用补偿机制对缓存进行更新,以达到最终缓存的数据与数据库的数据是一致的状态。
常用的方法有两种,分别是基于MQ和基于binlog的方式。
(1)基于MQ的缓存补偿方案。
这种方案是当缓存组件出现故障或网络出现抖动的时候,程序将MQ作为补偿的缓冲队列,通过重试的方式机制更新缓存,如图11-9所示。
图11-9
说明:
应用同时更新数据库和缓存。
如果数据库更新成功,则开始更新缓存;如果数据库更新失败,则整个更新过程失败。
判断更新缓存是否成功,如果成功则返回。
如果缓存没有更新成功,则将数据发到MQ中。
应用监控MQ通道,收到消息后继续更新Redis。
问题点:
如果更新Redis失败,同时在将数据发到MQ之前应用重启了,那么MQ就没有需要更新的数据,如果Redis对所有数据没有设置过期时间,同时在读多写少的场景下,那么只能通过人工介入来更新缓存。
(2)基于binlog的方式来实现统一缓存更新方案。
第一种方案对于应用的研发人员来讲比较“重”,需要研发人员同时判断据库和Redis是否成功来做不同的考虑,而使用binlog更新缓存的方案能够减轻业务研发人员的工作量,并且也有利于形成统一的技术方案,如图11-10所示。
图11-10
说明:
应用直接写数据到数据库中。
数据库更新binlog日志。
利用Canal中间件读取binlog日志。
Canal借助于限流组件按频率将数据发到MQ中。
应用监控MQ通道,将MQ的数据更新到Redis缓存中。
可以看到这种方案对研发人员来说比较轻量,不用关心缓存层面,虽然这个方案实现起来比较复杂,但却容易形成统一的解决方案。
问题点:
这种方案的弊端是需要提前约定缓存的数据结构,如果使用者采用多种数据结构来存放数据,则方案无法做成通用的方式,同时极大地增加了方案的复杂度。
8)程序中打印了大量的无用日志,并且引起性能问题
先来看一段伪代码:
QuataDTO quataDTO = null;
try {
quataDTO = getRiskLimit(payRequest.getQueryRiskInfo(),payRequest.getMerchantNo(), payRequest.getIndustryCatalog(),cardBinResDTO.getCardType(), cardBinResDTO.getBankCode(), bizName);
} catch (Exception e) {
logger.info("获取风控限额异常", e);
}
通过上面的代码,发现了以下需要注意的点:
日志的打印必须以logger.error或logger.warn的方式打印出来。
日志打印格式:[系统来源] 错误描述 [关键信息],日志信息要打印出能看懂的信息,有前因和后果。甚至有些方法的入参和出参也要考虑打印出来。
在输入错误信息的时候,Exception不要以e.getMessage的方式打印出来。
合理地日志打印,可以参考如下格式:
logger.warn("[innersys] - ["+ exceptionType.description + "] - [" + methodName + "] - "
+"errorCode:[" + errorCode + "], "
+"errorMsg:[" + errorMsg + "]", e);
logger.info("[innersys] - [入参] - [" +methodName + "] - "
+ LogInfoEncryptUtil.getLogString(arguments)+ "]");
logger.info("[innersys] - [返回结果] - [" +methodName + "] - " + LogInfoEncryptUtil.getLogString(result));
在程序中大量地打印日志,虽然能够打印很多有用信息帮助我们排查问题,但日志量太多不仅影响磁盘I/O,还会造成线程阻塞,对程序的性能造成较大影响。在使用Log4j1.2.14设置ConversionPattern的时候,使用如下格式:
%d %-5p %c:%L [%t] - %m%n
在对项目进行压测的时候却发现了大量的锁等待,如图11-11所示。
图11-11
对Log4j进行源码分析,发现在org.apache.log4j.spi.LocationInfo类中有如下代码:
String s;
// Protect against multiple access to sw.
synchronized(sw) {
t.printStackTrace(pw);
s = sw.toString();
sw.getBuffer().setLength(0);
}
//System.out.println("s is ["+s+"].");
int ibegin, iend;
可以看出在该方法中用了synchronized锁,然后又通过打印堆栈来获取行号,于是将ConversionPattern的格式修改为%d %-5p %c [%t] - %m%n后,线程大量阻塞的问题解决了,极大地提高了程序的并发能力。
9)关于索引的优化
组合索引的原则是偏左原则,所以在使用的时候需要多加注意。
不需要过多地添加索引的数量,在添加的时候要考虑聚集索引和辅助索引,两者的性能是有区别的。
索引不会包含NULL值的列。
只要列中包含NULL值都不会被包含在索引中,复合索引中只要有一列含有NULL值,那么这一列对于此复合索引就是无效的。所以我们在设计数据库时不要让字段的默认值为NULL。
MySQL索引排序。
MySQL查询只使用一个索引,如果where子句中已经使用了索引,那么order by中的列是不会使用索引的。因此数据库默认排序可以在符合要求的情况下不使用排序操作;尽量不要包含多个列的排序,如果需要,最好给这些列创建复合索引。
使用索引的注意事项。
以下操作符可以应用索引:
m 大于等于;
m Between;
m IN;
m LIKE 不以%开头。
以下操作符不能应用索引:
m NOT IN;
m LIKE %_开头。