本文目录导读:

- 并发环境下的数据一致性(悲观锁 vs 乐观锁)
- 缓存与数据库的一致性(Cache Aside Pattern)
- 分布式系统间的一致性(TCC / Saga / 最终一致性)
- 数据库主从延迟导致的不一致
- 数据一致性校验工具(对账)
- 总结与选型建议
在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 / 最终一致性)
跨服务、跨数据库时,无法使用数据库本地事务,需要分布式事务方案。
本地消息表 + 消息队列(最终一致性)
适用于对实时一致性要求不高的场景(如订单创建后发短信通知)。
流程:
- 主业务(如订单系统)执行本地事务:创建订单 + 写入“发短信”消息表(同一数据库)。
- 通过定时任务或消息队列消费消息表记录,调用下游服务(短信系统)。
- 下游服务处理成功后删除消息表记录;失败则重试。
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阶段释放资源(解冻金额)。
数据库主从延迟导致的不一致
场景: 写主库后立即查询从库,可能读到旧数据。
解决方案:
- 强制读主库:刚写完数据的请求,下一次读取强制走主库。
- 延迟查询:写入后等待几百毫秒再查询。
- 使用中间件:如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不一致不可避免,设计上要能容忍几秒到几分钟的不一致窗口。