在高并发的业务场景中,Redis作为常用的缓存和分布式存储组件,经常需要执行多个关联操作,比如先查询库存再扣减库存,或者先获取用户积分再更新积分。如果这些操作不保证原子性,多个请求同时执行时就会出现数据竞争,导致最终结果不符合预期。针对C#开发场景,Redis提供了事务和Lua脚本两种方案来保证操作的原子性,下面分别介绍两者的实现方式和特点。

Redis原子性的基本概念
Redis的原子性指的是一个或者多个操作在执行过程中不会被其他命令打断,要么全部执行成功,要么全部不执行。需要注意的是,Redis的单条命令本身就是原子性的,但是多条命令组合起来如果不做特殊处理,就不具备原子性。
使用Redis事务保证原子性
Redis事务的基本原理
Redis事务通过MULTI、EXEC、DISCARD三个命令实现,MULTI标记事务开始,之后的命令会进入队列暂存,EXEC命令触发所有队列中的命令按顺序执行,DISCARD可以取消事务。事务中的所有命令会作为一个整体执行,中间不会被其他客户端的命令插入。
C#中使用StackExchange.Redis实现事务
StackExchange.Redis是C#中最常用的Redis客户端库,下面是使用事务实现库存扣减的示例代码:
using StackExchange.Redis;
using System;
class RedisTransactionDemo
{
static void Main()
{
// 创建Redis连接
ConnectionMultiplexer redis = ConnectionMultiplexer.Connect("localhost:6379");
IDatabase db = redis.GetDatabase();
// 初始化库存为100
db.StringSet("product_stock", 100);
// 开启事务
ITransaction transaction = db.CreateTransaction();
// 事务中的命令会进入队列,不会立即执行
// 先获取当前库存
var stockTask = transaction.StringGetAsync("product_stock");
// 扣减库存,这里先模拟获取库存后的逻辑,实际事务中无法依赖前一个命令的结果
transaction.AddCondition(Condition.KeyExists("product_stock"));
var decrementTask = transaction.StringDecrementAsync("product_stock");
// 提交事务,执行所有队列中的命令
bool committed = transaction.Execute();
if (committed)
{
Console.WriteLine("事务执行成功");
// 注意:异步任务的结果需要在Execute之后获取
var stock = stockTask.Result;
Console.WriteLine($"当前库存:{stock}");
}
else
{
Console.WriteLine("事务执行失败,可能是条件不满足");
}
redis.Close();
}
}
Redis事务的局限性
- Redis事务不支持回滚,如果队列中的某个命令执行失败,其他命令仍然会继续执行。
- 事务中的命令在执行前不会返回结果,无法在事务内根据前一个命令的结果决定后续操作,比如无法先判断库存大于0再扣减。
- 事务通过乐观锁实现,使用WATCH命令监视的键如果被修改,事务会执行失败,需要重试。
使用Lua脚本保证原子性
Lua脚本的基本原理
Redis支持执行Lua脚本,脚本会被当作一个整体执行,执行期间不会被其他命令打断,天然具备原子性。同时Lua脚本内部可以编写逻辑判断,根据中间结果决定后续操作,弥补了事务的不足。
C#中执行Lua脚本实现库存扣减
下面是使用Lua脚本实现先判断库存再扣减的示例代码,逻辑更灵活:
-- Lua脚本:如果库存大于0则扣减1,返回1表示成功,0表示库存不足
local stock = tonumber(redis.call('get', KEYS[1]))
if stock and stock > 0 then
redis.call('decr', KEYS[1])
return 1
else
return 0
end
C#中调用该Lua脚本的代码:
using StackExchange.Redis;
using System;
class RedisLuaDemo
{
static void Main()
{
ConnectionMultiplexer redis = ConnectionMultiplexer.Connect("localhost:6379");
IDatabase db = redis.GetDatabase();
// 初始化库存
db.StringSet("product_stock", 100);
// 定义Lua脚本
string luaScript = @"
local stock = tonumber(redis.call('get', KEYS[1]))
if stock and stock > 0 then
redis.call('decr', KEYS[1])
return 1
else
return 0
end
";
// 加载脚本,避免每次执行都传输完整脚本
LoadedLuaScript loadedScript = LuaScript.Prepare(luaScript);
// 执行脚本,传入键名参数
RedisResult result = db.ScriptEvaluate(loadedScript, new RedisKey[] { "product_stock" });
if (result.ToString() == "1")
{
Console.WriteLine("库存扣减成功");
var currentStock = db.StringGet("product_stock");
Console.WriteLine($"当前库存:{currentStock}");
}
else
{
Console.WriteLine("库存不足,扣减失败");
}
redis.Close();
}
}
Lua脚本的优势
- 原子性更强,脚本执行过程中不会被其他命令打断,也不需要额外的乐观锁机制。
- 支持逻辑判断,可以在脚本内部根据中间结果决定后续操作,适合复杂的业务逻辑。
- 减少网络往返,多个操作放在一个脚本中执行,只需要一次网络请求,性能更好。
两种方案的对比与选型建议
| 对比维度 | Redis事务 | Lua脚本 |
|---|---|---|
| 原子性保证 | 队列中的命令整体执行,无回滚 | 脚本整体执行,天然原子性 |
| 逻辑灵活性 | 无法依赖前序命令结果 | 支持条件判断、循环等逻辑 |
| 性能 | 多次网络请求(MULTI、命令、EXEC) | 一次网络请求 |
| 适用场景 | 简单的多条命令批量执行,无逻辑依赖 | 复杂逻辑,需要条件判断的操作 |
在高并发场景下,如果操作逻辑简单,不需要根据前序结果判断,可以选择Redis事务;如果操作需要条件判断,或者逻辑相对复杂,优先选择Lua脚本,既能保证原子性,又能减少网络开销,性能更优。