3、Redis高级特性:Pipeline、事务、Lua脚本在批量下单和原子性操作中的实战

各位同学,今天我们来聊聊Redis的三个高级特性——Pipeline、事务和Lua脚本。说实话,这三个东西在交易系统里太常用了。尤其是批量下单和原子性操作,几乎每天都要打交道。

我个人习惯把这三个特性放在一起讲,因为它们解决的是同一个问题:如何在Redis里高效、安全地执行多个操作。但它们的实现思路和适用场景完全不同。你想想看,如果每次下单都要发10次网络请求,那延迟得多高?如果扣库存和生成订单不是原子操作,那并发场景下会出什么乱子?

嗯,咱们一个一个来看。

3.1 Pipeline:批量操作的加速器

Pipeline说白了就是管道。它允许你把多个命令打包在一起,一次性发给Redis服务器,然后一次性读取所有结果。

我在项目中遇到过这样一个场景:风控系统需要批量查询1000个股票的当前持仓。如果一条一条发命令,网络往返就要1000次。用Pipeline的话,一次往返就够了。

核心原理:Pipeline不保证原子性,它只是把多个命令打包发送。服务器按顺序执行,但中间可能插入其他客户端的命令。

来看代码:

import redis

r = redis.Redis(host='localhost', port=6379, decode_responses=True)

# 批量查询持仓
pipe = r.pipeline()
for stock_id in ['000001', '000002', '000003']:
    pipe.hgetall(f'position:{stock_id}')
results = pipe.execute()

# 批量写入订单
pipe = r.pipeline()
orders = [
    ('order:1001', {'stock': '000001', 'price': 10.5, 'qty': 100}),
    ('order:1002', {'stock': '000002', 'price': 20.3, 'qty': 200}),
]
for key, data in orders:
    pipe.hset(key, mapping=data)
pipe.execute()

避坑指南:我曾经在Pipeline里塞了10万个命令,结果Redis直接报内存错误。Pipeline虽然快,但也要控制单次批量的大小。我个人建议控制在1000-5000条之间,具体看你的数据大小。

3.2 事务:原子性的基本保证

Redis的事务和关系型数据库的事务不太一样。它用MULTI、EXEC、DISCARD和WATCH这四个命令来实现。

为什么说它不一样?因为Redis事务不支持回滚。如果中间某条命令执行失败,前面的命令不会撤销。说白了,它只保证「隔离执行」,不保证「全部成功」。

来看一个下单扣库存的例子:

def place_order(stock_id, buyer_id, qty, price):
    r = redis.Redis()
    
    # 开启事务
    pipe = r.pipeline(transaction=True)
    try:
        # 检查库存
        pipe.hget(f'stock:{stock_id}', 'available')
        # 扣减库存
        pipe.hincrby(f'stock:{stock_id}', 'available', -qty)
        # 创建订单
        pipe.hset(f'order:{buyer_id}:{stock_id}', mapping={
            'qty': qty,
            'price': price,
            'status': 'pending'
        })
        results = pipe.execute()
        
        # 检查库存是否足够
        available = int(results[0])
        if available < qty:
            # 这里只能手动处理,事务不会自动回滚
            print(f"库存不足: 可用{available}, 需要{qty}")
            return False
        return True
    except Exception as e:
        print(f"下单失败: {e}")
        return False

注意:上面的代码有个问题。如果库存检查通过后,另一个客户端扣了库存,那我们的扣减操作就会导致超卖。这就是为什么需要WATCH命令。

用WATCH实现乐观锁:

def safe_place_order(stock_id, buyer_id, qty, price):
    r = redis.Redis()
    
    # 监视库存键
    r.watch(f'stock:{stock_id}')
    
    # 读取当前库存
    available = int(r.hget(f'stock:{stock_id}', 'available'))
    if available < qty:
        r.unwatch()
        return False
    
    # 开启事务
    pipe = r.pipeline(transaction=True)
    pipe.hincrby(f'stock:{stock_id}', 'available', -qty)
    pipe.hset(f'order:{buyer_id}:{stock_id}', mapping={
        'qty': qty,
        'price': price,
        'status': 'confirmed'
    })
    
    try:
        results = pipe.execute()
        return True
    except redis.exceptions.WatchError:
        # 数据被其他客户端修改了,重试
        return safe_place_order(stock_id, buyer_id, qty, price)

3.3 Lua脚本:真正的原子性操作

说实话,事务虽然能用,但总感觉不够爽。尤其是那个不能回滚的问题,在金融系统里简直是噩梦。这时候Lua脚本就派上用场了。

Lua脚本在Redis里是原子执行的。整个脚本执行期间,不会有其他命令插入。而且脚本里可以做复杂的逻辑判断,失败了可以主动回滚。

来看一个完整的批量下单脚本:

# Lua脚本:批量下单并扣库存
batch_order_script = """
local stock_key = KEYS[1]
local order_prefix = KEYS[2]
local orders = cjson.decode(ARGV[1])

for i, order in ipairs(orders) do
    local stock_id = order['stock_id']
    local qty = order['qty']
    local buyer = order['buyer']
    local price = order['price']
    
    -- 检查库存
    local available = redis.call('HGET', stock_key .. ':' .. stock_id, 'available')
    if not available or tonumber(available) < tonumber(qty) then
        return {err = '库存不足: ' .. stock_id, index = i}
    end
    
    -- 扣库存
    redis.call('HINCRBY', stock_key .. ':' .. stock_id, 'available', -qty)
    redis.call('HINCRBY', stock_key .. ':' .. stock_id, 'frozen', qty)
    
    -- 生成订单
    local order_id = order_prefix .. ':' .. stock_id .. ':' .. buyer
    redis.call('HMSET', order_id,
        'stock_id', stock_id,
        'buyer', buyer,
        'qty', qty,
        'price', price,
        'status', 'confirmed',
        'timestamp', redis.call('TIME')[1]
    )
end

return {ok = 'success', count = #orders}
"""

# 注册脚本
sha = r.script_load(batch_order_script)

# 执行批量下单
orders = [
    {'stock_id': '000001', 'qty': 100, 'buyer': 'user_001', 'price': 10.5},
    {'stock_id': '000002', 'qty': 200, 'buyer': 'user_002', 'price': 20.3},
]
result = r.evalsha(sha, 2, 'stock', 'order', json.dumps(orders))

关键点:Lua脚本在Redis里是阻塞执行的。如果脚本执行时间太长,会阻塞其他所有操作。所以脚本要尽量短小精悍,别在里面做循环10万次这种傻事。

3.4 三者的对比与选型

我整理了一个表格,方便大家对比:

特性 Pipeline 事务 Lua脚本
原子性 不保证 保证(隔离执行) 保证(完全原子)
回滚能力 可编程实现
网络开销 低(批量发送) 中(多次交互) 低(一次发送)
复杂度
适用场景 批量查询、批量写入 简单原子操作 复杂业务逻辑

我个人习惯这样选:

  • 只是批量读数据?用Pipeline,简单粗暴
  • 需要原子性但逻辑简单?用事务+WATCH
  • 逻辑复杂、需要条件判断和回滚?上Lua脚本

3.5 实战中的避坑指南

最后分享几个我踩过的坑:

  1. Pipeline别太大——我曾经一次塞了5万条,Redis直接OOM。建议控制在2000条以内。
  2. 事务里别做耗时操作——事务期间Redis不会处理其他请求,别在里面做复杂计算。
  3. Lua脚本要注册——每次发送完整脚本太浪费带宽,用SCRIPT LOAD注册后用EVALSHA调用。
  4. 注意键的分布——如果用了Redis集群,Lua脚本里操作的键必须在同一个slot上。

小技巧:在Lua脚本里用redis.log()可以输出日志到Redis日志文件,调试时特别好用。我曾经靠这个定位过一个诡异的库存不一致问题。

好了,这一章的内容就到这里。Pipeline、事务和Lua脚本这三个工具,用好了能让你的交易系统性能提升一个量级。关键是要理解它们的本质区别,然后在合适的场景用合适的工具。

Redis高级特性在交易系统中的选型决策树 批量操作需求 需要原子性? 不需要原子性 逻辑简单 → 事务+WATCH 逻辑复杂 → Lua脚本 批量查询/写入 → Pipeline 选型原则:性能优先选Pipeline,安全优先选Lua脚本

专注资料整理