MongoDB迁移之后,Spring Data 和 Hibernate 各自接哪类请求

0 阅读7分钟

MongoDB迁移到 KingbaseES,代码改造量往往不由集合数量决定,而由应用实际调用了哪些 MongoDB API 决定。Repository 派生查询、聚合管道、批量写入、事务会话和索引管理,验证范围并不相同;驱动连通,只能说明连接层已经打通。

一个 Java 服务里常常不只有一种数据访问方式。文档接口沿用 Spring Data MongoDB,订单和用户实体由 Hibernate 管理,运营报表又需要 SQL 读取文档字段。把框架名字列在技术栈里,不能替代逐项回归。电科金仓的MongoDB 平滑迁移方案介绍了兼容能力和迁移路径;落到应用时,还要检查当前驱动、认证方式、调用 API 与目标版本的组合。

从代码里找出真实调用

迁移评审可以先从 Repository 和 MongoTemplate 的使用点开始。派生查询看起来简单,实际还可能带排序、分页、空值判断和嵌套字段路径。@Query 中的 JSON 片段、聚合管道、批量更新与事务会话要单独登记,不能由一个“基础 CRUD 通过”代替。

例如下面的 Repository 只覆盖按 SKU 和状态查找。分页是否稳定,还取决于排序字段能否唯一确定结果顺序;如果只按状态分页,翻页时同一条记录可能在相邻页重复出现。

public interface CatalogRepository
        extends MongoRepository<CatalogDocument, String> {

    Optional<CatalogDocument> findBySku(String sku);

    Page<CatalogDocument> findByPublishState(
            String publishState, Pageable pageable);
}

Pageable 查询的回归样例应固定总数、排序字段、边界页和空结果。若业务要求按更新时间倒序,建议再用 SKU 作为次级排序键:

Pageable page = PageRequest.of(
        pageNumber,
        pageSize,
        Sort.by(Sort.Order.desc("updatedAt"),
                Sort.Order.asc("sku")));

Page<CatalogDocument> result = catalogRepository
        .findByPublishState("PUBLISHED", page);

另一个常见入口是 MongoTemplate。它通常承接动态条件、更新语句和聚合查询,代码搜索时容易被忽略。下面的条件由租户、状态和嵌套属性组成,测试时要分别覆盖字段存在、字段缺失、数组为空和数值类型不匹配等文档:

Query query = Query.query(
        Criteria.where("tenantId").is(tenantId)
                .and("publishState").is("PUBLISHED")
                .and("attributes.warranty.months").gte(24))
        .with(Sort.by(Sort.Order.asc("sku")))
        .limit(200);

List<CatalogDocument> rows = mongoTemplate.find(
        query, CatalogDocument.class, "catalog");

这种调用清单比“Spring Data 支持”几个字更有用。迁移前可以按方法记录集合、操作类型、是否使用事务、输入边界和预期结果。应用日志中的慢查询也要纳入,低频的月末批处理不一定会出现在日常联调流量里。

Spring Data 保留文档访问习惯

Spring Data MongoDB 的 Repository 和模板接口能让现有服务继续用熟悉的对象模型处理文档。连接配置仍要对应目标端给出的服务地址、认证库和 TLS 要求;账号密码不能写在源码或日志里,配置注入方式沿用项目的密钥管理机制。连接成功后,先验证读写、更新条件、删除范围和异常返回,再跑业务层测试。

更新尤其需要关注匹配范围。下例只根据 SKU 更新一个字段,调用后应检查匹配数与修改数;若业务要求“没有找到就创建”,则要单独验证 upsert 行为,不能把普通更新成功当成插入也正确。

Query filter = Query.query(
        Criteria.where("tenantId").is(tenantId)
                .and("sku").is(sku));
Update update = new Update()
        .set("publishState", "PUBLISHED")
        .currentDate("updatedAt");

UpdateResult result = mongoTemplate.updateFirst(
        filter, update, CatalogDocument.class, "catalog");

if (result.getMatchedCount() != 1) {
    throw new IllegalStateException("catalog record not found");
}

数组更新、正则条件、聚合管道和批量写入要按应用实际用法验证。文档数据库的表达能力很宽,兼容框架不意味着所有操作符、驱动版本和参数组合都自动一致。测试样例应来自代码调用点,而不是只用一组手写的 find 和 save 做冒烟。

Repository、模板与 ORM 的调用边界

Hibernate 管关系实体,事务边界写在服务层

Hibernate 适合继续处理结构明确、需要关系约束的实体。实体类映射到目标端的表,外键、唯一约束和事务仍由关系数据库连接管理。下面的实体示例只包含订单主数据,不把文档里的任意嵌套属性都塞进 Java 字段:

@Entity
@Table(name = "sales_order")
public class SalesOrder {
    @Id
    @Column(name = "order_id")
    private Long orderId;

    @Column(name = "customer_id", nullable = false)
    private Long customerId;

    @Column(name = "order_status", nullable = false, length = 20)
    private String orderStatus;

    @Column(name = "created_at", nullable = false)
    private LocalDateTime createdAt;
}

需要把订单与文档状态一起改变时,先确认两个框架实际使用的事务管理器。一个 @Transactional 注解通常只覆盖它绑定的事务管理器;不能因为两个连接地址属于同一套数据库产品,就默认两种客户端 API 已经加入同一个原子事务。

@Transactional(transactionManager = "relationalTxManager")
public void createOrder(SalesOrder order) {
    orderRepository.save(order);
    outboxRepository.save(new OutboxEvent(
            order.getOrderId(), "ORDER_CREATED"));
}

如果文档写入失败会影响订单流程,可以把待处理事件先写进关系事务的 outbox,由单独消费者完成文档侧更新,再将处理状态落账。消费者要按事件编号幂等处理;失败时保留重试次数、错误摘要和最后尝试时间。这样故障恢复有记录可查,也不会把两个事务管理器的边界藏在一个服务方法里。

SQL 负责另一类读取,不代替框架回归

运营分析偶尔需要直接从 SQL 读取文档字段,例如按嵌套属性筛选商品。文档存储结构可以保留在 JSON 列中,SQL/JSON 函数负责从路径读取值。以下字段和数据是演示用的最小模型,JSON_VALUE 的具体可用语法应按目标版本和兼容模式确认:

CREATE TABLE catalog_document (
    sku          VARCHAR(40) PRIMARY KEY,
    document_body JSON NOT NULL,
    updated_at   TIMESTAMP NOT NULL
);

SELECT sku,
       JSON_VALUE(document_body, '$.attributes.color') AS color,
       CAST(JSON_VALUE(document_body,
                       '$.attributes.warrantyMonths') AS INTEGER)
           AS warranty_months
FROM catalog_document
WHERE CAST(JSON_VALUE(document_body,
                      '$.attributes.warrantyMonths') AS INTEGER) >= 24
ORDER BY updated_at DESC, sku;

这类 SQL 适合报表、批量核对和数据治理,不表示 Java 业务调用必须全部改成 JDBC。框架层负责领域对象的写入和应用规则,SQL 查询负责需要明确筛选口径的分析任务。路径拼错、数字被存成字符串、字段缺失等情况都要有回归数据;条件值使用参数绑定,避免把用户输入拼成 SQL。

迁移后的数据核验也可以用 SQL 先做轮廓检查:

SELECT JSON_VALUE(document_body, '$.publishState') AS publish_state,
       COUNT(*) AS document_count,
       MAX(updated_at) AS latest_update
FROM catalog_document
GROUP BY JSON_VALUE(document_body, '$.publishState')
ORDER BY publish_state;

总数和分组分布适合发现漏批次,不能证明每个嵌套数组和日期字段都正确。抽样时应按业务主键回到源文档,对照目标 JSON、框架反序列化对象和 SQL 取值。高风险集合再补全量主键校验、应用回归和写入期间的数据一致性检查。

验收清单落到方法,而不是框架名称

一个应用的验收表可以按操作类型拆开:Repository 派生查询、模板查询、更新和删除、批量写入、事务会话、索引操作、SQL/JSON 查询。每一项都记录驱动版本、认证方式、目标数据库版本、样例输入和核验结果。对还没被业务代码调用的 API,不需要为了“功能齐全”全部测试;对正在生产使用的低频分支,也不能因为平时很少触发而跳过。

灰度期间可先将查询结果做抽样比对,写操作按业务主键检查影响行数和最终状态。日志里只保留必要的请求标识、集合名、操作类型和耗时,不记录凭证或完整敏感文档。遇到差异时,先固定一条源记录和一组调用参数,再判断是驱动映射、字段类型、API 行为还是业务代码假设导致。

Spring Data 和 Hibernate 可以在同一应用中承担不同工作:前者维持文档访问模型,后者管理关系实体,SQL/JSON 查询处理明确的文档分析需求。切换完成的标准仍是当前服务实际调用过的路径都通过回归,而不是依赖项换成了兼容驱动。

字段映射要按业务值核对

迁移文档时,字段名相同不代表 Java 对象读出来的值就相同。MongoDB 文档中的 ObjectId、日期、Decimal128、数组和缺失字段,都可能映射到 Java 中不同的类型或默认值。尤其是金额、精确时间和可空字段,不能只比较 JSON 文本长得像不像。

文档字段情况Java 侧核对点目标数据核对点
ObjectId 或业务主键字符串长度、空值处理、对象引用主键是否唯一,引用是否仍指向同一对象
日期和时区Instant、Date、LocalDateTime 的转换规则是否统一时区,毫秒精度是否保留
Decimal128 金额BigDecimal 精度与舍入策略小数位、边界值及汇总金额
缺失字段与显式 null反序列化后的默认值查询条件是否把两者区分开
数组与嵌套对象泛型、空数组和未知字段数组顺序、元素数量和嵌套路径

这张表不是要把每个文档字段都改成关系列,而是把应用依赖的类型语义列出来。源文档里没有 status 和 status: null 可能代表两种业务状态;若 Java DTO 将两者都初始化为空字符串,迁移后某些过滤条件就会改变。金额字段则要拿最大值、最小值和边界小数单独测试,防止数据库侧精度合适、Java 侧却先被 double 舍入。

主键也要明确策略。若源端用 ObjectId,目标端使用字符串保存时要保留可逆的原始标识;若应用另有业务编码,应确认它才是跨系统关联键。迁移过程中生成的新标识不能被误当作业务键传回老接口,否则缓存、消息和外部链接都会出现无法匹配的记录。

灰度读取比一次性切换更容易定位差异

能双读的服务可以挑固定的一小段业务请求,同时读取源端与目标端,比较业务字段和排序结果;不适合双写的场景,可以在测试环境回放同一份请求样本。比较内容应包括命中数、主键集合、金额合计、更新时间和空字段分布。只比总行数,会漏掉一条记录丢失、另一条重复导入后数量刚好抵消的问题。

目标端 SQL 可以先做基础分布核验:

SELECT COUNT(*) AS document_count,
       COUNT(DISTINCT sku) AS distinct_sku_count,
       MIN(updated_at) AS earliest_update,
       MAX(updated_at) AS latest_update
FROM catalog_document;

SELECT JSON_VALUE(document_body, '$.publishState') AS publish_state,
       COUNT(*) AS rows_in_state
FROM catalog_document
GROUP BY JSON_VALUE(document_body, '$.publishState')
ORDER BY publish_state;

如果总数与去重主键数不一致,先查重复键处理;如果状态分布不同,抽样查看源文档、目标 JSON 和 Java 反序列化对象。查询样本应覆盖空字段、历史版本中的文档和刚更新记录,避免只抽取结构最规整的常见数据。

灰度期间还要区分读取和写入。查询流量可以按租户或业务键分批切换,写入切换前则要确认增量数据的最终同步位置。原服务仍在写源端时,目标端结果只能算某一时刻的快照;需要在切换窗口记录增量追平点,之后再停止源端写入或启用经过验证的写入策略。

若某个调用在目标端仍有差异,灰度时只回退对应接口或租户,并保持源端继续接收写入。两端同时接收新写入会让下一轮增量核对多出重复和冲突。

框架升级和数据库迁移分开排查

如果迁移时同时升级 Spring Boot、Spring Data、Hibernate 和 JDBC 驱动,接口异常就很难归因。排期允许时,先固定框架和驱动版本完成数据库兼容回归,再做框架升级;必须一起升级时,至少保存升级前依赖树、连接配置和关键 Repository 测试结果。

前面的 MongoTemplate 查询若源端有结果、目标端为空,先拿同一组 tenantId 和 sku 重放。去掉 warranty.months 条件后结果恢复,就检查这个字段在源文档和目标 JSON 中的类型;仍为空,再核对 publishState 和租户条件。找到具体差异后,再查对应的驱动请求和目标端返回值。Hibernate 实体映射异常则核对 JDBC 方言、列类型和约束。 重放时把这组入参记下来,避免两端实际查的不是同一份数据。

生产切换前,在预发布环境重建一次连接池,确认新连接确实指向目标端;月末批处理和异常补偿也单独回放,常用接口联调不一定会经过这些分支。切换窗口以增量追平点为界,按业务主键核对目标文档和更新时间,确认数据跟上后再移除旧连接配置。