Spring Boot服务端的自增ID计数器在应用重新启动时与MongoDB已持久化的文档发生冲突
背景
我有一个Spring Boot应用,提供三种可互换的持久化层:内存、PostgreSQL和 MongoDB。活动的持久化层通过 app.storage 在 application.properties 中选择。我的 TreeService 通过一个简单的内存中ID计数器来管理节点创建:
java
@Service
public class TreeService {
private final TreeRepository repo;
private final TreeAlgorithmStrategy strategy;
private Long idCounter = 1L;
public Node createRoot(String value) {
Node root = new Node(idCounter++, value, null);
return repo.save(root);
}
public Node addChild(Long parentId, String value) {
Node child = new Node(idCounter++, value, parentId);
return repo.save(child);
}
}
MongoDB文档直接使用 Long ID作为文档的 _id 字段:
java
@Document(collection = "nodes")
public class MongoNodeDocument {
@Id
private Long id;
private String value;
private Long parentId;
}
观察到的行为
使用 app.storage=memory 时,一切都能正确工作,因为所有数据都是易失性的,并会随JVM重启而重置。然而,使用 app.storage=mongo 时:
- 在第一会话中,节点被正确创建并持久化,ID为 1、2、3……
- 应用重启时,
idCounter重置为1 - 再次调用
POST /nodes/root会在MongoDB中抛出重复键异常,因为已经存在具有_id: 1的文档
期望行为
ID计数器应与MongoDB中已存在的文档保持一致,这样新节点的ID就不会与已持久化的数据冲突。
我尝试过的做法
- 在
@PostConstruct方法中通过查询存储库中已有的最大ID来初始化idCounter— 能工作,但感觉有hack的嫌疑,且并非线程安全 - 让MongoDB自行生成
ObjectId— 破坏了共享的TreeRepository接口,因为内存层和PostgreSQL层都使用Long类型的ID - 使用来自JPA的
@GeneratedValue— 对MongoDB文档不可用
问题
在一个多持久化的Spring Boot应用中,所有存储库共享同一个接口,且使用 Long 作为ID类型,如何以最干净的策略将服务层的ID计数器与MongoDB文档进行同步?ID生成的职责应该从服务转移到每个存储库实现吗?
解决方案
是的,将ID生成的职责移交给存储库实现(仓库驱动)将是最佳策略。服务不应该管理顺序ID的状态或计数器。它属于持久化层。你的 TreeService 应保持对数据库无关,并避免生命周期管理方面的错误。
首先,定义你的领域模型和接口:
public interface TreeRepository
{
Node save(Node node);
// ...
}
然后,在实体保存之前,使用一个 AbstractMongoEventListener 进行拦截,它将在后台处理ID的生成,同时让你的 MongoDB 存储库接口保持完全标准:
@Component
@ConditionalOnProperty(name = "app.storage", havingValue = "mongo")
public class MongoBeforeConvertListener extends AbstractMongoEventListener<MongoNodeDocument>
{
// Uses the findAndModify sequence tool
private final MongoSequenceGenerator sequenceGenerator;
public MongoBeforeConvertListener(MongoSequenceGenerator sequenceGenerator)
{
this.sequenceGenerator = sequenceGenerator;
}
@Override
public void onBeforeConvert(BeforeConvertEvent<MongoNodeDocument> event)
{
MongoNodeDocument doc = event.getSource();
if (doc.getId() == null)
doc.setId(sequenceGenerator.generateSequence("nodes_sequence"));
}
}
接着,让内存中的存储库在本地管理自己对 AtomicLong 的线程安全计数器:
@Repository
@ConditionalOnProperty(name = "app.storage", havingValue = "memory")
public class InMemoryTreeRepository implements TreeRepository
{
private final Map<Long, Node> storage = new ConcurrentHashMap<>();
private final AtomicLong idCounter = new AtomicLong(1L);
@Override
public Node save(Node node)
{
// Assign next ID automatically inside the repo
if (node.getId() == null)
node = new Node(idCounter.getAndIncrement(), node.getValue(), node.getParentId());
storage.put(node.getId(), node);
return node;
}
}
并用 @GeneratedValue(strategy = GenerationType.SEQUENCE) 对实体进行映射,使得 Hibernate 和 PostgreSQL 能在 repo.save() 上原生协同生成序列:
import jakarta.persistence.*;
@Entity
@Table(name = "nodes")
public class PostgresNodeEntity
{
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "node_seq_gen")
@SequenceGenerator(
name = "node_seq_gen",
sequenceName = "nodes_id_seq",
allocationSize = 1
)
private Long id;
private String value;
@Column(name = "parent_id")
private Long parentId;
// Constructors
public PostgresNodeEntity() {}
public PostgresNodeEntity(Long id, String value, Long parentId)
{
this.id = id;
this.value = value;
this.parentId = parentId;
}
// Getters / setters...
}
最后,你的服务方法将大致如下:
public Node addChild(Long parentId, String value)
{
// ID starts as null
Node child = new Node(null, value, parentId);
// The specialized repo assigns the ID safely
return repo.save(child);
}