💥 面试官:聊聊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 事务的三大特性(也是三大坑):
- 「不保证原子性」:部分命令失败不会回滚 ❌
- 「命令入队执行」:真正执行发生在
EXEC时 - 「无隔离级别」:在
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 事务通过
MULTI、EXEC、DISCARD、WATCH实现, 但它不是传统意义上的事务。它提供命令打包与顺序执行的机制, 不保证原子性,也不提供回滚机制。 可以结合WATCH实现乐观锁, 需要真正原子性的操作建议使用 Lua 脚本。❞
面试官一般都会继续问: “那你会怎么处理库存扣减?” 👉 我就顺势聊到 Watch 方案、Lua 原子脚本、以及单线程模型原理。 既实战又深入,稳!💪
🎯 总结一下
Redis 的事务,其实是一个“轻事务”:
| 场景 | 推荐方案 |
|---|---|
| 批量执行命令 | MULTI + EXEC |
| 简单并发控制 | WATCH(乐观锁) |
| 需要原子操作 | Lua 脚本 |
| 高并发库存扣减 | Lua + 限流机制 |
📌 「一句话总结:」
❝
Redis 的事务不是 MySQL 的事务。 它更像是一种“批处理 + 乐观锁 + 单线程”的组合拳。
❞
💬 最后的感悟
Redis 的“简单”其实是一种智慧。 它不追求大而全,而是给你足够的原语去自由组合。 学 Redis,最重要的是理解它**“单线程 + 命令原子性”**背后的设计哲学。
下次再有人问你:
❝
“Redis 的事务靠谱吗?”
❞
你就笑着说:
❝
“看场景吧——要真事务,我用 MySQL;要快、要轻、要原子,我用 Lua。”
❞
😉
「思考题」:在分布式Redis集群中,事务和Lua脚本又会遇到什么新的挑战呢?欢迎在评论区留言讨论!
记得点赞收藏,下次继续分享更多面试干货!👍
PS:有没有人和我一样,觉得Redis的这种"简单"设计其实很智慧?单线程模型避免了那么多复杂的并发问题,这大概就是"少即是多"的哲学体现吧!