【架构思考】巧用Java读写锁ReadWriteLock重构系统

问题

工作中遇到一个棘手的 BUG 。为方便理解,首先介绍下,涉及模块的流程简化如下:

Start -> Node -> Cache/DB -> Query_Engine

系统是按批次 Batch 处理数据,开始时,把一个批次的数据都发到某个 Node 上去,进行处理,完了之后存进 Cache/DB ,接着再加载到 Query_Engine 中。
这里使用的 Query_Engine 是 Dremio 。它是一个基于列式存储的 OLAP 系统,支持多种数据源(如 Oracle , Hadoop , S3 ,或者直接从文件读取,如 Parquet , JSON )。系统中用的是 Parquet 文件。
使用 Dremio 的好处是跑一些复杂的 query 会非常快,且可以结合一些可视化工具,如 Power BI , Tableau 生成报表直接面向用户。

考虑分布式的情况,两个 Batch 分别跑在两个 Node 上,最终写进 DB ,因为 Txn 是行级别的(为什么后面说),所以数据一条一条写入,有可能发生错位。

Node_1,写入一条数据,DB_KEY为1
Node_2,写入一条数据,DB_KEY为2
Node_1,写入一条数据,DB_KEY为3
Node_2,写入一条数据,DB_KEY为4

Batch_1 -> Node_1 -> DB: 1,3,5,7
Batch_2 -> Node_2 -> DB: 2,4,6,8

数据错位,本来也没什么问题,但是,当数据 flow 到 Query_Engine 的时候,为了防止反复 load ,会按照 DB_KEY 进行判断。

比如, Node_2 处理完了,触发 Query_Engine 加载数据。它会加载所有 DB_KEY<=8 的。
但是,这时,有可能 Node_1 的 DB_KEY=7 的这个块,并没有完成 Txn ,还不在数据库中,所以就没有被加载到 Query_Engine 中。
而当 Node_1 处理完了,触发 Dremio 加载数据时,由于它的最大 DB_KEY=7 ,系统认为已经加载过了,不会重复加载。
所以,最终的结果是, DB_KEY=7 的这条数据被漏掉了。

Txn

看到这个问题,第一反应,这个 Txn 为什么是行级别的?一条一条插入?如果是按照 Batch 级别做事务,不就能避免这个问题了吗?

经过分析发现

  • 首先,一个 Batch 做成一整个事务,代价很大,回滚成本很高,因为 Batch 里面的数据量可能非常大。
  • 其次,从设计和业务层面上讲,这里的数据有点特殊,在 Batch 层面做事务,需要考虑后续去重问题,因为 Node_1 和 Node_2 可能产生完全一样的一条数据,唯一差别就是 DB_KEY 不同。现在的设计,我们做的是事先去重,去重发生在两个层面,主要是缓存,然后在数据库中做最终一致性检查,如果有重复的, catch 住 Exception 进行额外处理。由于是一条一条 persist ,如果 Node_1 已经写入了(缓存或者数据库), Node_2就不会再 persist 了。

解决1 - 短期

从短期来看,我们需要快速解决问题,那么一个思路就是:改动 Query_Engine 端的逻辑,在数据表中新加一个属性 Node_Id ,用来标记这条数据是由哪个 Node 处理的。

然后在 load 到 Query_Engine 时,不是按照 DB_KEY 进行 load ,而是按照 Node_Id 来 load 。从而保证各个 Node 的数据完全被加载进去。

但是这样做的弊端是,新加了一个 business 不用的属性,增大了存储量。而且加载时由于数据不连续,也比较慢。

解决2 - 中期

从中期上看,这里的问题主要在于:写操作时,各个 Node 互相独立,没有影响。但是读操作时,特别是获取 MAX_DB_KEY 的时候,需要保证各个 Node 的写入已经完成。

我们可以引入 Java 中的 ReadWriteLock ,然后反向使用。

在 persist 的时候,加共享锁 readLock() ,各个 Node 可以随意写。
在 read MAX_DB_KEY 的时候,加独占锁 writeLock() ,需要其它 Node 都写完了才可以进行。

示例代码如下:

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantReadWriteLock;

public class App {
    private static final ReentrantReadWriteLock  rwlock = new ReentrantReadWriteLock();
    private static final Lock rlock = rwlock.readLock();
    private static final Lock wlock = rwlock.writeLock();
    private static int count = 0;

    public static void main(String[] args) {

        for (int i=1; i<=10; i++) {
            Thread t1 = new Thread(new NodeWrite());
            t1.setName("Node 1[" + i + "] Write");

            Thread t2 = new Thread(new NodeWrite());
            t2.setName("Node 2[" + i + "] Write");

            t1.start();
            t2.start();
        }

        Thread t3 = new Thread(new NodeRead());
        t3.setName("*** Node 3 Read");

        Thread t4 = new Thread(new NodeRead());
        t4.setName("*** Node 4 Read");

        t3.start();
        t4.start();

    }

    static class NodeWrite implements Runnable {
        public void run() {
            while (rwlock.isWriteLocked()) {
                System.out.println(Thread.currentThread().getName() + " waiting ... ");
            }
            rlock.lock();
            try {
                Long duration = (long) (Math.random() * 10000);
                System.out.println(Thread.currentThread().getName() + " " +  ++count + "; Time Taken " + (duration/1000) + " seconds.");
                Thread.sleep(duration);
            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                rlock.unlock();
            }
        }
    }

    static class NodeRead implements Runnable {
        public void run() {
            wlock.lock();
            try {
                Long duration = (long) (Math.random() * 1000);
                System.out.println(Thread.currentThread().getName() + " " + count + "; Time Taken " + (duration) + " mili seconds.");
                Thread.sleep(duration);
            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                wlock.unlock();
            }
        }
    }
}

结果如下:
Node 3 在 Read 时,保证所有 1-6 的数据都被 load 进去了。
Node 4 在 Read 时,保证所有 7-20 的数据都被 load 进去了。

Node 1[1] Write 1; Time Taken 1 seconds.
Node 2[2] Write 2; Time Taken 0 seconds.
Node 1[3] Write 3; Time Taken 4 seconds.
Node 2[3] Write 4; Time Taken 2 seconds.
Node 2[5] Write 5; Time Taken 8 seconds.
Node 1[6] Write 6; Time Taken 5 seconds.
*** Node 3 Read 6; Time Taken 585 mili seconds.
Node 2[8] Write 7; Time Taken 2 seconds.
Node 1[9] Write 8; Time Taken 0 seconds.
Node 2[9] Write 9; Time Taken 5 seconds.
Node 2[1] Write 10; Time Taken 7 seconds.
Node 1[2] Write 11; Time Taken 1 seconds.
Node 1[4] Write 12; Time Taken 0 seconds.
Node 2[4] Write 13; Time Taken 8 seconds.
Node 1[5] Write 14; Time Taken 2 seconds.
Node 1[7] Write 15; Time Taken 3 seconds.
Node 2[7] Write 16; Time Taken 8 seconds.
Node 1[8] Write 17; Time Taken 7 seconds.
Node 1[10] Write 18; Time Taken 2 seconds.
Node 2[10] Write 19; Time Taken 1 seconds.
Node 2[6] Write 20; Time Taken 6 seconds.
*** Node 4 Read 20; Time Taken 927 mili seconds.

解决3 - 长期

从长远来看,我们考虑把事先去重,变成事后去重

基于现有的框架,系统的处理能力已经到了一个瓶颈。为了向大数据处理方向转型,需要一步步对系统进行优化。
其中一个变化就是把计算和IO分离。

现有的框架如下:

PNG_1

计算和IO是糅合在一起的。做一段计算a,做一段存储,然后再做一段计算b,循环往复。

新的框架如下:

PNG_2

把计算a,b,c放在一起做,完了最后一起存储。

当然,针对这个问题,我们会在做完所有计算后,先利用 Spark 做一遍去重,然后再存储。

前者是一个基于模块化设计的应用,适用于中小量的数据处理,优点是层次分明。而后者,是一个典型的适用于大数据框架的应用。

总结

遇到问题分层次和阶段解决是非常有效的。这里的问题是按照 DB_KEY 读取数据时发生遗漏。

  • 那么最简单的做法是换一个条件,不按照 DB_KEY 读取,而是使用 NODE_ID 读取。
  • 经过分析,按照 DB_KEY 读取效率更高,那么我们可以采用 ReadWriteLock 保证数据完整性。
  • 从长期看,我们可以考虑重构系统,使用最新的技术解决问题。
posted @ 2020-12-17 21:14  MaxStack  阅读(5)  评论(0编辑  收藏  举报