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