SpringBoot + 分布式锁 + 事务日志 避坑指南

做微服务开发这些年,踩过最多的坑,莫过于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&lt;String&gt; 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就能测试,帮你少走弯路、高效开发!

关注我,后续会持续分享微服务实战干货,从基础到进阶,助力你快速成长,少熬夜、多高效~

©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

相关阅读更多精彩内容

友情链接更多精彩内容