高并发系统设计一般会考虑三个方面:限流、缓存、降级
限流:控制在一定时间内的访问量,比如秒杀,这种场景下访问量过于庞大,使用缓存或者降级根本无法解决访问量巨大的问题,那么只能选择限流
缓存:缓存设计是我们常用的减轻服务器压力的方案,常用的缓存有 redis(分布式)、 memcache(分布式)、google guava cache(本地缓存)等
降级:高并发高负载情况下,选择动态的关闭一些不重要的服务拒绝访问等,为重要的服务节省资源,比如双11当天淘宝关闭了退款等功能
常见的限流算法:令牌桶、漏桶、计数器
接入层限流:指请求流量的入口,该层的主要目的有 负载均衡、非法请求过滤、请求聚合、服务质量监控等等
Nginx接入层限流:使用Nginx自带了两个模块,连接数限流模块ngx_http_limit_conn_module和漏桶算法实现的请求限流模块ngx_http_limit_req_module
应用层限流:比如TPS/QPS超过一定范围后进行控制,比如tomcat可配置可接受的等待连接数、最大连接数、最大线程数等
令牌桶:Guava框架提供了令牌桶算法实现,可直接拿来使用,Guava RateLimiter提供了令牌桶算法实现:平滑突发限流(SmoothBursty)和平滑预热限流(SmoothWarmingUp)实现,代码如下:
import java.util.concurrent.atomic.AtomicInteger;
import org.junit.Test;
import com.google.common.util.concurrent.RateLimiter;
/**
* 固定速率请求,每200ms允许一个请求通过
*
* @date 2019-10-14 16:54
**/
public class FixedRequestLimitDemo {
@Test
public void fixedRequestTest() {
// 表示1秒内产生多少个令牌,即1秒内产生5个令牌,控制每200ms一个请求
RateLimiter rateLimiter = RateLimiter.create(5);
AtomicInteger counter = new AtomicInteger(0);
for (int i = 0; i < 15; i++) {
// 同时开启15个线程访问
new Thread(() -> fixedRequest(rateLimiter, counter)).start();
}
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
private void fixedRequest(RateLimiter rateLimiter, AtomicInteger counter) {
double time = rateLimiter.acquire();
if (time >= 0) {
System.out.println("时间:" + time + " ,第 " + counter.incrementAndGet() + " 个业务处理");
}
}
}
计数器:使用计数器方案简单粗暴的实现限流,使用 google guava cache(本地缓存),代码如下:
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
import org.junit.Test;
import com.google.common.cache.CacheBuilder;
import com.google.common.cache.CacheLoader;
import com.google.common.cache.LoadingCache;
/**
* 限流demo
*
* @date 2019-10-12 17:26
**/
public class LimitDemo {
@Test
public void limitTest() {
// 本地缓存、key (Long)表示当前时间秒、value (AtomicLong)表示请求计数器
LoadingCache counter = CacheBuilder.newBuilder().
expireAfterAccess(2, TimeUnit.SECONDS).build(
new CacheLoader() {
@Override
public AtomicLong load(Long aLong) throws Exception {
return new AtomicLong(0);
}
}
);
for (int i = 0; i < 15; i++) {
// 同时开启15个线程访问
new Thread(() -> requestLimit(counter)).start();
}
try {
// 这里休眠是为了多线程全部执行完输出结果
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
/**
* 请求限制,本方法加锁是为了控制缓存LoadingCache get数据时候的并发
*
* @param counter
*/
private synchronized void requestLimit(LoadingCache counter) {
try {
// 流量限制数量
long limit = 10;
// 当前秒数
long currentSecond = System.currentTimeMillis() / 1000;
if (counter.get(currentSecond).incrementAndGet() > limit) {
// 超出每秒内允许访问10个的限制
System.out.println("第 " + counter.get(currentSecond) + " 个请求超出上限,限流了");
return;
}
System.out.println("第 " + counter.get(currentSecond) + " 个业务处理");
} catch (Exception e) {
e.printStackTrace();
}
}
}
缓存方案可以有效地减轻服务器压力,但是它的设计也有一些必须考虑的问题,例如缓存雪崩、缓存击穿、缓存穿透、缓存预热、缓存降级、资源隔离等
设置缓存时使用了相同的过期时间,导致大量的缓存在同一时刻同时失效,请求全部访问了DB(数据层),DB瞬间压力过大而宕机,从而引起一系列的严重后果
解决方案
1、使用锁或者队列的方式控制多线程同时对DB的读写,即避免失效时所有请求一下子全部访问到DB
2、缓存失效时间分散开,即设置缓存时设置不同的过期时间(原有的缓存时间上增加一个随机数),避免同一时刻大量缓存失效
3、缓存数据增加缓存失效标记,如果缓存标记失效,则更新数据缓存
使用加锁一般适用于并发量不是特别大的场景,伪代码如下:
//伪代码
public object getProductList() {
int cacheTime = 30;
String cacheKey = "product_list";
String lockKey = cacheKey;
String cacheValue = CacheHelper.get(cacheKey);
if (cacheValue != null) {
return cacheValue;
} else {
// 对lockKey加锁
synchronized(lockKey) {
cacheValue = CacheHelper.get(cacheKey);
if (cacheValue != null) {
return cacheValue;
} else {
//这里一般是sql查询数据
cacheValue = getProductListFromDB();
CacheHelper.Add(cacheKey, cacheValue, cacheTime);
}
}
return cacheValue;
}
}
注意:加锁排队仅仅是减轻了数据库的压力,但是并没有提高系统吞吐量,它不仅要解决分布式锁的问题,还会产生线程阻塞问题,所以用户体验比较差!因此,在真正的高并发场景下很少使用!
缓存数据增加缓存失效标记,伪代码如下:
//伪代码
public object getProductList() {
int cacheTime = 30;
String cacheKey = "product_list";
// 缓存标记
String cacheSign = cacheKey + "_sign";
String sign = CacheHelper.Get(cacheSign);
// 获取缓存值
String cacheValue = CacheHelper.Get(cacheKey);
if (sign != null) {
//未过期,直接返回
return cacheValue;
} else {
CacheHelper.Add(cacheSign, "1", cacheTime);
// 开启后台线程来更新缓存
ThreadPool.QueueUserWorkItem((arg) -> {
// sql查询数据
cacheValue = getProductListFromDB();
//日期设缓存时间的2倍,用于脏读
CacheHelper.Add(cacheKey, cacheValue, cacheTime * 2);
});
return cacheValue;
}
}
注意:缓存标记的失效时间设置为缓存数据失效时间的一半,这样根据缓存标记后台线程提前更新缓存数据,在这之前还可以返回旧的缓存数据,这种方案对内存要求高,每个缓存都要设置一个对应的缓存标记
正好要过期的key在某一个时刻被高并发的访问,即某时刻的热点数据,key正好过期了需要请求DB回写到缓存中去,此时大量的请求都访问到DB,DB瞬间压力过大也崩掉了。这里和缓存雪崩不同的是缓存击穿针对的是某一个key,而缓存雪崩针对的是多个key
解决方案
1、使用互斥锁
2、缓存不过期,使用value内部的过期时间来控制过期更新缓存值
使用互斥锁,伪代码如下:
// 1、redis setnx 实现
public String getValue(key) {
String value = redis.get(key);
if(value != null){
return value;
}
// 缓存值过期,获取锁,设置3min的超时,防止del操作失败的时候产生死锁
if (redis.setnx(key_mutex, 1, 3 * 60) == 1) {
// 代表获取锁成功
value = db.get(key);
redis.set(key, value, expire_secs);
redis.del(key_mutex);
return value;
}
// 没有获取到锁表示其他线程已经重新设置缓存了,此时重试获取缓存即可
try {
Thread.sleep(50);
//重试
getValue(key);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
// 2、memcache 实现
public String getValue(String key) {
String value = memcache.get(key);
if (value != null) {
return value;
}
// 加锁
if (memcache.add(key_mutex, 3 * 60 * 1000) == true) {
value = db.get(key);
memcache.set(key, value);
memcache.delete(key_mutex);
return value;
}
// 没有获取到锁表示其他线程已经重新设置缓存了,此时重试获取缓存即可
try {
Thread.sleep(50);
//重试
getValue(key);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
缓存不过期,使用value内部过期值更新缓存,伪代码如下:
/**
* redis 实现
*
* V 对象中有两个属性,value 是缓存值,timeout 是缓存过期更新时间
*
* @param key
* @return
*/
public String getValue(String key) {
V v = redis.get(key);
String value = v.getValue();
long timeout = v.getTimeout();
if (timeout > System.currentTimeMillis()) {
// 缓存值没有到缓存过期时间,直接返回
return value;
}
// 缓存值过期,异步更新后台执行
threadPool.execute(new Runnable() {
public void run() {
String keyMutex = "mutex:" + key;
if (redis.setnx(keyMutex, "1", 3 * 60) == 1) {
String newValue = db.get(key);
redis.set(key, newValue);
redis.delete(keyMutex);
}
}
});
// 此时直接返回旧value值
return value;
}
此方法的优点是并发性能好,缺点是不能及时的获取到最新的缓存值,有点延迟
查询一个数据库中不存在的数据,此时缓存中也不会设置缓存值,那么所有请求都会直接查询到DB,将好像是缓存穿透了一样,请求过大将会导致DB宕机引起严重后果。黑客可以利用这种不存在的key来频繁请求我们的应用,拖垮应用服务器
解决方案
1、布隆过滤器,将所有可能存在的数据哈希到一个足够大的bitmap中,一个一定不存在的数据会被这个bitmap拦截掉,从而避免了对底层存储系统的查询压力
2、查询到数据为空的数据也设置到缓存系统中,缓存时间可以设置的相对短一些,这样子的话缓存就可以起作用,挡掉了直接访问DB的压力
查询到数据为空的数据也设置到缓存系统,伪代码如下:
public String getValue(key) {
String value = redis.get(key);
if (value != null) {
return value;
}
// 缓存值过期,获取锁,设置3min的超时,防止del操作失败的时候产生死锁
if (redis.setnx(key_mutex, 1, 3 * 60) == 1) {
// 代表获取锁成功
value = db.get(key);
if (value == null) {
// value 为空时也缓存起来
value = String.empty;
}
redis.set(key, value, expire_secs);
redis.del(key_mutex);
return value;
}
// 没有获取到锁表示其他线程已经重新设置缓存了,此时重试获取缓存即可
try {
Thread.sleep(50);
//重试
getValue(key);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
缓存预热就是系统上线后,将需要用的缓存数据直接加载到缓存系统。这样就可以避免在用户请求的时候再去查询DB设置数据缓存,用户直接查询事先被预热的缓存数据即可
解决方案
1、数据量不大的时候直接在项目启动的时候加载缓存数据
2、定时刷新缓存数据
3、页面按钮手动操作刷新缓存数据
当访问量剧增,缓存服务响应慢时,需要对某些数据自动缓存降级,也可以配置开关人工降级,例如redis缓存访问不到的时候降级访问二级缓存、本地缓存等
在进行降级之前要对系统进行梳理,那些缓存可以降级,那些不能降级,然后设置预案:
1、一般:比如有些服务偶尔因为网络抖动或者服务正在上线而超时,可以自动降级;
2、警告:有些服务在一段时间内成功率有波动(如在95~100%之间),可以自动降级或人工降级,并发送告警;
3、错误:比如可用率低于90%,或者数据库连接池被打爆了,或者访问量突然猛增到系统能承受的最大阀值,此时可以根据情况自动降级或者人工降级;
4、严重错误:比如因为特殊原因数据错误了,此时需要紧急人工降级
对redis的访问加上保护措施,全都用hystrix的command进行封装,做资源隔离,确保redis的访问只能在固定的线程池内的资源来进行访问,哪怕是redis访问的很慢,有等待和超时,也不要紧,只有少量额线程资源用来访问,缓存服务不会被拖垮
解决方案
引入Hystrix 保护redis
1、引入Hystrix依赖
com.netflix.hystrix
hystrix-core
1.5.18
com.netflix.hystrix
hystrix-metrics-event-stream
1.5.18
2、具体示例代码如下:
/**
* 保存商品信息到redis缓存中
*
* @date 2018/06/12
*/
public class SaveProductInfo2RedisCacheCommand extends HystrixCommand {
private ProductInfo productInfo;
public SaveProductInfo2RedisCacheCommand(ProductInfo productInfo) {
super(HystrixCommandGroupKey.Factory.asKey("RedisGroup"));
this.productInfo = productInfo;
}
@Override
protected Boolean run() {
StringRedisTemplate redisTemplate = SpringContext.getApplicationContext().getBean(StringRedisTemplate.class);
String key = "product_info_" + productInfo.getId();
redisTemplate.opsForValue().set(key, JSON.toJSONString(productInfo));
return true;
}
}
/**
* 将商品信息保存到redis中
*
* @param productInfo
*/
public void saveProductInfo2RedisCache(ProductInfo productInfo) {
SaveProductInfo2RedisCacheCommand command = new SaveProductInfo2RedisCacheCommand(productInfo);
command.execute();
}
public class GetProductInfoCommand extends HystrixCommand {
private Long productId;
public GetProductInfoCommand(Long productId) {
super(Setter.withGroupKey(HystrixCommandGroupKey.Factory.asKey("ProductInfoService"))
.andThreadPoolKey(HystrixThreadPoolKey.Factory.asKey("GetProductInfoPool"))
.andThreadPoolPropertiesDefaults(HystrixThreadPoolProperties.Setter()
.withCoreSize(10)
.withMaxQueueSize(12)
.withQueueSizeRejectionThreshold(8)
.withMaximumSize(30)
.withAllowMaximumSizeToDivergeFromCoreSize(true)
.withKeepAliveTimeMinutes(1)
.withMaxQueueSize(50)
.withQueueSizeRejectionThreshold(100))
.andCommandPropertiesDefaults(HystrixCommandProperties.Setter()
// 多少个请求以上才会判断断路器是否需要开启。
.withCircuitBreakerRequestVolumeThreshold(30)
// 错误的请求达到40%的时候就开始断路。
.withCircuitBreakerErrorThresholdPercentage(40)
// 3秒以后尝试恢复
.withCircuitBreakerSleepWindowInMilliseconds(4000))
);
this.productId = productId;
}
@Override
protected ProductInfo run() throws Exception {
String productInfoJSON = "{\"id\": " + productId + ", \"name\": \"iphone7手机\", \"price\": 5599, \"pictureList\":\"a.jpg,b.jpg\", \"specification\": \"iphone7的规格\", \"service\": \"iphone7的售后服务\", \"color\": \"红色,白色,黑色\", \"size\": \"5.5\", \"shopId\": 1, \"modifiedTime\": \"2017-01-01 12:01:00\"}";
return JSONObject.parseObject(productInfoJSON, ProductInfo.class);
}
}
高并发高负载情况下,选择动态的关闭一些不重要的服务拒绝访问,比如推荐、留言等不太重要的服务
上面是常用的高并发系统设计考虑的方面,尤其是缓存中的解决方案,没有哪一个是最优的,适合自己的业务场景才是最好的
参考文章:
https://blog.csdn.net/kevin_love_it/article/details/88095271
https://blog.csdn.net/zeb_perfect/article/details/54135506
https://blog.csdn.net/xlgen157387/article/details/79530877
http://www.saily.top/2018/06/12/cache06/