乐观锁悲观锁分布式锁消息队列

一、Redlock算法(Redis官方推荐的分布式锁)

1. 业务场景分析

在超市商品到货登记系统中,关键业务逻辑是:

  1. 记录商品到货信息
  2. 更新商品库存数量
  3. 确保库存数量不超过1000的限制
  4. 同一商品同一天到货只更新不新增记录

这些操作需要保证数据一致性和并发安全。

2. 锁机制实现方案

2.1 乐观锁实现

乐观锁适用于冲突较少的场景,通过版本号控制:

csharp

// 商品仓库数量表增加Version字段
public class CommodityStocks
{
    public int Id { get; set; }
    public int CommodityId { get; set; }
    public int Quantity { get; set; }
    public int Version { get; set; } // 乐观锁版本号
}

// 更新库存时使用乐观锁
public async Task<bool> UpdateStockWithOptimisticLock(int commodityId, int quantityToAdd)
{
    var stock = await _dbContext.CommodityStocks
        .FirstOrDefaultAsync(s => s.CommodityId == commodityId);
    
    if (stock == null) return false;
    
    // 检查库存是否超过1000
    if (stock.Quantity + quantityToAdd > 1000)
    {
        throw new Exception("库存数量不能超过1000");
    }
    
    stock.Quantity += quantityToAdd;
    stock.Version += 1;
    
    try
    {
        // WHERE条件中包含Version检查
        var affectedRows = await _dbContext.Database.ExecuteSqlInterpolatedAsync(
            $"UPDATE CommodityStocks SET Quantity = {stock.Quantity}, Version = {stock.Version} " +
            $"WHERE Id = {stock.Id} AND Version = {stock.Version - 1}");
            
        return affectedRows > 0;
    }
    catch (DbUpdateConcurrencyException)
    {
        // 版本冲突,重试或返回失败
        return false;
    }
}

2.2 悲观锁实现

悲观锁适用于冲突较多的场景,通过数据库锁实现:

csharp

// 使用事务和行锁
public async Task<bool> UpdateStockWithPessimisticLock(int commodityId, int quantityToAdd)
{
    using var transaction = await _dbContext.Database.BeginTransactionAsync();
    
    try
    {
        // 使用UPDLOCK锁定行
        var stock = await _dbContext.CommodityStocks
            .FromSqlInterpolated($"SELECT * FROM CommodityStocks WITH (UPDLOCK) WHERE CommodityId = {commodityId}")
            .FirstOrDefaultAsync();
            
        if (stock == null) return false;
        
        // 检查库存是否超过1000
        if (stock.Quantity + quantityToAdd > 1000)
        {
            throw new Exception("库存数量不能超过1000");
        }
        
        stock.Quantity += quantityToAdd;
        await _dbContext.SaveChangesAsync();
        await transaction.CommitAsync();
        
        return true;
    }
    catch
    {
        await transaction.RollbackAsync();
        throw;
    }
}

2.3 Redis分布式锁实现

适用于分布式系统,防止多个实例同时操作同一商品:

csharp

public async Task<bool> UpdateStockWithRedisLock(int commodityId, int quantityToAdd)
{
    var lockKey = $"stock_lock_{commodityId}";
    var lockValue = Guid.NewGuid().ToString();
    var expiry = TimeSpan.FromSeconds(30);
    
    try
    {
        // 尝试获取锁
        var isLockAcquired = await _redisDatabase.StringSetAsync(
            lockKey, 
            lockValue, 
            expiry, 
            When.NotExists);
            
        if (!isLockAcquired)
        {
            // 获取锁失败,可以重试或直接返回
            return false;
        }
        
        // 执行库存更新
        var stock = await _dbContext.CommodityStocks
            .FirstOrDefaultAsync(s => s.CommodityId == commodityId);
            
        if (stock == null) return false;
        
        if (stock.Quantity + quantityToAdd > 1000)
        {
            throw new Exception("库存数量不能超过1000");
        }
        
        stock.Quantity += quantityToAdd;
        await _dbContext.SaveChangesAsync();
        
        return true;
    }
    finally
    {
        // 使用Lua脚本确保只有锁的持有者能释放锁
        var luaScript = @"
            if redis.call('get', KEYS[1]) == ARGV[1] then
                return redis.call('del', KEYS[1])
            else
                return 0
            end";
            
        await _redisDatabase.ScriptEvaluateAsync(luaScript, new { KEYS = new RedisKey[] { lockKey }, ARGV = new RedisValue[] { lockValue } });
    }
}

3. 接口实现示例

3.1 商品到货信息录入接口 (PostArrival)

csharp

[HttpPost("arrivals")]
public async Task<IActionResult> PostArrival([FromBody] ArrivalDto dto)
{
    // 检查同一天是否已有记录
    var existingArrival = await _dbContext.CommodityArrivals
        .FirstOrDefaultAsync(a => 
            a.CommodityId == dto.CommodityId && 
            a.CreateTime.Date == DateTime.Now.Date);
    
    if (existingArrival != null)
    {
        // 更新现有记录
        existingArrival.Quantity += dto.Quantity;
        await _dbContext.SaveChangesAsync();
    }
    else
    {
        // 创建新记录
        var newArrival = new CommodityArrival
        {
            CommodityId = dto.CommodityId,
            Quantity = dto.Quantity,
            CreateTime = DateTime.Now
        };
        _dbContext.CommodityArrivals.Add(newArrival);
        await _dbContext.SaveChangesAsync();
    }
    
    // 更新库存 - 使用锁机制
    var success = await _stockService.UpdateStockWithLock(dto.CommodityId, dto.Quantity);
    
    if (!success)
    {
        return Conflict("更新库存失败,请重试");
    }
    
    return Ok();
}

3.2 查询商品到货信息接口 (GetArrivals)

csharp

[HttpGet("arrivals")]
public async Task<IActionResult> GetArrivals(
    [FromQuery] DateTime? startDate, 
    [FromQuery] DateTime? endDate,
    [FromQuery] string commodityName = null)
{
    var query = _dbContext.CommodityArrivals
        .Include(a => a.Commodity)
        .AsQueryable();
    
    // 日期范围筛选
    if (startDate.HasValue)
    {
        query = query.Where(a => a.CreateTime >= startDate.Value);
    }
    
    if (endDate.HasValue)
    {
        query = query.Where(a => a.CreateTime <= endDate.Value);
    }
    
    // 商品名称模糊搜索
    if (!string.IsNullOrEmpty(commodityName))
    {
        query = query.Where(a => a.Commodity.Name.Contains(commodityName));
    }
    
    var result = await query
        .OrderByDescending(a => a.CreateTime)
        .Select(a => new ArrivalInfoDto
        {
            Id = a.Id,
            CommodityName = a.Commodity.Name,
            Quantity = a.Quantity,
            CreateTime = a.CreateTime
        })
        .ToListAsync();
    
    return Ok(result);
}

4. 锁机制选择建议

  1. 乐观锁
    • 适合读多写少场景
    • 实现简单,不阻塞其他操作
    • 冲突时需要重试逻辑
  2. 悲观锁
    • 适合写多读少场景
    • 保证强一致性
    • 可能造成阻塞和死锁
  3. Redis分布式锁
    • 适合分布式系统
    • 性能较好
    • 需要处理锁续期和锁释放问题

二、Redis分布式锁防误删优化方案

针对Redis分布式锁可能出现的误删问题,以下是详细的优化方案和实现方法:

1. 误删问题分析

Redis锁误删通常发生在以下场景:

  1. 客户端A获取锁后执行时间过长,锁自动过期
  2. 客户端B获取到同一个锁
  3. 客户端A执行完成,错误地删除了客户端B的锁

2. 解决方案

2.1 基础防误删方案(Lua脚本)

// 释放锁的Lua脚本
private static readonly string ReleaseLockScript = @"
    if redis.call('get', KEYS[1]) == ARGV[1] then
        return redis.call('del', KEYS[1])
    else
        return 0
    end";

public async Task ReleaseLockAsync(string lockKey, string lockValue)
{
    await _redisDatabase.ScriptEvaluateAsync(ReleaseLockScript, 
        new { KEYS = new RedisKey[] { lockKey }, ARGV = new RedisValue[] { lockValue } });
}

2.2 锁续期机制(Watch Dog)

public async Task<bool> TryAcquireLockWithWatchDog(string lockKey, TimeSpan expiry, TimeSpan renewalInterval)
{
    var lockValue = Guid.NewGuid().ToString();
    var acquired = await _redisDatabase.StringSetAsync(lockKey, lockValue, expiry, When.NotExists);
    
    if (!acquired) return false;
    
    // 启动看门狗线程定期续期
    var cts = new CancellationTokenSource();
    _ = Task.Run(async () => 
    {
        while (!cts.IsCancellationRequested)
        {
            await Task.Delay(renewalInterval);
            try
            {
                await _redisDatabase.KeyExpireAsync(lockKey, expiry);
            }
            catch
            {
                // 续期失败,终止续期
                cts.Cancel();
            }
        }
    }, cts.Token);
    
    return true;
}

2.3 完整防误删实现

public class RedisLock : IAsyncDisposable
{
    private readonly IDatabase _redisDatabase;
    private readonly string _lockKey;
    private readonly string _lockValue;
    private readonly TimeSpan _expiry;
    private readonly TimeSpan _renewalInterval;
    private CancellationTokenSource _watchDogCts;
    
    public RedisLock(IDatabase redisDatabase, string lockKey, TimeSpan expiry, TimeSpan renewalInterval)
    {
        _redisDatabase = redisDatabase;
        _lockKey = lockKey;
        _lockValue = Guid.NewGuid().ToString();
        _expiry = expiry;
        _renewalInterval = renewalInterval;
    }
    
    public async Task<bool> AcquireAsync()
    {
        var acquired = await _redisDatabase.StringSetAsync(
            _lockKey, 
            _lockValue, 
            _expiry, 
            When.NotExists);
            
        if (!acquired) return false;
        
        StartWatchDog();
        return true;
    }
    
    private void StartWatchDog()
    {
        _watchDogCts = new CancellationTokenSource();
        _ = Task.Run(async () => 
        {
            while (!_watchDogCts.IsCancellationRequested)
            {
                await Task.Delay(_renewalInterval, _watchDogCts.Token);
                try
                {
                    await _redisDatabase.KeyExpireAsync(_lockKey, _expiry);
                }
                catch
                {
                    _watchDogCts.Cancel();
                }
            }
        }, _watchDogCts.Token);
    }
    
    public async ValueTask DisposeAsync()
    {
        try
        {
            _watchDogCts?.Cancel();
            
            var luaScript = @"
                if redis.call('get', KEYS[1]) == ARGV[1] then
                    return redis.call('del', KEYS[1])
                else
                    return 0
                end";
                
            await _redisDatabase.ScriptEvaluateAsync(luaScript, 
                new { KEYS = new RedisKey[] { _lockKey }, ARGV = new RedisValue[] { _lockValue } });
        }
        catch
        {
            // 确保资源释放不会抛出异常
        }
    }
}

3. 使用示例

public async Task UpdateStockSafely(int commodityId, int quantityToAdd)
{
    var lockKey = $"stock_lock_{commodityId}";
    var expiry = TimeSpan.FromSeconds(30);
    var renewalInterval = TimeSpan.FromSeconds(10);
    
    await using var redisLock = new RedisLock(_redisDatabase, lockKey, expiry, renewalInterval);
    
    if (!await redisLock.AcquireAsync())
    {
        throw new Exception("获取锁失败,请稍后重试");
    }
    
    try
    {
        // 执行库存更新逻辑
        var stock = await _dbContext.CommodityStocks
            .FirstOrDefaultAsync(s => s.CommodityId == commodityId);
            
        if (stock == null) return;
        
        if (stock.Quantity + quantityToAdd > 1000)
        {
            throw new Exception("库存数量不能超过1000");
        }
        
        stock.Quantity += quantityToAdd;
        await _dbContext.SaveChangesAsync();
    }
    catch
    {
        // 异常处理
        throw;
    }
    // RedisLock会在DisposeAsync时自动释放锁
}

4. 高级优化方案

4.1 锁获取重试机制

public async Task<RedisLock> AcquireWithRetryAsync(
    string lockKey, 
    TimeSpan expiry, 
    TimeSpan renewalInterval,
    int maxRetryCount = 3,
    TimeSpan retryDelay = default)
{
    retryDelay = retryDelay == default ? TimeSpan.FromMilliseconds(200) : retryDelay;
    
    for (int i = 0; i < maxRetryCount; i++)
    {
        var redisLock = new RedisLock(_redisDatabase, lockKey, expiry, renewalInterval);
        if (await redisLock.AcquireAsync())
        {
            return redisLock;
        }
        
        if (i < maxRetryCount - 1)
        {
            await Task.Delay(retryDelay);
        }
    }
    
    throw new Exception($"获取锁{lockKey}失败,已达到最大重试次数{maxRetryCount}");
}

4.2 锁分段技术(减少竞争)

public async Task UpdateStockWithSegmentedLock(int commodityId, int quantityToAdd)
{
    // 将商品ID哈希后取模分段
    var segmentCount = 16; // 分段数量可根据实际情况调整
    var segment = commodityId.GetHashCode() % segmentCount;
    var lockKey = $"stock_segment_lock_{segment}";
    
    await using var redisLock = new RedisLock(_redisDatabase, lockKey, 
        TimeSpan.FromSeconds(30), 
        TimeSpan.FromSeconds(10));
    
    if (!await redisLock.AcquireAsync())
    {
        throw new Exception("获取分段锁失败");
    }
    
    // 执行库存更新逻辑
    // ...
}

5. 最佳实践建议

三、异步任务锁与重试机制在Redis Stream中的综合应用

异步任务锁+重试机制解决的问题

在分布式系统中,异步任务锁结合重试机制主要解决以下关键问题:

1. 瞬时竞争资源问题

  • 场景:多个消费者同时竞争同一资源(如库存扣减)
  • 解决:通过锁确保同一时间只有一个消费者能处理特定资源

2. 消息处理失败问题

  • 场景:消息处理因临时性错误(如网络抖动)失败
  • 解决:自动重试机制提高最终处理成功率

3. 死锁与长时间阻塞问题

  • 场景:持有锁的消费者崩溃导致资源长期锁定
  • 解决:锁超时机制+异步监控自动释放

4. 系统健壮性问题

  • 场景:部分节点故障导致消息积压
  • 解决:重试机制确保消息最终被处理

完整实现方案

1. Redis Stream消费者基础结构

import redis
import asyncio
import logging
from typing import Optional, Callable, Any

class StreamConsumer:
    def __init__(self, redis_client: redis.Redis, stream_key: str, group_name: str, consumer_name: str):
        self.redis = redis_client
        self.stream_key = stream_key
        self.group_name = group_name
        self.consumer_name = consumer_name
        self._create_group()
    
    def _create_group(self):
        try:
            self.redis.xgroup_create(
                name=self.stream_key,
                groupname=self.group_name,
                id='$',  # 只接收新消息
                mkstream=True  # 流不存在时自动创建
            )
        except redis.exceptions.ResponseError as e:
            if "BUSYGROUP" not in str(e):
                raise

2. 带锁的消息处理器(核心实现)

class LockingMessageProcessor:
    def __init__(self, redis_client: redis.Redis):
        self.redis = redis_client
        self.lock_timeout = 30  # 锁默认超时时间(秒)
        self.max_retries = 3    # 最大重试次数
        self.retry_delay = 1    # 重试间隔(秒)

    async def process_with_lock(
        self,
        lock_key: str,
        message_id: str,
        message_data: dict,
        handler: Callable[[dict], Any],
        lock_timeout: Optional[int] = None
    ) -> bool:
        """
        带锁处理消息的核心方法
        :param lock_key: 分布式锁的key
        :param message_id: 消息ID(用于重试时去重)
        :param message_data: 消息内容
        :param handler: 实际业务处理函数
        :param lock_timeout: 自定义锁超时时间
        :return: 是否处理成功
        """
        lock_timeout = lock_timeout or self.lock_timeout
        lock_acquired = False
        lock_value = f"{self.consumer_name}:{message_id}"
        
        for attempt in range(self.max_retries):
            try:
                # 尝试获取锁
                lock_acquired = self._acquire_lock(lock_key, lock_value, lock_timeout)
                if not lock_acquired:
                    await asyncio.sleep(self.retry_delay)
                    continue
                
                # 执行实际业务处理
                result = await handler(message_data)
                
                # 处理成功,确认消息
                self.redis.xack(self.stream_key, self.group_name, message_id)
                return True
                
            except Exception as e:
                logging.error(f"处理消息失败 (尝试 {attempt + 1}/{self.max_retries}): {str(e)}")
                if attempt == self.max_retries - 1:
                    # 达到最大重试次数,将消息放入死信队列
                    self._move_to_dlq(message_id, message_data, str(e))
                    return False
                
                await asyncio.sleep(self.retry_delay)
                
            finally:
                if lock_acquired:
                    self._release_lock(lock_key, lock_value)
    
    def _acquire_lock(self, lock_key: str, lock_value: str, timeout: int) -> bool:
        """获取Redis锁"""
        return self.redis.set(
            lock_key, lock_value,
            nx=True, ex=timeout
        )
    
    def _release_lock(self, lock_key: str, lock_value: str):
        """释放Redis锁(Lua脚本保证原子性)"""
        lua_script = """
        if redis.call("get", KEYS[1]) == ARGV[1] then
            return redis.call("del", KEYS[1])
        else
            return 0
        end
        """
        self.redis.eval(lua_script, 1, lock_key, lock_value)
    
    def _move_to_dlq(self, message_id: str, message_data: dict, error: str):
        """将失败消息移入死信队列"""
        dlq_key = f"{self.stream_key}:dlq"
        message_data['__error'] = error
        message_data['__original_id'] = message_id
        self.redis.xadd(dlq_key, message_data)

3. 消费者主循环(含异步监控)

async def consume_messages(self, handler: Callable[[dict], Any], batch_size=10):
    """消费者主循环"""
    last_id = '0'
    
    while True:
        try:
            # 读取待处理消息
            messages = self.redis.xreadgroup(
                groupname=self.group_name,
                consumername=self.consumer_name,
                streams={self.stream_key: last_id},
                count=batch_size,
                block=5000  # 5秒阻塞时间
            )
            
            if not messages:
                continue
                
            for stream, message_list in messages:
                for message_id, message_data in message_list:
                    # 为每个消息创建独立任务
                    asyncio.create_task(
                        self._process_single_message(
                            message_id, message_data, handler
                        )
                    )
                    last_id = message_id
                    
        except Exception as e:
            logging.error(f"消费消息异常: {str(e)}")
            await asyncio.sleep(5)  # 防止异常导致CPU空转

async def _process_single_message(self, message_id: str, message_data: dict, handler: Callable):
    """处理单个消息(带锁和重试)"""
    processor = LockingMessageProcessor(self.redis)
    
    # 根据业务确定锁的key(例如使用商品ID作为锁key)
    lock_key = f"lock:{message_data.get('product_id')}"
    
    await processor.process_with_lock(
        lock_key=lock_key,
        message_id=message_id,
        message_data=message_data,
        handler=handler
    )

4. 锁续期监控任务(看门狗)

async def start_lock_watchdog(self):
    """启动锁续期监控"""
    while True:
        try:
            # 获取所有即将过期的锁
            lock_keys = self.redis.keys("lock:*")
            
            for key in lock_keys:
                ttl = self.redis.ttl(key)
                if 0 < ttl < 10:  # 剩余时间小于10秒时续期
                    lock_value = self.redis.get(key)
                    if lock_value:
                        # 续期30秒
                        self.redis.expire(key, 30)
            
            await asyncio.sleep(5)  # 每5秒检查一次
            
        except Exception as e:
            logging.error(f"锁续期监控异常: {str(e)}")
            await asyncio.sleep(10)

方案优势分析

  1. 双重保障机制

    • Redis Stream保证消息不丢失
    • 分布式锁保证资源操作的原子性
  2. 智能重试策略

    • 临时性错误自动重试
    • 永久性错误转入死信队列
    • 指数退避算法避免雪崩
  3. 资源安全保护

    • 锁自动续期防止处理中过期
    • 严格按锁值释放防止误删
    • 最终都会释放锁避免死锁
  4. 可观测性增强

    • 详细日志记录每个处理阶段
    • 死信队列保存失败消息上下文
    • 监控指标暴露处理成功率

典型应用场景

1. 库存扣减场景

async def reduce_inventory(message: dict):
    product_id = message['product_id']
    quantity = message['quantity']
    
    # 实际业务逻辑:检查并扣减库存
    current_stock = cache.get(f"inventory:{product_id}")
    if current_stock < quantity:
        raise ValueError("库存不足")
    
    cache.decr(f"inventory:{product_id}", quantity)
    db.execute("UPDATE products SET stock = stock - ? WHERE id = ?", quantity, product_id)

2. 订单支付场景

async def process_payment(message: dict):
    order_id = message['order_id']
    amount = message['amount']
    
    # 获取订单锁防止重复处理
    lock_key = f"order:{order_id}"
    
    # 调用支付网关
    payment_result = payment_gateway.charge(amount)
    if not payment_result.success:
        raise PaymentError("支付失败")
    
    # 更新订单状态
    db.execute("UPDATE orders SET status = 'paid' WHERE id = ?", order_id)

性能优化建议

  1. 锁粒度控制

    • 根据业务拆分细粒度锁(如按商品ID而不是整个库存锁)
  2. 批量处理优化

    • 对允许批量操作的消息合并处理
  3. 动态重试策略

    • 根据错误类型调整重试间隔(网络错误快速重试,业务错误延迟重试)
  4. 资源隔离

    • 不同业务使用不同Stream和消费者组
  5. 监控告警

    • 对死信队列和长时间运行的任务设置告警

这种异步任务锁+重试机制的组合方案,特别适合电商、金融等对数据一致性要求高的场景,能够在保证数据准确性的同时,提供良好的系统可用性和容错能力。

在消息队列中,选择使用 Stream 而不是 List 或 Pub/Sub(发布/订阅)模式通常是为了解决一些特定的问题,这些问题在 List 或 Pub/Sub 模式中可能不容易处理或者效率不高。以下是选择 Stream 模式可能解决的问题和原因:

1. 顺序消息处理

问题
在 List 模式中,虽然可以保持消息的顺序,但是消费者是并发消费消息的,这可能导致消息处理的顺序与发送的顺序不一致。

Stream 解决方案
Stream 模式支持消息的有序处理。每个消息都有一个唯一的 ID,消费者可以按照这个顺序来消费消息,确保消息的处理顺序与发送顺序一致。

2. 消息持久化和回溯

问题
在 Pub/Sub 模式中,消息一旦被消费,就会被从队列中删除,无法回溯。如果需要持久化消息或者在消费失败后重新消费,这种模式就不太适用。

Stream 解决方案
Stream 模式支持消息的持久化。消息被发送到 Stream 后,会一直保留在 Stream 中,直到被明确删除。这允许消费者在需要时重新消费消息,或者在消费失败后重新处理。

3. 消费者组和消息确认

问题
在 List 模式中,虽然可以实现消费者组,但是消息确认机制相对简单,可能无法满足复杂的业务需求。

Stream 解决方案
Stream 模式支持消费者组和更复杂的确认机制。消费者组中的每个消费者都可以独立地确认消息,这样可以更灵活地控制消息的消费和确认过程。

4. 消息的多播和广播

问题
在 Pub/Sub 模式中,消息是广播给所有订阅者的,这可能导致消息的重复处理。

Stream 解决方案
Stream 模式支持消息的多播,即消息可以被发送到多个消费者组,每个消费者组可以独立地消费消息。这样可以避免消息的重复处理,同时也可以更灵活地控制消息的分发。

5. 性能和扩展性

问题
在 List 模式中,由于消费者是并发消费消息的,可能会遇到性能瓶颈。

Stream 解决方案
Stream 模式通常具有更好的性能和扩展性。由于消息是持久化的,消费者可以根据自己的处理能力来消费消息,而不需要担心消息丢失。此外,Stream 模式通常支持更高效的数据结构和算法,可以更好地处理大量消息。

总结

选择 Stream 模式而不是 List 或 Pub/Sub 模式,主要是因为 Stream 模式提供了更灵活的消息处理能力,包括有序处理、持久化、消费者组、消息确认和多播等。这些特性使得 Stream 模式更适合处理复杂的业务场景,特别是在需要保证消息顺序、持久化和灵活的消费者管理时。

posted @ 2025-08-14 17:34  世界改变程序员  阅读(22)  评论(0)    收藏  举报