上一篇我们实战了Redis缓存数据库查询、页面访问量计数器两大高频场景,借助内存高速读写和原子命令,实现了性能提速与精准计数。但在高并发场景下,比如缓存击穿重建、库存扣减、重复提交、定时任务重复执行,单纯的缓存和计数无法解决并发竞争问题,极易出现数据错乱、超卖等故障。

这时候就需要分布式锁来兜底,而Redis凭借SETNX原子命令,能零成本实现轻量级分布式互斥锁,无需引入复杂中间件,是入门分布式锁的最佳选择。本篇从零拆解SETNX分布式锁,从原理、实战到避坑,带你快速落地安全可靠的互斥逻辑。

核心定位:分布式锁用于跨JVM、跨服务器的并发互斥,保证同一时间只有一个线程执行关键业务;SETNX(SET if Not eXists)是Redis实现最简分布式锁的核心,原子性操作无并发漏洞。

一、为什么需要分布式锁?

单机应用中,我们用synchronized、Lock就能解决线程安全问题,但分布式架构下,服务部署在多台服务器,单机锁失效,必须用分布式锁协调并发。

典型并发痛点场景

  • 缓存击穿:热点缓存过期,大量请求同时击穿缓存打到数据库
  • 库存超卖:商品秒杀时,多个线程同时扣减库存,导致库存为负
  • 重复提交:用户频繁点击提交按钮,重复发起请求创建多条数据
  • 定时任务重复执行:集群部署下,同一定时任务多节点同时运行

分布式锁的核心目标:加锁互斥、防并发、防死锁、保证数据一致性


二、SETNX实现分布式锁核心原理

1. SETNX命令基础

SETNX是Redis原子性命令,全称“不存在则设置”,执行逻辑具备排他性,天然适配锁机制:

  • 命令格式:SETNX key value
  • key不存在:创建key并赋值,返回1,代表加锁成功
  • key已存在:不做任何操作,返回0,代表加锁失败

致命缺陷:单纯SETNX加锁后,如果服务宕机、程序异常,锁永远不会释放,会造成死锁。因此必须结合EX过期时间,使用SET key value EX 超时时间 NX合并命令(Redis 2.6.12+支持),保证锁超时自动释放。

2. 标准锁命令(推荐)

摒弃单独SETNX,改用原子合并命令,彻底解决死锁问题:

  • 加锁SET lock:key uniqueValue EX 10 NX
  • 解锁DEL lock:key

参数说明:

  • lock:key:锁的Key,遵循业务命名规范
  • uniqueValue:唯一标识(如UUID),用于防止误删他人锁
  • EX 10:锁超时时间10秒,超时自动释放
  • NX:等价于SETNX,仅不存在时设置

3. 分布式锁执行流程

  1. 线程尝试加锁,执行SET…EX…NX命令
  2. 加锁成功:执行业务逻辑
  3. 业务执行完毕:主动释放锁(DEL)
  4. 加锁失败:等待重试或直接返回失败
  5. 服务宕机:锁超时自动释放,避免死锁

三、实战:SpringBoot实现SETNX分布式锁

基于前文RedisTemplate环境,实现可直接复用的分布式锁工具类,适配缓存击穿、库存扣减等场景。

第一步:定义锁Key规范

沿用Redis命名规范,做到见名知意、便于维护:

  • 缓存重建锁:lock:cache:article:1001
  • 库存扣减锁:lock:stock:goods:6688
  • 提交防重锁:lock:submit:user:1001

第二步:分布式锁工具类代码

import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.util.UUID;
import java.util.concurrent.TimeUnit;

/**
 * Redis SETNX 分布式锁工具类
 */
@Component
public class RedisLockUtil {

    @Resource
    private RedisTemplate<String, String> redisTemplate;

    // 锁前缀
    private static final String LOCK_PREFIX = "lock:";
    // 默认锁超时时间(秒)
    private static final long DEFAULT_LOCK_EXPIRE = 10;

    /**
     * 加锁(SETNX+EX原子命令)
     * @param lockKey 锁业务Key
     * @return 锁唯一标识(用于解锁),加锁失败返回null
     */
    public String lock(String lockKey) {
        // 生成唯一值,防止误删锁
        String uniqueValue = UUID.randomUUID().toString();
        String key = LOCK_PREFIX + lockKey;
        // 原子加锁:不存在则设置+超时时间
        Boolean success = redisTemplate.opsForValue()
                .setIfAbsent(key, uniqueValue, DEFAULT_LOCK_EXPIRE, TimeUnit.SECONDS);
        return Boolean.TRUE.equals(success) ? uniqueValue : null;
    }

    /**
     * 解锁(校验唯一值,防止误删)
     * @param lockKey 锁业务Key
     * @param uniqueValue 加锁时的唯一标识
     */
    public void unlock(String lockKey, String uniqueValue) {
        String key = LOCK_PREFIX + lockKey;
        // 校验唯一值,一致才删除锁
        String value = redisTemplate.opsForValue().get(key);
        if (uniqueValue.equals(value)) {
            redisTemplate.delete(key);
        }
    }
}

第三步:业务场景实战(缓存击穿防护)

结合前文文章查询缓存,用分布式锁解决热点缓存过期击穿问题:

@Service
public class ArticleService {

    @Resource
    private ArticleMapper articleMapper;

    @Resource
    private RedisTemplate<String, Object> redisTemplate;

    @Resource
    private RedisLockUtil redisLockUtil;

    private static final String ARTICLE_CACHE_KEY = "article:detail:";
    private static final long CACHE_EXPIRE = 7200;

    /**
     * 带分布式锁的文章查询(防缓存击穿)
     */
    public Article getArticleByIdWithLock(Long articleId) {
        String cacheKey = ARTICLE_CACHE_KEY + articleId;
        // 1. 先查缓存
        Article cacheArticle = (Article) redisTemplate.opsForValue().get(cacheKey);
        if (cacheArticle != null) {
            return cacheArticle;
        }

        // 2. 缓存未命中,加锁防止缓存击穿
        String lockKey = "cache:article:" + articleId;
        String uniqueValue = redisLockUtil.lock(lockKey);
        if (uniqueValue == null) {
            // 加锁失败,休眠重试(或直接返回提示)
            try {
                Thread.sleep(100);
                return getArticleByIdWithLock(articleId);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                return null;
            }
        }

        try {
            // 3. 二次校验缓存(防止其他线程已重建缓存)
            cacheArticle = (Article) redisTemplate.opsForValue().get(cacheKey);
            if (cacheArticle != null) {
                return cacheArticle;
            }

            // 4. 查数据库并重建缓存
            Article dbArticle = articleMapper.selectById(articleId);
            if (dbArticle != null) {
                redisTemplate.opsForValue().set(cacheKey, dbArticle, CACHE_EXPIRE, TimeUnit.SECONDS);
            }
            return dbArticle;
        } finally {
            // 5. 无论业务成败,必须释放锁
            redisLockUtil.unlock(lockKey, uniqueValue);
        }
    }
}

四、SETNX分布式锁注意事项(避坑指南)

1. 必须设置锁超时时间

严禁使用无过期时间的SETNX,服务宕机、程序异常会导致锁无法释放,引发死锁。超时时间根据业务耗时设置,建议5~30秒。

2. 解锁必须校验唯一值

如果不校验唯一标识,线程A的锁超时自动释放后,线程B加锁成功,线程A执行完毕会误删线程B的锁,导致锁失效。唯一值+校验解锁是必备操作。

3. 避免锁超时导致业务未执行完

如果业务执行时间超过锁超时时间,锁会提前释放,并发问题依旧存在。入门场景可适当延长超时时间;进阶场景可采用锁续期(守护线程定时延长锁时间)。

4. 加锁与解锁必须成对出现

解锁逻辑放在finally代码块中,保证无论业务是否异常,锁都会被释放,杜绝死锁隐患。

5. 不适合复杂分布式场景

SETNX锁是非重入、非阻塞、无等待队列的简易锁,适合低并发、简单互斥场景;高并发、集群可靠场景,建议使用Redisson、ZooKeeper等专业分布式锁组件。


五、SETNX分布式锁优缺点总结

优点缺点
实现简单,零额外组件依赖非重入锁,不支持嵌套加锁
性能极高,Redis内存操作速度快无等待队列,高并发下重试成本高
原子操作,无并发竞争漏洞Redis集群模式下,存在锁丢失风险
轻量级,适合入门与简单业务需手动处理超时、续期问题

核心总结:SETNX是Redis入门分布式锁的最优方案,通过“原子加锁+超时释放+校验解锁”三步,就能解决绝大多数简单并发互斥问题,完美衔接缓存、计数等场景,是后端开发者必备的基础技能。

发表回复