一、概述
高并发场景中,调用批量接口相比调用非批量接口有更大的性能优势。但有时候,请求更多的是单个接口,不能够直接调用批量接口,如果这个接口是高频接口,对其做请求合并就很有必要了。比如电影网站的获取电影详情接口,APP
的一次请求是单个接口调用,用户量少的时候请求也不多,完全没问题;但同一时刻往往有大量用户访问电影详情,是个高并发的高频接口,如果都是单次查询,后台就不一定能抗住了。为了优化这个接口,后台可以将相同的请求进行合并,然后调用批量的查询接口。
二、详情
2.1 要点
将一段短暂时间内的请求,先进行阻塞,进行合并之后,一次性去处理,然后在拆分结果,最后唤醒被阻塞的请求。
2.2 前提
- 如果是数据库操作,如果是插入、修改、删除,需要支持批量操作的
sql
语句,并且如果修改失败了,支持回滚;如果是查询,需要支持结果和请求的拆分,也就是要能够将查询结果进行拆分,可以将结果分配给每个请求。 - 如果是请求第三方接口,三方接口要支持批量操作,同时请求和响应也需要有能够标识区分的字段,以便可以将结果进行拆分。
2.3 流程图
通过对请求和响应对象封装成请求响应体,然后将请求响应体,加入缓存队列后,对请求响应体wait()
一段时间,让请求阻塞住,合并线程去缓存队列中定时去取一部分请求响应体,进行合并后,统一处理,然后再将结果进行拆分,将响应结果填入请求响应体中的响应后,最后,将已经封装结果的请求响应体notify()
。注意,要确保这块的操作是在一个锁里面,防止下面响应结果还没有装进去,就已经返回了。
2.4 步骤
- 每新来的请求
UserRequest
,先将请求UserRequest
和响应对象Result
进行封装成请求响应体RequestPromise
,存放到一个阻塞队列中,将当前线程wait固定时间(请求合并间隔时间 + 请求大概需要时间),防止超时。 - 启动一个线程,用于请求合并,在固定时间内,将队列中的一部分(根据实际情况,不能超过三方接口的最大限制)请求响应体拿出。
- 将请求信息进行合并,将所有请求中的
retrieval
列表拿出来组装成一个请求,因为每个retrieval
中都有一个唯一的retrievalId
,可以区分。 - 先去请求第三方接口。
- 再拿到结果里面的
FaceImageId
,即memberId
,去请求云库,获取人员信息。 - 最终将结果进行拆分,根据
retrievalId
分到每个请求的响应中。 - 最后将这一批请求线程
notify
唤醒。
注:retrievalId:每个请求来的时候唯一的标识,需要和响应结果中retrieval列表对应。
如下图所示
或者下图
设计决绝方案
三、使用场景
在我们平时业务中,经常会遇到一些情况,请求频率很高,需要频繁请求第三方接口,或者需要频繁操作数据库。
比如,如下几个例子:
- 电商系统,秒杀场景,需要频繁的去数据库修改库存。
- 业务场景,当前接口需要频繁的调用三方接口,当三方接口有反爬虫,或者有固定时间请求次数限制的话,就会导致请求报错或者超时。
四、案例
代码实现
@Service
public class CommodityMergeService {
// 线程池数量
@Value("${merge.num:1}")
private int mergeNum;
// 定时间隔时长
@Value("${merge.period:30}")
private long mergePeriod;
//最大任务数
public static int MAX_TASK_NUM = 100;
@Resource
CommodityService commodityService;
// 积攒请求(每隔N毫秒批量处理一次)
LinkedBlockingQueue<Request> queue = new LinkedBlockingQueue<>();
// 定时任务的实现,N秒钟处理一次数据
@PostConstruct
public void init() {
// 定时任务线程池,创建一个支持定时、周期性或延时任务的限定线程数目(这里传入的是1)的线程池
ScheduledExecutorService scheduledExecutorService = Executors.newScheduledThreadPool(mergeNum);
scheduledExecutorService.scheduleAtFixedRate(() -> {
// 1、取出queue的请求,生成一次批量查询
int size = queue.size();
if (size == 0) {
return;
}
List<Request> requests = new ArrayList<>();
for (int i = 0; i < size; i++) {
if (i < MAX_TASK_NUM) {
Request request = queue.poll();
requests.add(request);
}
}
// 2、组装一个批量查询(一定需要目的资源能够支持批量查询)
List<String> codes = new ArrayList<>();
for (Request request : requests) {
codes.add(request.getCommodityCode());
}
System.out.println("合并请求数据量:" + codes.size());
// 3、批量处理请求
List<Map<String, Object>> responses = commodityService.queryCommodityByCodes(codes);
// 4、处理结果方便快速查找
// [{"code":"500",star: tony}
// {"code":"600",star: tony}]
Map<String, Map<String, Object>> responseMap = new HashMap<>();
for (Map<String, Object> response : responses) {
String code = response.get("code").toString();
responseMap.put(code, response);
}
// 5、将结果响应分发给每一个单独的用户请求。由定时任务处理线程 --> 1000个用户的请求线程
for (Request request : requests) {
// 根据请求中携带的能表示唯一参数,去批量查询的结果中找响应
Map<String, Object> result = responseMap.get(request.getCommodityCode());
// 将结果返回到对应的请求线程
request.getFuture().complete(result);
}
// 立即执行任务,并间隔N毫秒重复执行
}, 0, mergePeriod, TimeUnit.MILLISECONDS);
}
// 1000 用户请求,1000个线程
public Map<String, Object> queryCommodity(String commodityCode)
throws ExecutionException, InterruptedException {
// 请求合并,减少接口调用次数,提升性能。
// 思路:将不同用户的同类请求合并起来
// 并非立刻发起接口调用,请求收集起来,再进行批量请求
Request request = new Request();
request.setCommodityCode(commodityCode);
// 异步编程:获取异步处理的结果
CompletableFuture<Map<String, Object>> future = new CompletableFuture<>();
request.setFuture(future);
// 请求参数放入队列中,定时任务去处理请求
queue.add(request);
// 此处get方法,会阻塞线程运行,直到future有返回
return future.get();
}
}
@Data
public class Request {
private String commodityCode;
private CompletableFuture<Map<String, Object>> future; // 接受结果
}
@Service
public class CommodityService {
/**
* 调用远程的商品信息查询接口
*
* @param code 商品编码
* @return 返回商品信息,map格式
*/
public HashMap<String, Object> queryCommodityByCode(String code) {
try {
Thread.sleep(50L);
} catch (InterruptedException e) {
e.printStackTrace();
}
HashMap<String, Object> hashMap = new HashMap<>();
hashMap.put("commodityId", new Random().nextInt(999999999));
hashMap.put("code", code);
hashMap.put("phone", "huawei");
hashMap.put("isOk", "true");
hashMap.put("price","4000");
return hashMap;
}
/**
* 批量查询 - 调用远程的商品信息查询接口
*
* @param codes 多个商品编码
* @return 返回多个商品信息
*/
public List<Map<String, Object>> queryCommodityByCodes(List<String> codes) {
// 不支持批量查询 http://moviewapi.com/query.do?id=10001 --> {code:10001, star:xxxx.....}
// http://moviewapi.com/query.do?ids=10001,10002 --> [{code:10001, star///}, {...},{....}]
List<Map<String, Object>> result = new ArrayList<>();
for (String code : codes) {
HashMap<String, Object> hashMap = new HashMap<>();
hashMap.put("commodityId", new Random().nextInt(999999999));
hashMap.put("code", code);
hashMap.put("phone", "huawei");
hashMap.put("isOk", "true");
hashMap.put("price","4000");
result.add(hashMap);
}
return result;
}
}
测试
@Test
public void benchmark() throws IOException {
// 创建,并不是马上发起请求
for (int i = 0; i < THREAD_NUM; i++) {
final String code = "code-" + (i + 1); // 番号
// 多线程模拟用户查询请求
Thread thread = new Thread(() -> {
try {
// 代码在这里等待,等待countDownLatch为0,代表所有线程都start,再运行后续的代码
countDownLatch.await();
// http请求,实际上就是多线程调用这个方法
Map<String, Object> result = commodityMergeService.queryCommodity(code);
System.out.println(Thread.currentThread().getName() + ",查询结束,结果:" + result);
} catch (Exception e) {
System.out.println(Thread.currentThread().getName() + ",执行异常:" + e.getMessage());
}
});
thread.setName("price-thread-" + code);
thread.start();
// 启动后,倒计时器倒计数减一,代表又有一个线程就绪了
countDownLatch.countDown();
}
// 输入任意内容退出
System.in.read();
}
弊端:
实行合并请求实际上是执行实际路基之前增加了延迟,如果平均需要5毫秒的执行时间,放在10毫秒做一次批处理的场景下,则最坏的情况可能变成15秒(不适合低延迟的RPC场景、低并发场景)
五、案例二 - 秒杀
- 【item表】商品id,库存count【防止超卖】,一般使用update判断count是否大于1,再count-1操作,用行锁,影响RT!!
- 【为何不放redis上方案】在于库存表扣减外,订单流水表均在同一个事务内【其实结算表也是】,如果卸载redis无法保证其原子性
突出两个问题的两个方案:
- 减低锁的颗粒度【已经是行锁了,不能再低了吧?】
- 减低锁的相应时间(推荐采用)
高并发方案:
- 时间间隔200ms一个阻塞队列存储request请求
- 一并将200ms的请求执行async操作扣减
- 扣减出现两种情况
1). 处理失败情况:- 库存扣减成功,订单扣减失败【数据不一致】,mq通过扣减的商品id做一个mq通知回滚【mq server出问题也不一定有数据一致性】
- 库存仅剩一件,但是现购买3件。【先去扣减2,不成功改扣减1件】,先把购买多的用户先做扣减多的,再扣减少的
- 流水表的DDL操作,先insert再update同一个商品id
- 商品的列表、详情页【库存数量】放在redis方便查询,由于ms级别且读写频繁,如何做到数据一致性【使用监听DB 的binlog同步到redis上,注意:这种刷新带有时间间隔,在监听中设定定时5秒钟去刷新,并添加version来防止更新时候最新版本】
- 采用iv方案之后真实库存与缓存存在数据不一致,当实际为0的真实库存通知redis同步为0时,binlog会覆盖真实值情况【版本号来判断防止覆盖】
2). 其他细节判断 - 这个扣减在横向扩容时,各个机器合并数据效果会变差,弹性扩容不是太好【改进方案:扣减拿出来做一个专门集群,避免和其他接口沾合】
- 调优:等待队列时间间隔、线程池设置大小、并发的数量、多久创建队列等参数需要调优
3). 淘宝应对方案 - 专门一个接口处理这块,但是DB【mysql集群】制作中间件添加队列去存储请求
- 所有队列均不在逻辑代码层级上处理,所以没有水平扩容
- 成果:相关接口的tps由200多到10000多的扩大5倍
public class KillDemo {
/**
* 启动10个用户线程
* 库存6个
* 生成一个合并队列,3个用户一批次
* 每个用户能拿到自己的请求响应
*/
public static void main(String[] args) throws InterruptedException {
ExecutorService executorService = Executors.newCachedThreadPool();
KillDemo killDemo = new KillDemo();
killDemo.mergeJob();
Thread.sleep(2000);
CountDownLatch countDownLatch = new CountDownLatch(10);
System.out.println("-------- 库存 --------");
System.out.println("库存初始数量 :" + killDemo.stock);
Map<UserRequest, Future<Result>> requestFutureMap = new HashMap<>();
for (int i = 0; i < 10; i++) {
final Long orderId = i + 100L;
final Long userId = Long.valueOf(i);
UserRequest userRequest = new UserRequest(orderId, userId, 1);
Future<Result> future = executorService.submit(() -> {
countDownLatch.countDown();
countDownLatch.await(1, TimeUnit.SECONDS);
return killDemo.operate(userRequest);
});
requestFutureMap.put(userRequest, future);
}
System.out.println("------- 客户端响应 -------");
Thread.sleep(1000);
requestFutureMap.entrySet().forEach(entry -> {
try {
Result result = entry.getValue().get(300, TimeUnit.MILLISECONDS);
System.out.println(Thread.currentThread().getName() + ":客户端请求响应:" + result);
if (!result.isSuccess() && result.getMsg().equals("等待超时")) {
// 超时,发送请求回滚
System.out.println(entry.getKey() + " 发起回滚操作");
killDemo.rollback(entry.getKey());
}
} catch (Exception e) {
e.printStackTrace();
}
});
System.out.println("------- 库存操作日志 -------");
System.out.println("扣减成功条数: " + killDemo.operateChangeLogList.stream()
.filter(e -> e.getOperateType().equals(1)).count());
killDemo.operateChangeLogList.forEach(e -> {
if (e.getOperateType().equals(1)) {
System.out.println(e);
}
});
System.out.println("扣减回滚条数: " + killDemo.operateChangeLogList.stream()
.filter(e -> e.getOperateType().equals(2)).count());
killDemo.operateChangeLogList.forEach(e -> {
if (e.getOperateType().equals(2)) {
System.out.println(e);
}
});
System.out.println("-------- 库存 --------");
System.out.println("库存初始数量 :" + killDemo.stock);
}
private void rollback(UserRequest userRequest) {
if (operateChangeLogList.stream().anyMatch(operateChangeLog ->
operateChangeLog.getOrderId().equals(userRequest.getOrderId()))) {
// 回滚
boolean hasRollback = operateChangeLogList.stream().anyMatch(operateChangeLog ->
operateChangeLog.getOrderId().equals(userRequest.getOrderId())
&& operateChangeLog.getOperateType().equals(2));
if (hasRollback) return;
System.out.println(" 最终回滚");
stock += userRequest.getCount();
saveChangeLog(Lists.newArrayList(userRequest), 2);
}
// 忽略
}
// 模拟数据库行
private Integer stock = 6;
private BlockingQueue<RequestPromise> queue = new LinkedBlockingQueue<>(10);
/**
* 用户库存扣减
* @param userRequest
* @return
*/
public Result operate(UserRequest userRequest) throws InterruptedException {
// TODO 阈值判断
// TODO 队列的创建
RequestPromise requestPromise = new RequestPromise(userRequest);
synchronized (requestPromise) {
boolean enqueueSuccess = queue.offer(requestPromise, 100, TimeUnit.MILLISECONDS);
if (!enqueueSuccess) {
return new Result(false, "系统繁忙");
}
try {
requestPromise.wait(200);
if (requestPromise.getResult() == null) {
return new Result(false, "等待超时");
}
} catch (InterruptedException e) {
return new Result(false, "被中断");
}
}
return requestPromise.getResult();
}
public void mergeJob() {
new Thread(() -> {
List<RequestPromise> list = new ArrayList<>();
while (true) {
if (queue.isEmpty()) {
try {
Thread.sleep(10);
continue;
} catch (InterruptedException e) {
e.printStackTrace();
}
}
int batchSize = 3;
for (int i = 0; i < batchSize; i++) {
try {
list.add(queue.take());
} catch (InterruptedException e) {
e.printStackTrace();
}
}
// 用户ID=5的批次和之后的批次,请求都会超时
if (list.stream().anyMatch(e -> e.getUserRequest().getUserId().equals(5L))) {
try {
Thread.sleep(200);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println(Thread.currentThread().getName() + ":合并扣减库存:" + list);
int sum = list.stream().mapToInt(e -> e.getUserRequest().getCount()).sum();
// 两种情况
if (sum <= stock) {
// 开始事务
stock -= sum;
saveChangeLog(list.stream().map(RequestPromise::getUserRequest)
.collect(Collectors.toList()), 1);
// 关闭事务
// notify user
list.forEach(requestPromise -> {
requestPromise.setResult(new Result(true, "ok"));
synchronized (requestPromise) {
requestPromise.notify();
}
});
list.clear();
continue;
}
for (RequestPromise requestPromise : list) {
int count = requestPromise.getUserRequest().getCount();
if (count <= stock) {
// 开启事务
stock -= count;
saveChangeLog(Lists.newArrayList(requestPromise.getUserRequest()), 1);
// 关闭事务
requestPromise.setResult(new Result(true, "ok"));
} else {
requestPromise.setResult(new Result(false, "库存不足"));
}
synchronized (requestPromise) {
requestPromise.notify();
}
}
list.clear();
}
}, "mergeThread").start();
}
// 模拟数据库操作日志表
// order_id_operate_type uk
private List<OperateChangeLog> operateChangeLogList = new ArrayList<>();
/**
* 写库存流水
* @param list
* @param operateType
*/
private void saveChangeLog(List<UserRequest> list, int operateType) {
List<OperateChangeLog> collect = list.stream().map(userRequest ->
new OperateChangeLog(userRequest.getOrderId(), userRequest.getCount(),
operateType)).collect(Collectors.toList());
operateChangeLogList.addAll(collect);
}
}
@Data
@AllArgsConstructor
class OperateChangeLog {
private Long orderId;
private Integer count;
// 1-扣减,2-回滚
private Integer operateType;
}
@Data
class RequestPromise {
private UserRequest userRequest;
private Result result;
public RequestPromise(UserRequest userRequest) {
this.userRequest = userRequest;
}
public RequestPromise(UserRequest userRequest, Result result) {
this.userRequest = userRequest;
this.result = result;
}
}
@Data
@AllArgsConstructor
class Result {
private Boolean success;
private String msg;
}
@Data
@AllArgsConstructor
class UserRequest {
private Long orderId;
private Long userId;
private Integer count;
}