日常开发中,如何减少bug?
常见问题 发布者:ou3377 2021-12-10 08:40 访问量:175
本文将从数据库、代码层面、缓存使用篇3个大方向,总结出一共50多个注意点,助大家成为开发质量之星。
慢查询
数据库篇的话,哪些地方容易导致bug出现呢?我总结了7个方面:慢查询、数据库字段注意点、事务失效的场景、死锁、主从延迟、新老数据兼容、一些SQL经典注意点。
提起慢查询,我们马上就会想到加索引。如果一条SQL没加索引,或者没有命中索引的话,就会产生慢查询。
索引哪些情况会失效?
单表数据量太大,就会影响SQL执行性能。我们知道索引数据结构一般是B+树,一棵高度为3的B+树,大概可以存储两千万的数据。超过这个数的话,B+树要变高,查询性能会下降。
因此,数据量大的时候,建议分库分表。分库分表的中间件有mycat、sharding-jdbc
日常开发中,笔者见过很多不合理的SQL:比如一个SQL居然用了6个表连接,连表太多会影响查询性能;再比如一个表,居然加了10个索引等等。索引是会降低了插入和更新SQL性能,所以索引一般不建议太多,一般不能超过五个。
数据库字段这块内容,很容易出bug。比如,你测试环境修改了表结构,加了某个字段,忘记把脚本带到生产环境,那发版肯定有问题了。
假设你的数据库字段是:
`name` varchar(255) DEFAULT NOT NULL
如果请求参数来了变量name,字段长度是300,那插入表的时候就报错了。所以需要校验参数,防止字段超长。
我们设计数据库表字段的时候,尽量把字段设置为not null。
如果数据库字段设置为NULL
值,容易导致程序空指针;如果数据库字段设置为NULL
值,需要注意count(具体列) 的使用,会有坑。
我们的日常开发任务,如果在测试环境,对表进行修改,比如添加了一个新字段,必须要把SQL脚本带到生产环境,否则字段缺失,发版就有问题啦。
如果一个表字段需要支持表情存储,使用utf8mb4。
如果你要用一个字段存储文件,考虑存储文件的路径,而不是保存整个文件下去。使用text时,涉及查询条件时,注意创建前缀索引。
@Transactional注解,加在非public修饰的方法上,事务是不会生效的。spring事务是借鉴了AOP的思想,也是通过动态代理来实现的。spring事务自己在调用动态代理之前,已经对非public方法过滤了,所以非public方法,事务不生效。
以下这个场景,@Transactional事务也是无效的
public class TransactionTest{
public void A(){
//插入一条数据
//调用方法B (本地的类调用,事务失效了)
B();
}
@Transactional
public void B(){
//插入数据
}
}
@Transactional
public void method(){
try{
//插入一条数据
insertA();
//更改一条数据
updateB();
}catch(Exception e){
logger.error("异常被捕获了,那你的事务就失效咯",e);
}
}
Spring默认抛出了未检查unchecked
异常(继承自RuntimeException 的异常)或者Error才回滚事务;其他异常不会触发回滚事务。如果在事务中抛出其他类型的异常,就需要指定rollbackFor
属性。
MyISAM存储引擎不支持事务,InnoDb就支持事务
业务代码要和spring事务的源码在同一个线程中,才会受spring事务的控制。比如下面代码,方法mothed的子线程,内部执行的事务操作,将不受mothed方法上spring事务的控制,这一点大家要注意。这是因为spring事务实现中使用了ThreadLocal,实现同一个线程中数据共享。
@Transactional
public void mothed() {
new Thread() {
事务操作
}.start();
}
死锁是指两个或多个事务在同一资源上相互占用,并请求锁定对方的资源,从而导致恶性循环的现象。
MySQL内部有一套死锁检测机制,一旦发生死锁会立即回滚一个事务,让另一个事务执行下去。但死锁有资源的利用率降低、进程得不到正确结果等危害。
要避免死锁,需要学会分析:一条SQL的加锁是如何进行的?一条SQL加锁,可以分9种情况进行探讨:
分析解决死锁的步骤如下:
有兴趣的小伙伴,可以看下我之前写的这篇文章:手把手教你分析Mysql死锁问题
先插入,接着就去查询,这类代码逻辑比较常见,这可能会有问题的。一般数据库都是有主库,从库的。写入的话是写主库,读一般是读从库。如果发生主从延迟,,很可能出现你插入成功了,但是查询不到的情况。
如果是重要业务,要求强一致性,考虑直接读主库
如果是一般业务,可以接受短暂的数据不一致的话,优先考虑读从库。因为从库可以分担主库的读写压力,提高系统吞吐。
我们日常开发中,随着业务需求变更,经常需要给某个数据库表添加个字段。比如在某个APP配置表,需要添加个场景号字段,如scene_type
,它的枚举值是 01、02、03
,那我们就要跟业务对齐,新添加的字段,老数据是什么默认值,是为空还是默认01,如果是为NULL
的话,程序代码就要做好空指针处理。
如果我们开发中,需要沿用数据库表的老字段,并且有存量数据,那就需要考虑老存量数据库的值是否有坑。比如我们表有个user_role_code 的字段,老的数据中,它枚举值是 01:超级管理员 02:管理员 03:一般用户
。假设业务需求是一般用户拆分为03查询用户和04操作用户,那我们在开发中,就要考虑老数据值的问题啦。
limit大分页是一个非常经典的SQL问题,我们一般有这3种对应的解决方案
方案一: 如果id是连续的,可以这样,返回上次查询的最大记录(偏移量),再往下limit
select id,name from employee where id>1000000 limit 10.
方案二: 在业务允许的情况下限制页数:
建议跟业务讨论,有没有必要查这么后的分页啦。因为绝大多数用户都不会往后翻太多页。谷歌搜索页也是限制了页数,因此不存在limit大分页问题。
方案三: 利用延迟关联或者子查询优化超多分页场景。(先快速定位需要获取的id段,然后再关联)
SELECT a.* FROM employee a, (select id from employee where 条件 LIMIT 1000000,10 ) b where a.id=b.id
我们更新或者查询数据库数据时,尽量避免循环去操作数据库,可以考虑分批进行。比如你要插入10万数据的话,可以一次插入500条,执行200次。
正例:
remoteBatchQuery(param);
反例:
for(int i=0;i<100000;i++){
remoteSingleQuery(param)
}
代码层面
我们编码的时候,需要注意这六种类型的空指针问题
if(object!=null){
String name = object.getName();
}
在高并发场景下,HashMap
可能会出现死循环。因为它是非线性安全的,可以考虑使用ConcurrentHashMap
。所以我们使用这些集合的时候,需要注意是不是线性安全的。
日常开发,经常需要对日期格式化,但是呢,年份设置为YYYY大写的时候,是有坑的哦。
Calendar calendar = Calendar.getInstance();
calendar.set(2019, Calendar.DECEMBER, 31);
Date testDate = calendar.getTime();
SimpleDateFormat dtf = new SimpleDateFormat("YYYY-MM-dd");
System.out.println("2019-12-31 转 YYYY-MM-dd 格式后 " + dtf.format(testDate));
运行结果:
2019-12-31 转 YYYY-MM-dd 格式后 2020-12-31
还有金额计算也比较常见,我们要注意精度问题:
public class DoubleTest {
public static void main(String[] args) {
System.out.println(0.1+0.2);
System.out.println(1.0-0.8);
System.out.println(4.015*100);
System.out.println(123.3/100);
double amount1 = 3.15;
double amount2 = 2.10;
if (amount1 - amount2 == 1.05){
System.out.println("OK");
}
}
}
运行结果:
0.30000000000000004
0.19999999999999996
401.49999999999994
1.2329999999999999
读取大文件的时候,不要Files.readAllBytes
直接读到内存,会OOM的,建议使用BufferedReader
一行一行来,或者使用NIO
使用try-with-resource,读写完文件,需要关闭流
/*
* 关注公众号,捡田螺的小男孩
*/
try (FileInputStream inputStream = new FileInputStream(new File("jay.txt")) {
// use resources
} catch (FileNotFoundException e) {
log.error(e);
} catch (IOException e) {
log.error(e);
}
日常开发中,这种代码实现经常可见:先查询是否有剩余可用的票,再去更新票余量。
if(selectIsAvailable(ticketId){
1、deleteTicketById(ticketId)
2、给现金增加操作
}else{
return “没有可用现金券”
}
如果是并发执行,很可能有问题的,应该利用数据库更新/删除的原子性,正解如下:
if(deleteAvailableTicketById(ticketId) == 1){
1、给现金增加操作
}else{
return “没有可用现金券”
}
我们提供对外的接口,不管是提供给客户端、还是前端,又或是别的系统调用,都需要校验一下入参的合法性。
★如果你的数据库字段设置为varchar(16),对方传了一个32位的字符串过来,你不校验参数长度,插入数据库直接异常了。
”
很多bug都是因为修改了对外老接口,但是却不做兼容导致的。关键这个问题多数是比较严重的,可能直接导致系统发版失败的。新手程序员很容易犯这个错误哦~
比如我们有个dubbo的分布式接口,本次你修改了入参,就需要考虑新老接口兼容。原本是只接收A,B参数,现在你加了一个参数C,就可以考虑这样处理。
//老接口
void oldService(A,B){
//兼容新接口,传个null代替C
newService(A,B,null);
}
//新接口,暂时不能删掉老接口,需要做兼容。
void newService(A,B,C);
如果瞬间的大流量请求过来,容易压垮系统。所以为了保护我们的系统,一般要做限流处理。可以使用guava ratelimiter 组件做限流,也可以用阿里开源的Sentinel
我们转账等类型的接口,一定要注意安全性。一定要鉴权,加签验签,为用户交易保驾护航。
接口是需要考虑幂等性的,尤其抢红包、转账这些重要接口。最直观的业务场景,就是用户连着点击两次,你的接口有没有hold住。
★”
幂等(idempotent、idempotence)是一个数学与计算机学概念,常见于抽象代数中。 在编程中.一个幂等操作的特点是其任意多次执行所产生的影响均与一次执行的影响相同。幂等函数,或幂等方法,是指可以使用相同参数重复执行,并能获得相同结果的函数。
一般幂等技术方案有这几种:
我们调用别人的接口,如果超时了怎么办呢?
★举个例子,我们调用一个远程转账接口,A客户给B客户转100万,成功的时候就把本地转账流水置为成功,失败的时候就把本地流水置为失败。如果调用转账系统超时了呢,我们怎么处理呢?置为成功还是失败呢?这个超时处理可要考虑好,要不然就资金损失了。这种场景下,调接口超时,我们就可以先不更新本地转账流水状态,而是重新发起查询远程转账请求,查询到转账成功的记录,再更新本地状态状态
”
如果我们调用一个远程http或者dubbo接口,调用失败了,我们可以考虑引入重试机制。有时候网路抖动一下,接口就调失败了,引入重试机制可以提高用户体验。但是这个重试机制需要评估次数,或者有些接口不支持幂等,就不适合重试的。
假设我们系统是一个提供注册的服务:用户注册成功之后,调远程A接口发短信,调远程B接口发邮件,最后更新注册状态为成功。
如果调用接口B发邮件失败,那用户就注册失败,业务可能就不会同意了。这时候我们可以考虑给B接口降级处理,提供有损服务。也就是说,如果调用B接口失败,那先不发邮件,而是先让用户注册成功,后面搞个定时补发邮件就好啦。
我还是使用上个小节的用户注册的例子。我们可以开个异步线程去调A接口发短信,异步调B接口发邮件,那即使A或者B接口调失败,我们还是可以保证用户先注册成功。
把发短信这些通知类接口,放到异步线程处理,可以降低接口耗时,提升用户体验哦。
如果我们调用一个远程接口,一般需要思考以下:如果别人接口异常,我们要怎么处理,怎么兜底,是重试还是当做失败?怎么保证数据的最终一致性等等。
使用缓存,可以降低耗时,提供系统吞吐性能。但是,使用缓存,会存在数据一致性的问题。
一般我们使用缓存,都是旁路缓存模式,读请求流程如下:
旁路缓存模式的写流程:
我们在操作缓存的时候,到底应该删除缓存还是更新缓存呢?我们先来看个例子:
这时候,缓存保存的是A的数据(老数据),数据库保存的是B的数据(新数据),数据不一致了,脏数据出现啦。如果是删除缓存取代更新缓存则不会出现这个脏数据问题。
双写的情况下,先操作数据库还是先操作缓存?我们再来看一个例子:假设有A、B两个请求,请求A做更新操作,请求B做查询读取操作。
image.png
酱紫就有问题啦,缓存和数据库的数据不一致了。缓存保存的是老数据,数据库保存的是新数据。因此,Cache-Aside缓存模式,选择了先操作数据库而不是先操作缓存。
★缓存穿透:指查询一个一定不存在的数据,由于缓存不命中时,需要从数据库查询,查不到数据则不写入缓存,这将导致这个不存在的数据每次请求都要到数据库去查询,进而给数据库带来压力。
”
缓存穿透一般都是这几种情况产生的:业务不合理的设计、业务/运维/开发失误的操作、黑客非法请求攻击。如何避免缓存穿透呢?一般有三种方法。
★缓存雪崩:指缓存中数据大批量到过期时间,而查询数据量巨大,引起数据库压力过大甚至down机。
”
★缓存击穿:指热点key在某个时间点过期的时候,而恰好在这个时间点对这个Key有大量的并发请求过来,从而大量的请求打到db。
”
缓存击穿看着有点像缓存雪崩,其实它两区别是,缓存雪奔是指数据库压力过大甚至down机,缓存击穿只是大量并发请求到了DB数据库层面。可以认为击穿是缓存雪奔的一个子集吧。有些文章认为它俩区别,是在于击穿针对某一热点key缓存,雪奔则是很多key。
解决方案就有两种:
在Redis中,我们把访问频率高的key,称为热点key。如果某一热点key的请求到服务器主机时,由于请求量特别大,可能会导致主机资源不足,甚至宕机,从而影响正常的服务。
如何解决热key问题?
如果我们使用的是Redis,而Redis的内存是比较昂贵的,我们不要什么数据都往Redis里面塞,一般Redis只缓存查询比较频繁的数据。同时,我们要合理评估Redis的容量,也避免频繁set覆盖,导致设置了过期时间的key失效。
如果我们使用的是本地缓存,如guava的本地缓存,也要评估下容量。避免容量不够。
为了避免Redis内存不够用,Redis用8种内存淘汰策略保护自己~
★”
volatile-lru:当内存不足以容纳新写入数据时,从设置了过期时间的key中使用LRU(最近最少使用)算法进行淘汰; allkeys-lru:当内存不足以容纳新写入数据时,从所有key中使用LRU(最近最少使用)算法进行淘汰。 volatile-lfu:4.0版本新增,当内存不足以容纳新写入数据时,在过期的key中,使用LFU算法进行删除key。 allkeys-lfu:4.0版本新增,当内存不足以容纳新写入数据时,从所有key中使用LFU算法进行淘汰; volatile-random:当内存不足以容纳新写入数据时,从设置了过期时间的key中,随机淘汰数据;。 allkeys-random:当内存不足以容纳新写入数据时,从所有key中随机淘汰数据。 volatile-ttl:当内存不足以容纳新写入数据时,在设置了过期时间的key中,根据过期时间进行淘汰,越早过期的优先被淘汰; noeviction:默认策略,当内存不足以容纳新写入数据时,新写入操作会报错。
文章源自捡田螺的小男孩
关键字:BUG,数据库,代码
http://m.chenzhankj.com/cjwt/795.html