做微服务开发这些年,最头疼的就是分布式场景下的数据一致性问题——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与分布式锁+事务日志协同使用,彻底解决分布式事务数据一致性难题,不见不散~