工作单元模式:让数据变更像事务一样可控

工作单元模式,说白了就是帮我们管理「一堆要做的数据操作」。

我刚开始做企业级应用时,经常遇到这样的场景:用户填了个表单,点了保存,结果后台要更新订单表、扣库存、写日志、发消息……如果每个操作都单独提交,中间任何一个环节失败,数据就乱套了。

工作单元模式就是来解决这个问题的。它把一组相关的数据变更打包在一起,要么全部成功,要么全部回滚。嗯,你可以把它理解成「内存里的事务管理器」。

核心概念:注册、脏检查、提交与回滚

工作单元模式有四个核心动作,我一个个说。

1. 注册对象

当你从数据库里加载一个实体,或者新建一个实体时,工作单元会把它「注册」到自己的管理列表中。注册时通常会标记对象的状态:新建(New)、脏(Dirty)、干净(Clean)、已删除(Deleted)。

我个人习惯在仓储层(Repository)里自动完成注册。每次调用仓储的查询方法,返回的实体自动进入当前工作单元的管理范围。

2. 脏检查

脏检查是工作单元最核心的机制。它定期扫描所有已注册的对象,看看哪些属性被修改了。

怎么判断脏?最简单的方式是保存对象的原始快照。每次注册时,把对象的字段值复制一份存起来。提交时,拿当前值和快照对比,有变化的就是脏对象。

我在项目中遇到过一个问题:如果实体有几十个字段,每次都全量快照,内存开销不小。后来我们改用「属性变更追踪」——在实体的 setter 方法里记录哪些字段被改过。这样脏检查就变成了 O(1) 的查询,而不是 O(n) 的对比。

3. 提交

提交时,工作单元遍历所有已注册的对象,按照「新建→更新→删除」的顺序生成 SQL 语句,然后在一个数据库事务里执行。

为什么要按这个顺序?因为新建的记录可能产生主键,更新和删除可能依赖这个主键。顺序错了,外键约束就炸了。

4. 回滚

回滚相对简单——清空工作单元内部的所有变更记录,丢弃脏检查的结果。数据库那边的事务回滚由外层控制。

我曾经踩过一个坑:工作单元的回滚只清内存,但数据库连接的事务没回滚,导致数据半提交。后来我强制要求:工作单元的提交/回滚必须和数据库事务的生命周期绑定。

核心要点:工作单元的本质是「延迟提交」。它把多次数据变更聚合成一次原子操作,避免频繁的数据库交互。

代码示例:Java 实现一个简单的工作单元

下面这个例子,我手写了一个轻量级的工作单元。它支持注册、脏检查、提交和回滚。

// 实体接口
public interface IEntity {
    Long getId();
    void setId(Long id);
}

// 实体状态
public enum EntityState {
    NEW, DIRTY, CLEAN, DELETED
}

// 工作单元
public class UnitOfWork {
    private final Map<IEntity, EntityState> entities = new HashMap<>();
    private final Map<IEntity, Map<String, Object>> snapshots = new HashMap<>();
    
    // 注册新对象
    public void registerNew(IEntity entity) {
        entities.put(entity, EntityState.NEW);
        takeSnapshot(entity);
    }
    
    // 注册脏对象(通常由仓储层调用)
    public void registerDirty(IEntity entity) {
        if (!entities.containsKey(entity)) {
            entities.put(entity, EntityState.DIRTY);
            takeSnapshot(entity);
        } else {
            entities.put(entity, EntityState.DIRTY);
        }
    }
    
    // 注册已删除对象
    public void registerDeleted(IEntity entity) {
        entities.put(entity, EntityState.DELETED);
    }
    
    // 脏检查:对比快照
    public boolean isDirty(IEntity entity) {
        Map<String, Object> snapshot = snapshots.get(entity);
        if (snapshot == null) return false;
        // 这里用反射或字段访问器对比每个字段
        // 简化实现:假设实体有 getFields() 方法
        Map<String, Object> current = entity.getFields();
        return !snapshot.equals(current);
    }
    
    // 提交
    public void commit() {
        // 按顺序处理:NEW → DIRTY → DELETED
        for (Map.Entry<IEntity, EntityState> entry : entities.entrySet()) {
            IEntity entity = entry.getKey();
            EntityState state = entry.getValue();
            switch (state) {
                case NEW:
                    // 执行 INSERT
                    System.out.println("INSERT " + entity);
                    break;
                case DIRTY:
                    // 执行 UPDATE
                    System.out.println("UPDATE " + entity);
                    break;
                case DELETED:
                    // 执行 DELETE
                    System.out.println("DELETE " + entity);
                    break;
            }
        }
        // 提交后清空
        entities.clear();
        snapshots.clear();
    }
    
    // 回滚
    public void rollback() {
        entities.clear();
        snapshots.clear();
        System.out.println("工作单元已回滚");
    }
    
    private void takeSnapshot(IEntity entity) {
        // 深拷贝实体的字段
        snapshots.put(entity, new HashMap<>(entity.getFields()));
    }
}
提示:实际项目中,脏检查通常不会每次提交都全量扫描。JPA 的实现里,只有当事务提交时,持久化上下文才会执行脏检查。这样可以减少不必要的性能开销。

JPA 中的持久化上下文:工作单元的最佳实践

如果你用过 JPA(Java Persistence API),那你其实每天都在用工作单元模式。JPA 的持久化上下文(Persistence Context)就是工作单元的一个经典实现。

持久化上下文的核心机制是这样的:

  • 实体管理(EntityManager):每个 EntityManager 实例维护一个持久化上下文。它负责管理所有被持久化的实体实例。
  • 自动脏检查:当事务提交时,EntityManager 会扫描所有受管实体,自动检测哪些属性被修改了。你不需要手动调用 registerDirty。
  • 一级缓存:持久化上下文也是 JPA 的一级缓存。同一个 EntityManager 内,多次查询同一个 ID 的实体,只会发一次 SQL。
  • 提交时刷新:调用 EntityManager.flush() 时,会把所有变更同步到数据库。但注意,flush 不等于事务提交,它只是把 SQL 发出去。

我举个例子,你感受一下:

@Transactional
public void updateOrder(Long orderId, String newStatus) {
    // EntityManager 自动开启持久化上下文
    Order order = entityManager.find(Order.class, orderId);
    order.setStatus(newStatus);  // 这里自动标记为脏
    
    // 方法结束时,事务提交
    // EntityManager 自动执行脏检查,生成 UPDATE 语句
    // 如果中间抛出异常,事务回滚,持久化上下文也回滚
}

你看,整个过程你只需要修改实体属性,脏检查和提交都是自动的。这就是工作单元模式在 JPA 里的体现。

注意事项:持久化上下文有生命周期。在 Spring 中,默认的 @Transactional 注解会绑定一个 EntityManager 到当前线程。如果你在事务外修改实体,修改不会被自动检测。我曾经见过同事在 Service 层之外修改了实体属性,结果数据没更新,排查了半天才发现是持久化上下文已经关闭了。

工作单元模式的核心逻辑图

下面这张图,我画了工作单元模式的完整流程。从注册对象开始,到脏检查,再到提交或回滚。你一看就明白。

工作单元模式核心流程 1. 注册对象 registerNew/registerDirty 2. 脏检查 对比快照 vs 当前值 提交? 是/否 提交 NEW→UPD→DEL 回滚 清空变更记录 数据库事务 BEGIN / COMMIT / ROLLBACK JPA 持久化上下文 自动脏检查 + 一级缓存 + 延迟写入 EntityManager 生命周期绑定 工作单元模式 = 注册对象 + 脏检查 + 提交/回滚 + 持久化上下文

工作单元模式的应用场景

不是所有项目都需要工作单元模式。我总结了几种典型场景:

场景 是否推荐 原因
单体应用 + 关系型数据库 强烈推荐 JPA/Hibernate 已经内置,开箱即用
微服务 + 独立数据库 推荐 每个服务内部使用,跨服务用 Saga 模式
纯 NoSQL 场景 视情况而定 如果 NoSQL 不支持事务,工作单元只能保证内存一致性
批量数据导入 推荐 可以显著减少数据库交互次数,提升性能
我的建议:如果你在用 Spring Data JPA,其实不需要自己实现工作单元。EntityManager 已经帮你做好了。但如果你用的是 MyBatis 或者纯 JDBC,自己封装一个轻量级的工作单元会很有帮助。我曾在 MyBatis 项目里手写过类似上面的代码,效果不错。

避坑指南

最后分享几个我踩过的坑:

  • 不要跨线程共享工作单元:工作单元不是线程安全的。每个线程或每个请求应该有自己的实例。Spring 的 @Transactional 默认绑定到当前线程,这个设计很合理。
  • 注意大对象的脏检查性能:如果一个实体有几十个字段,每次提交都全量对比,性能会下降。我建议用属性变更追踪,或者只对比关键字段。
  • 回滚时要清理干净:工作单元的回滚不仅要清内存,还要确保数据库连接的事务也回滚了。我曾经因为忘记回滚事务,导致脏数据写入了数据库,排查了一整天。
  • 不要滥用延迟提交:工作单元虽然能减少数据库交互,但如果长时间不提交,内存里会堆积大量变更记录。我建议每个业务操作完成后及时提交,不要跨请求持有工作单元。

工作单元模式,说白了就是帮我们把零散的数据操作「攒」起来,一次性处理。它不是什么高深的技术,但用好了,能让你的代码更干净、数据更一致。嗯,希望今天的分享对你有帮助。


公众号:蓝海资料掘金营,微信deep3321