乐观锁(Optimistic Locking)与悲观锁(Pessimistic Locking)在应用层实现

好的,各位观众老爷们,今天咱们来聊聊并发控制界两大门派的绝世武功:乐观锁和悲观锁!😎 话说江湖纷争,数据江湖更是刀光剑影,一不小心就数据错乱,人仰马翻。要维护数据的一致性,就得靠锁这玩意儿了。

第一回:话说锁的江湖,悲观锁横行霸道

很久很久以前,在并发控制的江湖里,悲观锁是当之无愧的霸主。这名字一听就透着一股“我不信任任何人”的劲儿。悲观锁就像一个疑心病极重的老头,总是觉得有人要偷他的宝贝,所以在任何时候,只要他一访问某个数据,就立刻把数据锁起来,生怕别人动它一根毫毛。

这就好比你去银行取钱,悲观锁就像银行保安,你一进门,他就拉起警戒线,说:“这钱柜现在是我的了,谁也不许靠近!” 等你取完钱走了,他才把警戒线撤掉,让别人进来。

悲观锁在数据库层面的典型实现就是事务锁(Transaction Lock)。比如,你在执行SELECT ... FOR UPDATE语句时,数据库就会对选中的数据行加锁,直到事务结束才会释放锁。

优点:

  • 简单粗暴,效果好: 保证数据绝对安全,适用于对数据一致性要求极高的场景,比如银行转账。
  • 实现容易: 数据库本身就提供了悲观锁机制,用起来很方便。

缺点:

  • 效率低下: 并发度低,因为每次只有一个线程能访问被锁定的数据。
  • 容易死锁: 如果多个线程互相等待对方释放锁,就会形成死锁,大家都僵在那里,谁也动不了。想象一下,两辆马车在狭窄的巷子里迎面相遇,谁也不让谁,结果就堵死了。

悲观锁的江湖地位:

虽然悲观锁有缺点,但在并发量不高,对数据一致性要求极高的场景下,它仍然是首选。就好比古代的城门,虽然进出很慢,但能保证城内绝对安全。

第二回:乐观锁横空出世,剑走偏锋

随着互联网的发展,并发量越来越高,悲观锁的效率问题就暴露出来了。这时候,乐观锁应运而生! 乐观锁就像一个心大的老哥,他觉得:“哎呀,没那么多人会来抢我的东西,我相信大家都是好人!” 所以,他在访问数据的时候,不会立刻加锁,而是假设没有人会修改数据。

但是,为了防止真的有人修改了数据,乐观锁会在更新数据的时候,检查一下数据是否被修改过。如果数据没有被修改,就更新数据;如果数据已经被修改,就放弃更新,并提示用户重新操作。

这就好比你去图书馆借书,乐观锁就像自助借书机,你直接拿书去扫描,如果这本书还在架上(没有被别人借走),你就能成功借走;如果这本书已经被别人借走了,系统就会提示你:“这本书已经被借走了,请选择其他书籍。”

应用层实现乐观锁的常见方法:

  1. 版本号机制:

    • 在数据表中增加一个版本号字段(version)。
    • 每次读取数据时,同时读取版本号。
    • 更新数据时,比较当前版本号与读取时的版本号是否一致。
    • 如果一致,则更新数据,并将版本号加1;如果不一致,则更新失败,提示用户重新操作。
    -- 假设表名为 products,包含 id, name, price, version 字段
    -- 读取数据
    SELECT id, name, price, version FROM products WHERE id = 1;
    
    -- 更新数据
    UPDATE products SET price = 100, version = version + 1 WHERE id = 1 AND version = 当前版本号;
  2. 时间戳机制:

    • 在数据表中增加一个时间戳字段(timestamp)。
    • 每次读取数据时,同时读取时间戳。
    • 更新数据时,比较当前时间戳与读取时的时间戳是否一致。
    • 如果一致,则更新数据,并将时间戳更新为当前时间;如果不一致,则更新失败,提示用户重新操作。
    -- 假设表名为 products,包含 id, name, price, timestamp 字段
    -- 读取数据
    SELECT id, name, price, timestamp FROM products WHERE id = 1;
    
    -- 更新数据
    UPDATE products SET price = 100, timestamp = NOW() WHERE id = 1 AND timestamp = '读取到的时间戳';
  3. CAS(Compare and Swap)算法:

    • CAS 是一种原子操作,它比较内存中的值是否等于预期值,如果相等,则将内存中的值更新为新的值。
    • 在 Java 中,可以使用 AtomicIntegerAtomicLong 等原子类来实现 CAS 操作。
    import java.util.concurrent.atomic.AtomicInteger;
    
    public class OptimisticLockExample {
        private AtomicInteger stock = new AtomicInteger(100);
    
        public void decreaseStock() {
            int oldValue;
            int newValue;
            do {
                oldValue = stock.get();
                newValue = oldValue - 1;
            } while (!stock.compareAndSet(oldValue, newValue)); // CAS 操作
            System.out.println("Stock decreased successfully!");
        }
    
        public static void main(String[] args) throws InterruptedException {
            OptimisticLockExample example = new OptimisticLockExample();
            for (int i = 0; i < 10; i++) {
                new Thread(example::decreaseStock).start();
            }
            Thread.sleep(1000); // 等待所有线程执行完毕
            System.out.println("Final stock: " + example.stock.get());
        }
    }

优点:

  • 并发度高: 允许多个线程同时访问数据,只有在更新的时候才进行冲突检测,大大提高了并发效率。
  • 避免死锁: 不需要加锁,避免了死锁的发生。

缺点:

  • 冲突率高时性能下降: 如果数据被频繁修改,导致冲突率很高,乐观锁会不断重试,反而降低了性能。
  • 需要额外的业务逻辑: 需要在业务逻辑中处理更新失败的情况,比如提示用户重新操作。

乐观锁的江湖地位:

乐观锁适用于读多写少的场景,比如商品库存查询。就好比现代的图书馆,虽然人很多,但自助借书机效率很高,能满足大部分人的需求。

第三回:应用层实现乐观锁的注意事项

在应用层实现乐观锁,需要注意以下几点:

  1. 版本号/时间戳的选择:

    • 版本号:适用于数据更新频率不高的场景,每次更新版本号加1。
    • 时间戳:适用于需要记录数据最后更新时间的场景,每次更新时间戳更新为当前时间。
    • 选择哪种方式取决于具体的业务需求。
  2. 并发冲突处理:

    • 当更新失败时,需要处理并发冲突,常见的处理方式有:
      • 重试: 提示用户重新操作,让用户再次尝试更新。
      • 合并: 将用户的修改与最新的数据进行合并,然后更新数据。
      • 放弃: 放弃用户的修改,提示用户数据已被修改。
    • 选择哪种处理方式取决于具体的业务需求。
  3. ABA 问题:

    • ABA 问题是指,一个值从 A 变成 B,又从 B 变回 A,乐观锁会认为数据没有被修改过,但实际上数据已经被修改过了。
    • 可以使用版本号来解决 ABA 问题,每次修改数据都增加版本号,即使数据的值变回原来的值,版本号也会发生变化。

    举个形象的例子:你有一个杯子,初始状态是满的(A)。你喝了一口(变成B),然后又倒满了(变回A)。如果只看杯子里的水,你会觉得什么都没发生。但实际上,你喝了一口这个动作已经被忽略了。版本号就像给杯子贴了一个标签,每次操作都更新标签,即使杯子里的水又满了,标签也变了,就能区分出这个杯子被动过了。

  4. 数据一致性:

    • 乐观锁只能保证最终一致性,不能保证强一致性。
    • 如果对数据一致性要求极高,建议使用悲观锁。

第四回:悲观锁 vs 乐观锁,谁更胜一筹?

要说谁更胜一筹,那得看具体场景。就好比华山论剑,各路高手都有自己的绝招,没有绝对的胜者。

特性 悲观锁 乐观锁
加锁方式 预先加锁 延迟加锁,更新时检查冲突
并发度
冲突处理 阻塞等待 重试、合并、放弃
死锁风险
适用场景 写操作频繁,冲突概率高,对数据一致性要求极高 读操作频繁,冲突概率低,允许最终一致性
优点 保证数据绝对安全,实现简单 并发度高,避免死锁
缺点 效率低下,容易死锁 冲突率高时性能下降,需要额外的业务逻辑处理

总而言之:

  • 悲观锁: 适合于“宁可错杀一千,不可放过一个”的场景,保证数据绝对安全,但效率较低。
  • 乐观锁: 适合于“相信人性本善”的场景,提高并发效率,但需要处理并发冲突。

第五回:应用层乐观锁的实战案例

案例一:秒杀系统

秒杀系统是一个典型的读多写少的场景,可以使用乐观锁来提高并发效率。

  1. 数据库表设计:

    CREATE TABLE `seckill_products` (
      `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '商品ID',
      `name` varchar(255) NOT NULL COMMENT '商品名称',
      `stock` int(11) NOT NULL COMMENT '库存',
      `version` int(11) NOT NULL DEFAULT '0' COMMENT '版本号',
      PRIMARY KEY (`id`)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='秒杀商品表';
  2. 秒杀逻辑:

    public boolean seckill(long productId) {
        // 1. 查询商品信息,包括库存和版本号
        SeckillProduct product = productMapper.selectById(productId);
        int stock = product.getStock();
        int version = product.getVersion();
    
        // 2. 判断库存是否足够
        if (stock <= 0) {
            return false;
        }
    
        // 3. 更新库存,使用乐观锁
        int rows = productMapper.updateStock(productId, version);
        if (rows > 0) {
            // 秒杀成功
            return true;
        } else {
            // 秒杀失败,说明库存已被其他线程更新
            return false;
        }
    }
    
    // Mapper 接口
    int updateStock(@Param("productId") long productId, @Param("version") int version);
    
    // Mapper XML
    <update id="updateStock">
        UPDATE seckill_products
        SET stock = stock - 1, version = version + 1
        WHERE id = #{productId} AND version = #{version} AND stock > 0
    </update>

案例二:电商订单系统

电商订单系统也经常用到乐观锁。比如,用户下单后,需要更新商品的库存。

  1. 数据库表设计:

    CREATE TABLE `products` (
      `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '商品ID',
      `name` varchar(255) NOT NULL COMMENT '商品名称',
      `stock` int(11) NOT NULL COMMENT '库存',
      `version` int(11) NOT NULL DEFAULT '0' COMMENT '版本号',
      PRIMARY KEY (`id`)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品表';
  2. 下单逻辑:

    public boolean createOrder(long productId, int quantity) {
        // 1. 查询商品信息,包括库存和版本号
        Product product = productMapper.selectById(productId);
        int stock = product.getStock();
        int version = product.getVersion();
    
        // 2. 判断库存是否足够
        if (stock < quantity) {
            return false;
        }
    
        // 3. 更新库存,使用乐观锁
        int rows = productMapper.updateStock(productId, quantity, version);
        if (rows > 0) {
            // 下单成功
            return true;
        } else {
            // 下单失败,说明库存已被其他线程更新
            return false;
        }
    }
    
    // Mapper 接口
    int updateStock(@Param("productId") long productId, @Param("quantity") int quantity, @Param("version") int version);
    
    // Mapper XML
    <update id="updateStock">
        UPDATE products
        SET stock = stock - #{quantity}, version = version + 1
        WHERE id = #{productId} AND version = #{version} AND stock >= #{quantity}
    </update>

第六回:总结与展望

好了,各位观众老爷们,今天咱们就聊到这里。乐观锁和悲观锁都是并发控制的重要手段,没有绝对的好坏,只有适合与不适合。在实际应用中,需要根据具体的业务场景选择合适的锁机制。

未来,随着技术的发展,可能会出现更多更高效的并发控制方法。让我们拭目以待! 🚀

希望今天的分享对大家有所帮助!如果觉得有用,请点赞、评论、转发,让更多人受益! 🙏

最后,祝大家编码愉快,bug 永远不再! 😄

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注