Redis 事务机制与 Java 实现:完整方案与优化详解

162 阅读11分钟

Redis 作为高性能内存数据库,在日常开发中使用广泛。当需要保证多个操作原子性时,事务功能就显得尤为重要。本文将探讨 Redis 事务的工作原理及如何在 Java 中正确实现。

问题场景

想象一个电商平台的库存管理场景:用户下单时,我们需要同时完成减库存和创建订单两个操作。这两个操作必须是原子的,否则在高并发环境下可能出现数据不一致。

下面是一个不使用事务的代码:

public boolean createOrder(String productId, String userId, int quantity) {
    Jedis jedis = new Jedis("localhost", 6379);
    try {
        // 检查库存
        String stockKey = "product:" + productId + ":stock";
        int stock = Integer.parseInt(jedis.get(stockKey));

        if (stock < quantity) {
            return false; // 库存不足
        }

        // 减少库存
        jedis.decrBy(stockKey, quantity);

        // 假设这里系统崩溃

        // 创建订单
        String orderKey = "order:" + UUID.randomUUID().toString();
        jedis.hset(orderKey, "userId", userId);
        jedis.hset(orderKey, "productId", productId);
        jedis.hset(orderKey, "quantity", String.valueOf(quantity));

        return true;
    } finally {
        jedis.close();
    }
}

这段代码的问题在于:如果系统在减库存后、创建订单前崩溃,将导致库存减少但订单未创建,造成数据不一致。

Redis 事务基本原理

Redis 事务与传统数据库事务有很大不同。Redis 通过 MULTI、EXEC、DISCARD 和 WATCH 四个命令来实现事务功能:

  • MULTI:标记事务开始
  • EXEC:执行事务队列中的所有命令
  • DISCARD:取消事务,放弃执行事务队列中的命令
  • WATCH:监视一个或多个键,在事务执行前检测这些键是否被修改

Redis 事务的特点:

  1. 命令会被打包,然后一次性、顺序性、排他性地执行
  2. 不支持回滚机制,即使事务中某个命令执行失败,其他命令仍会继续执行
  3. 可以通过 WATCH 命令实现乐观锁,处理并发修改问题

事务的特点.png

Redis 事务与传统事务的本质区别

传统数据库事务严格遵循 ACID 原则(原子性、一致性、隔离性、持久性),而 Redis 事务更像是"命令批处理"机制:

  1. 原子性:Redis 仅保证事务内命令的"全或无"执行,但不支持回滚
  2. 一致性:Redis 不检查命令执行结果的一致性状态
  3. 隔离性:由于 Redis 单线程执行命令,天然具有隔离性
  4. 持久性:取决于 Redis 的持久化配置,与事务机制无关

Redis 事务的底层实现原理

Redis 事务的实现相对简单,主要依赖几个核心数据结构:

// Redis事务核心数据结构(简化版)
struct client {
    dict *watched_keys;  // 存储WATCH的键,键->数据库指针
    list *cmd_queue;     // 事务队列,存储MULTI后的命令
    int flags;           // 标记是否在事务中(CLIENT_MULTI)
};

当客户端执行 WATCH 命令时,Redis 会在内部为键添加观察者,并在键被修改时标记相关客户端。当执行 EXEC 时,Redis 会检查是否有被监视的键发生了变化,如果有则拒绝执行事务。

这种设计让 Redis 事务能够在保证原子性的同时,避免了传统事务中的锁开销,非常适合高性能场景。

优化的 Redis 事务 Java 实现

下面是经过优化的使用 Jedis 客户端实现 Redis 事务的完整代码:

import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.JedisPoolConfig;
import redis.clients.jedis.Transaction;
import redis.clients.jedis.exceptions.JedisConnectionException;
import redis.clients.jedis.exceptions.JedisDataException;

import java.util.List;
import java.util.UUID;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.TimeUnit;
import java.util.logging.Logger;

public class RedisTransactionDemo {

    private static final Logger log = Logger.getLogger(RedisTransactionDemo.class.getName());
    private static final String REDIS_HOST = "localhost";
    private static final int REDIS_PORT = 6379;
    private static final int MAX_RETRY = 3;

    // 订单字段常量定义
    private static final String ORDER_USER_ID = "userId";
    private static final String ORDER_PRODUCT_ID = "productId";
    private static final String ORDER_QUANTITY = "quantity";
    private static final String ORDER_STATUS = "status";

    // 初始化Redis连接池
    private static final JedisPool jedisPool;
    static {
        JedisPoolConfig config = new JedisPoolConfig();
        config.setMaxTotal(100);  // 最大连接数
        config.setMaxIdle(20);    // 最大空闲连接
        config.setMinIdle(10);    // 最小空闲连接
        config.setMaxWaitMillis(3000); // 连接获取最大等待时间(ms)
        config.setTestOnBorrow(true); // 获取连接时测试
        config.setTestWhileIdle(true); // 空闲时测试连接有效性
        jedisPool = new JedisPool(config, REDIS_HOST, REDIS_PORT);
    }

    /**
     * 创建订单并减少库存,使用Redis事务保证原子性
     */
    public String createOrder(String productId, String userId, int quantity) {
        try (Jedis jedis = jedisPool.getResource()) {
            try {
                // 订单ID在每次尝试时重新生成
                String orderId = UUID.randomUUID().toString();
                String stockKey = "product:" + productId + ":stock";
                String orderKey = "order:" + orderId;

                int retryCount = 0;
                while (retryCount < MAX_RETRY) {
                    // 监视库存键,实现乐观锁
                    jedis.watch(stockKey);

                    // 检查库存
                    String stockStr = jedis.get(stockKey);
                    if (stockStr == null) {
                        jedis.unwatch();
                        return null; // 商品不存在
                    }

                    int stock = Integer.parseInt(stockStr);
                    if (stock < quantity) {
                        jedis.unwatch();
                        return null; // 库存不足
                    }

                    // 开始事务
                    Transaction transaction = jedis.multi();

                    // 减少库存
                    transaction.decrBy(stockKey, quantity);

                    // 创建订单
                    transaction.hset(orderKey, ORDER_USER_ID, userId);
                    transaction.hset(orderKey, ORDER_PRODUCT_ID, productId);
                    transaction.hset(orderKey, ORDER_QUANTITY, String.valueOf(quantity));
                    transaction.hset(orderKey, ORDER_STATUS, "created");

                    // 执行事务
                    List<Object> results = null;
                    try {
                        results = transaction.exec();
                    } catch (JedisDataException e) {
                        // 处理网络相关异常,可以立即重试
                        if (isNetworkRelatedException(e)) {
                            retryCount--; // 不消耗重试次数
                            continue;
                        }
                        throw e;
                    }

                    // 如果事务成功执行(没有被打断)
                    if (results != null) {
                        return orderId;
                    }

                    // 事务执行失败,说明监视的键被修改,使用指数退避重试
                    retryCount++;
                    try {
                        // 指数退避算法:2^retryCount * 100ms + 随机因子(±50ms)
                        long delay = ((1L << retryCount) * 100) +
                                ThreadLocalRandom.current().nextInt(100) - 50;
                        TimeUnit.MILLISECONDS.sleep(delay);
                    } catch (InterruptedException e) {
                        Thread.currentThread().interrupt();
                        log.warning("事务重试被中断");
                        return null;
                    }
                }

                log.warning("创建订单失败:多次重试后仍无法完成事务");
                return null; // 多次重试后仍失败
            } catch (Exception e) {
                log.severe("Redis事务执行异常: " + e.getMessage());
                return null;
            }
        } catch (JedisConnectionException e) {
            log.severe("Redis连接失败: " + e.getMessage());
            return null;
        }
    }

    /**
     * 判断是否为网络相关异常
     */
    private boolean isNetworkRelatedException(Exception e) {
        return e instanceof JedisConnectionException ||
               e.getMessage().contains("connection") ||
               e.getMessage().contains("timeout");
    }

    /**
     * 使用Lua脚本实现原子性的库存检查和扣减
     */
    public String createOrderWithLua(String productId, String userId, int quantity) {
        try (Jedis jedis = jedisPool.getResource()) {
            String orderId = UUID.randomUUID().toString();
            String stockKey = "product:" + productId + ":stock";
            String orderKey = "order:" + orderId;

            // Lua脚本:原子性地检查并减少库存
            String checkAndDecrScript =
                "local stock = redis.call('get', KEYS[1]) " +
                "if not stock then return 0 end " + // 库存键不存在
                "if tonumber(stock) >= tonumber(ARGV[1]) then " +
                "  redis.call('decrby', KEYS[1], ARGV[1]) " +
                "  return 1 " +
                "else " +
                "  return 0 " +
                "end";

            // 执行脚本
            Object result = jedis.eval(checkAndDecrScript, 1, stockKey, String.valueOf(quantity));

            if (Long.valueOf(1).equals(result)) {
                // 库存充足且已扣减,创建订单
                jedis.hset(orderKey, ORDER_USER_ID, userId);
                jedis.hset(orderKey, ORDER_PRODUCT_ID, productId);
                jedis.hset(orderKey, ORDER_QUANTITY, String.valueOf(quantity));
                jedis.hset(orderKey, ORDER_STATUS, "created");
                return orderId;
            } else {
                // 库存不足或不存在
                return null;
            }
        } catch (Exception e) {
            log.severe("创建订单失败: " + e.getMessage());
            return null;
        }
    }

    /**
     * 使用完整Lua脚本实现库存检查、扣减和订单创建的原子操作
     */
    public String createOrderWithFullLua(String productId, String userId, int quantity) {
        try (Jedis jedis = jedisPool.getResource()) {
            String orderId = UUID.randomUUID().toString();
            String stockKey = "product:" + productId + ":stock";
            String orderKey = "order:" + orderId;

            // 完整Lua脚本:检查库存、扣减和创建订单
            String script =
                "local stock = redis.call('get', KEYS[1]) " +
                "if not stock then return 0 end " + // 库存键不存在时返回失败
                "if tonumber(stock) >= tonumber(ARGV[1]) then " +
                "  redis.call('decrby', KEYS[1], ARGV[1]) " +
                "  redis.call('hset', KEYS[2], '" + ORDER_USER_ID + "', ARGV[2], '" +
                ORDER_PRODUCT_ID + "', ARGV[3], '" + ORDER_QUANTITY + "', ARGV[1], '" +
                ORDER_STATUS + "', 'created') " +
                "  return 1 " +
                "else " +
                "  return 0 " +
                "end";

            Object result = jedis.eval(script, 2, stockKey, orderKey,
                              String.valueOf(quantity), userId, productId);

            return Long.valueOf(1).equals(result) ? orderId : null;
        } catch (Exception e) {
            log.severe("创建订单失败: " + e.getMessage());
            return null;
        }
    }

    /**
     * 初始化商品库存
     */
    public void initializeProductStock(String productId, int stock) {
        try (Jedis jedis = jedisPool.getResource()) {
            String stockKey = "product:" + productId + ":stock";
            jedis.set(stockKey, String.valueOf(stock));
        }
    }

    /**
     * 获取商品当前库存
     */
    public int getProductStock(String productId) {
        try (Jedis jedis = jedisPool.getResource()) {
            String stockKey = "product:" + productId + ":stock";
            String stockStr = jedis.get(stockKey);
            return stockStr != null ? Integer.parseInt(stockStr) : -1;
        }
    }

    /**
     * 连接池状态监控
     */
    public void monitorPoolStatus() {
        log.info("Redis连接池状态 - " +
                "活跃连接: " + jedisPool.getNumActive() +
                " 空闲连接: " + jedisPool.getNumIdle() +
                " 等待线程: " + jedisPool.getNumWaiters());
    }

    /**
     * 使用示例
     */
    public static void main(String[] args) {
        RedisTransactionDemo demo = new RedisTransactionDemo();

        // 初始化商品库存
        String productId = "p001";
        demo.initializeProductStock(productId, 10);

        // 创建订单
        String userId = "user123";
        String orderId = demo.createOrder(productId, userId, 2);

        if (orderId != null) {
            System.out.println("订单创建成功:" + orderId);
            System.out.println("当前库存:" + demo.getProductStock(productId));
        } else {
            System.out.println("订单创建失败");
        }
    }
}

Redis 事务的核心流程

在改进后的实现中,核心流程如下:

核心流程.png

Redis 事务的错误处理机制

Redis 事务中的错误可以分为两大类:

1. 语法错误(编译时错误)

在 EXEC 命令执行前发现的错误,例如命令格式错误或命令不存在。这类错误会导致整个事务被拒绝执行。

// 示例:语法错误演示
Transaction transaction = jedis.multi();
transaction.set("key", "value");    // 正确命令
transaction.invalidCommand("key");  // 错误命令(不存在的命令)
List<Object> results = transaction.exec(); // 返回错误,整个事务失败

2. 运行时错误

在 EXEC 命令执行过程中发现的错误,例如对错误类型的键执行操作。这类错误不会导致事务回滚,其他命令会继续执行。

// 示例:运行时错误演示
Transaction transaction = jedis.multi();
transaction.set("key", "value");    // 正确命令
transaction.lpop("key");            // 错误命令(字符串类型不能执行lpop)
transaction.get("key");             // 正确命令
List<Object> results = transaction.exec();
// results中第一个命令成功,第二个报错,第三个继续执行

为了帮助理解,我们可以把 Redis 事务想象成一个订单处理流程:

  1. MULTI 就像是开启了一个购物车,你可以往里面放商品(命令)
  2. WATCH 就像是商品价格监控,如果价格变了就取消购买
  3. EXEC 就像是一次性结算所有商品,结算过程不会因为某个商品有问题而中断
  4. DISCARD 就像是清空购物车不买了

不同实现方式的性能对比

操作类型网络往返次数单次操作耗时1000 次操作耗时并发支持适用场景
非事务操作多次~0.8ms~800ms高(无锁开销)单一操作,无需原子性
事务操作(WATCH)3 次以上~1.5ms~1500ms中(乐观锁重试)多操作需要原子性,并发较低
Lua 脚本操作1 次~1.2ms~1200ms高(单命令执行)多操作需要原子性,并发较高
流水线操作(Pipeline)1 次~1.0ms~1000ms高(无锁无原子性)多操作批量执行,无需原子性

举个例子说明这些方式的区别:

想象你在一家快餐店点餐,四种方式分别相当于:

  • 非事务操作:单独点每个菜品,每点一个都要走一次流程
  • 事务操作:先锁定你看中的菜品,确认没人拿走后一次性下单
  • Lua 脚本:直接告诉服务员你想要的全部菜品组合,一次完成
  • 流水线:把所有菜品写在一张纸上交给服务员,但不保证全部能做好

并发场景下的 Redis 事务

在高并发环境下,多个客户端可能同时操作同一个键。以下序列图展示了两个客户端并发修改库存的情况:

序列图.png

事务与流水线(Pipeline)的区别

Redis 提供了两种批量执行命令的机制:事务和流水线,它们有着本质的区别:

// 事务示例
try (Jedis jedis = jedisPool.getResource()) {
    jedis.watch(stockKey);
    Transaction transaction = jedis.multi();
    transaction.decrBy(stockKey, quantity);
    transaction.hset(orderKey, ...);
    List<Object> results = transaction.exec(); // 原子执行所有命令
}

// 流水线示例
try (Jedis jedis = jedisPool.getResource()) {
    Pipeline pipeline = jedis.pipelined();
    pipeline.decrBy(stockKey, quantity);
    pipeline.hset(orderKey, ...);
    List<Object> results = pipeline.syncAndReturnAll(); // 批量发送命令,非原子
}

主要区别:

  • 事务:保证命令原子性执行,支持 WATCH 机制,可检测并发修改
  • 流水线:仅批量发送命令减少网络往返,不保证原子性,无并发控制,性能更高

单元测试示例

对 Redis 事务代码进行单元测试可以使用 Mockito 模拟 Jedis 行为:

@RunWith(MockitoJUnitRunner.class)
public class RedisTransactionDemoTest {
    @Mock
    private JedisPool jedisPool;
    @Mock
    private Jedis jedis;
    @Mock
    private Transaction transaction;

    @InjectMocks
    private RedisTransactionDemo demo;

    @Before
    public void setup() {
        // 设置基本的Mock行为
        when(jedisPool.getResource()).thenReturn(jedis);
    }

    @Test
    public void testCreateOrder_success() {
        // 模拟连接池返回Jedis实例
        when(jedis.watch(anyString())).thenReturn("OK");
        when(jedis.get(anyString())).thenReturn("10");
        when(jedis.multi()).thenReturn(transaction);
        when(transaction.decrBy(anyString(), anyLong())).thenReturn(transaction);
        when(transaction.hset(anyString(), anyString(), anyString())).thenReturn(transaction);
        when(transaction.exec()).thenReturn(Arrays.asList(9L, "OK", "OK", "OK"));

        String orderId = demo.createOrder("p001", "user123", 1);

        assertNotNull(orderId);
        verify(jedis, times(1)).close();
    }

    @Test
    public void testCreateOrder_retry() {
        // 模拟事务失败需要重试
        when(jedis.watch(anyString())).thenReturn("OK");
        when(jedis.get(anyString())).thenReturn("10");
        when(jedis.multi()).thenReturn(transaction);

        // 第一次事务失败,第二次成功
        when(transaction.exec())
            .thenReturn(null)
            .thenReturn(Arrays.asList(9L, "OK", "OK", "OK"));

        String orderId = demo.createOrder("p001", "user123", 1);

        assertNotNull(orderId);
        // 验证watch被调用了两次
        verify(jedis, times(2)).watch(anyString());
    }
}

分布式事务集成方案

当应用需要跨多个 Redis 实例或与其他数据源协同时,可以考虑与分布式事务框架结合:

// 简化的Seata与Redis集成示例
@GlobalTransactional
public String createOrderWithSeata(String productId, String userId, int quantity) {
    // 1. 扣减Redis库存
    boolean stockSuccess = redisTemplate.execute(redisScript, keys, args);
    if (!stockSuccess) {
        return null;
    }

    // 2. 创建订单(数据库操作)
    Order order = new Order();
    order.setUserId(userId);
    order.setProductId(productId);
    order.setQuantity(quantity);
    orderRepository.save(order);

    // 3. 其他微服务操作(如支付服务)
    paymentService.createPayment(order.getId(), calculateAmount(productId, quantity));

    return order.getId();
}

生产环境配置示例

在生产环境中使用 Redis 事务,可以参考以下配置:

# Redis连接池配置
redis.pool.max-total=200            # 最大连接数,根据峰值QPS设置
redis.pool.max-idle=50              # 最大空闲连接,避免频繁创建连接
redis.pool.min-idle=10              # 最小空闲连接,保证冷启动性能
redis.pool.max-wait=3000            # 连接获取最大等待时间(ms)
redis.pool.test-on-borrow=true      # 获取连接时测试
redis.pool.test-while-idle=true     # 空闲时测试连接有效性
redis.pool.time-between-eviction-runs=60000  # 空闲连接检测周期(ms)

# 事务重试配置
redis.transaction.max-retry=5       # 最大重试次数
redis.transaction.initial-delay=100 # 初始延迟(ms)
redis.transaction.max-delay=1000    # 最大延迟(ms)
redis.transaction.delay-multiplier=2 # 延迟倍数

生产环境监控要点

在生产环境中使用 Redis 事务时,建议监控以下指标:

  1. 事务失败率
事务失败率 = 失败事务数 / 总事务数 * 100%

当失败率超过 5%时,需要排查是否存在热点数据竞争

  1. 重试分布:记录事务重试次数分布,查看是否集中在特定业务

  2. 连接池状态:监控连接池使用率、等待时间,及时调整连接池大小

  3. 慢事务:监控执行时间超过预期的事务,优化事务内命令

总结

特性说明
事务支持Redis 支持事务,但不支持回滚
核心命令MULTI、EXEC、DISCARD、WATCH
原子性命令会一次性、顺序性、排他性地执行
并发控制通过 WATCH 命令实现乐观锁机制
错误处理区分语法错误(事务拒绝)和运行时错误(继续执行)
Java 实现使用连接池、指数退避重试、Lua 脚本优化
性能对比Lua 脚本 > 流水线 > 事务(WATCH) > 单命令
最佳实践监控事务失败率、合理设置重试、选择合适实现方式