内存 Join 可以如此简单!!!

1. 概览

数据库 Join 真的太香了,但由于各种原因,在实际项目中越来越受局限,只能由开发人员在应用层完成。这种繁琐、无意义的“体力劳动”让我们离“快乐生活”越来越远。

1.1. 背景

不知道什么时候,数据库 join 成为了公认的“性能杀手”,对此,很多公司严厉禁止其使用。上有政策下有对策,你的应对之道是什么?

数据库 Join 退出历史舞台,主要由以下几大推动力:

  1. 微服务。微服务要求“数据资产私有化”,也就是说每个服务的数据库是私有资产,不允许其他服务的直接访问;如果需要访问,只能通过服务所提供的接口完成;

  2. 分库分表的限制。当数据量超过 MySQL 单实例承载能力时,通常会通过“分库分表”这一技术手段来解决,分库分表后,数据被分散到多个分区中,导致 join 语句失效;

  3. 性能瓶颈。在高并发情况下,join 存在一定的性能问题,高并发、高性能端场景不适合使用;

不管原因几何,目前,很多大厂已经将 “禁止join” 列入编码规范,我们该如何面对?

只定规范,不给工具,是一种极度不负责任的表现。

1.1.1. 线上问题跟踪

线上 order/list 接口 tp99 超过 2s,严重影响用户体验,同时还有愈演愈烈之势。通过 Trace 系统,发现一个请求居然存在几百甚至上千次 DB 调用!

第一反应,肯定是在 for 循环中调用了 DB,翻看代码果然如此,代码示例如下:

@Override
public List<? extends OrderDetailVO> getByUserId(Long userId) {
    List<Order> orders = this.orderRepository.getByUserId(userId);
    return orders.stream()
            .map(order -> convertToOrderDetailVO(order))
            .collect(toList());
}

private OrderDetailVOV1 convertToOrderDetailVO(Order order) {
    OrderVO orderVO = OrderVO.apply(order);
    OrderDetailVOV1 orderDetailVO = new OrderDetailVOV1(orderVO);

    Address address = this.addressRepository.getById(order.getAddressId());
    AddressVO addressVO = AddressVO.apply(address);
    orderDetailVO.setAddress(addressVO);

    User user = this.userRepository.getById(order.getUserId());
    UserVO userVO = UserVO.apply(user);
    orderDetailVO.setUser(userVO);

    Product product = this.productRepository.getById(order.getProductId());
    ProductVO productVO = ProductVO.apply(product);
    orderDetailVO.setProduct(productVO);

    return orderDetailVO;
}

复制代码

代码非常简单,只做了几件事:

  1. 获取用户的 order 信息;

  2. 遍历每一个 order,为其装配关联数据;

  3. 返回最终结果;

逻辑非常清晰,单请求数据库访问总次数 = 1(获取用户订单)+ N(订单数量) * 3(需要抓取的关联数据)

可见,N(订单数量) * 3(关联数据数量)  是性能的最大杀手,存在严重的读放大效应。不同的用户,订单数量相差巨大,导致该接口性能差距巨大。

1.1.2. 繁琐、无意义的代码

如何应对?第一反应就是 批量获取,然后在内存中完成 Join。这是一个好的方案,但引入了大量繁琐、无意义的代码。

该问题常规解决方案如下:

@Override
public List<? extends OrderDetailVO> getByUserId(Long userId) {
    List<Order> orders = this.orderRepository.getByUserId(userId);

    List<OrderDetailVOV2> orderDetailVOS = orders.stream()
            .map(order -> new OrderDetailVOV2(OrderVO.apply(order)))
            .collect(toList());

    List<Long> userIds = orders.stream()
            .map(Order::getUserId)
            .collect(toList());
    List<User> users = this.userRepository.getByIds(userIds);
    Map<Long, User> userMap = users.stream()
            .collect(toMap(User::getId, Function.identity(), (a, b) -> a));
    for (OrderDetailVOV2 orderDetailVO : orderDetailVOS){
        User user = userMap.get(orderDetailVO.getOrder().getUserId());
        UserVO userVO = UserVO.apply(user);
        orderDetailVO.setUser(userVO);
    }

    List<Long> addressIds = orders.stream()
            .map(Order::getAddressId)
            .collect(toList());
    List<Address> addresses = this.addressRepository.getByIds(addressIds);
    Map<Long, Address> addressMap = addresses.stream()
            .collect(toMap(Address::getId, Function.identity(), (a, b) -> a));
    for (OrderDetailVOV2 orderDetailVO : orderDetailVOS){
        Address address = addressMap.get(orderDetailVO.getOrder().getAddressId());
        AddressVO addressVO = AddressVO.apply(address);
        orderDetailVO.setAddress(addressVO);
    }

    List<Long> productIds = orders.stream()
            .map(Order::getProductId)
            .collect(toList());
    List<Product> products = this.productRepository.getByIds(productIds);
    Map<Long, Product> productMap = products.stream()
            .collect(toMap(Product::getId, Function.identity(), (a, b) -> a));
    for (OrderDetailVOV2 orderDetailVO : orderDetailVOS){
        Product product = productMap.get(orderDetailVO.getOrder().getProductId());
        ProductVO productVO = ProductVO.apply(product);
        orderDetailVO.setProduct(productVO);
    }

    return orderDetailVOS;
}

复制代码

相对上一版本,代码量和复杂性提升不少,每一处核心代码逻辑基本一致,主要包括:

  1. 为每条原始数据提取关联键

  2. 调用 DB 批量获取所有关联数据

  3. 将数据转换为 Map

    形式

  4. 依次遍历数据,执行内存关联

  • 从原始数据中提取关联键

  • 从 Map

    获取关联数据

  • 将关联数据转换为最终结果

  • 将关联数据进行写回原始数据

经过改造,单请求中数据库访问总次数 = 1(获取用户订单)+  3(关联数据数量)。数据库访问总次数大大降低,性能提升明显。

1.1.3. 并行优化

聪明的伙伴可能马上会提出,上面方案还有优化空间,引入多线程并行执行 内存 join。

非常优秀,多线程引入会再次提升性能,但也提升了系统复杂性(并发安全性、资源配置等)。先准再快,建议有必要时再引入。

代码调整如下:

@Override
public List<? extends OrderDetailVO> getByUserId(Long userId) {
    List<Order> orders = this.orderRepository.getByUserId(userId);

    List<OrderDetailVOV2> orderDetailVOS = orders.stream()
            .map(order -> new OrderDetailVOV2(OrderVO.apply(order)))
            .collect(toList());

    List<Callable<Void>> callables = Lists.newArrayListWithCapacity(3);
    callables.add(() -> {
        bindUser(orders, orderDetailVOS);
        return null;
    });

    callables.add(() ->{
        bindAddress(orders, orderDetailVOS);
        return null;
    });

    callables.add(() -> {
        bindProduct(orders, orderDetailVOS);
        return null;
    });
    this.executorService.invokeAll(callables);

    return orderDetailVOS;
}

private void bindProduct(List<Order> orders, List<OrderDetailVOV2> orderDetailVOS) {
    List<Long> productIds = orders.stream()
            .map(Order::getProductId)
            .collect(toList());
    List<Product> products = this.productRepository.getByIds(productIds);
    Map<Long, Product> productMap = products.stream()
            .collect(toMap(Product::getId, Function.identity(), (a, b) -> a));
    for (OrderDetailVOV2 orderDetailVO : orderDetailVOS){
        Product product = productMap.get(orderDetailVO.getOrder().getProductId());
        ProductVO productVO = ProductVO.apply(product);
        orderDetailVO.setProduct(productVO);
    }
}

private void bindAddress(List<Order> orders, List<OrderDetailVOV2> orderDetailVOS) {
    List<Long> addressIds = orders.stream()
            .map(Order::getAddressId)
            .collect(toList());
    List<Address> addresses = this.addressRepository.getByIds(addressIds);
    Map<Long, Address> addressMap = addresses.stream()
            .collect(toMap(Address::getId, Function.identity(), (a, b) -> a));
    for (OrderDetailVOV2 orderDetailVO : orderDetailVOS){
        Address address = addressMap.get(orderDetailVO.getOrder().getAddressId());
        AddressVO addressVO = AddressVO.apply(address);
        orderDetailVO.setAddress(addressVO);
    }
}

private void bindUser(List<Order> orders, List<OrderDetailVOV2> orderDetailVOS) {
    List<Long> userIds = orders.stream()
            .map(Order::getUserId)
            .collect(toList());
    List<User> users = this.userRepository.getByIds(userIds);
    Map<Long, User> userMap = users.stream()
            .collect(toMap(User::getId, Function.identity(), (a, b) -> a));
    for (OrderDetailVOV2 orderDetailVO : orderDetailVOS){
        User user = userMap.get(orderDetailVO.getOrder().getUserId());
        UserVO userVO = UserVO.apply(user);
        orderDetailVO.setUser(userVO);
    }
}

复制代码

可见,复杂性又提升不少。

1.2. 目标

能否做的更好?我们先列下小目标:

  1. 使用 “批量 + 内存Join” 替代 “for + 单条抓取”;

  2. 简化开发,最好不写代码;

  3. 具备并行执行的能力,以进一步提升性能;

2. 快速入门

2.1. 添加 starter

在项目中引入 joininmemory-starter,具体如下:

<groupId>com.geekhalo.lego</groupId>
<artifactId>lego-starter-joininmemory</artifactId>
<version>0.0.1-SNAPSHOT</version>

复制代码

2.2. 使用 @JoinInMemory 通用注解

在结果 Bean 的属性上添加 @JoinInMemory 注解,具体如下:

@Data
public class OrderDetailVOV4 extends OrderDetailVO {
    private final OrderVO order;
    @JoinInMemory(keyFromSourceData = "#{order.userId}",
            keyFromJoinData = "#{id}",
            loader = "#{@userRepository.getByIds(#root)}",
            dataConverter = "#{T(com.geekhalo.lego.joininmemory.web.UserVO).apply(#root)}"
        )
    private UserVO user;

    @JoinInMemory(keyFromSourceData = "#{order.addressId}",
            keyFromJoinData = "#{id}",
            loader = "#{@addressRepository.getByIds(#root)}",
        dataConverter = "#{T(com.geekhalo.lego.joininmemory.web.AddressVO).apply(#root)}"
    )
    private AddressVO address;

    @JoinInMemory(keyFromSourceData = "#{order.productId}",
            keyFromJoinData = "#{id}",
            loader = "#{@productRepository.getByIds(#root)}",
         dataConverter = "#{T(com.geekhalo.lego.joininmemory.web.ProductVO).apply(#root)}"
    )
    private ProductVO product;
}

复制代码

JoinInMemory 注解定义如下:

@Target({ElementType.FIELD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface JoinInMemory {
    /**
     * 从 sourceData 中提取 key
     * @return
     */
    String keyFromSourceData();

    /**
     * 从 joinData 中提取 key
     * @return
     */
    String keyFromJoinData();

    /**
     * 批量数据抓取
     * @return
     */
    String loader();

    /**
     * 结果转换器
     * @return
     */
    String joinDataConverter() default "";

    /**
     * 运行级别,同一级别的 join 可 并行执行
     * @return
     */
    int runLevel() default 10;
}

复制代码

JoinInMemory 注解属性有些多,以 UserVO  为例,解释如下:

@JoinInMemory(keyFromSourceData = "#{order.userId}",
        keyFromJoinData = "#{id}",
        loader = "#{@userRepository.getByIds(#root)}",
       joinDataConverter = "#{T(com.geekhalo.lego.joininmemory.web.UserVO).apply(#root)}"
    )
private UserVO user;

复制代码
属性 含义
keyFromSourceData = "#{order.userId}" 以 order 中的 userId 作为 JoinKey
keyFromJoinData = "#{id}" 以 user 的 id 作为 JoinKey
loader = "#{@userRepository.getByIds(#root)}" 将 userRepository bean 的 getByIds 方法作为加载器,其中 #root 为 joinKey 集合(user id 集合)
joinDataConverter = "#{T(com.geekhalo.lego.joininmemory.web.UserVO).apply(#root)}" 将 com.geekhalo.lego.joininmemory.web.UserVO 静态方法 apply 作为转换器,#root 指的是 User 对象

配置中用到大量的 SpEL 表达式,不熟悉的同学可以自行 Google;

@JoinInMemory 注解赋予 OrderDetailVOV4 自动 Join 的能力,具体使用如下:

@Override
public List<? extends OrderDetailVO> getByUserId(Long userId) {
    List<Order> orders = this.orderRepository.getByUserId(userId);

    List<OrderDetailVOV4> orderDetailVOS = orders.stream()
            .map(order -> new OrderDetailVOV4(OrderVO.apply(order)))
            .collect(toList());

    // 执行关联数据抓取
    this.joinService.joinInMemory(OrderDetailVOV4.class, orderDetailVOS);
    return orderDetailVOS;
}

复制代码

其中,this.joinService.joinInMemory(OrderDetailVOV4.class, orderDetailVOS); 完成对 orderDetailVOS 关联数据的组装。

2.3. 使用自定义注解

@JoinInMemory 注解属性过多,使用起来过于繁琐,同时有很多属性是通用的,分散到各处不利于维护,此时,建议使用 Spring AliasFor 对其进行简化。

首先,新建自定义注解

@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@JoinInMemory(keyFromSourceData = "",
        keyFromJoinData = "#{id}",
        loader = "#{@userRepository.getByIds(#root)}",
       joinDataConverter = "#{T(com.geekhalo.lego.joininmemory.web.UserVO).apply(#root)}"
)
public @interface JoinUserVOOnId {
    @AliasFor(
            annotation = JoinInMemory.class
    )
    String keyFromSourceData();
}

复制代码

在新注解上,添加 @JoinInMemory 完成对通用属性的配置;

新增属性,使用 @AliasFor 为 @JoinInMemory 进行个性化配置;

使用自定义注解的新 OrderDetailVO 如下:

@Data
public class OrderDetailVOV5 extends OrderDetailVO {
    private final OrderVO order;

    @JoinUserVOOnId(keyFromSourceData = "#{order.userId}")
    private UserVO user;

    @JoinAddressVOOnId(keyFromSourceData = "#{order.addressId}")
    private AddressVO address;

    @JoinProductVOOnId(keyFromSourceData = "#{order.productId}")
    private ProductVO product;
}

复制代码

其他使用方式不变,相对于底层的 @JoinInMemory,配置简化不少;

2.4. 增加并行处理能力

如果需要使用并行处理方案进一步提升性能,也非常简单,只需在 OrderDetailVO 上新增一个注解即可,具体如下:

@Data
@JoinInMemoryConfig(executorType = JoinInMemeoryExecutorType.PARALLEL)
public class OrderDetailVOV6 extends OrderDetailVO {
    private final OrderVO order;

    @JoinUserVOOnId(keyFromSourceData = "#{order.userId}")
    private UserVO user;

    @JoinAddressVOOnId(keyFromSourceData = "#{order.addressId}")
    private AddressVO address;

    @JoinProductVOOnId(keyFromSourceData = "#{order.productId}")
    private ProductVO product;
}

复制代码

其他部分不变,其中 @JoinInMemoryConfig 有如下几个属性:

属性 含义
executorType PARALLEL 并行执行;SERIAL 串行执行
executorName 执行器名称,并行执行所使用的线程池名称,默认为 defaultExecutor

2.5. 性能比较

测试环境简单如下:

  1. 获取订单耗时 5 ms

  2. 获取单条记录 耗时 3 ms

  3. 获取批量记录 耗时 10 ms

  4. 订单列表返回记录 100 条

简单对比性能如下:

方案 耗时
for + 单条抓取 1130ms
批量 + 内存join (手工) 42ms
批量 + 内存join (手工) + 并行 16ms
@JoinInMemory 50ms
@自定义注解 48ms
@自定义注解 + 并行 24ms

3. 示例代码

附上项目地址:gitee.com/litao851025…

猜你喜欢

转载自juejin.im/post/7128968148937605150