一、从生产踩坑看分布式事务的锁冲突痛点

1.1 真实的生产异常案例

去年帮一个做电商的朋友排查问题,他们的整点秒杀活动出了大bug:10点整用户蜂拥下单,差不多有3成请求返回“系统繁忙请重试”,后台看日志全是数据库锁等待超时。具体是用户下单扣库存的场景:用户付完钱,系统要同时做“新增订单”和“扣减对应商品的库存”两个数据库操作,而且这两个操作要在一个事务里,不然会出现“有订单但库存没扣”或者“库存扣了但没订单”的不一致问题。但当时他们的代码把所有能加的操作都塞进了事务:比如调用用户积分校验、调用第三方的支付结果确认、甚至还有一段缓存查询的代码都放在了事务里,导致这个事务的执行时间长达几百毫秒,锁(可以理解成数据库里的“排队凭证”)一直被占用,后面的请求只能等,最后等超时就报错了。

1.2 为什么分布式里锁会打架

很多开发者对单机数据库的锁有概念,但分布式里的锁不一样——GaussDB是分布式数据库,就像把库存分到了多个节点(比如不同地区的仓库),每个节点自己管自己的库存,同时有个总节点管全局数据。当多个请求同时操作同一个节点的同一条库存数据时,就像好几个人抢同一个仓库的提货单,每个人都要先拿到“这个仓库的锁”才能提货,如果有人拿了锁去办别的事,其他人只能等,人多了就会排队到超时,这就是生产里看到的锁冲突。

二、锁冲突的定位方法(基于GaussDB生产环境)

2.1 怎么快速找到锁冲突?

GaussDB自带了专门的系统视图pg_locks,就像数据库的“实时排队登记本”,能看到所有正在申请的锁和已经被持有的锁。还有慢查询日志,里面会记录超过设定时间的慢SQL,往往锁冲突的请求也会变成慢查询。首先可以用bash命令登录GaussDB,查询当前所有等待的锁:

# 登录GaussDB分布式节点,查询未被授予的等待锁(锁冲突的核心)
gsql -d 你的数据库名 -U 用户名 -h 数据库节点IP -p 端口号 -c "SELECT pid, locktype, mode, granted, query FROM pg_locks WHERE NOT granted;"

这个命令的输出里,granted字段为f的就是在等锁的请求,对应的query字段就是出问题的SQL,能直接定位到哪条SQL在等锁,还有对应的进程ID(pid)。另外,应用侧的超时日志也很重要,如果多个请求都是同一个超时时间,而且报错内容一致,大概率是锁冲突导致的。

2.2 定位的关键维度

找到锁之后,还要看几个关键信息:锁的类型(是行锁还是表锁,行锁的冲突概率比表锁小很多)、锁的持有时间(如果持有时间超过1秒,就是长事务的问题)、哪个事务持有了锁(可以结合pg_stat_activity视图看这个事务在做什么,是不是做了不必要的操作)。比如之前的案例里,持有锁的事务正在调用第三方支付接口,这个操作和数据库完全无关,却占着锁不放,这就是典型的长事务问题。

三、调优方案:从代码到配置的落地动作

3.1 代码层面的锁优化

最见效的优化是把事务的范围缩小,只保留核心的数据库操作,把非数据库操作(比如远程调用、缓存查询、积分校验)全部移出事务。另外要缩短事务的超时时间,避免锁被 hold 太久。对比有问题的代码和优化后的代码: 技术栈:Java + Spring Data GaussDB

// 优化前的问题代码
@Service
public class BadOrderService {
    @Autowired
    private GaussDBTemplate gaussDBTemplate;
    @Autowired
    private IntegralService integralService;
    @Autowired
    private PaymentService paymentService;

    @Transactional(rollbackFor = Exception.class) // 未设超时,事务范围过大
    public String createOrder(Long userId, Long skuId, Integer count) {
        // 把非数据库操作塞进事务,拖长事务时长
        boolean hasEnough = integralService.check(userId);
        if (!hasEnough) return "积分不足";
        boolean payOk = paymentService.confirm(userId);
        if (!payOk) return "支付失败";
        
        // 核心数据库操作
        gaussDBTemplate.update("UPDATE stock SET total = total - ? WHERE sku_id = ?", count, skuId);
        gaussDBTemplate.update("INSERT INTO orders(user_id, sku_id, count) VALUES (?, ?, ?)", userId, skuId, count);
        return "下单成功";
    }
}

优化后的代码:

// 技术栈:Java + Spring Data GaussDB
@Service
public class GoodOrderService {
    @Autowired
    private GaussDBTemplate gaussDBTemplate;
    @Autowired
    private IntegralService integralService;
    @Autowired
    private PaymentService paymentService;
    @Autowired
    private StockService stockService;

    // 事务仅保留核心操作,超时设为3秒避免锁长期持有
    @Transactional(rollbackFor = Exception.class, timeout = 3)
    public String createOrder(Long userId, Long skuId, Integer count) {
        // 非数据库操作全移到事务外,减少事务执行时间
        boolean hasEnough = integralService.check(userId);
        if (!hasEnough) return "积分不足";
        boolean payOk = paymentService.confirm(userId);
        if (!payOk) return "支付失败";
        
        // 核心数据库操作快速提交
        stockService.reduceStock(skuId, count);
        gaussDBTemplate.update("INSERT INTO orders(user_id, sku_id, count) VALUES (?, ?, ?)", userId, skuId, count);
        return "下单成功";
    }
}
// StockService核心减库存方法,事务内操作
@Service
class StockService {
    @Autowired
    private GaussDBTemplate gaussDBTemplate;

    @Transactional(rollbackFor = Exception.class)
    public void reduceStock(Long skuId, Integer count) {
        // 必须用唯一键作为WHERE条件,避免锁全表
        int affected = gaussDBTemplate.update("UPDATE stock SET total = total - ? WHERE sku_id = ? AND total >= ?", count, skuId, count);
        if (affected == 0) throw new RuntimeException("库存不足或扣减失败");
    }
}

优化点总结:1. 事务仅包含数据库操作;2. 设置合理的事务超时;3. WHERE条件用唯一键避免全表锁;4. 所有远程调用移出事务,压缩事务时长。

3.2 GaussDB配置层面的调整

如果代码优化后还有少量锁冲突,可调整GaussDB的锁等待超时,默认一般是1分钟,太长容易阻塞,设置为2秒能让等待过久的请求自动放弃,避免影响其他请求。用bash命令修改全局参数:

# 修改GaussDB锁等待超时为2秒
gsql -d 你的数据库名 -U 用户名 -h 节点IP -p 端口号 -c "ALTER SYSTEM SET lock_timeout = '2s';"
# 刷新配置立即生效
gsql -d 你的数据库名 -U 用户名 -h 节点IP -p 端口号 -c "SELECT pg_reload_conf();"

另外,若业务允许,可把默认的可重复读(RR)隔离级别改成读已提交(RC),RR会保留更多锁保证数据一致,RC的锁开销更小,适合普通业务场景,减少锁持有时间。

3.3 业务层面的锁规避(秒杀场景专属)

秒杀这种高并发场景,直接操作数据库锁成本太高,可用Redis缓存预扣库存,异步同步到GaussDB,请求不用等数据库锁,性能大幅提升。示例代码: 技术栈:Java + Redis + Spring Task

@Service
public class SeckillService {
    @Autowired
    private RedisTemplate<String, Integer> redisTemplate;
    @Autowired
    private GaussDBTemplate gaussDBTemplate;
    @Autowired
    private ThreadPoolTaskExecutor asyncExecutor;
    private static final String SECKILL_STOCK_KEY = "seckill:stock:";

    // 秒杀下单:先操作Redis完全避免锁冲突
    public String seckill(Long skuId, Long userId) {
        String key = SECKILL_STOCK_KEY + skuId;
        // 原子扣减Redis库存,不存在则初始化(活动前提前存入库存)
        Long currentStock = redisTemplate.opsForValue().decrement(key, 1);
        // 库存不足则回滚Redis防止超卖
        if (currentStock < 0) {
            redisTemplate.opsForValue().increment(key, 1);
            return "库存不足";
        }
        // 异步同步到GaussDB,不阻塞下单流程
        asyncExecutor.submit(() -> syncStockToDB(skuId, -1));
        return "秒杀成功";
    }

    // 异步同步到GaussDB,用新事务减少锁冲突
    @Transactional(rollbackFor = Exception.class, propagation = Propagation.REQUIRES_NEW)
    public void syncStockToDB(Long skuId, int delta) {
        gaussDBTemplate.update("UPDATE stock SET total = total + ? WHERE sku_id = ?", delta, skuId);
    }
}

这个方案核心是“缓存前置”,把高并发的锁操作从数据库转移到Redis,仅把最终同步操作交给数据库,适合秒杀场景,但要注意处理数据一致性,比如Redis宕机时加定时任务核对库存做补偿。

四、技术优缺点与注意事项

4.1 各个方案的优缺点

代码层面优化的优点是侵入性小,不用改全局配置,见效快;缺点是需要调整老代码,耦合度高的系统改造成本大。GaussDB配置调整的优点是全局生效,无需改代码,适合快速临时解决线上问题;缺点是配置过大会导致锁等待超时太长,过小会误杀正常请求(比如正常长查询被超时)。缓存前置的优点是性能极高,能支撑几万QPS的并发;缺点是开发成本高,需要处理数据一致性和兜底补偿机制。

4.2 注意事项

  1. 绝对不要在事务内做远程调用(比如调用其他服务、发消息),这类操作延迟不可控,会直接拖长事务导致锁长期持有;2. 事务的WHERE条件必须用主键或唯一键,否则会锁全表引发死锁;3. 锁等待超时不要设太小,至少留1秒给业务执行,避免误杀正常操作;4. 金融、结算等一致性要求极高的场景,不要降隔离级别,应优先用缓存前置或拆分长事务来优化,不能牺牲一致性换性能。

五、总结

分布式GaussDB的锁冲突本质是“多请求争抢数据库的排队凭证(锁)”,核心解决思路是“减少锁的持有时间,降低锁的粒度,或把高并发锁操作转移到其他组件”。生产排查要先从日志和系统视图定位到具体SQL和事务,再对应优化:代码层缩小事务范围,配置层调整锁超时,业务层用缓存分流。不同场景选不同方案,平衡性能和一致性,才能提升系统并发稳定性,解决分布式事务的一致性难题。