1. 八个代码问题优化点和我的性能调优总结
前言: 项目开发的各个阶段的疏忽都可能引起生产问题。以下主要基于之前查生产问题、代码review及我们部门其它同事的经验,并查了相关资料整理了一下。有更好的做法或这对我整理出来的做法也欢迎提出来,我们一起讨论:
1.1. 额度、库存问题及跨服务调用时的幂等------通过简单的行锁来处理,而不是悲观锁。跨服务时:动账表+更新做事务
-
先说结论:建议优先用数据库行锁的做法更新额度。mysql数据库的tps我了解到的一个值是4000,大部分场景是满足的。各应用的真实情况还需要自行再压测。不建议刚开始就引入redis等中间件增加复杂度。
-
以下主要讨论:乐观锁、悲观锁、数据库行锁。RPC调用的幂等问题
-
数据库行锁(建议):通过where 条件限制不会超额或者超卖
// 其中seq=#{seq}的seq指额表记录的业务主键:在根据个人限额的场景下可能是身份证信息或者卡号;在根据产品限额的场景下可能就是产品码 String sql="update limit_info set avai_amount= avai_amount - #{current} where seq=#{seq} and avai_amount > #{current}"; int res = excute(sql); //根据影响的行数,res是0还是1判断更新是否成功 -
额度模块独立后的调用:通过insert额度变更表,业务订单号加唯一索引判重。重复请求入库会失败。 针对额度服务拆分单独服务的场景,额度服务需考虑重发、幂等的场景。
//需考虑幂等 //开启事务 更新额度和入库流水表作为一个事务。 //通过orderNo在流水表加索引判重,保证一笔订单不会被处理多次。 begin(); insert(String orderNo, Limit limit); updateLimit(); commit(); -
以下列举下悲观锁及乐观锁的写法以及可能存在的问题
- 悲观锁:select...for update。缺点:性能会有所下降,大部分业务场景不需要悲观锁。
//开启事务 begin(); Bigdecimal avaiAmount = excute("select avai_amount from limit where id=#{id} for update"); Bigdecimal newAvaiAmount = avaiAmount - current; //判断余额是否足够 //更新余额 if(newAvaiAmount.compareTo(BigDecimal.ZERO) >= 0){ int res = excute("update limit_info set avai_amount=#{newAvaiAmount} where id=#{id}"); } commit();- 乐观锁:update时添加条件avai_amount=#{avaiAmount}。缺点:要有重试机制,增加了代码复杂度。
Bigdecimal avaiAmount = excute("select avai_amount from limit_info where id=#{id}"); Bigdecimal newAvaiAmount = avaiAmount - current; //判断余额是否足够 //更新余额 if(newAvaiAmount.compareTo(BigDecimal.ZERO) >= 0){ int res = excute("update limit_info set avai_amount=#{newAvaiAmount} where id=#{id} and avai_amount=#{avaiAmount}"); } -
ps: 某应用里关于按天控制额度用sql的写法(做了简化):
--冻结客户交易份额 update LIMIT_INFO set USED_AMT = CASE WHEN WORK_DATE = #workDate# then USED_AMT + #usedAmt# else #usedAmt# end, WORK_DATE = #wokdDate#, MAX_AMT = #maxAmt#, where CST_NO = #cstNo# AND CASE WHEN WORK_DATE = #workDate# then USED_AMT + #usedAmt# else #usedAmt# end <= MAX_AMT --回滚已使用额度 update LIMIT_INFO SET USED_AMT = CASE WHEN WORK_DATE = #workDate# then USED_AMT - #usedAmt# else 0 end, WORK_DATE = #workDate#, WHERE CST_NO = #cstNo# AND WORK_DATE <= #workDate# AND (CASE WHEN WORK_DATE = #workDate# then USED_AMT - #usedAmt# else 0 end) >= 0
1.2. 交易判重------数据库底层判重,避免查询判重在并发下存在的问题
首先,用查询判重在并发高的情况下是有可能防不了重复的。
-
建议在数据库的orderNo字段加唯一索引。
try{ return insert(order); }catch(DuplicateKeyException e){ logger.error(e.getMessage(), e); return -1; } -
错误的做法
Order exsitOrder = excute("select order_no from fund_order where order_no = #{orderNo}"); if(exsitOrder = null){ return insert(order); }else{ return -1; }
1.3. 远程服务调用防重复处理---通过业务单号做链路幂等
建议用业务订单号做全链路的防重,幂等处理。将上游送上来的业务单号直接发下游,防止异常处理时由于新生成的下游流水号导致下游重复处理。 Ps:有些场景,下游流水号长度会比较短,如果是需要再生成下游流水号,建议远程调用下游前先落表。异常处理里,直接从数据库拿该字段。
CbibTransferReqVo req = new CbibTransferReqVo();
req.setAmt(amt);
...
req.setSerialId(SerialNumberUtil.generateSerialId()); //错误:调用前新生成了订单号
//正确:req.setSerialId(inMessage.getOrderNo());
res = cbibService.doTransfer(req);
1.4. 文件的防止多进程读------数据库或FileLock防重复处理
服务双活情况下,有可能存在多个应用扫到同一个文件,然后重复处理。可以通过以下做法防止多进程处理同一个文件:
-
a. (建议该做法)处理文件前,入库insert。并且文件名添加唯一索引,确保同一个文件只会被处理一次。
-
b. 先尝试加锁文件,成功后移动文件至处理目录。(不需要记表) 锁方法: 通过FileLock获取锁。获取成功后创建锁文件,如果创建成功则锁成功释放锁,否则失败释放锁。代码如下:
// 加锁代码 public static boolean fileLocker(File file) { boolean flag = false; FileOutputStream fos = null; FileLock lock = null; try { fos = new FileOutputStream(file, true); lock = fos.getChannel().tryLock(); if (lock != null) { File lockFile = new File(file.getParent(), file.getName() + ".lock"); if (lockFile.isFile()) { lock.release(); flag = false; } else { lockFile.createNewFile(); lock.release(); flag = true; } } } catch (Exception e) { throw new RuntimeException(e); } finally { try { if (fos != null) fos.close(); } catch (Exception e2) { throw new RuntimeException(e2); } } return flag; } //释放锁代码 public static void fileUnLocker(File file) { File lockFile = new File(file.getParent(), file.getName() + ".lock"); lockFile.delete(); }// 移动文件代码 public static boolean moveFileWithLock(File file, String path) { if (file.exists()) { if (fileLocker(file)) { try { if (new File(path, file.getName()).exists()) { FileUtils.forceDelete(new File(path, file.getName())); } FileUtils.moveFileToDirectory(file, new File(path), false); } catch (IOException e) { throw new RuntimeException(e); } finally { fileUnLocker(file); } return true; } else { throw new RuntimeException("文件["+file.getAbsolutePath()+"]加锁失败,移动失败"); } } else { throw new RuntimeException("文件[" + file.getAbsolutePath() + "]不存在,移动失败"); } }
1.5. 逐笔处理优化为批量处理------针对数据库、redis等减少网络交互,提升性能
针对数据库的读,数据量大时用分页读
具体写法看持久化框架是否支持以及数据库,以下是oracle我比较推荐的写法
select *
from (select a.*, rownum rowno
from (select t.*
from test t
order by t.create_date desc) a
where rownum <= 20) b
where b.rowno >= 11;
针对批量数据库的更新或redis操作,可分批次3000条(具体需根据实际测试情况)处理提交
//对 allList 操作
List<Order> tmpList = new ArrayList<>();
for(int i=0; i < allList.size(); i++){
tmpList.ad(allList.get(i));
if(i%5000 ==0){
handle(tmpList);
...
tmpList=new ArrayList<>();
}
}
handle(tmpList);
特殊场景更新可用in条件
针对oracle in的1000条限制:update... where orderNo in ("", "", "") or in("", "", "")
对文件读,采用批量读取后处理,避免全部读取造成内存溢出,逐笔处理效率差
项目可以用Spring Batch的话,建议FlatFileItemReader读文件。用java实现批量读文件处理我没有实现过,可以自行google网上找代码。
1.6. 手工开启事务注意保证commit或rollback及避免长事务------避免事务不提交而占用连接进而导致应用故障
用事务时,确保commit或rollback执行。避免由于代码问题,commit或rollback未执行,连接未正常归还连接池,导致服务整体挂掉。(生产我知道的有两次)。错误代码示例:
ITransactionManager txManager = getTransactionManager();
txManager.begin();
try{
insert(String orderNo, Limit limit);
// 问题一:有可能外部服务不稳定,导致长事务
callRemoteService(orderNo);
int res = updateOrder(orderNo);
// 问题二:有可能事务未提交
if(res > 0)
txManager.commit();
}catch(Exception e){
txManager.rollback();
logger.error(e.getMessage(), e);
}
正确代码:
callRemoteService(orderNo);
ITransactionManager txManager = getTransactionManager();
txManager.begin();
try{
insert(String orderNo, Limit limit);
updateOrder(orderNo);
txManager.commit();
}catch(Exception e){
txManager.rollback();
logger.error(e.getMessage(), e);
}
1.7. 合理添加索引------选择合理的索引字段,并避免空值不带条件查询全量或大量数据
以下是几个简单的原则,具体建议咨询DBA
- 在where条件中:索引字段应该在where的查询条件中
- 用区分度高的字段:即做为索引列不要有太多重复数据,例如性别,只有男、女,效果就不会太好
- 最左匹配:例如联合索引idx_ba(b,a),那查询条件where b="x"和where b="x" and a="y"查询都有效。where a="y"查询时就不会用到索引
- 经常更新的列不要建索引:更新字段时会更新索引,导致性能变差
- 对where条件的索引字段使用函数会导致不走索引
- 不要过多索引:单表索引数量有上限,过多降低插入和更新的效率,甚至有些情况下会降低查询效率
- 表数据量过少没必要建索引
- 生产上,mysql,在已经停交易的情况下新增表字段或者添加索引还报错:Lock wait timemout exceeded:try restarting transaction,需要检查是否还有连接没有释放,比如:生产堡垒机链接不释放。
- 建立分区表:如果联机交易流水表是分区表,联机的时候有去分区表查询的操作,查询的条件最好带上分区键,否则表中数据量的了之后,全分区扫描耗时较长(生产oracle出现过类似问题)。
- 联机查询表:查询条件如果存在空值情况,需注意判空。如果数据库中记录的空值较多,而又没有判空操作,会导致联机查询内存溢出,影响联机交易。
1.8. 找到瓶颈,做针对性的优化------对优化提前评估,避免不必要的优化、过度优化引起额外的问题
- 做性能优化前,要能评估优化的代码或方法对性能有多大影响。
- 过度优化、过度设计是之前经常碰到问题。包括不必要的公共化、不必要的引入redis缓存等 不要手里拿着锤子,就看什么都是钉子。而锤子就是代码。
- 举一个最近碰到的反例: 某个项目组: 原主逻辑,我们应用根据文件,针对每笔交易,对汇总账号下账即对总金额做减法,然后对客户账户上账,即金额做加法。 生产发现太慢,每个小时只能处理13000笔左右交易。 原优化逻辑:改业务逻辑,优化后预计20000笔交易只需要下账800次,即对汇总账号操作800次。 我和他们一起分析了一下,目前每次下账数据库操作不超过20ms,假设每小时的13000笔交易全部不做下账操作,13000*20ms,也只是少处理260s,不到5分钟。 看代码后优化逻辑:看了代码是for循环,单线程操作(防止下账获取不到行锁),简单了改法,同时处理3个文件,相当于3线程处理。
- 总结:数据库性能一般是足够的,这种业务场景,一般可以批量读,线程池多线程处理。
1.9. 性能优化
很多项目都碰到过性能问题,优化的步骤,提供思路。经常被忽略的点:日志
工具
a. nmon: 监控系统资源
b. jmeter: 接口压测
c. arthas(阿尔萨斯): java线上诊断工具,trace命令看方法调用路径及耗时
以往发现的性能问题主要存在点
a. 日志:可以通过调高日志级别压测做对比
b. 数据库索引
c. 线程池配置不合理,配置过大或者默认的配置太小
d. 账户热点问题
e. 系统资源瓶颈:网络、CPU、IO等
f. GC频繁
调优过程(主要针对从几TPS或者几十TPS优化到上百TPS)
a. 先一个并发,用postman或者jemeter或其它工具发几笔交易,看RS(响应时间)
- 排查是否有耗时长的步骤,包括数据库未加索引导致的耗时、外部服务耗时
- 正常响应(排除外部服务)应该在100ms以下
b. 外部服务加挡板,先压测下游服务或原子服务。找最优并发数
-
排查配置是否合理,主要线程池配置、连接池配置
-
日志打印多建议先调高日志为error,排除日志影响
-
建议 10、20、30、50并发分别压测,确认TPS最高时的并发数 发现问题,及时找瓶颈调优
-
如果用独立的服务做挡板,建议先评估挡板的性能
c. 通过druid,hystrix等监控面板,或者arthas工具,找到慢的方法,做针对性优化
d. 没有监控面板可以用threadLocal记录各个节点时间,打印帮助查问题