java中如何保证数据库数据的一致性

发布时间 2023-09-23 09:54:09作者: 程序晓猿

本文使用的数据库是mysql

一、不考虑并发时的写法

假设现在有一张t_product表,我们先只考虑单实例部署时的情况

CREATE TABLE t_product(
id INT PRIMARY KEY,
NAME VARCHAR(50)
,nums INT
);
INSERT INTO t_product(id,NAME,nums) VALUES(1,'aa',1);

我们现在有一个加库存的接口会被并发调用,如果保证并发调用时存到数据库里的结果是对的。

先看下不考虑并发时接口一般怎么写。

service层

@Service
public class ProductService {

    @Autowired
    ProductDao productDao;

    public void add(){
        //查询旧的库存值
        int old = productDao.getProductNumById(1);
        //库存值加1更新到数据库
        productDao.add(old+1,1);
    }
}

	<select id="getProductNumById" resultType="int">
        SELECT nums FROM t_product WHERE id=#{id}
    </select>

    <update id="add">
        UPDATE t_product set nums=#{nums} WHERE id=#{id}
    </update>

这种写法中如果add方法被并发调用,线程t1先查询出old,然后线程t2已经把数据库更新了,这时t1接着执行就会造成数据不一致,可以用下面的代码模拟下这种场景

public class MyTest {

    public static void main(String[] args) throws IOException, InterruptedException {
        HttpClientBuilder builder = HttpClientBuilder.create();
        for (int i = 0; i < 10; i++) {
            Thread t = new Thread(new Runnable() {
                @Override
                public void run() {
                    CloseableHttpClient httpClient = builder.build();
                    HttpGet get = new HttpGet("http://localhost:8090/demo/product/add");
                    get.setHeader("Accept","*/*");
                    get.setHeader("Content-Type","application/json");
                    CloseableHttpResponse response = null;
                    try {
                        response = httpClient.execute(get);
                    } catch (IOException e) {
                        e.printStackTrace();
                    }
                    System.out.println(response);
                }
            });
            t.start();
        }

    }
}

上边这段代码模拟了10个人同时调用add接口时的场景,理论上数据库中nums的值应该增加10,实际上因为并发更新的问题nums增加的值不是10

二、解决办法

2.1使用mysql的行锁

使用mysql提供的行锁,t1执行add时把表记录锁住让其他线程阻塞,

把getProductNumById的sql改成

SELECT nums FROM t_product WHERE id=#{id} for update;

前提时是add方法要使用事务,只有add方法有事务才能保证在事务提交前上边sql获取的行锁不会释放,如果add方法没事务那查询旧值的sql执行完后事务被自动提交获取的锁旧又被释放了,起不到阻塞别的线程的作用,所以测试时记得给add方法加上@Transactional 注解。

2.2 java代码中使用synchronize

这种方法的思路是把add方法变成同步方法,同时只有一个线程再执行,然后就可以保证查询旧值和更新新值这两个操作和起来是原子性的。可能会写出这样的代码,但这样的代码真的可以保证一致性吗?

	@Transactional
    public synchronized void add(){
        int old = productDao.getProductNumById(1);//(1)
        productDao.add(old+1,1);
    }

要注意数据库的事务隔离级别要是读已提交,mysql默认的隔离级别是可重复读,假设线程t1和t2都已经开启事务,t1先进入add方法执行,当它更新完数据库、释放锁、提交事务后t2再开始执行,那么t2能查询到nums的最新值吗,可重复读级别下是不能的,所以我们要修改这个方法使用的事务的隔离级别,所以我们对代码做出改进,

   //指定这个方法中使用的事务隔离级别是读已提交
    @Transactional(isolation = Isolation.READ_COMMITTED)
    public synchronized void add(){
        int old = productDao.getProductNumById(1);
        productDao.add(old+1,1);
    }

那么现在的代码看着好像没问题了,再思考两个问题,事务是什么时候开启的?锁又是什么时候加上去的?

spring的事务控制是基于aop实现的,那么开启事务和加锁是这样的顺序:

(1)aop开启事务

(2)开始执行目标方法加锁

(3)目标方法执行完成解锁

(4)aop提交事务

所以在解锁的时候事务还没有提交,别的线程这个时候去执行add方法查询到的还是旧值,所以为了保证数据一致性我们要实现先加锁再开启事务,那只能把add方法中查询和更新的逻辑抽取成另一个事务方法,这样才可以实现加锁后开启事务。

有因为同一个service中方法互相调用不走aop所以需要抽取到另一个service中

@Service
public class ProductService {

    @Autowired
    AddProductService addProductService;

    //因为这个方法现在不需要读取数据了所以可以不开启事务
    public synchronized void add(){
        addProductService.addNums();
    }
}

@Service
public class AddProductService {
    @Autowired
    ProductDao productDao;

    //指定这个方法中使用的事务隔离级别是读已提交
    @Transactional(isolation = Isolation.READ_COMMITTED)
    public  void addNums(){
        int old = productDao.getProductNumById(1);
        productDao.add(old+1,1);
    }
}

2.3 更新时使用旧值作为条件

UPDATE t_product set nums=#{nums} WHERE id=#{id} and nums=#{oldNums}

这种方式利用了cas的思想在更新的时候检查旧值对不对,如果不对update语句就不会执行成功,然后可以在代码里重新查询值

public class ProductService {

    @Autowired
    ProductDao productDao;

    //指定这个方法中使用的事务隔离级别是读已提交
    @Transactional(isolation = Isolation.READ_COMMITTED)
    public void add(){
        int old = productDao.getProductNumById(1);
        int res = productDao.add(old + 1, 1, old);
        while(res==0) {
            //进循环说明update语句执行的时候nums已经被更新过了
            old = productDao.getProductNumById(1);
            res = productDao.add(old + 1, 1, old);
        }
    }
}

因为要在service中查询旧值,所以也要注意事务的隔离级别,但因为这种方式不涉及在代码中加锁所以不需要单独抽象servcie去开启新事物。

那么这种方式可行的原理是什么呢?

mysql中的update语句会给某一行数据加上行锁,事务t1先执行update会给数据行加行锁,

那事务t2要执行update时也会先给数据加行锁,这时因为数据行已经有行锁就会被阻塞住直到t1提交,这时t2才能执行update,因为nums的值已经被改了所以update不会更新数据影响行数是0,所以代码里就会进入while循环重新查询。

2.4 使用分布式锁