[思考]从COW到MVCC

404 阅读5分钟

这是我参与2022首次更文挑战的第6天,活动详情查看:2022首次更文挑战

前言

COW - copyOnWriteArrayList

java中的CopyOnWrite的同步,大致上是通过以下方式维护线程安全性的:

  • 读取时,预先获取array,对array进行操作

  • 写入时(包括更新、添加、删除),预先上锁(RLock),获取array,在获取到的array上进行操作:

    • 先copy(System.copy方式深拷贝一份新的)一份新的arr
    • 在新的arr上做修改
    • 把新的arrset回去

    随后再解锁。

某种程度上来说,COW的性能实际并不好:

  • 读取的时候相当于是基于当前版本读,修改的时候相当于是通过锁进行了串行。

因为每次修改都需要copy数组,因此COW更适合读多写少的场景。

而COW的这种方式,实际上和MVCC很相似:

  • COW的读是基于当前稳定的数组(即:当前的数组),而不考虑当前并发的变更;
  • MVCC下的读是基于版本进行的。

因此,这里通过ArrayList和数组的例子,来看看MVCC是如何实现的,以及COW设计较为不完善的地方。

隔离级别

mySQL相关的部分,都从juejin.cn/book/684473…

隔离级别之前,先来看看并发条件下,可能发生的问题。

并发下数据库的问题

脏写

此时假设有以下两个线程,并发地运行:

    public static void job1(List<Integer> list){
        list.set(1,2);
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(list.get(1));
    }

    public static void job2(List<Integer> list){
        int originVal = list.get(1);
        list.set(1,3);
        list.set(1,originVal);
    }

    public static void main(String[] args) {
        List<Integer> l1 = new ArrayList<>();
        l1.add(0);
        l1.add(0);
        Thread t1 = new Thread(()->job1(l1));
        t1.start();
        new Thread(()->job2(l1)).start();
    }

打印:0(或2,与线程分配有关)

也就是说:

  • job2的修改被job1覆盖了

如果我们把job1和job2对应成一个事务(原子性的,方法结束后才算作事务执行成功提交了),那么这里就是数据库中的脏写了:

  • job2修改了job1中未提交的数据

脏读

修改一下上面的代码,此时job1就不再修改数据了,我们在main方法中最后查看一下list的结果:

public static void job1(List<Integer> list){
    try {
        Thread.sleep(1000);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
    System.out.println(list.get(1));
}

public static void job2(List<Integer> list){
    int originVal = list.get(1);
    list.set(1,3);
    try {
        Thread.sleep(2000);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
    list.set(1,originVal);
}

public static void main(String[] args) throws InterruptedException {
    List<Integer> l1 = new ArrayList<>();
    l1.add(0);
    l1.add(0);
    Thread t1 = new Thread(()->job1(l1));
    t1.start();
    new Thread(()->job2(l1)).start();
    Thread.sleep(3000);
    System.out.println(l1.get(1));
}

输出结果:

3 0

这种情况对应的就是脏读,我们在job1中读取到了job2中未提交的数据,随后job2进行了回滚,导致job1中读到的数据找不到。

不可重复读

不可重复读相对来说是比较可以接受的一种并发下的错误,定义如下:

  • A事务在条件相同的情况下,前后取出的同一个位置的数据,读出来的数据内容不同,这是由于读取到了其他事务最新的提交。

我们修改上面的代码,通过上锁和wait、notify,来模拟事务的提交:

    static Object monitor = new Object();

    public static void job1(List<Integer> list){
       synchronized (monitor){
           try {
               System.out.println(list.get(0));
               park();
               System.out.println(list.get(0));
               park();
               System.out.println(list.get(0));
               park();
               System.out.println(list.get(0));
           } catch (Exception e) {
               e.printStackTrace();
           }
           System.out.println("exit job1");
       }

    }

    public static void job2(List<Integer> list){
        synchronized (monitor){
            try {
                list.set(0,1);
                monitor.notify();
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }

    public static void job3(List<Integer> list){
        synchronized (monitor) {
            list.set(0,2);
            monitor.notify();
        }
    }

    public static void job4(List<Integer> list){
        synchronized (monitor) {
            list.set(0,3);
            monitor.notify();
        }
    }

    public static void park() throws InterruptedException {
        monitor.notify();
        monitor.wait();
    }

    public static void main(String[] args) throws InterruptedException {
        List<Integer> l1 = new ArrayList<>();
        l1.add(0);
        l1.add(0);
        new Thread(()->job1(l1)).start();
        new Thread(()->job2(l1)).start();
        new Thread(()->job3(l1)).start();
        new Thread(()->job4(l1)).start();
        Thread.sleep(1000);
        System.out.println(l1.get(0));
    }

这里注意:

  • 我们的job1模拟的是读取操作,job2~job4模拟的是多个事务修改的操作。

输出结果如下:

0 1 2 exit job1 3

在这里可以看到:在job1*”事务“*里,我们拿的位置都是相同的,但获取到的数据却不相同。

其实相对来说,不可重复读相对脏读脏写而言是可以忍受的,毕竟都提交了

幻读

幻读和不可重复读的区别在于:

  • 不可重复读指的是之前查到的记录后面查不到了,幻读指的是读取到了之前没有读取到的记录。

我们再次修改上述代码来模拟幻读的情况:

static Object monitor = new Object();

public static void job1(List<Integer> list){
    synchronized (monitor){
        try {
            System.out.println(list);
            park();
            System.out.println(list);
        } catch (Exception e) {
            e.printStackTrace();
        }
        System.out.println("exit job1");
    }

}

public static void job2(List<Integer> list){
    synchronized (monitor){
        try {
            list.add(1);
            monitor.notify();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

public static void park() throws InterruptedException {
    monitor.notify();
    monitor.wait();
}

public static void main(String[] args) throws InterruptedException {
    List<Integer> l1 = new ArrayList<>();
    l1.add(0);
    l1.add(0);
    new Thread(()->job1(l1)).start();
    new Thread(()->job2(l1)).start();
}

输出结果:

[0, 0] [0, 0, 1] exit job1

这里可以看到,第二次读取读取到了新的这个1,这是之前没有读取到的。

解决方式

CopyOnWrite

在COW是不会出现这些问题的,COW的解决方式堪称简单粗暴:

  • 读取的操作,都以当前副本为准。
  • 修改的操作,都以当前副本的copy为准,并且修改操作严格串行,直到修改结束,会让当前的list引用指向修改后的副本copy

也就是说,不管我们查多少次,都是以我们进入时获取到的副本为基准的。

我们对上述代码中的job2做修改,来模拟COW中所做的操作:

public static void job1(List<Integer> list){
    List<Integer> lista = new ArrayList<>(list);
    //下面都以lista做修改了
    synchronized(monitor){
        //做完之后,把修改的写入,随后退出锁
        setList(lista);
    }
}

那么修改和读取本身就会被隔离,毕竟如果是读取,那么这个读取到的副本本身就是稳定的,并不会对这一对象做出修改。

MySQL

但对于上面COW而言性能上是有一个问题的:

  • 写入都变成了串行,锁的粒度相当于是全体数据

那么,当并发写操作相对多的时候,会严重阻塞写的线程,虽然安全但性能过低,在写操作相对多的情况下就不会是一个好的选择了。

//todo

//MVCC+锁

MVCC:undo日志+readView

RC:每次读取生成一个readView

RR:只在事务开始的时候,生成一个readView