数据库 Join 真的太香了,但由于各种原因,在实际项目中越来越受局限,只能由开发人员在应用层完成。这种繁琐、无意义的“体力劳动”让我们离“快乐生活”越来越远。
不知道什么时候,数据库 join 成为了公认的“性能杀手”,对此,很多公司严厉禁止其使用。上有政策下有对策,你的应对之道是什么?
数据库 Join 退出历史舞台,主要由以下几大推动力:
微服务。微服务要求“数据资产私有化”,也就是说每个服务的数据库是私有资产,不允许其他服务的直接访问;如果需要访问,只能通过服务所提供的接口完成;
分库分表的限制。当数据量超过 MySQL 单实例承载能力时,通常会通过“分库分表”这一技术手段来解决,分库分表后,数据被分散到多个分区中,导致 join 语句失效;
性能瓶颈。在高并发情况下,join 存在一定的性能问题,高并发、高性能端场景不适合使用;
不管原因几何,目前,很多大厂已经将 “禁止join” 列入编码规范,我们该如何面对?
只定规范,不给工具,是一种极度不负责任的表现。
线上 order/list 接口 tp99 超过 2s,严重影响用户体验,同时还有愈演愈烈之势。通过 Trace 系统,发现一个请求居然存在几百甚至上千次 DB 调用!
第一反应,肯定是在 for 循环中调用了 DB,翻看代码果然如此,代码示例如下:
@Override
public List extends OrderDetailVO> getByUserId(Long userId) {
List 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;
}
代码非常简单,只做了几件事:
获取用户的 order 信息;
遍历每一个 order,为其装配关联数据;
返回最终结果;
逻辑非常清晰,单请求数据库访问总次数 = 1(获取用户订单)+ N(订单数量) * 3(需要抓取的关联数据)
可见,N(订单数量) * 3(关联数据数量) 是性能的最大杀手,存在严重的读放大效应。不同的用户,订单数量相差巨大,导致该接口性能差距巨大。
如何应对?第一反应就是 批量获取,然后在内存中完成 Join。这是一个好的方案,但引入了大量繁琐、无意义的代码。
该问题常规解决方案如下:
@Override
public List extends OrderDetailVO> getByUserId(Long userId) {
List orders = this.orderRepository.getByUserId(userId);
List orderDetailVOS = orders.stream()
.map(order -> new OrderDetailVOV2(OrderVO.apply(order)))
.collect(toList());
List userIds = orders.stream()
.map(Order::getUserId)
.collect(toList());
List users = this.userRepository.getByIds(userIds);
Map 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 addressIds = orders.stream()
.map(Order::getAddressId)
.collect(toList());
List addresses = this.addressRepository.getByIds(addressIds);
Map 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 productIds = orders.stream()
.map(Order::getProductId)
.collect(toList());
List products = this.productRepository.getByIds(productIds);
Map 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;
}
相对上一版本,代码量和复杂性提升不少,每一处核心代码逻辑基本一致,主要包括:
为每条原始数据提取关联键
调用 DB 批量获取所有关联数据
将数据转换为 Map
形式
依次遍历数据,执行内存关联
从原始数据中提取关联键
从 Map
获取关联数据
将关联数据转换为最终结果
将关联数据进行写回原始数据
经过改造,单请求中数据库访问总次数 = 1(获取用户订单)+ 3(关联数据数量)。数据库访问总次数大大降低,性能提升明显。
聪明的伙伴可能马上会提出,上面方案还有优化空间,引入多线程并行执行 内存 join。
非常优秀,多线程引入会再次提升性能,但也提升了系统复杂性(并发安全性、资源配置等)。先准再快,建议有必要时再引入。
代码调整如下:
@Override
public List extends OrderDetailVO> getByUserId(Long userId) {
List orders = this.orderRepository.getByUserId(userId);
List orderDetailVOS = orders.stream()
.map(order -> new OrderDetailVOV2(OrderVO.apply(order)))
.collect(toList());
List> 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 orders, List orderDetailVOS) {
List productIds = orders.stream()
.map(Order::getProductId)
.collect(toList());
List products = this.productRepository.getByIds(productIds);
Map 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 orders, List orderDetailVOS) {
List addressIds = orders.stream()
.map(Order::getAddressId)
.collect(toList());
List addresses = this.addressRepository.getByIds(addressIds);
Map 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 orders, List orderDetailVOS) {
List userIds = orders.stream()
.map(Order::getUserId)
.collect(toList());
List users = this.userRepository.getByIds(userIds);
Map 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);
}
}
可见,复杂性又提升不少。
能否做的更好?我们先列下小目标:
使用 “批量 + 内存Join” 替代 “for + 单条抓取”;
简化开发,最好不写代码;
具备并行执行的能力,以进一步提升性能;
在项目中引入 joininmemory-starter,具体如下:
com.geekhalo.lego
lego-starter-joininmemory
0.0.1-SNAPSHOT
在结果 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 orders = this.orderRepository.getByUserId(userId);
List 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 关联数据的组装。
@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,配置简化不少;
如果需要使用并行处理方案进一步提升性能,也非常简单,只需在 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 |
测试环境简单如下:
获取订单耗时 5 ms
获取单条记录 耗时 3 ms
获取批量记录 耗时 10 ms
订单列表返回记录 100 条
简单对比性能如下:
方案 | 耗时 |
---|---|
for + 单条抓取 | 1130ms |
批量 + 内存join (手工) | 42ms |
批量 + 内存join (手工) + 并行 | 16ms |
@JoinInMemory | 50ms |
@自定义注解 | 48ms |
@自定义注解 + 并行 | 24ms |
附上项目地址:https://gitee.com/litao851025/lego