滴滴面试题:聊聊Redis事务?

42 阅读6分钟

💥 面试官:聊聊Redis事务?我差点因为库存扣减翻车!

大家好,我是Tech有道,一个曾经在 Redis 事务上栽过跟头的程序员 😅。 前几天面试被问:“介绍一下 Redis 的事务吧”,这问题表面简单,却让我想起那段不堪回首的库存扣减经历……


🚗 从我的翻车经历说起

记得那是我刚工作不久,负责一个电商项目的库存管理。 我当时心想:「Redis 这么快,用事务处理库存扣减肯定没问题!」 结果——上线第一天,库存变成了负数!😱


🧩 初识 Redis 事务:看似简单

相信很多同学都写过这样的代码:

// Java版本(使用Jedis)
Jedis jedis = new Jedis("localhost");
Transaction tx = jedis.multi();
try {
    tx.decr("product:123:stock");
    tx.sadd("order:456:products", "123");
    tx.hincrBy("user:789:stats", "total_orders", 1);
    tx.exec();
    System.out.println("库存扣减成功!");
} catch (Exception e) {
    tx.discard();
    System.out.println("事务执行失败:" + e.getMessage());
}
// Go版本(使用go-redis)
client := redis.NewClient(&redis.Options{Addr: "localhost:6379"})
pipe := client.TxPipeline()
pipe.Decr(ctx, "product:123:stock")
pipe.SAdd(ctx, "order:456:products", "123")
pipe.HIncrBy(ctx, "user:789:stats", "total_orders", 1)
_, err := pipe.Exec(ctx)
if err != nil {
    fmt.Println("事务执行失败:", err)
    return
}
fmt.Println("库存扣减成功!")

看起来很优雅对吧? 但我当时就掉进了坑。🕳️


💣 翻车现场:库存为啥会变负数?

假设库存只剩 1 件,两个用户几乎同时下单👇

// 用户A & 用户B 同时执行
Transaction tx = jedis.multi();
int stock = Integer.parseInt(jedis.get("product:123:stock")); // 在事务外读!
if (stock > 0) {
    tx.decr("product:123:stock");
    tx.exec(); // 两个事务都成功执行!
}

结果:「库存变成了 -1!」 这是典型的超卖问题。


🤔 原因分析:Redis 的事务不是你想的那种“事务”

Redis 的事务听起来很高级,但和 MySQL 的事务完全不是一回事。 它更像一个“命令打包执行器”。

我们先看看 Redis 的事务执行流程👇

客户端          Redis服务器
 |                |
 |   MULTI         |
 | ---------------> |  开启事务模式
 |  命令1,命令2...  |
 | ---------------> |  命令入队
 |   EXEC           |
 | ---------------> |  依次执行队列中所有命令
 | 结果1,结果2...   |
 | <--------------- |

⚙️ Redis 事务的三大特性(也是三大坑):

  1. 「不保证原子性」:部分命令失败不会回滚 ❌
  2. 「命令入队执行」:真正执行发生在 EXEC
  3. 「无隔离级别」:在 EXEC 前数据可能已被别人改掉

来个例子感受下👇

Transaction tx = jedis.multi();
tx.set("count", "100");
tx.incr("count");  // 失败,类型错误
tx.sadd("tags", "java", "golang");
List<Object> results = tx.exec();
// set 成功, incr 失败, sadd 依旧执行 ✅❌✅

在数据库事务中,这种情况会整体回滚; 但在 Redis 中,它会“「部分成功」”地继续执行。


🕵‍♂ Redis 为什么要这么设计?

其实这跟 Redis 的“哲学”有关。 Redis 是 「单线程模型」,天生避免了复杂的锁竞争。 它追求的是**「高性能与简单性」**,而不是强一致性。

所以,Redis 的事务更像是一种“批处理命令队列”, 而非数据库里的那种“ACID 保证”。


🔍 Watch 命令:Redis 的乐观锁救星

Redis 给了我们一个解决方案 —— WATCH。 它类似 MySQL 的乐观锁机制:在执行前监控某些 key。

// Java版本 - 正确的库存扣减
public boolean deductStock(String productKey, int quantity) {
    Jedis jedis = new Jedis("localhost");
    while (true) {
        jedis.watch(productKey);
        int stock = Integer.parseInt(jedis.get(productKey));
        if (stock < quantity) {
            jedis.unwatch();
            System.out.println("库存不足!");
            return false;
        }
        Transaction tx = jedis.multi();
        tx.decrBy(productKey, quantity);
        List<Object> res = tx.exec();
        if (res != null) {
            System.out.println("扣减成功,剩余库存:" + (stock - quantity));
            return true;
        }
        System.out.println("库存变化,重试中...");
    }
}
// Go版本 - 乐观锁实现库存扣减
err := client.Watch(ctx, func(tx *redis.Tx) error {
    stock, _ := tx.Get(ctx, "product:123:stock").Int()
    if stock < 1 {
        return fmt.Errorf("库存不足")
    }
    _, err := tx.TxPipelined(ctx, func(pipe redis.Pipeliner) error {
        pipe.Decr(ctx, "product:123:stock")
        return nil
    })
    return err
}, "product:123:stock")

WATCH 会在 EXEC 前检查 key 是否被改动, 如果变了,整个事务就作废(EXEC 返回 null), 于是我们就可以**「重试」**。

这就是 Redis 的“「乐观锁式事务」”。


⚔️ 对比一下:Redis vs MySQL 事务

特性Redis 事务MySQL 事务
原子性❌ 部分命令失败仍执行✅ 全部成功或全部回滚
一致性✅ 可用 WATCH 辅助✅ 强一致性
隔离性✅ 单线程天然隔离✅ 依赖 MVCC
持久性❌ 取决于 RDB/AOF 配置✅ WAL 日志保证

可以看到,Redis 的事务更像一个“轻量化打包器”, 「不是用来替代数据库事务的。」


🧱 Lua脚本:更优雅的原子性方案

到了后来,我终于明白:真正需要强原子性时, 用 Lua 脚本才是 Redis 的正确姿势 ✅

// Java使用Lua脚本实现原子扣减
String script = 
"local stock = tonumber(redis.call('get', KEYS[1])) " +
"if not stock or stock < tonumber(ARGV[1]) then return -1 end " +
"redis.call('decrby', KEYS[1], ARGV[1]) return stock - ARGV[1]";
Object res = jedis.eval(script, 1, "product:123:stock", "1");
System.out.println(res.equals(-1L) ? "库存不足" : "扣减成功,剩余:" + res);
// Go版本
lua := `
local stock = tonumber(redis.call('GET', KEYS[1]))
if not stock or stock < tonumber(ARGV[1]) then return -1 end
redis.call('DECRBY', KEYS[1], ARGV[1])
return stock - ARGV[1]`
res, _ := client.Eval(ctx, lua, []string{"product:123:stock"}, 1).Int()

「优点:」

  • ✅ 整个脚本原子执行
  • ✅ 性能更高(减少网络交互)
  • ✅ 支持逻辑判断

这就是为什么大厂几乎都用 Lua 代替事务。


💼 面试官问到该怎么答?

现在再遇到“介绍下 Redis 事务”,我会这么回答👇

Redis 事务通过 MULTIEXECDISCARDWATCH 实现, 但它不是传统意义上的事务。它提供命令打包与顺序执行的机制, 不保证原子性,也不提供回滚机制。 可以结合 WATCH 实现乐观锁, 需要真正原子性的操作建议使用 Lua 脚本。

面试官一般都会继续问: “那你会怎么处理库存扣减?” 👉 我就顺势聊到 Watch 方案、Lua 原子脚本、以及单线程模型原理。 既实战又深入,稳!💪


🎯 总结一下

Redis 的事务,其实是一个“轻事务”:

场景推荐方案
批量执行命令MULTI + EXEC
简单并发控制WATCH(乐观锁)
需要原子操作Lua 脚本
高并发库存扣减Lua + 限流机制

📌 「一句话总结:」

Redis 的事务不是 MySQL 的事务。 它更像是一种“批处理 + 乐观锁 + 单线程”的组合拳。


💬 最后的感悟

Redis 的“简单”其实是一种智慧。 它不追求大而全,而是给你足够的原语去自由组合。 学 Redis,最重要的是理解它**“单线程 + 命令原子性”**背后的设计哲学。

下次再有人问你:

“Redis 的事务靠谱吗?”

你就笑着说:

“看场景吧——要真事务,我用 MySQL;要快、要轻、要原子,我用 Lua。”

😉


「思考题」:在分布式Redis集群中,事务和Lua脚本又会遇到什么新的挑战呢?欢迎在评论区留言讨论!

记得点赞收藏,下次继续分享更多面试干货!👍


PS:有没有人和我一样,觉得Redis的这种"简单"设计其实很智慧?单线程模型避免了那么多复杂的并发问题,这大概就是"少即是多"的哲学体现吧!