SpringBoot+分布式锁+事务日志 实战延伸|避开这些坑,少熬半夜排查故障

做微服务开发这些年,最头疼的就是分布式场景下的数据一致性问题——SpringBoot+分布式锁+事务日志,这组“黄金组合”几乎是刚需,不管是库存扣减、订单创建,还是跨服务调用,都离不开它。

但很多小伙伴(包括我刚开始做的时候),落地时总容易忽略一些冷门细节,结果线上出故障、数据不一致,熬夜排查到凌晨,最后发现只是一个小疏忽。

之前分享过这三者实现的7个高频避坑点,后台收到了很多私信,都是大家实战中遇到的真实问题,整理出5个问得最多的,今天就专门做一期补充延伸,结合我的项目经验,把解决方案拆解得明明白白,附可直接复用的代码,新手也能轻松上手。

先铺垫:3句核心口诀,避开80%基础坑

在拆解具体问题前,先快速回顾3句核心原则,这是我踩过很多坑后总结的,记牢它,能避开大部分基础故障,为后续优化打基础:

时序原则:锁在事务外,日志在业务前,顺序别搞反;

原子原则:加解锁、日志写入,核心操作要原子;

兜底原则:锁有过期+续期,日志有追溯+关联,补偿有幂等+重试。

记住这3点,再来看下面的5个高频问题,就更容易理解了——每一个问题,都是大家实实在在遇到的,每一个解决方案,都是我在项目里亲测有效的。

问题1:Redisson和手写Redis锁,到底该怎么选?

很多小伙伴问我:“手写Redis锁能跑通测试,为什么还要多此一举引入Redisson?会不会太冗余?”

其实我刚开始也有这个疑问,直到在小项目里硬套Redisson,反而增加了不必要的麻烦;后来在高并发项目里手写Redis锁,漏了续期逻辑,导致线上翻车,才明白——选型没有“最好”,只有“最适配”。

不用纠结,一张表帮你分清两者的适用场景,对照自己的项目选就好,新手直接抄作业:

下表清晰对比两者核心差异,涵盖适用场景、优势、劣势,可直接对照项目场景选型:

对比维度手写Redis锁Redisson

适用场景小团队、简单业务(单服务库存扣减)中大型团队、高并发/复杂业务

优势轻量、灵活,无额外依赖自动续期、支持集群,少写重复代码

劣势需手动处理续期,易漏细节翻车引入依赖,简单业务略显冗余

结合我的实战经验,给大家3个具体建议,避免选型踩坑:

1.  小团队、简单业务(比如单服务库存扣减、接口防重复提交):手写Redis锁就够了,搭配Lua脚本保证解锁原子性,手动续期兜底,不用额外加依赖,轻量又灵活;

2.  中大型团队、高并发/复杂业务(跨服务调用、长耗时任务):优先选Redisson,它内置自动续期、集群适配,还有各种锁类型,能少写很多重复代码,避免手动写锁漏细节翻车;

3.  重点提醒:Redisson和SpringBoot版本一定要匹配!我之前因为版本不兼容,项目启动失败,排查了半天,分享个亲测无冲突的搭配:SpringBoot 2.7.x对应Redisson 3.23.x,SpringBoot 3.x对应Redisson 3.27.x以上。

问题2:海量事务日志存MySQL,越存越卡怎么办?

这个问题,很多做中高并发项目的小伙伴都遇到过:项目上线后,每天产生几十万条事务日志,全存在MySQL里,慢慢的,数据库越来越卡,查询超时,甚至影响核心业务。

我之前负责的项目,也遇到过这种情况,最后总结了3个优化方案,优先推荐第一个,实现简单、成本低,不用大规模改造项目,新手也能快速落地。

结合多项目优化经验,整理3种可直接落地的存储优化方案,覆盖中小团队、高并发等不同场景,优先推荐方案1(实现简单、成本低,无需大规模改造项目,新手可快速落地)。

方案1:MySQL分表(中小团队首选,落地最快)

核心逻辑很简单:按全局事务ID哈希取模,把日志分散到8张表中,避免单表数据过载。我一般分8张表(log_transaction_0~7),数据分布均匀,查询效率能提升很多。

核心逻辑:基于「全局事务ID哈希取模」实现分表,将海量日志分散至多个数据表,避免单表数据量过载,提升查询效率与数据库稳定性。实战建议分8张表(log_transaction_0 ~ log_transaction_7),实现日志数据均匀分配。

基于MyBatis-Plus的完整配置代码,直接复制到项目里,改下表名就能用:

// 1. 导入分表依赖(pom.xml)

<dependency>

    <groupId>com.baomidou</groupId>

    <artifactId>dynamic-datasource-spring-boot-starter</artifactId>

    <version>3.5.2</version>

</dependency>

// 2. 分表策略配置(按全局事务ID哈希)

@Component

public class TransactionLogShardingStrategy implements TableShardingStrategy {

    @Override

    public String doSharding(Collection<String> tableNames, ShardingValue<?> shardingValue) {

        // 全局事务ID哈希取模,分8张表(log_transaction_0 ~ 7)

        String globalTxId = shardingValue.getValue().toString();

        int tableIndex = Math.abs(globalTxId.hashCode()) % 8;

        return "log_transaction_" + tableIndex;

    }

}

方案2:日志分级存储(高并发、海量日志适用)

如果日志量特别大(每天上百万条),可以按使用频率分级存储:7天内的常用日志存MySQL,方便查询和补偿;7天以上的远期日志,同步到MinIO或OSS,既省成本,又不影响数据库性能。

核心逻辑:根据日志使用频率分级存储,平衡查询效率与存储成本——近期常用日志存储于MySQL,远期不常用日志迁移至低成本存储介质,既保障常用日志查询效率,又降低数据库存储压力。

1.  近期日志(7天内):存储于MySQL,便于快速查询、事务补偿;2.  远期日志(7天以上):同步至MinIO/OSS,按需下载查询;

实现也很简单:每天凌晨(避开业务高峰),用定时任务把远期日志同步到MinIO/OSS,同步完成后,删除MySQL里的对应数据,既保留日志可追溯性,又减轻数据库压力。

方案3:日志字段简化+压缩(所有项目都能套)

还有一个小技巧:删除日志表的冗余字段(比如不需要的中间状态),对业务参数这类大字段,用Gzip压缩存储,能大幅减少单条日志的体积,延缓表膨胀。

核心逻辑:删除日志表中冗余字段(如无需存储的中间状态、重复字段),对体积较大的字段(如业务参数business_params)采用Gzip压缩存储,减少单条日志存储体积,延缓表膨胀速度。

// 业务参数压缩存储

public String compressParams(Object params) {

    String json = JSONUtil.toJsonStr(params);

    // Gzip压缩,减少存储体积

    byte[] compress = GzipUtil.compress(json.getBytes(StandardCharsets.UTF_8));

    return Base64.getEncoder().encodeToString(compress);

}

问题3:全局ID和链路追踪脱节,排查问题熬到疯?

很多小伙伴跟我吐槽:“已经加了全局事务ID,也配了Sleuth+Zipkin,但两者脱节,排查线上问题时,要分别查链路和日志,效率太低,经常熬到凌晨。”

其实解决这个问题很简单,核心就是“统一ID”——让全局事务ID和Sleuth的链路ID(TraceId)变成同一个,实现“输入一个ID,查遍全链路+事务日志”。

核心解决方案:实现“全局事务ID”与“Sleuth链路ID(TraceId)”统一。两者核心目标均为分布式追踪,仅应用场景不同,统一后可实现“输入单个ID,查询全链路调用+事务日志详情”,大幅提升排查效率。

我实战中最常用的方法:复用Sleuth的TraceId作为全局事务ID。它本身就具备分布式唯一性,不用额外手动生成(比如雪花算法),能少写很多冗余代码,无缝衔接。

3步就能实现,配置简单,代码可直接复用:

基于SpringCloud项目的完整实现步骤如下,配置简单、代码可直接复用,无需复杂改造,新手可快速完成整合:

步骤1:整合Sleuth+Zipkin(基础配置)

# application.yml 完整配置(直接复制)

spring:

  sleuth:

    sampler:

      probability: 1.0 # 开发环境全采样,方便测试;生产环境设0.1,减少消耗

  zipkin:

    base-url: http://localhost:9411 # 改自己的Zipkin地址

    sender:

      type: WEB # HTTP方式发送链路数据

步骤2:复用TraceId作为全局事务ID

在事务日志的AOP切面里,直接获取Sleuth存在MDC里的TraceId,不用额外生成,保证唯一性和统一性:

在事务日志AOP切面中,直接获取Sleuth存入MDC的TraceId作为全局事务ID,无需额外手动生成,保证ID唯一性与统一性,减少代码冗余:

// AOP切面中复用TraceId

@Aspect

@Component

public class TransactionLogAspect {

    @Resource

    private TransactionLogMapper transactionLogMapper;

    @Around("@annotation(transactionLog)")

    public Object around(ProceedingJoinPoint joinPoint, TransactionLog transactionLog) throws Throwable {

        // 复用Sleuth的TraceId

        String globalTxId = MDC.get("traceId");

        // 兜底:为空则手动生成

        if (StrUtil.isBlank(globalTxId)) {

            globalTxId = IdUtil.getSnowflakeNextIdStr();

            MDC.put("traceId", globalTxId);

        }

        // 写日志、执行业务(省略,和上一篇一致)

        TransactionLog log = buildLog(joinPoint, transactionLog, globalTxId, 0);

        transactionLogMapper.insert(log);

        try {

            Object result = joinPoint.proceed();

            log.setStatus(1);

            transactionLogMapper.updateById(log);

            return result;

        } catch (Exception e) {

            log.setStatus(2);

            log.setErrorMsg(e.getMessage());

            transactionLogMapper.updateById(log);

            throw e;

        }

    }

}

步骤3:配置日志输出,实现“一ID通查”

最后一步,在Logback/Log4j2配置里,把traceId写入日志格式,这样每条日志都包含这个ID。

排查问题时,只要拿到一个traceId(从异常日志或接口返回里找),在Zipkin里查全链路,在事务日志里查执行详情,1分钟就能定位根源,再也不用熬夜排查了。

1.  日志配置:在Logback/Log4j2配置文件中,将MDC中的traceId(即全局事务ID)写入日志输出格式,确保每条日志均包含该ID;

2.  排查流程:线上出现故障时,获取异常日志或接口返回中的traceId,在Zipkin中输入该ID可查看全链路调用详情,在事务日志表中输入该ID可查看对应事务执行日志,1分钟内即可定位故障根源,大幅提升排查效率。

问题4:加了分布式锁,高并发还是阻塞超时?

这个坑我踩过好几次:加了分布式锁,但高并发场景下,接口还是超时、大量请求阻塞,排查后发现——锁粒度过粗,一锁锁全局,所有请求都排队,系统性能直接拉胯。

核心原则很简单:锁粒度“越小越好”,只锁需要保护的核心资源,不锁无关内容。分享3个我常用的优化方案,按需套用:

方案1:按资源ID加锁(最常用,抄作业就行)

比如扣库存,不要锁整个库存表,只锁当前商品的ID;操作订单,只锁当前订单的ID。这样只有操作同一资源的请求才排队,不同资源的请求能并行执行,并发性能直接提升。

实战代码,直接复制使用:

// 按商品ID加锁,只锁当前商品

public void deductStock(Long productId, Integer quantity) {

    String lockKey = "lock:stock:" + productId;

    boolean locked = redisDistributedLock.tryLock(lockKey, 30);

    if (!locked) {

        throw new RuntimeException("系统繁忙,请稍后再试");

    }

    try {

        // 扣库存业务(省略)

    } finally {

        redisDistributedLock.unlock(lockKey);

    }

}

方案2:分段锁(秒杀、批量操作适用)

如果资源特别多(比如几十万件商品),按单个ID加锁还是会有竞争,就把资源分段,每段加一把锁。比如秒杀场景,把商品ID分10段,每段一把锁,进一步分散竞争。

核心逻辑:当资源数量极多(如几十万、几百万个商品),按单个资源ID加锁仍存在部分资源竞争激烈的情况,此时将资源分段,每段设置一把锁,进一步分散锁竞争,提升并发能力。秒杀场景实战代码如下:

// 分段锁:商品ID哈希分10段

public void seckillStock(Long productId, Integer quantity) {

    int segment = Math.abs(productId.hashCode()) % 10;

    String lockKey = "lock:seckill:stock:" + segment;

    boolean locked = redisDistributedLock.tryLock(lockKey, 30);

    if (!locked) {

        throw new RuntimeException("秒杀火爆,请重试");

    }

    try {

        // 秒杀扣库存(省略)

    } finally {

        redisDistributedLock.unlock(lockKey);

    }

}

方案3:读写分离锁(读多写少场景适用)

如果是商品详情查询、库存查询这种读多写少的场景,用Redisson的读写分离锁,读请求能并行,写请求互斥,既保证数据一致,又提升读请求的并发性能。

核心逻辑:针对读多写少场景,采用Redisson读写分离锁(RReadWriteLock),读操作加读锁(多请求可并行),写操作加写锁(互斥执行),既保障数据一致性,又大幅提升读请求并发性能。实战代码如下:

// Redisson读写分离锁

public void queryStock(Long productId) {

    RReadWriteLock rwLock = redissonClient.getReadWriteLock("lock:stock:rw:" + productId);

    // 读操作加读锁,可并发

    RLock readLock = rwLock.readLock();

    readLock.lock(30, TimeUnit.SECONDS);

    try {

        // 查询库存(省略)

    } finally {

        readLock.unlock();

    }

}

问题5:补偿重试失败,数据不一致没人发现?

很多小伙伴实现了事务补偿,但重试几次失败后,就没人管了,结果线上出现数据不一致,等到业务反馈才发现,损失已经造成了。

补偿的核心是“兜底”,不能只做自动重试,必须加上告警和人工介入机制——重试失败,立刻通知负责人,避免数据长期不一致。

补偿逻辑的核心是“兜底保障”,多数开发者仅实现自动重试,忽略重试失败后的告警与人工介入,易导致数据长期不一致,影响业务正常运行。核心优化:新增钉钉/企业微信告警机制,结合规范人工介入流程,确保补偿失败问题及时处理。

3步就能实现,代码可直接复用,适配大部分项目:

步骤1:实现告警工具类(钉钉/企业微信二选一)

我用的是钉钉告警,替换成自己团队的机器人webhook就能用,企业微信可参考这个逻辑调整:

以下为钉钉告警工具类完整代码,替换自身团队钉钉机器人webhook即可使用;企业微信可参考该逻辑调整请求参数:

// 钉钉告警工具类,可直接复制

@Component

public class DingTalkAlarmUtil {

    // 替换成自己团队的钉钉机器人webhook

    private static final String DING_TALK_WEBHOOK = "https://oapi.dingtalk.com/robot/send?access_token=xxx";

    public void sendCompensateAlarm(TransactionLog log) {

        String content = "【补偿失败告警】\n" +

                "全局事务ID:" + log.getGlobalTransactionId() + "\n" +

                "业务类型:" + log.getBusinessType() + "\n" +

                "失败原因:" + log.getErrorMsg() + "\n" +

                "请及时人工介入!";

        // 发送告警(hutool HttpUtil直接调用)

        Map<String, Object> param = new HashMap<>();

        param.put("msgtype", "text");

        param.put("text", Map.of("content", content));

        HttpUtil.post(DING_TALK_WEBHOOK, JSONUtil.toJsonStr(param));

    }

}

步骤2:优化补偿任务,添加告警触发

在补偿任务里加判断,重试达到上限还失败,就发送告警,通知负责人及时处理:

// 优化后的补偿任务

@Scheduled(cron = "0 */1 * * * ?")

public void compensateFailed() {

    List<TransactionLog> failLogs = transactionLogMapper.selectCompensateLogs(2, MAX_RETRY);

    if (CollUtil.isEmpty(failLogs)) return;

    for (TransactionLog log : failLogs) {

        if (checkCompensated(log.getGlobalTransactionId(), log.getBusinessType())) {

            continue;

        }

        try {

            // 执行补偿逻辑(省略)

            log.setStatus(3);

        } catch (Exception e) {

            log.setRetryCount(log.getRetryCount() + 1);

            if (log.getRetryCount() >= MAX_RETRY) {

                log.setStatus(4);

                log.setErrorMsg("补偿失败:" + e.getMessage());

                // 重试上限,发送告警

                dingTalkAlarmUtil.sendCompensateAlarm(log);

            }

        }

        transactionLogMapper.updateById(log);

    }

}

步骤3:规范人工介入流程(关键一步)

告警发出去不是结束,还要有规范的处理流程,避免告警形同虚设:

1.  告警接收人:明确开发、测试、运维负责人,确保告警能及时触达;

2.  处理流程:收到告警 → 查事务日志(找ID、失败原因) → 查链路日志(定位根源) → 手动补偿 → 更新日志状态;

3.  兜底检查:每周专人排查“补偿失败”的日志,确保所有问题都能解决,避免遗漏。

写在最后:避坑口诀+实战感悟

最后整理一句避坑口诀,记牢它,能避开这三者实现的大部分坑:

锁在事务外,日志业务前;原子防漏判,过期加续期;

Redisson按需选,日志分表减压力;全局ID连链路,补偿告警不被动!

我已经把今天分享的所有优化方案,整理成了可直接运行的SpringBoot源码,包含完整配置、代码注释和数据库脚本,导入IDEA就能测试使用,不用自己从零写。

源码包含:Redisson完整配置+手写Redis锁优化、事务日志分表+压缩存储、全局ID+Sleuth链路追踪整合、补偿告警+人工介入完整流程、数据库脚本。

互动交流,一起成长

其实SpringBoot+分布式锁+事务日志的实现,没有那么复杂,大部分线上故障,都是源于细节的疏忽——可能是版本不匹配,可能是锁粒度过粗,也可能是补偿忘了加告警。

欢迎大家在评论区分享,你在实战中遇到的相关踩坑经历,或者有什么疑问,我们一起交流探讨,互相避坑、共同成长。

如果在方案落地时遇到具体问题,也可以留言说明你的业务场景,我会尽力帮你出解决方案,助力大家高效开发、少熬夜。

感谢读到这里,如果你觉得这篇分享对你有帮助,不妨点个赞、收藏一下,后续我会持续分享微服务实战干货和避坑指南,陪大家一起,把代码写得更稳、更高效。

下期预告:Seata与分布式锁+事务日志协同使用,彻底解决分布式事务数据一致性难题,不见不散~

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

相关阅读更多精彩内容

友情链接更多精彩内容