Redis原子性与事务机制深度解析:Lua脚本才是王道!

难度:⭐⭐⭐⭐ | 适合人群:想掌握Redis高级特性的开发者


💥 开场:一次"恐怖"的库存为负数

时间: 周三下午
地点: 办公室
事件: 运营反馈异常

运营妹子: "你们的库存有问题!后台显示库存是-50!"

我: "什么?库存怎么会是负数???" 😱

立即查看Redis:

redis> GET stock:product:123
"-50"  # 卧槽,真的是负数

我: "不可能啊,我明明做了判断..." 😰


查看代码:

@Service
public class StockService {
    
    public boolean deductStock(Long productId, int quantity) {
        String key = "stock:product:" + productId;
        
        // 1. 获取库存
        String stockStr = redisTemplate.opsForValue().get(key);
        int stock = Integer.parseInt(stockStr);
        
        // 2. 判断库存
        if (stock < quantity) {
            return false;  // 库存不足
        }
        
        // 3. 扣减库存
        stock -= quantity;
        redisTemplate.opsForValue().set(key, String.valueOf(stock));
        
        return true;
    }
}

哈吉米走过来: "这代码有并发问题,不是原子操作!"

我: "哪里不是原子?" 🤔

南北绿豆画了个图:

sequenceDiagram
    participant 线程1
    participant 线程2
    participant Redis
    
    Note over Redis: 当前库存:1
    
    线程1->>Redis: GET stock(返回1)
    线程2->>Redis: GET stock(返回1)
    
    线程1->>线程1: 判断:1 >= 1,通过 ✅
    线程2->>线程2: 判断:1 >= 1,通过 ✅
    
    线程1->>线程1: 计算:1 - 1 = 0
    线程2->>线程2: 计算:1 - 1 = 0
    
    线程1->>Redis: SET stock 0
    线程2->>Redis: SET stock 0
    
    Note over Redis: 卖出2件,但库存从1变成0<br/>库存不准确!

阿西噶阿西: "更糟的情况:库存变成负数!"

sequenceDiagram
    participant 线程1
    participant 线程2
    participant 线程3
    participant Redis
    
    Note over Redis: 当前库存:1
    
    线程1->>Redis: GET stock(1)
    线程2->>Redis: GET stock(1)
    线程3->>Redis: GET stock(1)
    
    Note over 线程1,线程3: 三个线程都判断通过
    
    线程1->>Redis: SET stock 0
    线程2->>Redis: SET stock 0  
    线程3->>Redis: SET stock 0
    
    Note over 线程1,线程3: 又来3个请求...
    
    线程1->>Redis: GET stock(0)
    Note over 线程1: 判断:0 >= 1,失败
    
    线程2->>Redis: GET stock(0)
    Note over 线程2: 判断通过(读到旧值)
    线程2->>Redis: SET stock -1
    
    Note over Redis: 库存变成-1!💥

我: "原来是这样!那怎么解决?" 😓

哈吉米: "必须保证操作的原子性!Redis有几种方式..."


🎯 第一问:什么是原子性?

原子性定义

原子性(Atomicity): 一个操作不可分割,要么全部执行,要么全部不执行。

生活中的例子:

转账操作:
1. 从A账户扣款1002. 给B账户加款100元

必须原子执行:
- 成功:两步都完成
- 失败:两步都不执行

不能出现:
- A扣了钱,B没收到 ❌
- A没扣钱,B收到了 ❌

Redis中的原子性

南北绿豆: "Redis中的原子性分两个层面。"

1. 单个命令的原子性

Redis是单线程的
    ↓
单个命令执行时不会被打断
    ↓
天然具有原子性

例如:
INCR、DECR、LPUSH、SADD等
都是原子操作

2. 多个命令的原子性

多个命令组合:
1. GET stock
2. 判断
3. SET stock

三个步骤之间会被其他命令打断
    ↓
不是原子操作
    ↓
需要特殊机制保证原子性

🔧 第二问:实现原子性的4种方式

方式1:使用原子命令(推荐)

哈吉米: "Redis提供了很多原子命令,直接用就行!"

INCR/DECR(自增/自减):

// ❌ 错误:不是原子操作
public void deductStock(String key) {
    String stock = redisTemplate.opsForValue().get(key);
    int value = Integer.parseInt(stock) - 1;
    redisTemplate.opsForValue().set(key, String.valueOf(value));
}

// ✅ 正确:原子操作
public void deductStock(String key) {
    redisTemplate.opsForValue().decrement(key);  // DECR命令,原子的
}

INCRBY/DECRBY(指定增减量):

// 扣减指定数量
redisTemplate.opsForValue().decrement("stock:product:123", 5);  // 减5

// 增加指定数量
redisTemplate.opsForValue().increment("stock:product:123", 10);  // 加10

GETSET(获取旧值并设置新值):

// 原子操作:获取旧值 + 设置新值
String oldValue = redisTemplate.opsForValue().getAndSet("counter", "0");
System.out.println("旧值:" + oldValue);

SETNX(不存在才设置):

// 原子操作:检查 + 设置
Boolean success = redisTemplate.opsForValue().setIfAbsent("lock:key", "value");

// 可以加过期时间(Redis 2.6.12+)
Boolean success = redisTemplate.opsForValue().setIfAbsent(
    "lock:key", "value", 10, TimeUnit.SECONDS);

方式2:Lua脚本(最强大,推荐)

阿西噶阿西: "Lua脚本是保证原子性的终极方案!"

为什么Lua脚本是原子的?

Redis执行Lua脚本时:
1. 脚本作为一个整体执行
2. 执行期间不会执行其他命令
3. 不会被打断
    ↓
天然保证原子性

案例1:库存扣减(带判断)

@Service
public class StockService {
    
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    
    /**
     * Lua脚本:原子扣减库存
     */
    private static final String DEDUCT_STOCK_SCRIPT = 
        "local stock = redis.call('get', KEYS[1]) " +
        "if not stock then " +
        "    return -1 " +  // 库存key不存在
        "end " +
        "if tonumber(stock) < tonumber(ARGV[1]) then " +
        "    return 0 " +   // 库存不足
        "end " +
        "redis.call('decrby', KEYS[1], ARGV[1]) " +
        "return 1";  // 扣减成功
    
    public boolean deductStock(Long productId, int quantity) {
        String key = "stock:product:" + productId;
        
        // 执行Lua脚本
        Long result = redisTemplate.execute(
            new DefaultRedisScript<>(DEDUCT_STOCK_SCRIPT, Long.class),
            Collections.singletonList(key),
            String.valueOf(quantity)
        );
        
        if (result == null) {
            return false;
        }
        
        switch (result.intValue()) {
            case -1:
                System.out.println("库存key不存在");
                return false;
            case 0:
                System.out.println("库存不足");
                return false;
            case 1:
                System.out.println("扣减成功");
                return true;
            default:
                return false;
        }
    }
}

并发测试:

@Test
public void testConcurrentDeduct() throws InterruptedException {
    
    // 初始化库存
    redisTemplate.opsForValue().set("stock:product:123", "100");
    
    CountDownLatch latch = new CountDownLatch(1);
    AtomicInteger successCount = new AtomicInteger(0);
    
    // 1000个线程并发扣减
    ExecutorService executor = Executors.newFixedThreadPool(100);
    
    for (int i = 0; i < 1000; i++) {
        executor.submit(() -> {
            try {
                latch.await();
                
                if (stockService.deductStock(123L, 1)) {
                    successCount.incrementAndGet();
                }
                
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });
    }
    
    latch.countDown();  // 开始
    Thread.sleep(5000);
    
    System.out.println("成功扣减:" + successCount.get());
    System.out.println("剩余库存:" + redisTemplate.opsForValue().get("stock:product:123"));
    
    executor.shutdown();
}

输出:

成功扣减:100
剩余库存:0

完全准确!没有超卖! ✅

案例2:限流(滑动窗口)

/**
 * Lua脚本:滑动窗口限流
 */
private static final String RATE_LIMIT_SCRIPT =
    "local key = KEYS[1] " +
    "local limit = tonumber(ARGV[1]) " +
    "local window = tonumber(ARGV[2]) " +
    "local current = tonumber(ARGV[3]) " +
    
    // 删除窗口外的数据
    "redis.call('zremrangebyscore', key, 0, current - window) " +
    
    // 统计窗口内的请求数
    "local count = redis.call('zcard', key) " +
    
    // 判断是否超过限制
    "if count < limit then " +
    "    redis.call('zadd', key, current, current) " +
    "    redis.call('expire', key, window) " +
    "    return 1 " +  // 允许请求
    "else " +
    "    return 0 " +  // 拒绝请求
    "end";

public boolean isAllowed(String userId, int limit, int windowSeconds) {
    String key = "rate:limit:" + userId;
    long current = System.currentTimeMillis();
    
    Long result = redisTemplate.execute(
        new DefaultRedisScript<>(RATE_LIMIT_SCRIPT, Long.class),
        Collections.singletonList(key),
        String.valueOf(limit),
        String.valueOf(windowSeconds * 1000),
        String.valueOf(current)
    );
    
    return result != null && result == 1;
}

使用:

// 限流:每秒最多10个请求
if (isAllowed("user:123", 10, 1)) {
    // 处理请求
} else {
    // 拒绝请求:限流
}

方式3:Redis事务(有限制)

南北绿豆: "Redis也有事务,但和MySQL的事务不一样!"

基本使用:

public void transfer() {
    // 开启事务
    redisTemplate.execute(new SessionCallback<Object>() {
        @Override
        public Object execute(RedisOperations operations) throws DataAccessException {
            
            operations.multi();  // MULTI:开启事务
            
            try {
                // 一系列命令
                operations.opsForValue().decrement("account:A", 100);  // A扣100
                operations.opsForValue().increment("account:B", 100);  // B加100
                
                // 提交事务
                return operations.exec();  // EXEC:执行事务
                
            } catch (Exception e) {
                operations.discard();  // DISCARD:取消事务
                return null;
            }
        }
    });
}

Redis事务的特点:

1. 不支持回滚
   - 命令错误会继续执行其他命令
   - 不像MySQL那样全部回滚

2. 不保证原子性(有争议)
   - MULTI到EXEC之间的命令会被打断吗?不会
   - 但部分命令失败不会回滚

3. 使用场景有限

示例:错误不会回滚

redis> MULTI
OK

redis> SET key1 "value1"
QUEUED

redis> INCR key1  # 错误:key1不是数字
QUEUED

redis> SET key2 "value2"
QUEUED

redis> EXEC
1) OK
2) (error) ERR value is not an integer
3) OK

# 结果:
# key1 = "value1"  ✅ 执行了
# INCR失败       ❌ 失败了
# key2 = "value2"  ✅ 还是执行了

# MySQL的事务:全部回滚
# Redis的事务:部分失败,其他照常执行

阿西噶阿西: "所以Redis事务不适合复杂业务,Lua脚本才是王道!"


方式4:WATCH乐观锁

南北绿豆: "WATCH可以实现乐观锁。"

public boolean deductStockWithWatch(String key, int quantity) {
    
    while (true) {
        // 1. WATCH key
        redisTemplate.watch(key);
        
        // 2. 获取当前值
        String stockStr = redisTemplate.opsForValue().get(key);
        int stock = Integer.parseInt(stockStr);
        
        // 3. 判断库存
        if (stock < quantity) {
            redisTemplate.unwatch();
            return false;
        }
        
        // 4. 开启事务
        redisTemplate.multi();
        
        // 5. 扣减库存
        redisTemplate.opsForValue().set(key, String.valueOf(stock - quantity));
        
        // 6. 执行事务
        List<Object> results = redisTemplate.exec();
        
        // 7. 检查结果
        if (results != null && !results.isEmpty()) {
            // 执行成功
            return true;
        }
        
        // 执行失败(key被修改了),重试
        System.out.println("检测到并发修改,重试...");
    }
}

WATCH原理:

sequenceDiagram
    participant 线程1
    participant 线程2
    participant Redis
    
    线程1->>Redis: WATCH stock
    Note over 线程1: 开始监视stock
    
    线程1->>Redis: GET stock(100)
    
    线程2->>Redis: SET stock 99
    Note over Redis: stock被修改了
    
    线程1->>Redis: MULTI
    线程1->>Redis: SET stock 99
    线程1->>Redis: EXEC
    
    Redis-->>线程1: nil(事务失败)
    Note over 线程1: WATCH的key被修改<br/>事务不执行

缺点:

  • ❌ 需要重试(失败了要重新执行)
  • ❌ 高并发时重试次数多
  • ❌ 性能不如Lua脚本

📊 第三问:四种方式对比

对比表格

方式原子性性能易用性适用场景推荐度
原子命令✅ 强⚡⚡ 最快⭐⭐⭐ 简单简单操作⭐⭐⭐⭐⭐
Lua脚本✅ 强⚡⚡ 快⭐⭐ 稍复杂复杂业务逻辑⭐⭐⭐⭐⭐
MULTI/EXEC⚠️ 弱⚡ 一般⭐⭐⭐ 简单简单批量操作⭐⭐
WATCH✅ 强🐢 慢(需重试)⭐ 复杂乐观锁场景⭐⭐

选择建议

阿西噶阿西: "选择决策树。"

你的需求是?
│
├─ 简单的自增/自减
│  └─ 使用:INCR/DECR原子命令 ⭐⭐⭐⭐⭐
│
├─ 需要判断 + 操作
│  └─ 使用:Lua脚本 ⭐⭐⭐⭐⭐
│
├─ 简单的批量操作(不需要判断)
│  └─ 使用:MULTI/EXEC
│
├─ 乐观锁场景
│  └─ 使用:WATCH
│
└─ 复杂业务逻辑
   └─ 使用:Lua脚本 ⭐⭐⭐⭐⭐

💻 第四问:Lua脚本实战案例

案例1:秒杀扣库存(完整版)

@Service
public class SeckillService {
    
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    
    /**
     * Lua脚本:秒杀扣库存
     * 功能:
     * 1. 检查库存
     * 2. 检查用户是否已购买
     * 3. 扣减库存
     * 4. 记录用户购买
     */
    private static final String SECKILL_SCRIPT =
        "local stockKey = KEYS[1] " +                    // 库存key
        "local userKey = KEYS[2] " +                     // 用户购买记录key
        "local quantity = tonumber(ARGV[1]) " +          // 购买数量
        "local userId = ARGV[2] " +                      // 用户ID
        
        // 1. 检查库存是否存在
        "local stock = redis.call('get', stockKey) " +
        "if not stock then " +
        "    return -1 " +  // 库存key不存在
        "end " +
        
        // 2. 检查库存是否足够
        "if tonumber(stock) < quantity then " +
        "    return 0 " +  // 库存不足
        "end " +
        
        // 3. 检查用户是否已购买
        "if redis.call('sismember', userKey, userId) == 1 then " +
        "    return -2 " +  // 已经购买过了
        "end " +
        
        // 4. 扣减库存
        "redis.call('decrby', stockKey, quantity) " +
        
        // 5. 记录用户购买
        "redis.call('sadd', userKey, userId) " +
        
        "return 1";  // 成功
    
    public SeckillResult seckill(Long productId, Long userId, int quantity) {
        
        String stockKey = "stock:product:" + productId;
        String userKey = "seckill:users:product:" + productId;
        
        // 执行Lua脚本
        Long result = redisTemplate.execute(
            new DefaultRedisScript<>(SECKILL_SCRIPT, Long.class),
            Arrays.asList(stockKey, userKey),
            String.valueOf(quantity),
            String.valueOf(userId)
        );
        
        if (result == null) {
            return SeckillResult.fail("系统错误");
        }
        
        switch (result.intValue()) {
            case -1:
                return SeckillResult.fail("商品不存在");
            case -2:
                return SeckillResult.fail("您已经购买过了");
            case 0:
                return SeckillResult.fail("库存不足");
            case 1:
                return SeckillResult.success("秒杀成功");
            default:
                return SeckillResult.fail("未知错误");
        }
    }
}

案例2:分布式锁(带过期时间)

/**
 * Lua脚本:加锁
 */
private static final String LOCK_SCRIPT =
    "if redis.call('exists', KEYS[1]) == 0 then " +
    "    redis.call('hset', KEYS[1], ARGV[1], 1) " +
    "    redis.call('expire', KEYS[1], ARGV[2]) " +
    "    return 1 " +
    "elseif redis.call('hexists', KEYS[1], ARGV[1]) == 1 then " +
    "    redis.call('hincrby', KEYS[1], ARGV[1], 1) " +
    "    redis.call('expire', KEYS[1], ARGV[2]) " +
    "    return 1 " +
    "else " +
    "    return 0 " +
    "end";

/**
 * Lua脚本:解锁
 */
private static final String UNLOCK_SCRIPT =
    "if redis.call('hexists', KEYS[1], ARGV[1]) == 0 then " +
    "    return nil " +
    "end " +
    "local counter = redis.call('hincrby', KEYS[1], ARGV[1], -1) " +
    "if counter > 0 then " +
    "    return 0 " +
    "else " +
    "    redis.call('del', KEYS[1]) " +
    "    return 1 " +
    "end";

案例3:批量操作 + 限流

/**
 * Lua脚本:批量获取 + 限流
 */
private static final String BATCH_GET_SCRIPT =
    "local limit = tonumber(ARGV[1]) " +
    "if #KEYS > limit then " +
    "    return redis.error_reply('Too many keys') " +
    "end " +
    
    "local result = {} " +
    "for i, key in ipairs(KEYS) do " +
    "    local value = redis.call('get', key) " +
    "    if value then " +
    "        table.insert(result, value) " +
    "    else " +
    "        table.insert(result, false) " +
    "    end " +
    "end " +
    "return result";

public List<String> batchGet(List<String> keys, int limit) {
    
    List<Object> results = redisTemplate.execute(
        new DefaultRedisScript<>(BATCH_GET_SCRIPT, List.class),
        keys,
        String.valueOf(limit)
    );
    
    return results.stream()
        .map(obj -> obj instanceof String ? (String) obj : null)
        .collect(Collectors.toList());
}

💡 最佳实践

1. 优先使用原子命令

// ✅ 推荐:直接用原子命令
redisTemplate.opsForValue().increment("counter");
redisTemplate.opsForValue().decrement("stock");

// ❌ 不推荐:自己实现(不是原子的)
String value = redisTemplate.opsForValue().get("counter");
int newValue = Integer.parseInt(value) + 1;
redisTemplate.opsForValue().set("counter", String.valueOf(newValue));

2. Lua脚本优化

// ✅ 推荐:脚本预编译,复用DefaultRedisScript对象
private static final DefaultRedisScript<Long> DEDUCT_SCRIPT;

static {
    DEDUCT_SCRIPT = new DefaultRedisScript<>();
    DEDUCT_SCRIPT.setScriptText(DEDUCT_STOCK_SCRIPT);
    DEDUCT_SCRIPT.setResultType(Long.class);
}

public boolean deductStock(String key, int quantity) {
    return redisTemplate.execute(DEDUCT_SCRIPT, 
        Collections.singletonList(key), 
        String.valueOf(quantity)) == 1;
}

// ❌ 不推荐:每次都new DefaultRedisScript
public boolean deductStock(String key, int quantity) {
    return redisTemplate.execute(
        new DefaultRedisScript<>(DEDUCT_STOCK_SCRIPT, Long.class),  // 每次new
        Collections.singletonList(key), 
        String.valueOf(quantity)) == 1;
}

3. 错误处理

try {
    Long result = redisTemplate.execute(script, keys, args);
    
    if (result == null) {
        log.error("Lua脚本执行失败");
        return false;
    }
    
    return result == 1;
    
} catch (Exception e) {
    log.error("Redis操作异常", e);
    
    // 降级方案
    return fallbackMethod();
}

4. 脚本管理

// 推荐:单独管理Lua脚本

// 1. 脚本文件:resources/lua/deduct_stock.lua
local stock = redis.call('get', KEYS[1])
if not stock then
    return -1
end
if tonumber(stock) < tonumber(ARGV[1]) then
    return 0
end
redis.call('decrby', KEYS[1], ARGV[1])
return 1

// 2. 加载脚本
@Configuration
public class LuaScriptConfig {
    
    @Bean
    public DefaultRedisScript<Long> deductStockScript() {
        DefaultRedisScript<Long> script = new DefaultRedisScript<>();
        script.setLocation(new ClassPathResource("lua/deduct_stock.lua"));
        script.setResultType(Long.class);
        return script;
    }
}

// 3. 使用
@Autowired
private DefaultRedisScript<Long> deductStockScript;

public boolean deductStock(String key, int quantity) {
    return redisTemplate.execute(deductStockScript, 
        Collections.singletonList(key), 
        String.valueOf(quantity)) == 1;
}

💡 知识点总结

原子性与事务核心要点

原子性概念

  • 操作不可分割
  • 要么全部成功,要么全部失败
  • Redis单命令天然原子

4种实现方式

  1. 原子命令(INCR/DECR等)- 最简单
  2. Lua脚本 - 最强大(推荐)
  3. MULTI/EXEC事务 - 有限制
  4. WATCH乐观锁 - 需重试

Lua脚本优势

  • 在Redis中原子执行
  • 支持复杂逻辑
  • 性能好(减少网络开销)
  • 可复用

Redis事务特点

  • 不支持回滚
  • 部分命令失败不影响其他命令
  • 和MySQL事务不同

实战场景

  • 库存扣减:Lua脚本
  • 秒杀:Lua脚本 + 判断
  • 限流:Lua脚本 + 滑动窗口
  • 分布式锁:Lua脚本

最佳实践

  • 优先使用原子命令
  • 复杂逻辑用Lua脚本
  • 脚本要复用
  • 做好错误处理

记忆口诀

Redis单命令原子性,
多命令需要特殊技。
INCR、DECR最简单,
简单场景直接用。
Lua脚本最强大,
复杂逻辑全搞定。
MULTI、EXEC有事务,
不支持回滚要记住。
WATCH实现乐观锁,
并发高时要重试。
生产推荐Lua脚本,
原子性能两兼顾。

🤔 常见面试题

Q1: 如何实现Redis原子性?

A:

4种方式:

1. 使用原子命令(最简单)
   - INCR、DECR、INCRBY、DECRBY
   - SETNX、GETSET
   - 适合简单操作

2. Lua脚本(推荐)
   - 脚本整体原子执行
   - 支持复杂逻辑
   - 性能好

3. MULTI/EXEC事务
   - 批量命令一起执行
   - 不支持回滚
   - 使用场景有限

4. WATCH乐观锁
   - 监视key变化
   - 需要重试
   - 性能较差

生产推荐:简单用原子命令,复杂用Lua脚本

Q2: 除了Lua脚本,还有什么能保证原子性?

A:

1. 原子命令
   - INCR、DECR等
   - 天然原子

2. MULTI/EXEC事务
   - 有限的原子性
   - 不支持回滚

3. WATCH乐观锁
   - 结合事务使用
   - 需要重试逻辑

4. Redis Module(扩展模块)
   - 自定义原子命令
   - 需要编译安装

实际:Lua脚本是最佳方案

Q3: Redis事务和MySQL事务的区别?

A:

MySQL事务:
- 支持回滚(ROLLBACK)
- 满足ACID
- 部分失败全部回滚
- 支持隔离级别

Redis事务:
- 不支持回滚
- 不满足ACID
- 部分失败继续执行
- 不支持隔离级别

示例:
MySQL:命令2失败 → 命令1回滚
Redis:命令2失败 → 命令1和3照常执行

结论:Redis事务更像"批量执行"

💬 写在最后

从原子性到Lua脚本,我们深入学习了Redis的原子操作:

  • ⚛️ 理解了原子性的重要性
  • 🔧 掌握了4种实现方式
  • 📜 学会了Lua脚本编写
  • 💻 完成了秒杀等实战案例

这篇文章,希望能让你彻底掌握Redis原子操作!

如果这篇文章对你有帮助,请:

  • 👍 点赞支持
  • ⭐ 收藏备用
  • 🔄 转发分享
  • 💬 评论交流

感谢阅读,期待下次再见! 👋