一. 代码
优化代码实现是第一位的,特别是一些不合理的复杂实现。结合需求从代码
实现的角度,使用更高效的算法或方案实现。
二.数据库
数据库的优化,总体上有3个方面:
- SQL调优:除了掌握SQL基本的优化手段,使用慢日志定位到具体问题SQL,使用explain、profile等工具来逐步调优。
- 连接池调优:选择高效适用的连接池,结合当前使用连接池的原理、具体的连接池监控数据和当前的业务量作一个综合的判断,通过反复的几次调试得到最终的调优参数。
- 架构层面:包括读写分离、主从库负载均衡、水平和垂直分库分表等方面,一般需要的改动较大,需要从整体架构方面综合考虑。
三、缓存
- 本地缓存(HashMap/ConcurrentHashMap、Ehcache、RocksDB、Guava Cache等)。
- 缓存服务(Redis/Tair/Memcache等)。系列缓存教程请关注公众号Java技术栈阅读,都是实战干货。
设计关键点:
<1>什么时候更新缓存?如何保障更新的可靠性和实时性?
更新缓存的策略,需要具体问题具体分析。基本的更新策略有两个:
① 接收变更的消息,准实时更新。
② 给每一个缓存数据设置5分钟的过期时间,过期后从DB加载再回设到DB。这个策略是对第一个策略的有力补充,解决了手动变更DB不发消息、接收消息更新程序临时出错等问题导致的第一个策略失效的问题。通过这种双保险机制,有效地保证了缓存数据的可靠性和实时性。
<2>缓存是否会满,缓存满了怎么办?
对于一个缓存服务,理论上来说,随着缓存数据的日益增多,在容量有限的情况下,缓存肯定有一天会满的。如何应对?
① 给缓存服务,选择合适的缓存逐出算法,比如最常见的LRU。
② 针对当前设置的容量,设置适当的警戒值,比如10G的缓存,当缓存数据达到8G的时候,就开始发出报警,提前排查问题或者扩容。
③给一些没有必要长期保存的key,尽量设置过期时间。
<3>缓存是否允许丢失?丢失了怎么办?
根据业务场景判断,是否允许丢失。如果不允许,就需要带持久化功能的缓存服务来支持,比如Redis或者Tair。更细节的话,可以根据业务对丢失时间的容忍度,还可以选择更具体的持久化策略,比如Redis的RDB或者AOF。
缓存问题
<1>缓存穿透
描述:缓存穿透是指缓存和数据库中都没有的数据,而用户不断发起请求,如发起为id为“-1”的数据或id为特别大不存在的数据。这时的用户很可能是攻击者,攻击会导致数据库压力过大。
解决方案:
① 接口层增加校验,如用户鉴权校验,id做基础校验,id<=0的直接拦截;
②从缓存取不到的数据,在数据库中也没有取到,这时也可以将key-value对写为key-null,缓存有效时间可以设置短点,如30秒(设置太长会导致正常情况也没法使用)。这样可以防止攻击用户反复用同一个id暴力攻击。
<2>缓存击穿
描述:缓存击穿是指缓存中没有但数据库中有的数据(一般是缓存时间到期),这时由于并发用户特别多,同时读缓存没读到数据,又同时去数据库去取数据,引起数据库压力瞬间增大,造成过大压力。
解决方案:
① 设置热点数据永远不过期。
② 加互斥锁,业界比较常用的做法,是使用mutex。简单地来说,就是在缓存失效的时候(判断拿出来的值为空),不是立即去load db,而是先使用缓存工具的某些带成功操作返回值的操作(比如Redis的SETNX或者Memcache的ADD)去set一个mutex key,当操作返回成功时,再进行load db的操作并回设缓存;否则,就重试整个get缓存的方法。类似下面的代码:
public String get(key) {
String value = redis.get(key);
if (value == null) { //代表缓存值过期
//设置3min的超时,防止del操作失败的时候,下次缓存过期一直不能load db
if (redis.setnx(key_mutex, 1, 3 * 60) == 1) { //代表设置成功
value = db.get(key);
redis.set(key, value, expire_secs);
redis.del(key_mutex);
} else { //这个时候代表同时候的其他线程已经load db并回设到缓存了,这时候重试获取缓存值即可
sleep(50);
get(key); //重试
}
} else {
return value;
}
}
<3>缓存雪崩
描述:缓存雪崩是指缓存中数据大批量到过期时间,而查询数据量巨大,引起数据库压力过大甚至down机。
和缓存击穿不同的是,缓存击穿是并发查同一条数据,缓存雪崩是不同数据都过期了,很多数据都查不到从而查数据库。
解决方案:
①缓存数据的过期时间设置随机,防止同一时间大量数据过期现象发生。
②如果缓存系统是分布式部署,将热点数据均匀分布在不同的缓存节点中。
③设置热点数据永远不过期。
<4>缓存更新
Cache Aside 模式:这是最常用最常用的pattern了。其具体逻辑如下:
失效:应用程序先从cache取数据,没有得到,则从数据库中取数据,成功后,放到缓存中。
命中:应用程序从cache中取数据,取到后返回。
更新:先把数据存到数据库中,成功后,再让缓存失效。
四.异步
使用场景:
针对某些客户端的请求,在服务端可能需要针对这些请求做一些附属额外的事情,这些事情其实用户并不关心或者不需要立即拿到这些事情的处理结果,这种情况就比较适合用异步的方式去处理。
作用:
异步处理的好处:
- 缩短接口响应时间,使用户的请求快速返回,用户体验更好。
- 避免线程长时间处于运行状态,这样会引起服务线程池的可用线程长时间不够用,进而引起线程池任务队列长度增大,从而阻塞更多请求任务,使得更多请求得不到及时处理。
- 提升服务的处理性能。
实现方式
<1>线程(线程池)
采用额外开辟一个线程或者使用线程池的做法,在IO线程(处理请求响应)之外的线程来处理相应的任务,在IO线程中让response先返回。
如果异步线程处理的任务设计的数据量非常大,那么可以引入阻塞队列BlockingQueue作进一步的优化。具体做法是让一批异步线程不断地往阻塞队列里添加要处理的数据,然后额外起一个或一批处理线程,循环批量从队列里拿预设大小的数据,来进行批处理,这样进一步提高了性能。
<2>消息队列(MQ)
使用消息队列(MQ)中间件服务,MQ天生就是异步的。一些额外的任务,可能不需要这个系统来处理,但是需要其他系统来处理。这个时候可以先把它封装成一个消息,扔到消息队列里面,通过消息中间件的可靠性保证把消息投递到关心它的系统,然后让其他系统来做相应的处理。
五.NoSQL
和缓存的区别:
这里介绍的NoSQL和缓存不一样,虽然可能会使用一样的数据存储方案(比如Redis或者Tair),但是使用的方式不一样,这一节介绍的是把它作为DB来用。如果当作DB来用,需要有效保证数据存储方案的可用性、可靠性。
使用场景:
需要结合具体的业务场景,看这块业务涉及的数据是否适合用NoSQL来存储,对数据的操作方式是否适合用NoSQL的方式来操作,或者是否需要用到NoSQL的一些额外特性(比如原子加减等)。
如果业务数据不需要和其他数据作关联,不需要事务或者外键之类的支持,而且有可能写入会异常频繁,这个时候就比较适合用NoSQL(比如HBase)。监控类、日志类系统通常会采集大量的时序数据,这类时序指标数据往往都是“读少写多”的类型,可以使用Elasticsearch、OpenTSDB等。
六. 多线程与分布式
使用场景:
离线任务、异步任务、大数据任务、耗时较长任务的运行,适当地利用,可达到加速的效果。系列多线程教程请关注公众号Java技术栈阅读,都是实战干货。
注意:线上对响应时间要求较高的场合,尽量少用多线程,尤其是服务线程需要等待任务线程的场合(很多重大事故就是和这个息息相关),如果一定要用,可以对服务线程设置一个最大等待时间。
常见做法:
如果单机的处理能力可以满足实际业务的需求,那么尽可能地使用单机多线程的处理方式,减少复杂性;反之,则需要使用多机多线程的方式。
对于单机多线程,可以引入线程池的机制,作用有二:
① 提高性能,节省线程创建和销毁的开销。
②限流,给线程池一个固定的容量,达到这个容量值后再有任务进来,就进入队列进行排队,保障机器极限压力下的稳定处理能力在使用JDK自带的线程池时,一定要仔细理解构造方法的各个参数的含义,如core pool size、max pool size、keepAliveTime、worker queue等,在理解的基础上通过不断地测试调整这些参数值达到最优效果。
如果单机的处理能力不能满足需求,这个时候需要使用多机多线程的方式。这个时候就需要一些分布式系统的知识了,可以选用一些开源成熟的分布式任务调度系统如xxl-job