小易说IT 小易说IT

SpringBoot + MyBatis 乐观锁(版本号机制)完整 Demo

核心原理:version 版本号,更新时带上查询出来的 version,where 条件匹配 version,更新成功 version+1;如果行数返回 0,说明已经被其他线程修改,更新失败,业务重试。 技术栈:SpringBoot 3.x + MyBatis + MySQL,纯版本号乐观锁(不是 CAS、不是 Redis 乐观锁)

1. 数据库表

CREATE DATABASE IF NOT EXISTS mybatis_optimistic;
USE mybatis_optimistic;

CREATE TABLE `product` (
  `id` BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键',
  `product_name` VARCHAR(64) NOT NULL COMMENT '商品名称',
  `stock` INT NOT NULL DEFAULT 0 COMMENT '库存',
  `version` INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
  `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP,
  `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 初始化数据
INSERT INTO product(product_name, stock, version) VALUES ('测试手机', 100, 0);

2. Maven pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.2.0</version>
        <relativePath/>
    </parent>
    <groupId>com.optlock</groupId>
    <artifactId>optlock-demo</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>optlock-demo</name>

    <properties>
        <java.version>17</java.version>
    </properties>

    <dependencies>
        <!-- web 用于接口测试 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <!-- mybatis -->
        <dependency>
            <groupId>org.mybatis.spring.boot</groupId>
            <artifactId>mybatis-spring-boot-starter</artifactId>
            <version>3.0.3</version>
        </dependency>
        <!-- mysql驱动 -->
        <dependency>
            <groupId>com.mysql</groupId>
            <artifactId>mysql-connector-j</artifactId>
            <scope>runtime</scope>
        </dependency>
        <!-- lombok -->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        <!-- 测试 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <excludes>
                        <exclude>
                            <groupId>org.projectlombok</groupId>
                            <artifactId>lombok</artifactId>
                        </exclude>
                    </excludes>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>

3. application.yml

spring:
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://127.0.0.1:3306/mybatis_optimistic?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
    username: root
    password: 你的数据库密码

mybatis:
  mapper-locations: classpath:mapper/*.xml
  type-aliases-package: com.optlock.entity
  configuration:
    map-underscore-to-camel-case: true
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

4. 实体类 Product.java

package com.optlock.entity;

import lombok.Data;
import java.time.LocalDateTime;

@Data
public class Product {
    private Long id;
    private String productName;
    private Integer stock;
    // 乐观锁核心版本字段
    private Integer version;
    private LocalDateTime createTime;
    private LocalDateTime updateTime;
}

5. Mapper 接口 ProductMapper.java

package com.optlock.mapper;

import com.optlock.entity.Product;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;

public interface ProductMapper {

    @Select("SELECT * FROM product WHERE id = #{id}")
    Product selectById(@Param("id") Long id);

    /**
     * 乐观锁更新:where id=? AND version=?,更新成功 version + 1
     * 返回值:影响行数,0 = 版本不匹配,更新失败(被别人抢先修改)
     */
    int updateStockByOptLock(Product product);
}

6. Mapper XML resources/mapper/ProductMapper.xml

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.optlock.mapper.ProductMapper">

    <update id="updateStockByOptLock">
        UPDATE product
        SET stock = #{stock},
            version = version + 1
        WHERE id = #{id} AND version = #{version}
    </update>

</mapper>

7. Service 层(核心乐观锁业务逻辑,带重试)

业务逻辑:扣减库存,版本冲突时自动重试(控制最大重试次数,防止死循环)

package com.optlock.service;

import com.optlock.entity.Product;
import com.optlock.mapper.ProductMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
public class ProductService {

    private final ProductMapper productMapper;

    // 最大重试次数
    private static final int MAX_RETRY = 3;

    /**
     * 乐观锁扣减库存
     * @param id 商品id
     * @param num 扣减数量
     * @return true扣减成功,false失败(重试耗尽/库存不足)
     */
    @Transactional
    public boolean deductStock(Long id, int num) {
        for (int i = 0; i < MAX_RETRY; i++) {
            Product product = productMapper.selectById(id);
            if (product == null) {
                throw new RuntimeException("商品不存在");
            }
            if (product.getStock() < num) {
                System.out.println("库存不足");
                return false;
            }
            // 计算新库存
            product.setStock(product.getStock() - num);
            // 执行乐观锁更新
            int rows = productMapper.updateStockByOptLock(product);
            if (rows > 0) {
                System.out.println("第" + (i+1) + "次尝试,扣减成功!version:" + product.getVersion());
                return true;
            }
            System.out.println("第" + (i+1) + "次冲突,版本已被修改,准备重试");
        }
        System.out.println("乐观锁重试次数耗尽,扣减失败");
        return false;
    }
}

8. Controller 测试接口

package com.optlock.controller;

import com.optlock.service.ProductService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequiredArgsConstructor
public class ProductController {

    private final ProductService productService;

    @GetMapping("/deduct")
    public String deduct(@RequestParam Long id, @RequestParam int num) {
        boolean ok = productService.deductStock(id, num);
        return ok ? "扣减成功" : "扣减失败";
    }
}

9. 启动类

package com.optlock;

import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
@MapperScan("com.optlock.mapper")
public class OptLockApplication {
    public static void main(String[] args) {
        SpringApplication.run(OptLockApplication.class, args);
    }
}

10. 单元测试(多线程并发模拟冲突,推荐)

package com.optlock;

import com.optlock.service.ProductService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import java.util.concurrent.CountDownLatch;

@SpringBootTest
class OptLockApplicationTests {

    @Autowired
    private ProductService productService;

    @Test
    void testConcurrentDeduct() throws InterruptedException {
        // 模拟20个线程同时扣减1个库存,id=1
        int threadCount = 20;
        CountDownLatch latch = new CountDownLatch(threadCount);
        for (int i = 0; i < threadCount; i++) {
            new Thread(() -> {
                try {
                    productService.deductStock(1L, 1);
                } finally {
                    latch.countDown();
                }
            }).start();
        }
        latch.await();
        System.out.println("所有线程执行完毕");
    }
}

✅ 关键点说明

  1. 乐观锁没有数据库锁,不会阻塞其他事务;依靠版本号校验冲突;

  2. update 返回行数 = 0 代表版本不匹配,数据已经被别的线程更新;

  3. 适合读多写少场景;写竞争非常激烈的场景,大量重试会拖垮性能,适合悲观锁;

  4. 重试一定要加上限,不能无限循环;

  5. 注意:select + update 不是原子,就是靠 version 来做校验;

  6. 和 MyBatis-Plus 自带乐观锁区别:MP 自动填充 version,这个是原生 MyBatis 手写,更好理解底层。

📌 扩展:MyBatis-Plus 版本(如果你后面需要)

MP 只需要实体上加 @Version,不用手写 version+1,Mapper 和 xml 不用改 version 逻辑,框架自动处理。

@Version
private Integer version;

测试方式

  1. 启动项目,访问:http://127.0.0.1:8080/deduct?id=1&num=1

  2. 或者直接跑单元测试看并发冲突日志