Java案例怎么校验数据一致性?

wen java案例 79

本文目录导读:

Java案例怎么校验数据一致性?

  1. 并发环境下的数据一致性(悲观锁 vs 乐观锁)
  2. 缓存与数据库的一致性(Cache Aside Pattern)
  3. 分布式系统间的一致性(TCC / Saga / 最终一致性)
  4. 数据库主从延迟导致的不一致
  5. 数据一致性校验工具(对账)
  6. 总结与选型建议

在Java中校验数据一致性,通常指的是确保在不同系统、不同数据库或缓存之间数据保持一致,或者在并发环境下数据不发生冲突(如丢失更新、脏读等)。

核心思路分为单系统内分布式系统间两大类,下面从最常见的场景出发,给出具体的Java案例和实现方案。


并发环境下的数据一致性(悲观锁 vs 乐观锁)

这是最基础的场景,如多人同时抢购商品、修改订单。

悲观锁(数据库行锁)

适合写冲突非常频繁的场景,使用 SELECT ... FOR UPDATE 锁住数据行,直到事务结束。

Java + MyBatis 案例:

@Service
public class InventoryService {
    @Autowired
    private InventoryMapper inventoryMapper;
    @Transactional
    public boolean deductStockWithPessimisticLock(Long productId, int quantity) {
        // 1. 加锁查询(FOR UPDATE)
        Inventory inventory = inventoryMapper.selectForUpdate(productId);
        // 2. 校验一致性:库存是否足够
        if (inventory.getStock() < quantity) {
            throw new RuntimeException("库存不足");
        }
        // 3. 更新库存
        inventory.setStock(inventory.getStock() - quantity);
        return inventoryMapper.updateById(inventory) > 0;
    }
}

Mapper 定义:

<select id="selectForUpdate" resultType="Inventory">
    SELECT * FROM inventory WHERE product_id = #{productId} FOR UPDATE
</select>

注意: 必须在 @Transactional 事务内执行,否则锁无效。

乐观锁(版本号或CAS)

适合读多写少的情况,通过在表中增加 version 字段,更新时检查版本号。

表结构:

CREATE TABLE inventory (
    product_id BIGINT PRIMARY KEY,
    stock INT,
    version INT DEFAULT 0
);

Java 案例:

@Transactional
public boolean deductStockWithOptimisticLock(Long productId, int quantity) {
    // 1. 读取数据(不锁行)
    Inventory inventory = inventoryMapper.selectById(productId);
    int currentVersion = inventory.getVersion();
    // 2. 业务校验
    if (inventory.getStock() < quantity) {
        throw new RuntimeException("库存不足");
    }
    // 3. 更新时校验版本号(CAS思想)
    int affectedRows = inventoryMapper.updateStockAndVersion(
        productId, 
        inventory.getStock() - quantity, 
        currentVersion, 
        currentVersion + 1
    );
    // 4. affectedRows == 0,说明版本号已被修改
    if (affectedRows == 0) {
        throw new OptimisticLockException("数据已被其他线程修改,请重试");
    }
    return true;
}

Mapper XML:

<update id="updateStockAndVersion">
    UPDATE inventory 
    SET stock = #{newStock}, version = #{newVersion}
    WHERE product_id = #{productId} AND version = #{oldVersion}
</update>

缓存与数据库的一致性(Cache Aside Pattern)

最典型的场景是Redis缓存与MySQL数据库之间的数据同步。

基本原则:写DB后删缓存,读时先读缓存

方案描述:

  • 读取流程: 先读Redis,命中则返回;未命中则读MySQL,然后写入Redis并设置过期时间。
  • 写入流程(更新数据): 先更新MySQL,再删除Redis缓存(不是更新缓存)。

Java 案例:

@Service
public class UserService {
    @Autowired
    private RedisTemplate<String, User> redisTemplate;
    @Autowired
    private UserMapper userMapper;
    // 读取:先查缓存
    public User getUserById(Long userId) {
        String key = "user:" + userId;
        User user = redisTemplate.opsForValue().get(key);
        if (user == null) {
            // 缓存未命中,查数据库
            user = userMapper.selectById(userId);
            if (user != null) {
                // 写入缓存(设置合理的过期时间,如1小时)
                redisTemplate.opsForValue().set(key, user, 1, TimeUnit.HOURS);
            }
        }
        return user;
    }
    // 写入:更新数据库,删除缓存
    @Transactional
    public boolean updateUser(User user) {
        // 1. 更新数据库
        int result = userMapper.updateById(user);
        if (result > 0) {
            // 2. 删除缓存(不要更新缓存,让下次读取时重建)
            String key = "user:" + user.getId();
            redisTemplate.delete(key);
        }
        return result > 0;
    }
}

常见问题:

  • 为什么是删除缓存而不是更新? 因为更新操作可能导致缓存中存在版本不一致的中间数据,而删除后下一次读取会重建最新数据,更加安全。
  • 如何避免并发下的脏读? 极端情况下,线程A更新DB后、删除缓存前,线程B读取了旧缓存,这种问题可通过延迟双删异步队列缓解(但通常业务上可接受短暂不一致)。

分布式系统间的一致性(TCC / Saga / 最终一致性)

跨服务、跨数据库时,无法使用数据库本地事务,需要分布式事务方案。

本地消息表 + 消息队列(最终一致性)

适用于对实时一致性要求不高的场景(如订单创建后发短信通知)。

流程:

  1. 主业务(如订单系统)执行本地事务:创建订单 + 写入“发短信”消息表(同一数据库)。
  2. 通过定时任务或消息队列消费消息表记录,调用下游服务(短信系统)。
  3. 下游服务处理成功后删除消息表记录;失败则重试。

TCC(Try-Confirm-Cancel)

适用于强一致性、高成功率场景(如跨行转账)。

Java 示例(使用Seata框架):

@LocalTCC
public interface AccountService {
    @TwoPhaseBusinessAction(name = "transfer", commitMethod = "confirm", rollbackMethod = "cancel")
    void tryDecrease(@BusinessActionContextParameter(paramName = "accountId") Long accountId,
                     @BusinessActionContextParameter(paramName = "amount") BigDecimal amount);
    boolean confirm(BusinessActionContext ctx);
    boolean cancel(BusinessActionContext ctx);
}

具体实现中,try阶段预留资源(冻结金额),confirm阶段真正扣减,cancel阶段释放资源(解冻金额)。


数据库主从延迟导致的不一致

场景: 写主库后立即查询从库,可能读到旧数据。

解决方案:

  1. 强制读主库:刚写完数据的请求,下一次读取强制走主库。
  2. 延迟查询:写入后等待几百毫秒再查询。
  3. 使用中间件:如ShardingSphere的hint-force-master

Java 代码片段(使用读写分离中间件):

// 写入后,通过Hint强制后续查询走主库
HintManager hintManager = HintManager.getInstance();
hintManager.setWriteRouteOnly();
// 执行查询...
hintManager.close();

数据一致性校验工具(对账)

用于离线检测数据是否一致,如每天检查缓存与数据库、A系统与B系统的数据是否对得上。

案例: 校验缓存与数据库的库存是否一致

@Component
public class DataConsistencyChecker {
    // 定时任务,每天凌晨执行
    @Scheduled(cron = "0 0 3 * * ?")
    public void checkInventoryConsistency() {
        List<Long> productIds = inventoryMapper.selectAllProductIds();
        for (Long productId : productIds) {
            int dbStock = inventoryMapper.selectStockById(productId);
            String key = "inventory:" + productId;
            Integer cacheStock = redisTemplate.opsForValue().get(key);
            if (cacheStock != null && !dbStock.equals(cacheStock)) {
                log.warn("数据不一致!productId: {}, DB: {}, Cache: {}", 
                         productId, dbStock, cacheStock);
                // 可以选择:以DB为准,重新设置缓存
                redisTemplate.opsForValue().set(key, dbStock);
            }
        }
    }
}

总结与选型建议

场景 推荐校验方案 特点
单库高并发库存扣减 乐观锁(version) 性能好,适合低冲突
单库激烈竞争 悲观锁(FOR UPDATE) 牺牲性能保准确
缓存与数据库同步 写DB删缓存 简单可靠
跨服务转账 TCC(Seata) 强一致性但开发成本高
最终一致性业务 本地消息表 + MQ 高可用,可容忍秒级延迟
主从延迟 强制读主库 配置简单
离线对账 定时任务逐条对比 及时发现但不实时

核心原则:

  • 不追求绝对的强一致性,除非业务要求(如支付转账)。
  • 优先使用数据库自身的控制机制(唯一索引、行锁)来保证一致性。
  • 读多写少用乐观锁,写多读少用悲观锁
  • 缓存与DB不一致不可避免,设计上要能容忍几秒到几分钟的不一致窗口

抱歉,评论功能暂时关闭!