做微服务开发这些年,踩过最多的坑,莫过于SpringBoot+分布式锁+事务日志的组合实现。
说起来,这组组合确实是中小团队的“福音”——不用搭Seata那样复杂的分布式事务框架,仅凭SpringBoot整合、分布式锁控并发、事务日志做兜底,就能解决大部分跨服务原子性问题,比如防止超卖、避免数据错乱、故障后能补偿。
但正是这种“看似简单”,让很多新手(包括曾经的我)栽了大跟头:代码写完本地测试没问题,一上线就出状况——锁失效导致超卖、日志丢失没法补偿、补偿多了搞乱数据,连夜排查、回滚,熬得人身心俱疲。
今天就以博主的实战经历,把这7个高频易错的注意事项,用通俗易懂的话讲透,配套简单好懂的代码示例,不管是新手还是有一定经验的开发者,都能避开这些坑,少走弯路、少熬夜。
一、先理清:三者协同的核心,避坑先懂逻辑
很多人一上来就写代码,连三者各自的作用、怎么配合都没搞懂,不出错才怪。其实不用死记硬背,一句话就能理清:
SpringBoot管“整合”,简化分布式锁、事务日志的配置和编码;分布式锁管“并发”,防止多个服务同时操作同一个资源;事务日志管“兜底”,记录操作全程,出问题了能追溯、能补偿。
核心流程也很简单,记牢这个顺序,就能避开一半的坑:
获取分布式锁 → 写入“待执行”事务日志 → 执行业务(本地事务+跨服务调用) → 更新日志状态 → 释放锁 → 定时补偿失败日志
二、7个高频避坑点|实战踩坑+解决方案,直接复用
以下每个坑点,都是我在真实项目中踩过、或者身边同行反馈最多的,没有多余的理论,全是能直接落地的经验,新手可以对照着自查,老开发者也能查漏补缺。
避坑点1:分布式锁,千万别加在事务里面
我的踩坑经历:刚接触分布式锁时,我下意识把锁的获取和释放,都放在了加了@Transactional注解的方法里,觉得这样“代码更集中”,结果上线第一天就出问题——部分订单出现超卖,排查了半天才发现,是锁提前失效了。
错误示例(新手慎踩):
// 错误示范:事务内加解锁,易导致锁失效、死锁
@Transactional(rollbackFor = Exception.class)
public void createOrder(Long userId, Long productId, Integer quantity) {
// 事务内获取锁
boolean locked = redisDistributedLock.tryLock("lock:product:" + productId, 30);
if (!locked) {
throw new RuntimeException("系统繁忙,请稍后再试");
}
try {
// 扣库存、创建订单
stockFeignService.deductStock(productId, quantity);
Order order = new Order(userId, productId, quantity);
orderMapper.insert(order);
// 事务内释放锁
redisDistributedLock.unlock("lock:product:" + productId);
} catch (Exception e) {
throw new RuntimeException("订单创建失败", e);
}
}
坑点本质:@Transactional注解的事务,是在方法执行完之后才提交/回滚的。也就是说,你设置的锁过期时间,要覆盖“业务执行时间+事务提交时间”,一旦业务复杂、数据量大,锁就会提前过期,引发并发冲突。
正确做法:把锁的获取和释放,放在事务外面,业务逻辑单独抽离成一个方法,加上事务注解,这样能缩短锁的持有时间,避免冲突。
// 正确示范:事务外加解锁,业务单独抽离
public void createOrder(Long userId, Long productId, Integer quantity) {
String lockKey = "lock:product:" + productId;
boolean locked = false;
// 事务外部获取锁
locked = redisDistributedLock.tryLock(lockKey, 30);
if (!locked) {
throw new RuntimeException("系统繁忙,请稍后再试");
}
try {
// 调用事务方法执行业务
doCreateOrder(userId, productId, quantity);
} finally {
// 无论成功失败,都要释放锁(必写)
if (locked) {
redisDistributedLock.unlock(lockKey);
}
}
}
// 单独抽离业务,添加事务注解
@Transactional(rollbackFor = Exception.class)
private void doCreateOrder(Long userId, Long productId, Integer quantity) {
stockFeignService.deductStock(productId, quantity);
Order order = new Order(userId, productId, quantity);
orderMapper.insert(order);
}
避坑点2:解锁逻辑要“原子”,别误删别人的锁
踩坑提醒:手写Redis分布式锁时,很多人会分两步解锁——先判断锁是不是自己的,再删除。这种写法,高并发下必出问题,我曾经因为这个,导致两个线程互相误删锁,出现数据错乱。
错误示例:
// 错误示范:解锁分两步,高并发下易误删
public void unlock(String lockKey) {
// 步骤1:判断锁归属
String currentLock = redisTemplate.opsForValue().get(lockKey);
String lockFlag = lockThreadLocal.get();
if (lockFlag.equals(currentLock)) {
// 步骤2:删除锁(非原子操作,有时间间隙)
redisTemplate.delete(lockKey);
}
}
坑点本质:判断和删除是两个独立操作,中间有时间间隙——比如线程A判断锁是自己的,但还没来得及删除,锁就过期了;此时线程B获取到锁,线程A再删除,就会误删掉线程B的锁。
正确做法:用Lua脚本,把“判断+删除”改成原子操作,Redis会一次性执行完,没有时间间隙;如果用Redisson,就更简单了,框架已经封装好了,直接调用unlock()就行。
// 正确示范1:Lua脚本实现原子解锁(手写锁必备)
@Component
public class RedisDistributedLock {
@Resource
private StringRedisTemplate stringRedisTemplate;
private final ThreadLocal<String> lockThreadLocal = new ThreadLocal<>();
// Lua脚本:判断并删除,原子操作
private static final String UNLOCK_LUA = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
public void unlock(String lockKey) {
DefaultRedisScript<Long> script = new DefaultRedisScript<>(UNLOCK_LUA, Long.class);
stringRedisTemplate.execute(script, Collections.singletonList(lockKey), lockThreadLocal.get());
lockThreadLocal.remove(); // 清除本地标识,避免内存泄漏
}
}
// 正确示范2:Redisson(推荐,无需手动写脚本)
public void createOrder() {
RLock lock = redissonClient.getLock("lock:product:1001");
try {
boolean locked = lock.tryLock(10, 30, TimeUnit.SECONDS);
if (locked) {
doCreateOrder();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
// 仅当前线程持有锁时,才释放
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
避坑点3:锁的过期时间,别凭感觉乱设
踩坑经历:有一次做批量订单处理,我随手把锁的过期时间设成了10秒,结果因为批量处理耗时太长,锁提前释放,导致多线程并发操作,批量订单出现重复创建的问题,后续花了很久才清理完数据。
常见错误有两种:
过期时间设太短:业务还没执行完,锁就释放,引发并发冲突;
过期时间设太长:服务宕机、线程挂掉后,锁无法释放,导致死锁,后续请求全卡壳。
正确做法:结合业务实际设置,留足冗余,再做好兜底:
// 过期时间设置规范(实战参考)
public class LockExpireConfig {
// 基础过期时间:统计业务最大执行时间,留2-3倍冗余
public static final long BASE_EXPIRE = 30; // 常规业务(如普通订单)
public static final long LONG_TASK_EXPIRE = 60; // 长任务(如批量操作)
// 长业务兜底:Redisson看门狗自动续期(无需手动操作)
// application.yml配置参考
/*redisson:
singleServerConfig:
address: redis://127.0.0.1:6379
lockWatchdogTimeout: 30000 # 30秒续一次期*/
// 手写锁手动续期(不使用Redisson时)
public void renewLock(String lockKey, long expireSeconds) {
String lockFlag = lockThreadLocal.get();
if (StrUtil.isNotBlank(lockFlag)) {
stringRedisTemplate.opsForValue().set(lockKey, lockFlag, expireSeconds, TimeUnit.SECONDS);
}
}
}
避坑点4:事务日志,一定要“先写后执行”
核心提醒:这是最容易被忽略,但最致命的一个坑!很多人习惯先执行业务,再写日志,觉得“业务成功了,再记录也不迟”,但这样很容易导致日志丢失。
错误示例:
// 错误示范:先执行业务,再写日志,易丢失日志
@Transactional(rollbackFor = Exception.class)
public void deductStock(Long productId, Integer quantity) {
// 先扣库存(执行业务)
Stock stock = stockMapper.selectById(productId);
if (stock == null || stock.getQuantity() < quantity) {
throw new RuntimeException("库存不足");
}
stock.setQuantity(stock.getQuantity() - quantity);
stockMapper.updateById(stock);
// 再写日志(存在丢失风险)
TransactionLog log = new TransactionLog();
log.setGlobalTransactionId(globalTxId);
log.setBusinessType("STOCK_DEDUCT");
log.setStatus(1); // 执行成功
transactionLogMapper.insert(log);
}
坑点本质:如果业务执行成功,但日志写入失败(比如数据库宕机、网络异常),就会出现“业务成功、日志缺失”的情况。后续出问题,想补偿都没有依据,数据不一致还没法排查。
正确做法:先写日志(状态设为“待执行”),再执行业务,成功就更改为“成功”,失败就改为“失败”,确保日志不丢失。推荐用AOP实现,不侵入业务代码。
// 正确示范:先写日志,再执行业务(AOP无侵入)
// 1. 自定义注解,标记需要记日志的方法
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface TransactionLog {
String businessType(); // 业务类型(如库存扣减、订单创建)
}
// 2. AOP切面,自动记日志
@Aspect
@Component
public class TransactionLogAspect {
@Resource
private TransactionLogMapper transactionLogMapper;
private final ThreadLocal<String> globalTxIdLocal = new ThreadLocal<>();
@Around("@annotation(transactionLog)")
public Object around(ProceedingJoinPoint joinPoint, TransactionLog transactionLog) throws Throwable {
// 生成全局事务ID
String globalTxId = globalTxIdLocal.get();
if (StrUtil.isBlank(globalTxId)) {
globalTxId = IdUtil.fastUUID();
globalTxIdLocal.set(globalTxId);
}
// 1. 先写日志(待执行状态)
TransactionLog log = buildLog(joinPoint, transactionLog, globalTxId, 0);
transactionLogMapper.insert(log);
try {
// 2. 执行业务
Object result = joinPoint.proceed();
// 3. 业务成功,更新日志状态
log.setStatus(1);
transactionLogMapper.updateById(log);
return result;
} catch (Exception e) {
// 4. 业务失败,更新日志状态
log.setStatus(2);
log.setErrorMsg(e.getMessage());
transactionLogMapper.updateById(log);
throw e;
} finally {
globalTxIdLocal.remove();
}
}
// 构建日志对象(简化版)
private TransactionLog buildLog(ProceedingJoinPoint joinPoint, TransactionLog annotation, String globalTxId, Integer status) {
TransactionLog log = new TransactionLog();
log.setGlobalTransactionId(globalTxId);
log.setBusinessType(annotation.businessType());
log.setBusinessParams(JSONUtil.toJsonStr(joinPoint.getArgs()));
log.setStatus(status);
return log;
}
}
避坑点5:事务日志,一定要关联全局ID
踩坑提醒:跨服务操作时(比如订单+库存+余额扣减),每个服务都写日志,但如果没有一个统一的关联标识,出问题后,你根本没法串联所有日志,不知道哪个服务、哪个步骤失败了,排查起来堪比“大海捞针”。
正确做法:生成一个全局事务ID,贯穿整个跨服务链路,每个服务的日志都存入这个ID,排查时,输入ID就能找到所有相关日志。
// 全局事务ID实现(两种方案,按需选)
public class GlobalTxIdUtil {
// 方案1:手动生成(非SpringCloud项目)
public static String generateGlobalTxId() {
// 雪花算法,分布式环境唯一
return IdUtil.getSnowflakeNextIdStr();
}
// 方案2:SpringCloud项目(自动生成,无需手动处理)
// pom.xml引入依赖(简化)
/*<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-sleuth</artifactId>
</dependency>*/
// 跨服务传递ID(Feign拦截器)
@Component
public class FeignTxIdInterceptor implements RequestInterceptor {
@Override
public void apply(RequestTemplate requestTemplate) {
String globalTxId = globalTxIdLocal.get();
if (StrUtil.isNotBlank(globalTxId)) {
requestTemplate.header("X-GLOBAL-TX-ID", globalTxId);
}
}
}
}
避坑点6:补偿逻辑,必须实现幂等性
我的踩坑经历:曾经做库存补偿时,没考虑幂等性,定时任务重试了3次,导致库存被回滚了3次,凭空多了很多库存,后续花了大量时间核对数据、修正错误,教训深刻。
坑点本质:定时任务扫描失败日志,会重试补偿,如果补偿逻辑不做幂等性,重复执行就会导致数据错乱(比如库存多回滚、订单重复创建)。
正确做法:补偿前先校验,确保同一笔业务,只补偿一次,推荐两种简单方案:
// 补偿逻辑幂等性实现(实战版)
@Component
@EnableScheduling
public class CompensateTask {
@Resource
private TransactionLogMapper transactionLogMapper;
@Resource
private StockService stockService;
private static final int MAX_RETRY = 3; // 重试上限
// 每分钟扫描一次失败日志
@Scheduled(cron = "0 */1 * * * ?")
public void compensateFailed() {
// 查询待补偿日志:失败状态、重试次数<3
List<TransactionLog> failLogs = transactionLogMapper.selectCompensateLogs(2, MAX_RETRY);
if (CollUtil.isEmpty(failLogs)) return;
for (TransactionLog log : failLogs) {
// 幂等性校验:判断是否已补偿(方案1:全局ID+业务类型)
if (checkCompensated(log.getGlobalTransactionId(), log.getBusinessType())) {
continue;
}
try {
// 执行补偿逻辑(比如库存回滚)
if ("STOCK_DEDUCT".equals(log.getBusinessType())) {
StockParam param = JSONUtil.toBean(log.getBusinessParams(), StockParam.class);
// 方案2:业务层面校验(商品ID+全局ID)
stockService.compensateStock(param.getProductId(), param.getQuantity(), log.getGlobalTransactionId());
}
log.setStatus(3); // 补偿成功
} catch (Exception e) {
log.setRetryCount(log.getRetryCount() + 1);
// 重试上限,标记为人工处理
if (log.getRetryCount() >= MAX_RETRY) {
log.setStatus(4);
log.setErrorMsg("补偿失败:" + e.getMessage());
}
}
transactionLogMapper.updateById(log);
}
}
// 幂等性校验
private boolean checkCompensated(String globalTxId, String businessType) {
LambdaQueryChainWrapper<TransactionLog> query = new LambdaQueryChainWrapper<>(transactionLogMapper);
Integer count = query.eq(TransactionLog::getGlobalTransactionId, globalTxId)
.eq(TransactionLog::getBusinessType, businessType)
.eq(TransactionLog::getStatus, 3)
.count();
return count > 0;
}
}
避坑点7:Redis要高可用,别搞单机部署
核心提醒:分布式锁大多基于Redis实现,但很多中小团队图省事,用Redis单机部署,一旦Redis宕机,所有获取锁的请求都会失败,跨服务操作全卡壳,甚至引发服务雪崩,损失惨重。
正确做法:生产环境,必须保证Redis高可用,两种方案按需选,再加上降级兜底:
# 方案1:Redis主从+哨兵(中小团队首选,部署简单、成本低)
spring:
redis:
password: 123456
sentinel:
master: mymaster # 主节点名称
nodes: 127.0.0.1:26379,127.0.0.1:26380 # 哨兵节点
# 方案2:Redis Cluster集群(高并发场景,支持分片)
spring:
redis:
password: 123456
cluster:
nodes: 127.0.0.1:7001,127.0.0.1:7002 # 集群节点
max-redirects: 3
# 兜底:Redis宕机降级,避免服务雪崩
@Component
public class RedisDowngradeHandler {
@Resource
private RedissonClient redissonClient;
// 检查Redis是否可用
public boolean isRedisAvailable() {
try {
return redissonClient.getNodesGroup().pingAll().size() > 0;
} catch (Exception e) {
return false;
}
}
// 降级逻辑:关闭核心操作,返回友好提示
public void downgrade() {
throw new RuntimeException("系统临时维护,请稍后再试");
}
}
三、最后想说:避坑的核心,就3个原则
其实不用死记硬背上面7个坑点,记牢这3个核心原则,就能避开80%的问题,让SpringBoot+分布式锁+事务日志稳定落地:
时序原则:锁在事务外,日志在业务前,不颠倒顺序;
原子原则:加解锁、日志写入,核心操作要原子,避免中间状态;
兜底原则:锁有过期+续期,日志有追溯+关联,补偿有幂等+重试,Redis有高可用+降级。
四、互动交流
做技术开发,踩坑是常态,重要的是踩过之后,能总结经验,避免再犯。
不知道你在实现SpringBoot+分布式锁+事务日志时,有没有踩过什么印象深刻的坑?是怎么解决的?欢迎在评论区留言交流,互相避坑、共同进步~
另外,本文所有代码示例,我都整理成了可直接运行的SpringBoot项目,包含数据库脚本和完整配置,需要的朋友,评论区留言“简书避坑源码”,就能免费获取,导入IDEA就能测试,帮你少走弯路、高效开发!
关注我,后续会持续分享微服务实战干货,从基础到进阶,助力你快速成长,少熬夜、多高效~