1. 概述

Spring Data 的 CrudRepository#save 方法用起来确实简单粗暴✅,但有个“坑”:它会更新表中所有字段。这其实是 CRUD 中 “U”(Update)的默认语义,但如果我们只想做类似 HTTP PATCH 的局部更新呢?

本文将介绍几种在 Spring Data 中实现部分更新(Partial Update)的实用技巧,避免全量覆盖带来的性能损耗和逻辑风险。

2. 问题背景

save() 方法的行为是:用传入的对象完全覆盖数据库中匹配的实体。这意味着你无法只传几个字段做更新——哪怕其他字段是 null,也会被写入数据库,导致数据丢失。

对于字段较多的实体,这种全量更新非常不友好。

常见的“取巧”方式有两种:

  • 使用 Hibernate 的 @DynamicUpdate 注解,让生成的 SQL 只包含实际修改过的字段
  • 在 JPA 的 @Column 上设置 updatable = false,禁止某些字段被更新

⚠️ 但这些方式都有明显缺点:把业务逻辑耦合到了实体类上,且是全局生效的。比如加了 @DynamicUpdate,所有对该实体的更新都会变成动态 SQL,失去可控性。

本文的目标是:不依赖实体类注解,灵活实现局部更新

3. 示例场景

我们以一个简单的 Customer(客户)实体为例:

@Entity 
public class Customer {
    @Id 
    @GeneratedValue(strategy = GenerationType.AUTO)
    public long id;
    public String name;
    public String phone;
}

配套的 Repository:

@Repository 
public interface CustomerRepository extends CrudRepository<Customer, Long> {
    Customer findById(long id);
}

以及一个基础 Service:

@Service 
public class CustomerService {
    @Autowired 
    CustomerRepository repo;

    public void addCustomer(String name) {
        Customer c = new Customer();
        c.name = name;
        repo.save(c);
    }    
}

接下来,我们围绕如何更新 phone 字段展开讨论。

4. 先查后存(Load and Save)

最常见、最直观的方案:先从数据库查出实体,再修改字段,最后保存

public void updateCustomer(long id, String phone) {
    Customer myCustomer = repo.findById(id);
    myCustomer.phone = phone;
    repo.save(myCustomer);
}

✅ 优点:

  • 简单易懂,适合字段少、逻辑简单的场景
  • 利用 JPA 一级缓存,性能尚可

❌ 缺点:

  • 如果实体字段很多,每次都要查全量数据,浪费资源
  • 并发环境下可能覆盖其他字段(比如另一个线程改了 name,这里会被覆盖)

4.1. 结合 DTO 与 MapStruct

当对象字段众多时,手动 set/get 不现实。此时推荐使用 DTO + MapStruct 的映射策略。

定义一个 CustomerDto

public class CustomerDto {
    private long id;
    public String name;
    public String phone;
    //...
    private String phone99;
}

创建 CustomerMapper,使用 @MappingTarget 实现对象合并

@Mapper(componentModel = "spring")
public interface CustomerMapper {
    @BeanMapping(nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE)
    void updateCustomerFromDto(CustomerDto dto, @MappingTarget Customer entity);
}

关键点:

  • @MappingTarget:指定目标对象,实现“更新已有对象”而非创建新对象
  • NullValuePropertyMappingStrategy.IGNORE:忽略 null 值,避免把 null 写入数据库

在 Service 中使用:

@Service
public class CustomerService {
    @Autowired 
    CustomerRepository repo;
    @Autowired 
    CustomerMapper mapper;

    public void updateCustomer(CustomerDto dto) {
        Customer myCustomer = repo.findById(dto.id);
        mapper.updateCustomerFromDto(dto, myCustomer);
        repo.save(myCustomer);
    }
}

✅ 效果:只更新 DTO 中非 null 的字段,其他字段保持原值。

⚠️ 注意:这种方式无法真正传 null。如果你希望把某个字段更新为 null,此方案不适用。

4.2. 实体拆分:小而美

更根本的解法:从设计上避免大实体

比如,把 Customer 中的多个 phone 字段拆成独立的 ContactPhone 实体,建立一对多关系:

@Entity 
public class CustomerStructured {
    @Id 
    @GeneratedValue(strategy = GenerationType.AUTO)
    public Long id;
    public String name;
    
    @OneToMany(fetch = FetchType.EAGER, targetEntity = ContactPhone.class, mappedBy = "customerId")    
    private List<ContactPhone> contactPhones;
}

这样,更新联系方式时只需操作 ContactPhone 表,无需加载整个 Customer

✅ 优势:

  • 职责清晰,易于维护
  • 更新粒度更细,性能更好

⚠️ 建议:实体设计时就考虑“高内聚、低耦合”,但也要避免过度设计(over-engineering)。

5. 自定义查询(Custom Query)

如果你只想更新一两个字段,直接写 SQL 是最高效的方式

JPA 提供了 @Query@Modifying 注解,支持自定义更新语句。

在 Repository 中添加方法:

@Modifying
@Query("UPDATE Customer u SET u.phone = :phone WHERE u.id = :id")
void updatePhone(@Param("id") long id, @Param("phone") String phone);

调用方式:

public void updateCustomerWithCustomQuery(long id, String phone) {
    repo.updatePhone(id, phone);
}

✅ 优点:

  • SQL 精确控制,性能最佳
  • 不查数据,直接更新,避免并发覆盖问题
  • 不依赖实体状态

❌ 缺点:

  • 每个字段组合都要写一个方法,维护成本高
  • 绕过 JPA 一级缓存,需手动处理缓存一致性

💡 建议:适合字段少、更新逻辑固定的场景,比如“更新用户最后登录时间”。

6. 总结

方案 适用场景 是否支持 null 更新 推荐指数
先查后存 + MapStruct 字段多,需灵活更新 ⭐⭐⭐⭐
实体拆分 关联数据多,高并发 ⭐⭐⭐⭐⭐
自定义 Query 单字段高频更新 ⭐⭐⭐⭐

选择哪种方案,取决于你的业务场景:

  • 字段不多?直接 findById + save 就行。
  • 字段多且常局部更新?上 MapStruct
  • 实体太“胖”?赶紧拆表
  • 更新频率极高?写 @Query 最稳。

所有示例代码已上传至 GitHub:https://github.com/yourname/spring-data-partial-update-demo


原始标题:Partial Data Update With Spring Data