从理论到实战:图结构在仓库关联业务中的落地(小白→中级,附完整代码)
很多开发者在大学数据结构课上都学过图这种数据结构,但大多停留在“知道节点、边、BFS/DFS这些概念”的层面,很少有机会在实际业务中落地。本文将以仓库关联关系维护(后续用于库存上限关联合并的前置需求)为场景,从图的基础概念讲起,由浅入深地讲解如何将图结构与实际业务结合,解决“无向连通图下仓库关联关系维护”的问题。即使你只听过图的名字,也能跟着本文完成从理论到代码的落地。
一、先补基础:图结构的核心概念(小白友好)
在开始业务代码前,我们先花几分钟回顾图的核心概念,并用仓库场景做类比,让抽象的概念变具体。
1. 图的基本组成
图(Graph)由**节点(Vertex)和边(Edge)**组成:
- 节点:代表一个实体。比如仓库场景中,每个仓库(如仓库35、36)就是一个节点。
- 边:代表节点之间的关系。比如仓库35关联仓库36,这条关联关系就是一条边。
2. 图的常见分类
(1)有向图 vs 无向图
- 有向图:边有方向。比如“仓库A是仓库B的上游”,这条边是单向的。
- 无向图:边无方向。比如“仓库A和仓库B关联”,等同于“仓库B和仓库A关联”(本文的仓库场景就是无向图)。
(2)连通图 vs 非连通图
- 连通图:任意两个节点之间都有路径相连(直接或间接)。比如仓库35关联36,36关联39,那么35和39通过36间接相连,这三个节点构成连通图。
- 非连通图:存在两个节点之间没有路径相连。比如仓库35的关联网络和仓库40的关联网络完全独立。
(3)连通分量
连通分量是指非连通图中的最大连通子图。比如仓库35、36、39是一个连通分量,仓库40、41是另一个连通分量。
3. 图的遍历算法(核心)
遍历图的目的是从一个节点出发,访问所有可达的节点(直接+间接),常用的有两种算法:
- BFS(广度优先搜索):按层级遍历,先访问当前节点的直接邻居,再访问邻居的邻居(比如先找仓库35的直接关联仓库,再找这些仓库的关联仓库)。
- DFS(深度优先搜索):先深入遍历一条路径,再回溯(比如仓库35→36→39,再回溯到35→37)。
这两个算法是处理图业务的基础,后面会详细实现。
二、业务场景与数据表设计
1. 核心业务需求
先明确我们的业务目标(这是后续库存上限关联合并的前置需求):
- 关联维护:前端传递一个主仓库ID,以及该仓库需要关联的其他仓库列表,后端需要同步数据库中的关联关系(新增缺失的,删除多余的)。
- 关联查询:从任意仓库节点出发,能快速获取所有关联的仓库(直接+间接),为后续库存上限合并做准备。
- 无向关联:仓库A关联B,等同于B关联A。
- 数据可追溯:支持逻辑删除,保留创建/修改时间、操作人等信息。
2. 数据表设计(warehouse_ext)
我们先采用边存储的方式设计表(这是小白最容易想到的方式,后续会分析其优缺点),表名改为warehouse_ext(见名知意),用于存储仓库之间的直接关联边:
DROP TABLE IF EXISTS `warehouse_ext`;
CREATE TABLE IF NOT EXISTS `warehouse_ext`
(
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`warehouse_id` bigint null comment '仓库ID(节点A)',
`warehouse` varchar(128) null comment '仓库名称/编码',
`warehouse_ext_id` bigint DEFAULT NULL COMMENT '衍生仓库ID(节点B)',
`warehouse_ext` varchar(128) DEFAULT NULL COMMENT '衍生仓库名称/编码',
`creator` bigint DEFAULT NULL COMMENT '创建者ID',
`updater` bigint DEFAULT NULL COMMENT '修改者ID',
`ct` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
`ut` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
`is_deleted` tinyint(1) NULL DEFAULT 0 COMMENT '是否删除:0-未删除,1-已删除',
PRIMARY KEY (`id`) USING BTREE,
-- 唯一索引:避免同一对仓库重复存储直接关联(A-B和B-A视为不同?后续会处理)
UNIQUE KEY `uk_warehouse_ext` (`warehouse_id`, `warehouse_ext_id`, `is_deleted`) USING BTREE,
-- 业务查询索引:加速根据仓库ID查询关联关系
INDEX idx_warehouse_id (`warehouse_id`, `is_deleted`),
INDEX idx_warehouse_ext_id (`warehouse_ext_id`, `is_deleted`),
-- 时间索引:支持时间范围查询
INDEX idx_ct (`ct`),
INDEX idx_ut (`ut`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_unicode_ci
COMMENT = '仓库关联表(存储节点间的直接边,双向关联)';
表设计关键点说明:
warehouse_id和warehouse_ext_id:分别代表关联的两个仓库节点(边的两个端点)。- 唯一索引
uk_warehouse_ext:防止同一对仓库重复存储直接关联(比如多次插入35-36的记录)。 - 逻辑删除
is_deleted:保留数据溯源能力,而非物理删除。 - 索引优化:为常用查询字段(仓库ID、时间)建立索引,提升查询性能。
三、小白版实现:边存储+简单维护(踩坑前奏)
作为基础开发者,首先会想到“前端传什么,我就存什么”,通过对比前端传递的关联列表和数据库中的直接关联列表,进行新增/删除操作。我们先实现这个版本,再看会遇到什么问题。
1. 技术栈准备
本文使用Spring Boot 2.7.x + MyBatis + MySQL + Lombok,先配置核心依赖和配置文件。
(1)Maven依赖(pom.xml)
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.18</version>
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>warehouse-graph-demo</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>warehouse-graph-demo</name>
<description>图结构在仓库关联中的应用</description>
<properties>
<java.version>1.8</java.version>
</properties>
<dependencies>
<!-- Spring Boot Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- MyBatis Spring Boot Starter -->
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.3.0</version>
</dependency>
<!-- MySQL驱动 -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- 测试依赖(可选) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>
(2)配置文件(application.yml)
server:
port: 8080 # 端口号
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
# 替换为你的数据库地址和名称
url: jdbc:mysql://localhost:3306/warehouse_db?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai
username: root # 你的数据库用户名
password: root # 你的数据库密码
mybatis:
mapper-locations: classpath:mybatis/mapper/*.xml # Mapper XML文件路径
type-aliases-package: com.example.warehouse.entity # 实体类别名包
configuration:
map-underscore-to-camel-case: true # 开启下划线转驼峰命名
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # 打印SQL日志(开发环境用)
2. 核心代码实现
(1)实体类与DTO
实体类(WarehouseExt.java):对应数据库表warehouse_ext
package com.example.warehouse.entity;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 仓库关联表实体类(存储直接边)
*/
@Data
public class WarehouseExt {
/**
* 主键ID
*/
private Long id;
/**
* 仓库ID(节点A)
*/
private Long warehouseId;
/**
* 仓库名称/编码
*/
private String warehouse;
/**
* 衍生仓库ID(节点B)
*/
private Long warehouseExtId;
/**
* 衍生仓库名称/编码
*/
private String warehouseExt;
/**
* 创建者ID
*/
private Long creator;
/**
* 修改者ID
*/
private Long updater;
/**
* 创建时间
*/
private LocalDateTime ct;
/**
* 更新时间
*/
private LocalDateTime ut;
/**
* 是否删除:0-未删除,1-已删除
*/
private Integer isDeleted;
}
DTO(WarehouseRelationDto.java):接收前端传递的参数
package com.example.warehouse.dto;
import lombok.Data;
import java.util.List;
/**
* 前端传递的仓库关联数据DTO
*/
@Data
public class WarehouseRelationDto {
/**
* 主仓库ID
*/
private Long warehouseId;
/**
* 主仓库名称/编码
*/
private String warehouse;
/**
* 关联的衍生仓库列表
*/
private List<Item> items;
/**
* 衍生仓库子项
*/
@Data
public static class Item {
/**
* 衍生仓库名称/编码
*/
private String warehouseExt;
/**
* 衍生仓库ID
*/
private Long warehouseExtId;
}
}
(2)MyBatis Mapper接口与XML
Mapper接口(WarehouseExtMapper.java):
package com.example.warehouse.mapper;
import com.example.warehouse.entity.WarehouseExt;
import org.apache.ibatis.annotations.Param;
import java.util.List;
import java.util.Set;
/**
* 仓库关联表Mapper接口
*/
public interface WarehouseExtMapper {
/**
* 根据仓库ID,查询其直接关联的所有仓库ID(双向)
* @param warehouseId 仓库ID
* @return 直接关联的仓库ID列表
*/
List<Long> selectRelatedWarehouseIds(@Param("warehouseId") Long warehouseId);
/**
* 查询两个仓库之间是否存在直接关联(双向)
* @param mainId 仓库A
* @param extId 仓库B
* @return 关联记录ID(不存在则返回null)
*/
Long selectRelationIdByTwoWarehouse(@Param("mainId") Long mainId, @Param("extId") Long extId);
/**
* 批量插入仓库关联记录
* @param list 关联记录列表
* @return 影响行数
*/
int batchInsert(@Param("list") List<WarehouseExt> list);
/**
* 逻辑删除两个仓库之间的直接关联(双向)
* @param mainId 仓库A
* @param extId 仓库B
* @return 影响行数
*/
int logicDeleteRelation(@Param("mainId") Long mainId, @Param("extId") Long extId);
/**
* 查询指定仓库已有的直接关联仓库ID列表(未删除)
* @param warehouseId 仓库ID
* @return 直接关联的仓库ID集合
*/
Set<Long> selectExistingRelatedIds(@Param("warehouseId") Long warehouseId);
}
Mapper XML(WarehouseExtMapper.xml):
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.warehouse.mapper.WarehouseExtMapper">
<!-- 查询指定仓库的直接关联仓库ID(双向) -->
<select id="selectRelatedWarehouseIds" resultType="java.lang.Long">
SELECT
CASE
WHEN warehouse_id = #{warehouseId} THEN warehouse_ext_id
ELSE warehouse_id
END AS related_id
FROM warehouse_ext
WHERE is_deleted = 0
AND (warehouse_id = #{warehouseId} OR warehouse_ext_id = #{warehouseId})
</select>
<!-- 查询两个仓库之间的直接关联记录ID(双向) -->
<select id="selectRelationIdByTwoWarehouse" resultType="java.lang.Long">
SELECT id
FROM warehouse_ext
WHERE is_deleted = 0
AND (
(warehouse_id = #{mainId} AND warehouse_ext_id = #{extId})
OR (warehouse_id = #{extId} AND warehouse_ext_id = #{mainId})
)
LIMIT 1
</select>
<!-- 批量插入仓库关联记录 -->
<insert id="batchInsert">
INSERT INTO warehouse_ext (
warehouse_id, warehouse, warehouse_ext_id, warehouse_ext,
creator, updater, ct, ut, is_deleted
) VALUES
<foreach collection="list" item="item" separator=",">
(
#{item.warehouseId}, #{item.warehouse},
#{item.warehouseExtId}, #{item.warehouseExt},
#{item.creator}, #{item.updater},
NOW(3), NOW(3), 0
)
</foreach>
</insert>
<!-- 逻辑删除两个仓库之间的直接关联(双向) -->
<update id="logicDeleteRelation">
UPDATE warehouse_ext
SET is_deleted = 1, ut = NOW(3)
WHERE is_deleted = 0
AND (
(warehouse_id = #{mainId} AND warehouse_ext_id = #{extId})
OR (warehouse_id = #{extId} AND warehouse_ext_id = #{mainId})
)
</update>
<!-- 查询指定仓库已有的直接关联仓库ID列表 -->
<select id="selectExistingRelatedIds" resultType="java.lang.Long">
SELECT
CASE
WHEN warehouse_id = #{warehouseId} THEN warehouse_ext_id
ELSE warehouse_id
END AS related_id
FROM warehouse_ext
WHERE is_deleted = 0
AND (warehouse_id = #{warehouseId} OR warehouse_ext_id = #{warehouseId})
</select>
</mapper>
(3)业务逻辑层(WarehouseRelationService.java)
package com.example.warehouse.service;
import com.example.warehouse.dto.WarehouseRelationDto;
import com.example.warehouse.entity.WarehouseExt;
import com.example.warehouse.mapper.WarehouseExtMapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.CollectionUtils;
import javax.annotation.Resource;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
/**
* 仓库关联关系业务服务(小白版:仅处理直接边)
*/
@Service
public class WarehouseRelationService {
@Resource
private WarehouseExtMapper warehouseExtMapper;
/**
* 维护仓库关联关系:新增缺失的直接边,删除多余的直接边
* @param dto 前端传递的关联数据
* @param operatorId 操作人ID(创建者/修改者)
*/
@Transactional(rollbackFor = Exception.class)
public void maintainWarehouseRelations(WarehouseRelationDto dto, Long operatorId) {
// 1. 参数校验
if (dto == null || dto.getWarehouseId() == null) {
throw new IllegalArgumentException("主仓库ID不能为空");
}
Long mainWarehouseId = dto.getWarehouseId();
String mainWarehouseName = dto.getWarehouse();
// 2. 提取前端传递的关联仓库ID(去重、过滤空值)
Set<Long> frontRelatedIds = new HashSet<>();
List<WarehouseRelationDto.Item> items = dto.getItems();
if (!CollectionUtils.isEmpty(items)) {
frontRelatedIds = items.stream()
.filter(item -> item.getWarehouseExtId() != null)
.map(WarehouseRelationDto.Item::getWarehouseExtId)
.collect(Collectors.toSet());
}
// 3. 获取数据库中已有的直接关联仓库ID
Set<Long> dbRelatedIds = warehouseExtMapper.selectExistingRelatedIds(mainWarehouseId);
// 4. 计算需要新增和删除的直接边
// 需要新增:前端有,数据库无
Set<Long> needAddIds = new HashSet<>(frontRelatedIds);
needAddIds.removeAll(dbRelatedIds);
// 需要删除:数据库有,前端无
Set<Long> needDeleteIds = new HashSet<>(dbRelatedIds);
needDeleteIds.removeAll(frontRelatedIds);
// 5. 执行新增操作
if (!CollectionUtils.isEmpty(needAddIds)) {
List<WarehouseExt> addList = new ArrayList<>();
for (Long extId : needAddIds) {
// 先判断是否已存在双向关联(避免违反唯一索引)
Long relationId = warehouseExtMapper.selectRelationIdByTwoWarehouse(mainWarehouseId, extId);
if (relationId == null) { // 不存在则新增
WarehouseExt entity = new WarehouseExt();
entity.setWarehouseId(mainWarehouseId);
entity.setWarehouse(mainWarehouseName);
// 从items中获取衍生仓库名称
String extWarehouseName = items.stream()
.filter(item -> extId.equals(item.getWarehouseExtId()))
.map(WarehouseRelationDto.Item::getWarehouseExt)
.findFirst()
.orElse("");
entity.setWarehouseExtId(extId);
entity.setWarehouseExt(extWarehouseName);
entity.setCreator(operatorId);
entity.setUpdater(operatorId);
addList.add(entity);
}
}
if (!CollectionUtils.isEmpty(addList)) {
warehouseExtMapper.batchInsert(addList);
}
}
// 6. 执行逻辑删除操作
if (!CollectionUtils.isEmpty(needDeleteIds)) {
for (Long extId : needDeleteIds) {
warehouseExtMapper.logicDeleteRelation(mainWarehouseId, extId);
}
}
}
}
(4)控制器层(WarehouseRelationController.java)
package com.example.warehouse.controller;
import com.example.warehouse.dto.WarehouseRelationDto;
import com.example.warehouse.service.WarehouseRelationService;
import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource;
import java.util.Map;
/**
* 仓库关联关系接口控制器
*/
@RestController
@RequestMapping("/api/warehouse/relation")
public class WarehouseRelationController {
@Resource
private WarehouseRelationService warehouseRelationService;
/**
* 维护仓库关联关系
* @param dto 前端传递的关联数据
* @return 操作结果
*/
@PostMapping("/maintain")
public Map<String, Object> maintainRelations(@RequestBody WarehouseRelationDto dto) {
try {
// 实际项目中,operatorId从登录用户信息中获取,这里模拟为1L
Long operatorId = 1L;
warehouseRelationService.maintainWarehouseRelations(dto, operatorId);
return Map.of("code", 200, "message", "关联关系维护成功", "data", null);
} catch (IllegalArgumentException e) {
return Map.of("code", 400, "message", e.getMessage(), "data", null);
} catch (Exception e) {
return Map.of("code", 500, "message", "服务器内部错误:" + e.getMessage(), "data", null);
}
}
}
3. 小白版的踩坑过程
我们执行三次操作,看看会遇到什么问题:
第一次操作:绑定节点(35关联36、37、38、39)
前端请求体:
{
"warehouseId": 35,
"warehouse": "FBA-股份-美国",
"items": [
{"warehouseExt": "FBA-忻凯-日本", "warehouseExtId": 36},
{"warehouseExt": "FBA-迅果-美国", "warehouseExtId": 37},
{"warehouseExt": "FBA-云赫-美国", "warehouseExtId": 38},
{"warehouseExt": "FBA-钰洲-美国", "warehouseExtId": 39}
]
}
数据库结果(符合预期,存储了35的4条直接边):
| warehouse_id | warehouse | warehouse_ext_id | warehouse_ext | is_deleted |
|---|---|---|---|---|
| 35 | FBA-股份-美国 | 36 | FBA-忻凯-日本 | 0 |
| 35 | FBA-股份-美国 | 37 | FBA-迅果-美国 | 0 |
| 35 | FBA-股份-美国 | 38 | FBA-云赫-美国 | 0 |
| 35 | FBA-股份-美国 | 39 | FBA-钰洲-美国 | 0 |
第二次操作:错误传递(36关联35、39)
前端请求体(36的关联列表包含35和39,其中39是间接关联):
{
"warehouseId": 36,
"warehouse": "FBA-忻凯-日本",
"items": [
{"warehouseExt": "FBA-股份-美国", "warehouseExtId": 35},
{"warehouseExt": "FBA-钰洲-美国", "warehouseExtId": 39}
]
}
数据库结果(出现问题:新增了36-39的直接边):
| warehouse_id | warehouse | warehouse_ext_id | warehouse_ext | is_deleted |
|---|---|---|---|---|
| 35 | FBA-股份-美国 | 36 | FBA-忻凯-日本 | 0 |
| 35 | FBA-股份-美国 | 37 | FBA-迅果-美国 | 0 |
| 35 | FBA-股份-美国 | 38 | FBA-云赫-美国 | 0 |
| 35 | FBA-股份-美国 | 39 | FBA-钰洲-美国 | 0 |
| 36 | FBA-忻凯-日本 | 39 | FBA-钰洲-美国 | 0 |
第三次操作:删除节点(36的关联列表为空)
前端请求体:
{
"warehouseId": 36,
"warehouse": "FBA-忻凯-日本",
"items": []
}
数据库结果(连锁问题:35-36和36-39的边都被逻辑删除):
| warehouse_id | warehouse | warehouse_ext_id | warehouse_ext | is_deleted |
|---|---|---|---|---|
| 35 | FBA-股份-美国 | 36 | FBA-忻凯-日本 | 1 |
| 35 | FBA-股份-美国 | 37 | FBA-迅果-美国 | 0 |
| 35 | FBA-股份-美国 | 38 | FBA-云赫-美国 | 0 |
| 35 | FBA-股份-美国 | 39 | FBA-钰洲-美国 | 0 |
| 36 | FBA-忻凯-日本 | 39 | FBA-钰洲-美国 | 1 |
4. 问题现象总结
- 数据冗余:间接关联的节点(36和39)被错误存储为直接边,增加了不必要的数据。
- 逻辑异常:冗余的直接边导致删除操作出现非预期结果,影响后续库存上限合并的准确性。
- 性能下降:后续查询所有关联仓库时,需要遍历更多的边,降低查询效率。
四、问题根源分析(小白→中级的关键)
之所以会踩坑,核心是业务逻辑(无向连通图)与代码逻辑(仅处理直接边)的错位,具体有三个层面的原因:
1. 核心原因:未区分“直接关联”和“间接关联”
小白版代码只关注当前节点的直接边,却忽略了整个连通图的间接关联。
- 第二次操作时,代码只知道36的直接关联是35,却不知道36还通过35间接关联39。
- 因此,代码将39视为“需要新增的直接边”,错误插入了36-39的记录。
2. 次要原因:无向边的双向性处理不彻底
表中的唯一索引uk_warehouse_ext将(A,B)和(B,A)视为不同的记录(比如35-36和36-35),虽然代码查询了双向关联,但无法阻止“间接关联转直接边”的错误。
3. 底层原因:边存储模型的局限性
边存储模型适合稀疏的、非连通的关联关系,但对于无向连通图的业务场景,这种模型存在天然的局限性:
- 数据冗余:间接关联被转成直接边存储,增加了数据量。
- 维护复杂:需要处理各种边界情况(如间接关联的过滤)。
- 性能瓶颈:遍历所有关联节点需要递归/循环查询,数据量大时性能下降。
五、进阶版实现:结合图遍历+分组模型(解决问题)
针对无向连通图的业务场景,我们有两个解决方案:一是修复原有代码(边存储+间接过滤),二是重构为分组模型(连通分量)(推荐,因为后续库存上限合并需要高效的关联查询)。
方案一:修复原有代码(边存储+间接过滤)
核心思路:在维护关联关系前,先通过BFS/DFS遍历整个连通图,获取所有关联节点(包括直接和间接),然后过滤掉间接关联的节点,只处理真正需要新增的直接边。
1. 实现图的遍历算法(BFS/DFS)
在WarehouseRelationService中添加遍历方法:
/**
* BFS(广度优先搜索):获取当前仓库的所有关联节点(直接+间接)
* @param startWarehouseId 起始仓库ID
* @return 所有关联的仓库ID集合(包含起始节点)
*/
public Set<Long> traverseAllRelatedWarehousesByBFS(Long startWarehouseId) {
if (startWarehouseId == null) {
throw new IllegalArgumentException("起始仓库ID不能为空");
}
// 存储已访问的节点,避免循环遍历(比如A-B-A的环)
Set<Long> visited = new HashSet<>();
// 队列:存储待访问的节点(BFS的核心数据结构)
java.util.Queue<Long> queue = new java.util.LinkedList<>();
// 初始化:将起始节点加入队列和已访问集合
queue.offer(startWarehouseId);
visited.add(startWarehouseId);
while (!queue.isEmpty()) {
// 取出队首节点
Long currentId = queue.poll();
// 获取当前节点的直接关联节点
List<Long> relatedIds = warehouseExtMapper.selectRelatedWarehouseIds(currentId);
for (Long relatedId : relatedIds) {
// 如果未访问过,则加入队列和已访问集合
if (!visited.contains(relatedId)) {
visited.add(relatedId);
queue.offer(relatedId);
}
}
}
return visited;
}
/**
* DFS(深度优先搜索):获取当前仓库的所有关联节点(直接+间接)
* @param startWarehouseId 起始仓库ID
* @return 所有关联的仓库ID集合(包含起始节点)
*/
public Set<Long> traverseAllRelatedWarehousesByDFS(Long startWarehouseId) {
if (startWarehouseId == null) {
throw new IllegalArgumentException("起始仓库ID不能为空");
}
Set<Long> visited = new HashSet<>();
// 调用递归方法进行DFS遍历
dfs(startWarehouseId, visited);
return visited;
}
/**
* DFS递归方法
* @param currentId 当前仓库ID
* @param visited 已访问的节点集合
*/
private void dfs(Long currentId, Set<Long> visited) {
// 标记当前节点为已访问
visited.add(currentId);
// 获取当前节点的直接关联节点
List<Long> relatedIds = warehouseExtMapper.selectRelatedWarehouseIds(currentId);
for (Long relatedId : relatedIds) {
// 如果未访问过,递归遍历
if (!visited.contains(relatedId)) {
dfs(relatedId, visited);
}
}
}
2. 修改维护逻辑(增加间接关联过滤)
修改maintainWarehouseRelations方法,在计算需要新增的节点时,过滤掉间接关联的节点:
@Transactional(rollbackFor = Exception.class)
public void maintainWarehouseRelations(WarehouseRelationDto dto, Long operatorId) {
// 1. 参数校验(不变)
if (dto == null || dto.getWarehouseId() == null) {
throw new IllegalArgumentException("主仓库ID不能为空");
}
Long mainWarehouseId = dto.getWarehouseId();
String mainWarehouseName = dto.getWarehouse();
// 2. 提取前端传递的关联仓库ID(不变)
Set<Long> frontRelatedIds = new HashSet<>();
List<WarehouseRelationDto.Item> items = dto.getItems();
if (!CollectionUtils.isEmpty(items)) {
frontRelatedIds = items.stream()
.filter(item -> item.getWarehouseExtId() != null)
.map(WarehouseRelationDto.Item::getWarehouseExtId)
.collect(Collectors.toSet());
}
// ====== 新增:获取当前节点的所有关联节点(直接+间接) ======
Set<Long> allRelatedIds = traverseAllRelatedWarehousesByBFS(mainWarehouseId);
// 3. 获取数据库中已有的直接关联仓库ID(不变)
Set<Long> dbRelatedIds = warehouseExtMapper.selectExistingRelatedIds(mainWarehouseId);
// ====== 修改:过滤间接关联节点,只处理真正需要的直接边 ======
Set<Long> needAddIds = new HashSet<>();
for (Long extId : frontRelatedIds) {
// 需新增的条件:前端有 && 数据库无 && 不在间接关联中(避免间接转直接)
if (!dbRelatedIds.contains(extId) && !allRelatedIds.contains(extId)) {
needAddIds.add(extId);
}
}
// 需删除的条件:数据库有 && 前端无(不变)
Set<Long> needDeleteIds = new HashSet<>(dbRelatedIds);
needDeleteIds.removeAll(frontRelatedIds);
// 4. 执行新增和删除操作(不变)
// ... 原有代码省略
}
3. 修复后的效果
第二次操作时,36的间接关联节点包含39,因此needAddIds为空,不会新增36-39的直接边,数据库保持正确。
方案二:进阶方案(推荐):连通分量分组模型
修复代码只是“治标”,而连通分量分组模型是“治本”——不再存储具体的直接边,而是为每个连通分量(关联的仓库组)分配一个分组ID,仓库节点与分组ID关联。这种模型更适合后续库存上限合并的需求,因为可以快速获取所有关联仓库。
1. 数据库表设计(新增分组表)
-- 仓库分组表:存储连通分量的分组信息
DROP TABLE IF EXISTS `warehouse_group`;
CREATE TABLE IF NOT EXISTS `warehouse_group` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '分组ID(连通分量标识)',
`group_name` varchar(128) DEFAULT NULL COMMENT '分组名称(可自定义)',
`creator` bigint DEFAULT NULL COMMENT '创建者ID',
`updater` bigint DEFAULT NULL COMMENT '修改者ID',
`ct` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
`ut` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
`is_deleted` tinyint(1) DEFAULT 0 COMMENT '是否删除:0-未删除,1-已删除',
PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='仓库连通分量分组表';
-- 仓库-分组关联表:存储仓库与分组的归属关系
DROP TABLE IF EXISTS `warehouse_group_relation`;
CREATE TABLE IF NOT EXISTS `warehouse_group_relation` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`warehouse_id` bigint NOT NULL COMMENT '仓库ID',
`warehouse` varchar(128) DEFAULT NULL COMMENT '仓库名称/编码',
`group_id` bigint NOT NULL COMMENT '分组ID',
`creator` bigint DEFAULT NULL COMMENT '创建者ID',
`updater` bigint DEFAULT NULL COMMENT '修改者ID',
`ct` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
`ut` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
`is_deleted` tinyint(1) DEFAULT 0 COMMENT '是否删除:0-未删除,1-已删除',
-- 唯一索引:一个仓库只能属于一个未删除的分组
UNIQUE KEY `uk_warehouse_id` (`warehouse_id`, `is_deleted`) USING BTREE,
-- 分组查询索引:加速根据分组ID查询仓库
INDEX `idx_group_id` (`group_id`, `is_deleted`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='仓库-分组关联表';
2. 核心业务逻辑(维护分组归属)
package com.example.warehouse.service;
import com.example.warehouse.dto.WarehouseRelationDto;
import com.example.warehouse.entity.WarehouseGroup;
import com.example.warehouse.entity.WarehouseGroupRelation;
import com.example.warehouse.mapper.WarehouseGroupMapper;
import com.example.warehouse.mapper.WarehouseGroupRelationMapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.CollectionUtils;
import javax.annotation.Resource;
import java.util.HashSet;
import java.util.Set;
import java.util.stream.Collectors;
/**
* 仓库分组业务服务(进阶版:连通分量分组模型)
*/
@Service
public class WarehouseGroupService {
@Resource
private WarehouseGroupMapper warehouseGroupMapper;
@Resource
private WarehouseGroupRelationMapper warehouseGroupRelationMapper;
/**
* 维护仓库的分组归属(核心方法)
* 逻辑:前端传递的主仓库+items中的仓库,归属于同一个分组(连通分量)
* @param dto 前端传递的关联数据
* @param operatorId 操作人ID
*/
@Transactional(rollbackFor = Exception.class)
public void maintainWarehouseGroup(WarehouseRelationDto dto, Long operatorId) {
// 1. 参数校验
if (dto == null || dto.getWarehouseId() == null) {
throw new IllegalArgumentException("主仓库ID不能为空");
}
Long mainWarehouseId = dto.getWarehouseId();
String mainWarehouseName = dto.getWarehouse();
// 2. 提取所有需要关联的仓库ID(主仓库+items中的仓库)
Set<Long> warehouseIds = new HashSet<>();
warehouseIds.add(mainWarehouseId);
if (!CollectionUtils.isEmpty(dto.getItems())) {
warehouseIds.addAll(dto.getItems().stream()
.filter(item -> item.getWarehouseExtId() != null)
.map(WarehouseRelationDto.Item::getWarehouseExtId)
.collect(Collectors.toSet()));
}
// 3. 查询这些仓库已有的分组ID(取第一个非空的分组ID,保证分组唯一)
Long groupId = null;
for (Long wid : warehouseIds) {
groupId = warehouseGroupRelationMapper.selectGroupIdByWarehouseId(wid);
if (groupId != null) {
break;
}
}
// 4. 若没有分组ID,新建分组
if (groupId == null) {
WarehouseGroup group = new WarehouseGroup();
group.setGroupName("仓库分组_" + System.currentTimeMillis()); // 可自定义分组名
group.setCreator(operatorId);
group.setUpdater(operatorId);
warehouseGroupMapper.insert(group);
groupId = group.getId();
}
// 5. 为每个仓库设置分组归属(新增/更新)
for (Long wid : warehouseIds) {
String warehouseName = getWarehouseName(dto, wid, mainWarehouseId);
// 查询仓库是否已有分组关联(未删除)
Long relationId = warehouseGroupRelationMapper.selectRelationIdByWarehouseId(wid);
if (relationId == null) {
// 新增关联
WarehouseGroupRelation relation = new WarehouseGroupRelation();
relation.setWarehouseId(wid);
relation.setWarehouse(warehouseName);
relation.setGroupId(groupId);
relation.setCreator(operatorId);
relation.setUpdater(operatorId);
warehouseGroupRelationMapper.insert(relation);
} else {
// 更新分组(若需要调整分组,此处可修改group_id)
warehouseGroupRelationMapper.updateGroupId(wid, groupId, operatorId);
}
}
// 6. 若前端传递items为空(删除节点),则逻辑删除该仓库的分组关联
if (CollectionUtils.isEmpty(dto.getItems())) {
warehouseGroupRelationMapper.logicDeleteByWarehouseId(mainWarehouseId, operatorId);
}
}
/**
* 查询仓库的所有关联节点(同分组的所有仓库)
* @param warehouseId 仓库ID
* @return 所有关联的仓库ID集合
*/
public Set<Long> listRelatedWarehouses(Long warehouseId) {
Long groupId = warehouseGroupRelationMapper.selectGroupIdByWarehouseId(warehouseId);
if (groupId == null) {
return new HashSet<>();
}
return warehouseGroupRelationMapper.selectWarehouseIdsByGroupId(groupId);
}
/**
* 辅助方法:从DTO中获取仓库名称
*/
private String getWarehouseName(WarehouseRelationDto dto, Long wid, Long mainWarehouseId) {
if (wid.equals(mainWarehouseId)) {
return dto.getWarehouse();
}
return dto.getItems().stream()
.filter(item -> wid.equals(item.getWarehouseExtId()))
.map(WarehouseRelationDto.Item::getWarehouseExt)
.findFirst()
.orElse("");
}
}
3. 分组模型的优势(针对后续库存上限合并)
- 查询高效:获取所有关联仓库只需查询同分组的仓库,无需遍历图,性能提升显著。
- 数据简洁:无需存储大量的直接边,只存储仓库与分组的关联关系,数据量大幅减少。
- 逻辑清晰:分组ID直接代表连通分量,后续库存上限合并时,只需按分组计算即可。
六、方案对比与选择建议
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 边存储+间接过滤 | 无需重构表结构,小改代码即可修复 | 性能略低(每次维护需遍历图),逻辑稍复杂 | 已有系统无法大规模重构的场景 |
| 连通分量分组模型 | 逻辑简单,性能高,彻底解决问题,适配后续库存计算 | 需要重构表结构和原有代码 | 新系统开发、无向连通图的核心场景 |
建议:
- 如果是已有系统,先使用边存储+间接过滤修复问题,后续逐步重构为分组模型。
- 如果是新系统,直接使用连通分量分组模型,为后续库存上限合并等业务打下基础。
七、总结与拓展
1. 核心知识点回顾
- 图的基础:节点、边、无向图、连通图、BFS/DFS是处理关联业务的基础。
- 业务与算法结合:在实现业务代码前,要先明确业务模型(如无向连通图),再选择合适的技术方案。
- 方案选择:边存储适合简单关联,分组模型适合连通图场景,尤其是后续有批量查询需求的业务。
2. 场景拓展
本文的思路不仅适用于仓库关联,还可以迁移到以下场景:
- 社交关系:用户的好友关系(好友的好友属于间接关联)。
- 部门关联:企业的部门之间的关联(同一个事业部的部门是连通的)。
- 设备关联:物联网设备的组网关联(同一个网络的设备是连通的)。
3. 进阶优化建议
- 缓存优化:对分组信息和关联仓库列表增加Redis缓存,提升查询性能。
- 批量操作:对于大规模仓库关联,支持批量导入和批量维护。
- 权限控制:在接口中添加用户权限校验,避免非法操作。
- 监控告警:添加接口性能监控和数据异常告警,保障系统稳定。
希望本文能帮助你将图结构的理论知识落地到实际业务中,如果你觉得有用,欢迎点赞收藏~


被折叠的 条评论
为什么被折叠?



