Java高并发之请求合并

一、概述

高并发场景中,调用批量接口相比调用非批量接口有更大的性能优势。但有时候,请求更多的是单个接口,不能够直接调用批量接口,如果这个接口是高频接口,对其做请求合并就很有必要了。比如电影网站的获取电影详情接口,APP的一次请求是单个接口调用,用户量少的时候请求也不多,完全没问题;但同一时刻往往有大量用户访问电影详情,是个高并发的高频接口,如果都是单次查询,后台就不一定能抗住了。为了优化这个接口,后台可以将相同的请求进行合并,然后调用批量的查询接口。

二、详情

2.1 要点

将一段短暂时间内的请求,先进行阻塞,进行合并之后,一次性去处理,然后在拆分结果,最后唤醒被阻塞的请求。

2.2 前提

  1. 如果是数据库操作,如果是插入、修改、删除,需要支持批量操作的sql语句,并且如果修改失败了,支持回滚;如果是查询,需要支持结果和请求的拆分,也就是要能够将查询结果进行拆分,可以将结果分配给每个请求。
  2. 如果是请求第三方接口,三方接口要支持批量操作,同时请求和响应也需要有能够标识区分的字段,以便可以将结果进行拆分。

2.3 流程图

通过对请求和响应对象封装成请求响应体,然后将请求响应体,加入缓存队列后,对请求响应体wait()一段时间,让请求阻塞住,合并线程去缓存队列中定时去取一部分请求响应体,进行合并后,统一处理,然后再将结果进行拆分,将响应结果填入请求响应体中的响应后,最后,将已经封装结果的请求响应体notify()。注意,要确保这块的操作是在一个锁里面,防止下面响应结果还没有装进去,就已经返回了。

2.4 步骤

  1. 每新来的请求UserRequest,先将请求UserRequest和响应对象Result进行封装成请求响应体RequestPromise,存放到一个阻塞队列中,将当前线程wait固定时间(请求合并间隔时间 + 请求大概需要时间),防止超时。
  2. 启动一个线程,用于请求合并,在固定时间内,将队列中的一部分(根据实际情况,不能超过三方接口的最大限制)请求响应体拿出。
  3. 将请求信息进行合并,将所有请求中的retrieval列表拿出来组装成一个请求,因为每个retrieval中都有一个唯一的retrievalId,可以区分。
  4. 先去请求第三方接口。
  5. 再拿到结果里面的FaceImageId,即memberId,去请求云库,获取人员信息。
  6. 最终将结果进行拆分,根据retrievalId分到每个请求的响应中。
  7. 最后将这一批请求线程notify唤醒。

注:retrievalId:每个请求来的时候唯一的标识,需要和响应结果中retrieval列表对应。

如下图所示

或者下图

设计决绝方案

三、使用场景

在我们平时业务中,经常会遇到一些情况,请求频率很高,需要频繁请求第三方接口,或者需要频繁操作数据库。

比如,如下几个例子:

  1. 电商系统,秒杀场景,需要频繁的去数据库修改库存。
  2. 业务场景,当前接口需要频繁的调用三方接口,当三方接口有反爬虫,或者有固定时间请求次数限制的话,就会导致请求报错或者超时。

四、案例

代码实现

@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无法保证其原子性

突出两个问题的两个方案:

  1. 减低锁的颗粒度【已经是行锁了,不能再低了吧?】
  2. 减低锁的相应时间(推荐采用)

高并发方案:

  1. 时间间隔200ms一个阻塞队列存储request请求
  2. 一并将200ms的请求执行async操作扣减
  3. 扣减出现两种情况
    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集群】制作中间件添加队列去存储请求
    • 所有队列均不在逻辑代码层级上处理,所以没有水平扩容
  4. 成果:相关接口的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;
}

参考

posted @ 2023-04-19 18:38  夏尔_717  阅读(615)  评论(0编辑  收藏  举报