分布式微服务系统的跨库查询/操作的解决思路(关系型数据库)

本文涉及的产品
注册配置 MSE Nacos/ZooKeeper,118元/月
任务调度 XXL-JOB 版免费试用,400 元额度,开发版规格
服务治理 MSE Sentinel/OpenSergo,Agent数量 不受限
简介: 分布式微服务系统的跨库查询/操作的解决思路(关系型数据库)

在后端开发过程中,我们绕不开的就是数据结构设计以及关联的问题。

然而在传统的单体架构的开发中,解决数据关联的问题并不难,通过关系型数据库中的关联查询功能,以及MyBatis的级联功能即可实现。

但是在分布式微服务中,整个系统都被拆分成了一个个单独的模块,每个模块也都是使用的单独的数据库。这种情况下,又如何解决不同模块之间数据关联问题呢?

事实上,分布式微服务是非常复杂的,无论是系统架构,还是数据结构设计,都没有一个统一的方案,因此根据实际情况进行确定即可,对于数据关联的跨库查询,事实上也有很多方法,在网上有如下思路:

  • 数据冗余法
  • 远程连接表
  • 数据复制
  • 使用非关系型数据库
  • ...

今天,我就来分享一个简单的分布式微服务跨库查询操作,大家可以参考一下。

我们还是从一对多多对多的角度来解决这个问题。

1,学习前需要了解

在继续往下看之前,我想先介绍一下这次的示例中所使用的组件:

  • Spring Cloud 2022.0.0和Spring Cloud Alibaba 2022.0.0.0-RC1
  • MyBatis-Plus作为ORM框架
  • Dynamic Datasource作为多数据源切换组件
  • Nacos作为注册中心
  • MySQL数据库
  • OpenFeign远程调用

因此,在往下看之前,需要先掌握上述这些组件的使用,本文不再赘述。

之前都是使用MyBatis作为ORM框架,而MyBatis-Plus可以视作其升级版,省去了我们写繁杂的Mapper.xml文件的步骤,上手特别简单。

将上述所有前置内容掌握之后,再来往下看最好。

2,跨库操作解决思路

我们从数据的联系形式,即一对多多对多这两个角度依次进行分析。

(1) 一对多

一对多事实上比较好解决,这里我使用字段冗余 + 远程调用的方式解决。

这里以订单(Order)用户(User)为例,订单对象中通常要包含用户关系,一个用户会产生多个订单,因此用户和订单构成了一对多的关系。

单体架构中,我们很容易想到设计成这样:

image.png

这样,在查询订单的时候,可以通过关联查询的方式得到用户字段信息。

但是在分布式微服务中,用户和订单模块被拆分开来,两者的数据库也分开了,无法使用关联查询了,怎么办呢?

这时,我们可以在订单类中,冗余一个userId字段,可以直接从数据库取出,再通过远程调用的方式调用用户模块,用这个userId去得到用户对象,最后组装即可。

image.png

这样,订单服务查询订单对象,可以分为如下几步:

  1. 先直接从数据库取出订单对象,这样上述Order类中的idnameuserId都可以直接从数据库取出
  2. 然后拿着这个userId的值去远程调用用户服务得到用户对象,填充到user字段

与此同时,我们还可以注意一下细节:

  • Order对象返回给前端时,可以过滤掉冗余字段userId,节省流量,通过Jackson注解可以实现
  • 前端若要将Order对象作为参数传递给后端,则无需带着user字段内容,这样前端传来后可以直接丢进数据库,并且更加简洁

(2) 多对多

我们知道,多对多通常是以搭桥表方式实现关联。

在此我们增加一个商品类(Product),和订单类构成多对多关系,即需要查询一个订单中包含的所有商品,还需要查询这个商品被哪些订单包含。

在传统单体架构中,我们如下设计:

image.png

那么在分布式微服务中,数据库分开的情况下,这个搭桥表order_product放在哪呢?

可以将其单独放在一个数据库中,这个数据库在这里称之为搭桥表数据库

image.png

这样,比如说订单服务查询订单的时候,可以分为如下几步:

  1. 先直接从订单数据库查询出订单信息,这样Order类中的idnameuserId就得到了
  2. 然后从搭桥表数据库去查询和这个订单关联的商品id,这样就得到了一个商品id列表
  3. 用这个商品id列表去远程调用商品服务,查询到每个id对应的商品对象得到一个商品对象列表
  4. 将商品列表组装到Order中的products字段中

那么反过来,商品服务也是通过一样的方式得到订单列表并组装。

可见,这两个多对多模块,有下列细节:

  • 需要用到两个数据库,因此需要配置多数据源
  • 两者需要暴露批量id查询的接口,但是批量id查询的时候,要注意死循环问题,这个我们在下面代码中具体来看

(3) 总结

可见上述解决数据关联的方式,都是要通过远程调用的方式来实现,这样符合微服务中职责单一原则,不过缺点是网络性能不是很好。

但是,这种方式解决规模不是特别复杂的项目已经足够了。

整体的类图和数据库如下:

image.png

image.png

那么下面,我们就来实现一下。

3,代码实现

(1) 环境配置

在写代码之前,我们先要在本地搭建并运行好MySQL和Nacos注册中心,这里我已经在本地通过Docker的方式部署好了,大家可以先自行部署。

image.png

然后在这里,整个工程模块组织如下:

image.png

  1. 存放全部实体类的模块,是普通Maven项目,被其它模块依赖
  2. 远程调用层,是普通Maven项目,其它服务模块依赖这个模块进行远程调用
  3. 订单服务模块,是Spring Boot项目
  4. 商品服务模块,是Spring Boot项目
  5. 用户服务模块,是Spring Boot项目

我们知道服务提供者和服务消费者在整个分布式微服务中是非常相对的概念,而服务消费者是需要进行远程调用的,这样每个服务消费者都要引入OpenFeign依赖并注入等等,因此我们可以单独把所有的消费者的远程调用层feignclient抽离出来,作为这个远程调用模块。

在最后我会给出项目的仓库的地址,大家可以在示例仓库中自行查看每个模块的配置文件和依赖配置。

(2) 数据库的初始化

在MySQL中通过以下命令,创建如下数据库:

create database `db_order`;
create database `db_product`;
create database `db_user`;
create database `db_bridge`;

上述db_bridge就是专门存放搭桥表的数据库。

然后依次初始化三个数据库。

db_order数据库:

-- 订单数据库
drop table if exists `order_info`;

create table `order_info`
(
    `id`      int unsigned auto_increment,
    `name`    varchar(16)  not null,
    `user_id` int unsigned not null,
    primary key (`id`)
) engine = InnoDB
  default charset = utf8mb4;

-- 测试数据
insert into `order_info` (`name`, `user_id`)
values ('订单1', 1), -- id:1~4
       ('订单2', 1),
       ('订单3', 2),
       ('订单4', 3);

db_product数据库:

-- 商品数据库
drop table if exists `product`;

create table `product`
(
    `id`   int unsigned auto_increment,
    `name` varchar(32) not null,
    primary key (`id`)
) engine = InnoDB
  default charset = utf8mb4;

-- 初始化测试数据
insert into `product` (`name`)
values ('商品1'), -- id:1~3
       ('商品2'),
       ('商品3');

db_user数据库:

-- 用户数据库
drop table if exists `user`;

create table `user`
(
    `id`       int unsigned auto_increment,
    `username` varchar(16) not null,
    primary key (`id`)
) engine = InnoDB
  default charset = utf8mb4;

-- 初始化数据
insert into `user` (`username`)
values ('dev'), -- id:1~3
       ('test'),
       ('admin');

db_bridge数据库:

-- 多对多关联记录数据库
drop table if exists `order_product`;

-- 订单-商品多对多关联表
create table `order_product`
(
    `order_id`   int unsigned,
    `product_id` int unsigned,
    primary key (`order_id`, `product_id`)
) engine = InnoDB
  default charset = utf8mb4;

-- 初始化测试数据
insert into `order_product`
values (1, 1),
       (1, 2),
       (2, 1),
       (2, 2),
       (3, 2),
       (3, 3),
       (4, 1),
       (4, 2),
       (4, 3);

(3) 实体类的定义

所有的实体类存放在db-entity模块中。

首先我们还是定义一个结果类Result<T>专用于返回给前端:

package com.gitee.swsk33.dbentity.model;

import lombok.Data;

import java.io.Serializable;

/**
 * 返回给前端的结果对象
 */
@Data
public class Result<T> implements Serializable {
   

    /**
     * 是否操作成功
     */
    private boolean success;

    /**
     * 消息
     */
    private String message;

    /**
     * 数据
     */
    private T data;

    /**
     * 设定成功
     *
     * @param message 消息
     */
    public void setResultSuccess(String message) {
   
        this.success = true;
        this.message = message;
        this.data = null;
    }

    /**
     * 设定成功
     *
     * @param message 消息
     * @param data    数据
     */
    public void setResultSuccess(String message, T data) {
   
        this.success = true;
        this.message = message;
        this.data = data;
    }

    /**
     * 设定失败
     *
     * @param message 消息
     */
    public void setResultFailed(String message) {
   
        this.success = false;
        this.message = message;
        this.data = null;
    }

}

然后就是数据库对象了。

1. 用户类

package com.gitee.swsk33.dbentity.dataobject;

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import lombok.Data;

/**
 * 用户类
 */
@Data
public class User {
   

    /**
     * 用户id
     */
    @TableId(type = IdType.AUTO)
    private Integer id;

    /**
     * 用户名
     */
    private String username;

}

2. 商品类

package com.gitee.swsk33.dbentity.dataobject;

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import lombok.Data;

import java.util.List;

/**
 * 商品表
 */
@Data
public class Product {
   

    /**
     * 商品id
     */
    @TableId(type = IdType.AUTO)
    private Integer id;

    /**
     * 商品名
     */
    private String name;

    /**
     * 所有购买了这个商品的订单(需组装)
     */
    @TableField(exist = false)
    private List<Order> orders;

}

可见这里用了@TableField注解将orders字段标注为非数据库字段,因为这个字段是我们后续要手动组装的多对多字段。

3. 订单类

package com.gitee.swsk33.dbentity.dataobject;

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.Data;

import java.util.List;

/**
 * 订单类
 */
@Data
@JsonIgnoreProperties(allowSetters = true, value = {
   "userId"})
@TableName("order_info")
public class Order {
   

    /**
     * 订单id
     */
    @TableId(type = IdType.AUTO)
    private Integer id;

    /**
     * 订单名
     */
    private String name;

    /**
     * 关联用户id(一对多冗余字段,不返回给前端,但是前端作为参数传递)
     */
    private Integer userId;

    /**
     * 关联用户(需组装)
     */
    @TableField(exist = false)
    private User user;

    /**
     * 这个订单中所包含的商品(需组装)
     */
    @TableField(exist = false)
    private List<Product> products;

}

可见这里使用了@JsonIgnoreProperties过滤掉了冗余字段userId不返回给前端。

(4) 各个服务模块

基本上每个服务模块仍然是Spring Boot的四层架构中的三层,daoserviceapi。因此这里只讲关键性的东西,其余细节可以在文末示例仓库中看代码。

来看订单模块,定义数据库操作层OrderDAO如下:

package com.gitee.swsk33.dborder.dao;

import com.baomidou.dynamic.datasource.annotation.DS;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.gitee.swsk33.dbentity.dataobject.Order;
import org.apache.ibatis.annotations.Delete;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Select;

import java.util.List;

public interface OrderDAO extends BaseMapper<Order> {
   

    /**
     * 添加关联记录
     *
     * @param orderId   订单id
     * @param productId 商品id
     * @return 增加记录条数
     */
    @Insert("insert into `order_product` values (#{orderId}, #{productId})")
    @DS("bridge")
    int insertRecord(int orderId, int productId);

    /**
     * 根据订单id查询其对应的所有商品id列表
     *
     * @param orderId 订单id
     * @return 商品id列表
     */
    @Select("select `product_id` from `order_product` where `order_id` = #{orderId}")
    @DS("bridge")
    List<Integer> selectProductIds(int orderId);

    /**
     * 删除和某个订单关联的商品id记录
     *
     * @param orderId 订单id
     * @return 删除记录数
     */
    @Delete("delete from `order_product` where `order_id` = #{orderId}")
    @DS("bridge")
    int deleteProductIds(int orderId);

}

由于继承了MyBatis-Plus的BaseMapper,因此基本的增删改查这里不需要写了,所以这里只需要写对搭桥表数据库中的操作,比如说获取这个订单中包含的商品id列表等等,也可见这里只获取id或者是传入id为参数对搭桥表进行增删查操作,并且这些方法标注了@DS切换数据源查询。

再来看Service层代码:

package com.gitee.swsk33.dborder.service.impl;

import com.gitee.swsk33.dbentity.dataobject.Order;
import com.gitee.swsk33.dbentity.dataobject.Product;
import com.gitee.swsk33.dbentity.dataobject.User;
import com.gitee.swsk33.dbentity.model.Result;
import com.gitee.swsk33.dbfeign.feignclient.ProductClient;
import com.gitee.swsk33.dbfeign.feignclient.UserClient;
import com.gitee.swsk33.dborder.dao.OrderDAO;
import com.gitee.swsk33.dborder.service.OrderService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

import java.util.List;

@Component
public class OrderServiceImpl implements OrderService {
   

    @Autowired
    private OrderDAO orderDAO;

    @Autowired
    private UserClient userClient;

    @Autowired
    private ProductClient productClient;

    @Override
    public Result<Void> add(Order order) {
   
        Result<Void> result = new Result<>();
        // 先直接插入
        if (orderDAO.insert(order) < 1) {
   
            result.setResultFailed("插入失败!");
            return result;
        }
        // 插入后,添加与之关联的商品多对多记录
        for (Product each : order.getProducts()) {
   
            orderDAO.insertRecord(order.getId(), each.getId());
        }
        result.setResultSuccess("插入完成!");
        return result;
    }

    @Override
    public Result<Void> delete(int id) {
   
        Result<Void> result = new Result<>();
        // 先直接删除
        if (orderDAO.deleteById(id) < 1) {
   
            result.setResultFailed("删除失败!");
            return result;
        }
        // 然后删除关联部分
        orderDAO.deleteProductIds(id);
        result.setResultSuccess("删除成功!");
        return result;
    }

    @Override
    public Result<Order> getById(int id) {
   
        Result<Order> result = new Result<>();
        // 先查询订单
        Order getOrder = orderDAO.selectById(id);
        if (getOrder == null) {
   
            result.setResultFailed("查询失败!");
            return result;
        }
        // 远程调用用户模块,组装订单中的用户对象字段(一对多关联查询)
        User getUser = userClient.getById(getOrder.getUserId()).getData();
        if (getUser == null) {
   
            result.setResultFailed("查询失败!");
            return result;
        }
        getOrder.setUser(getUser);
        // 远程调用商品模块,组装订单中关联的商品列表(多对多关联查询)
        List<Integer> productIds = orderDAO.selectProductIds(id);
        getOrder.setProducts(productClient.getByBatchId(productIds).getData());
        result.setResultSuccess("查询成功!", getOrder);
        return result;
    }

    @Override
    public Result<List<Order>> getByBatchId(List<Integer> ids) {
   
        Result<List<Order>> result = new Result<>();
        // 先批量查询
        List<Order> getOrders = orderDAO.selectBatchIds(ids);
        // 组装其中的用户对象字段
        for (Order each : getOrders) {
   
            each.setUser(userClient.getById(each.getUserId()).getData());
        }
        // 由于批量查询目前专门提供给内部模块作为多对多关联查询时远程调用,因此这里不再对每个对象进行多对多查询,否则会陷入死循环
        result.setResultSuccess("查询完成!", getOrders);
        return result;
    }

}

可以先看代码和注释,这里面已经写好了增删查记录的时候的流程和操作,包括查询多对多搭桥表中的id以及远程调用等等,远程调用代码这里不再赘述。

然后,将这些服务暴露为接口即可,反过来商品服务模块也是基本一样的思路。

上述有以下需要注意的地方:

  • 将搭桥表的操作,即增加、查询和删除这个订单包含的商品id的操作,定义在了OrderDAO,反过来在商品服务中,ProductDAO中也需要定义增加、查询和删除和这个商品所有关联的订单id的操作,具体可以看项目源码
  • 在服务中编写了getByBatchId这个方法专用于模块远程调用查询多对多的对象,但是可见在这个方法中批量查询时,没有继续组装每个对象中包含的多对多对象,防止死循环

对于远程调用,需要注意的是远程调用层的代码和这些服务不在一个模块中,因此在模块主类上标注@EnableFeignClients注解启用远程调用功能时,还需要指定需要用到的远程调用的FeignClient类,否则会注入失败:

image.png

所有模块写完后,启动并测试接口,来看一下效果:

image.png

image.png

4,总结

事实上,解决分布式微服务的跨库增删改查操作,有很多的方式,这里只是提供一个思路,大家可以适当采纳,不能说这里的方案就是最优雅、性能最好的,还需要根据实际情况考虑。

相关实践学习
使用PolarDB和ECS搭建门户网站
本场景主要介绍基于PolarDB和ECS实现搭建门户网站。
阿里云数据库产品家族及特性
阿里云智能数据库产品团队一直致力于不断健全产品体系,提升产品性能,打磨产品功能,从而帮助客户实现更加极致的弹性能力、具备更强的扩展能力、并利用云设施进一步降低企业成本。以云原生+分布式为核心技术抓手,打造以自研的在线事务型(OLTP)数据库Polar DB和在线分析型(OLAP)数据库Analytic DB为代表的新一代企业级云原生数据库产品体系, 结合NoSQL数据库、数据库生态工具、云原生智能化数据库管控平台,为阿里巴巴经济体以及各个行业的企业客户和开发者提供从公共云到混合云再到私有云的完整解决方案,提供基于云基础设施进行数据从处理、到存储、再到计算与分析的一体化解决方案。本节课带你了解阿里云数据库产品家族及特性。
相关文章
|
4月前
|
SQL 关系型数据库 MySQL
乐观锁在分布式数据库中如何与事务隔离级别结合使用
乐观锁在分布式数据库中如何与事务隔离级别结合使用
|
1月前
|
存储 运维 安全
盘古分布式存储系统的稳定性实践
本文介绍了阿里云飞天盘古分布式存储系统的稳定性实践。盘古作为阿里云的核心组件,支撑了阿里巴巴集团的众多业务,确保数据高可靠性、系统高可用性和安全生产运维是其关键目标。文章详细探讨了数据不丢不错、系统高可用性的实现方法,以及通过故障演练、自动化发布和健康检查等手段保障生产安全。总结指出,稳定性是一项系统工程,需要持续迭代演进,盘古经过十年以上的线上锤炼,积累了丰富的实践经验。
|
1月前
|
存储 分布式计算 Hadoop
基于Java的Hadoop文件处理系统:高效分布式数据解析与存储
本文介绍了如何借鉴Hadoop的设计思想,使用Java实现其核心功能MapReduce,解决海量数据处理问题。通过类比图书馆管理系统,详细解释了Hadoop的两大组件:HDFS(分布式文件系统)和MapReduce(分布式计算模型)。具体实现了单词统计任务,并扩展支持CSV和JSON格式的数据解析。为了提升性能,引入了Combiner减少中间数据传输,以及自定义Partitioner解决数据倾斜问题。最后总结了Hadoop在大数据处理中的重要性,鼓励Java开发者学习Hadoop以拓展技术边界。
50 7
|
1月前
|
存储 关系型数据库 分布式数据库
[PolarDB实操课] 01.PolarDB分布式版架构介绍
《PolarDB实操课》之“PolarDB分布式版架构介绍”由阿里云架构师王江颖主讲。课程涵盖PolarDB-X的分布式架构、典型业务场景(如实时交易、海量数据存储等)、分布式焦点问题(如业务连续性、一致性保障等)及技术架构详解。PolarDB-X基于Share-Nothing架构,支持HTAP能力,具备高可用性和容错性,适用于多种分布式改造和迁移场景。课程链接:[https://developer.aliyun.com/live/253957](https://developer.aliyun.com/live/253957)。更多内容可访问阿里云培训中心。
[PolarDB实操课] 01.PolarDB分布式版架构介绍
|
1月前
|
关系型数据库 分布式数据库 PolarDB
[PolarDB实操课] 02.使用云起实验室资源快速体验PolarDB分布式版
本次课程由阿里云PolarDB开源架构师黄心雨分享,重点介绍如何使用云起实验室资源快速体验PolarDB分布式版。主要内容包括: 1. **PolarDB-X的四种安装方法**:Docker、PXD工具、Kubernetes和源码编译。 2. **容器技术简介**:解释容器在云原生环境中的作用,解决代码跨环境迁移问题。 3. **云起实验室实操**:通过云起实验室提供的零门槛平台,快速部署PolarDB-X,体验其主要功能。 4. **课程小结**:总结PolarDB-X的安装方式及实际操作步骤,并展望后续课程内容。
|
2月前
|
Cloud Native 关系型数据库 分布式数据库
PolarDB 分布式版 V2.0,安全可靠的集中分布式一体化数据库管理软件
阿里云PolarDB数据库管理软件(分布式版)V2.0 ,安全可靠的集中分布式一体化数据库管理软件。
|
2月前
|
机器学习/深度学习 存储 运维
分布式机器学习系统:设计原理、优化策略与实践经验
本文详细探讨了分布式机器学习系统的发展现状与挑战,重点分析了数据并行、模型并行等核心训练范式,以及参数服务器、优化器等关键组件的设计与实现。文章还深入讨论了混合精度训练、梯度累积、ZeRO优化器等高级特性,旨在提供一套全面的技术解决方案,以应对超大规模模型训练中的计算、存储及通信挑战。
122 4
|
3月前
|
存储 运维 负载均衡
构建高可用性GraphRAG系统:分布式部署与容错机制
【10月更文挑战第28天】作为一名数据科学家和系统架构师,我在构建和维护大规模分布式系统方面有着丰富的经验。最近,我负责了一个基于GraphRAG(Graph Retrieval-Augmented Generation)模型的项目,该模型用于构建一个高可用性的问答系统。在这个过程中,我深刻体会到分布式部署和容错机制的重要性。本文将详细介绍如何在生产环境中构建一个高可用性的GraphRAG系统,包括分布式部署方案、负载均衡、故障检测与恢复机制等方面的内容。
232 4
构建高可用性GraphRAG系统:分布式部署与容错机制
|
3月前
|
关系型数据库 Serverless 分布式数据库
PolarDB Serverless 模式通过自动扩缩容技术,根据实际工作负载动态调整资源,提高系统灵活性与成本效益
PolarDB Serverless 模式通过自动扩缩容技术,根据实际工作负载动态调整资源,提高系统灵活性与成本效益。用户无需预配高固定资源,仅需为实际使用付费,有效应对流量突变,降低总体成本。示例代码展示了基本数据库操作,强调了合理规划、监控评估及结合其他云服务的重要性,助力企业数字化转型。
64 6
|
2月前
|
存储 消息中间件 SQL
微服务改造血泪史:数据库拆分踩过的那些坑!
本文复盘了传统项目改造成微服务架构时,数据库拆分过程中遇到的问题。主要问题包括:1. 数据库拆分过细,导致跨服务调用频繁,破坏服务独立性;2. 数据一致性难以保证,分布式事务管理复杂;3. 跨服务查询影响性能,复杂查询难以实现。初次改造时应避免过度拆分,逐步演进架构。
61 0