高并发系统设计方案
高并发系统设计一般会考虑三个方面:限流、缓存、降级
限流:控制在一定时间内的访问量,比如秒杀,这种场景下访问量过于庞大,使用缓存或者降级根本无法解决访问量巨大的问题,那么只能选择限流
缓存:缓存设计是我们常用的减轻服务器压力的方案,常用的缓存有 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<Long, AtomicLong> counter = CacheBuilder.newBuilder().
-
expireAfterAccess(
2, TimeUnit.SECONDS).build(
-
new
CacheLoader<Long, AtomicLong>() {
-
@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<Long, AtomicLong> 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 实现
-
* <p>
-
* 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、严重错误:比如因为特殊原因数据错误了,此时需要紧急人工降级
使用Hystrix对Redis进行资源隔离
对redis的访问加上保护措施,全都用hystrix的command进行封装,做资源隔离,确保redis的访问只能在固定的线程池内的资源来进行访问,哪怕是redis访问的很慢,有等待和超时,也不要紧,只有少量额线程资源用来访问,缓存服务不会被拖垮
解决方案
引入Hystrix 保护redis
1、引入Hystrix依赖
-
<dependency>
-
<groupId>com.netflix.hystrix</groupId>
-
<artifactId>hystrix-core</artifactId>
-
<version>
1.5
.18</version>
-
</dependency>
-
<dependency>
-
<groupId>com.netflix.hystrix</groupId>
-
<artifactId>hystrix-metrics-event-stream</artifactId>
-
<version>
1.5
.18</version>
-
</dependency>
2、具体示例代码如下:
-
/**
-
* 保存商品信息到redis缓存中
-
*
-
* @date 2018/06/12
-
*/
-
public
class
SaveProductInfo2RedisCacheCommand
extends
HystrixCommand<Boolean> {
-
-
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<ProductInfo> {
-
-
-
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