RandomAccessFile文件锁踩坑--write高并发引起漏数据

背景

多线程写入文件,要考虑线程同步问题,实现数据完整落盘磁盘备份。
操作系统:
win10:没问题
centos7:有问题

    public static void writeFileLock(String content, String filePath) {
        File file = new File(filePath);
        RandomAccessFile raf = null;
        FileChannel fileChannel = null;
        FileLock fileLock = null;
        try {
            raf = new RandomAccessFile(file, "rw");
            fileChannel = raf.getChannel();
            while (true) {
                try {
                    fileLock = fileChannel.tryLock();
                    if (fileLock != null) {
                        break;
                    }
                } catch (Exception e) {
                    Thread.sleep(0);
                }
            }
            raf.seek(raf.length());
            raf.write(content.getBytes());
            fileLock.release();
            fileChannel.close();
            raf.close();
        } catch (Exception e) {
            log.error("写文件异常", e);
            log.error("写入文件路径:{}, 文件内容:{}", filePath, content);
        }
    }

RandomAccessFile建立文件连接符,raf获取文件管道,文件管道获取文件锁,tryLock方法有两个特点:第一、非阻塞,调用后立刻返回;第二、没拿到锁可能返回null,也可以能抛出异常,所以if判断循环获取,异常块捕获异常再重新尝试获取锁,注意Thread.sleep(0)的作用并不是睡0秒,而是马上加入到可执行队列,等待cpu的时间分片。

这段代码承载线上的kafka多线程备份消息的任务,用lock协调多线程的写入同步,埋点监控发现,备份数据偶发遗漏,大概2.3亿数据,会有5条偏差,就是漏了。

下面记录压测思路及过程。

准备

压测代码:

private static final ExecutorService FILE_THREADS = Executors.newFixedThreadPool(100);

public void execute(String... strings) throws Exception {

        int cnt = 100 * 100 * 100;
        int idx = 1;
        long begin = 1574305200000L;
        while (idx <= cnt) {
            Map<String, Object> map = new HashMap<>();
            map.put("id", idx);
            map.put("time", begin);
            String timeDirectory = DateUtil.getBeforeOneHour("yyyyMMddHHmm", 8, begin);
            String mm = DateUtil.getBeforeOneHour("mm", 0, begin).concat(".txt");
            String json = JsonUtil.getJosnString(map).concat(System.getProperty("line.separator"));
            FILE_THREADS.execute(new PersistThread(timeDirectory, mm , json));
            if (idx % 10000 == 0) {
                begin += 60000L;
            }
            idx++;
        }
}

private class PersistThread extends Thread {

        String time;
        String filename;
        String content;

        PersistThread(String time, String filename, String content) {
            this.time = time;
            this.filename = filename;
            this.content = content;
        }

        @Override
        public void run() {
            String folder = "/data/job_project/txt/" + time + "/";
            FileUtil.createDirectory(folder);
            FileUtil.writeFileIO(content, folder + filename);
        }
}

创建100个线程的线程池,提交写入文件Thread任务,实现多线程写入文件,且文件目录、文件是动态创建的(模拟线上),id每自增1万创建一个时间戳目录,格式是:yyyyMMddHHmm,在目录下创建一个文件,写入1万行数据,相当于100个线程,动态写入100个目录下的100个文件中,每个文件写入1万行。

首先怀疑创建目录和文件:

代码如下:

    public static File createDirectory(String path) {
        File file = new File(path);
        if (!file.exists() && !file.isDirectory()) {
             file.mkdirs();
        }
        return file;
    }

    public static File createFile(String file) {
        File f = null;
        try {
            f = new File(file);
            if (!f.exists()) {
                f.createNewFile();
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
        return f;
    }

创建目录和文件,逻辑都是先检查再创建,显然不是原子的,所以怀疑有没有可能是多线程环境中,目录重复创建导致,所以把代码优化成两次判断的同步方式,如下:

    public static File createDirectory(String path) {
        File file = new File(path);
        if (!file.exists() && !file.isDirectory()) {
            synchronized (FileUtil.class) {
                if (!file.exists() && !file.isDirectory()) {
                    file.mkdirs();
                }
            }
        }
        return file;
    }

    public static File createFile(String file) {
        File f = null;
        try {
            f = new File(file);
            if (!f.exists()) {
                synchronized (FileUtil.class) {
                    if (!f.exists()) {
                        f.createNewFile();
                    }
                }
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
        return f;
    }

压入100w数据,观察结果,大失所望:

/data/job_project/txt/201911211100/00.txt lines: 9989
/data/job_project/txt/201911211101/01.txt lines: 9996
/data/job_project/txt/201911211102/02.txt lines: 9984
/data/job_project/txt/201911211103/03.txt lines: 9984
/data/job_project/txt/201911211104/04.txt lines: 9982

事实是绝大部分文件都漏了,下面把所有的目录和文件全部规划好,再试。
规划目录脚本:

#!/bin/sh
txt=/data/job_project/txt/*
for folder in $txt;do
    filename=${folder##*/}
    if [[ $filename = "f.sh" ]] || [[ $filename = "search.sh" ]];then
        echo "$filename is a shell file"
    else
        filename=${filename:10}
        filepath=${folder}/${filename}.txt
        #rm -f $filepath
        #touch $filepath
        lines=$(wc -l ${filepath} | awk '{print $1}')
        if [ $lines -ne 10000 ];then
            echo "$filepath lines: $lines"
        fi
    fi
done

结果仍然会漏数据。

为了彻底屏蔽创建目录和文件带来的影响,下面的压测前都创建好了文件和目录。

使用RandomAccessFile的rws方式同步写入文件。

测试结果:

/data/job_project/txt/201911211101/01.txt lines: 9998
/data/job_project/txt/201911211106/06.txt lines: 9999
/data/job_project/txt/201911211107/07.txt lines: 9999
/data/job_project/txt/201911211109/09.txt lines: 9999
/data/job_project/txt/201911211112/12.txt lines: 9999
/data/job_project/txt/201911211116/16.txt lines: 9998
/data/job_project/txt/201911211119/19.txt lines: 9999
/data/job_project/txt/201911211120/20.txt lines: 9998
...

压测过程十分缓慢,写入性能非常差,但是结果震惊,仍然漏了,仔细看了官网api注解:

     * <p>The <tt>"rwd"</tt> mode can be used to reduce the number of I/O
     * operations performed.  Using <tt>"rwd"</tt> only requires updates to the
     * file's content to be written to storage; using <tt>"rws"</tt> requires
     * updates to both the file's content and its metadata to be written, which
     * generally requires at least one more low-level I/O operation.
     *
     * <p>If there is a security manager, its {@code checkRead} method is
     * called with the pathname of the {@code file} argument as its
     * argument to see if read access to the file is allowed.  If the mode
     * allows writing, the security manager's {@code checkWrite} method is
     * also called with the path argument to see if write access to the file is
     * allowed.

rwd模式同步文件内容,rws模式同步文件内容和文件元数据,压测首选当然选择更严格的rws,结果仍然遗漏,此时已经开始怀疑jdk源码了。

调整close顺序,校验lock

第一处改动:
    if (fileLock != null) {
        break;
    }
多加一层校验,改成
    if (fileLock != null && fileLock.isValid()) {
        break;
    }

第二处改动:
    fileLock.release();
    fileChannel.close();
    raf.close();
调整close顺寻,改成:
    fileLock.release();
    raf.close();
    fileChannel.close();

测试结果:

/data/job_project/txt/201911211100/00.txt lines: 9989
/data/job_project/txt/201911211101/01.txt lines: 9996
/data/job_project/txt/201911211102/02.txt lines: 9984
/data/job_project/txt/201911211103/03.txt lines: 9984
/data/job_project/txt/201911211104/04.txt lines: 9982
...

结果显示,反而漏了更多数据,此时已经自闭了,但是还要接着撸。

使用channel写入缓冲区

public static void writeFileLock(String content, String filePath, String time) {
        File file = createFile(filePath);
        RandomAccessFile raf = null;
        FileChannel fileChannel = null;
        FileLock fileLock = null;
        try {
            raf = new RandomAccessFile(file, "rw");
            fileChannel = raf.getChannel();
            while (true) {
                try {
                    fileLock = fileChannel.tryLock();
                    if (fileLock != null && fileLock.isValid()) {
                        break;
                    }
                } catch (Exception e) {
                    Thread.sleep(0);
                }
            }
            fileChannel.write(ByteBuffer.wrap(content.getBytes()), fileChannel.size());
            fileLock.release();
            raf.close();
            fileChannel.close();
        } catch (Exception e) {
            log.error("写文件异常", e);
            log.error("写入文件路径:{}, 文件内容:{}", filePath, content);
        }
    }

改变写入方式,用nio的管道channel写入数据,结果仍然失望。

日志埋点——使用redis计数器

埋点代码:

    public static void writeFileLock(String content, String filePath, String time) {
        File file = createFile(filePath);
        RandomAccessFile raf = null;
        FileChannel fileChannel = null;
        FileLock fileLock = null;
        try {
            redisHelper.incr("filelock0:".concat(time));
            raf = new RandomAccessFile(file, "rw");
            fileChannel = raf.getChannel();
            while (true) {
                try {
                    fileLock = fileChannel.tryLock();
                    if (fileLock != null && fileLock.isValid()) {
                        break;
                    }
                } catch (Exception e) {
                    Thread.sleep(0);
                }
            }

            redisHelper.incr("filelock1:".concat(time));
            raf.seek(raf.length());
            redisHelper.incr("filelock2:".concat(time));
            raf.write(content.getBytes());
            redisHelper.incr("filelock3:".concat(time));
            fileLock.release();
            redisHelper.incr("filelock4:".concat(time));
            raf.close();
            redisHelper.incr("filelock5:".concat(time));
            fileChannel.close();
            redisHelper.incr("filelock6:".concat(time));
        } catch (Exception e) {
            log.error("写文件异常", e);
            log.error("写入文件路径:{}, 文件内容:{}", filePath, content);
        }
    }

此时对这段代码彻底失望,得找到数据在哪个位置漏掉的,所以使用了redis计数器,incr是线程安全得,所以能够很快发现到底哪里出问题了,问题马上浮出水面,心中窃喜。
再说明一下:redis的key包含目录名称,即一个目录一个文件一个key,埋点的密集显示出来必胜的信心。
结果是所有key的value都是完美的10000,毫无破绽,心如死灰,于是有同事提议,搞个反查,看看RangdomAccessFile的指针到底有没有更新。

判断RandomAccessFile的文件指针,是不是有没更新指针的情况

    long filelength = raf.length();
    raf.seek(filelength);
    raf.write(content.getBytes());
    if(filelength == raf.length()){
        log.error ( "errorrrrrrrrrrrrr: "+ content);
    }

如果write方法没有写入文件,那么文件指针必然没有更新,调用write后再反查文件指针是否更新,就能判断write是否有写入。结果仍然失望,预期的日志没有打印,说明write确实更新了文件指针,但是就是漏掉了几行数据,结合上述redis计数器埋点和文件指针判断,压测已经走进了死胡同,所有的情况都试过了,至少可以说两点:第一、文件锁没有问题,锁的线程没有逃逸出while循环;第二、测试的每一行代码都执行了到位了,没有哪一行没有执行的。百思不得其解,那就下班,次日再战。

java.io包+可重入锁的方式

昨天的压测可以说把所有情况都试过了,还有试过lock阻塞方式,fileChannel方式写入缓冲区,此处不表。今天决定换个思路,拒绝花里胡哨,就用jdk1.0版本的java.io包+ReentrantLock可重入锁的方式写,代码如下:

    public static void writeSyncFile(String content, String filePath) {
        try {
            fileLock.lock();
            File file = createFile(filePath);
            FileWriter fw = new FileWriter(file, true);
            BufferedWriter bw = new BufferedWriter(fw);
            bw.write(content);
            bw.flush();
            fw.close();
            bw.close();
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            fileLock.unlock();
        }
    }

结果可想而知,每个目录的每个文件,都是完美的10000行,且由于使用了缓冲区,文件写入效率大幅提升,具体提升幅度没有严格计算,使用同步块的方式+写入buffer的方式大概2分钟就能写完,而使用上述方式可能要1小时以上,效率杠杠的。普通的文件io方式没有问题,于是同事提议,用FileOutputStream替代RandomAccessFile看看。

替换RandomAccessFile,使用FileOutputStream获取channel

决定抛弃RandomAccessFile,使用FileOutputStream获取channel,代码如下:

    public static void writeFileIO(String content, String path) {
        FileLock lock = null;
        try {
            FileChannel channel = new FileOutputStream(path, true).getChannel();
            while (true) {
                try {
                    lock = channel.lock();
                    if (lock != null && lock.isValid()) {
                        break;
                    }
                } catch (Exception e) {
                    Thread.sleep(10);
                }
            }
            channel.write(ByteBuffer.wrap(content.getBytes()));
            lock.release();
            channel.close();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

RandomAccessFile是任意读写的类,而FileOutputStream没有这个功能,要想追加写入文件末尾,在构造方法加个true就行,同样能实现我们想要的功能,第一次压测后,3分钟就出结果,100w数据压入100个文件,每个文件10000行,与预期结果完全相符,完美!乘胜追亚,再压1000w发现数据有误,结果是oom,压入的数据全部写入线程池的阻塞队列中了,于是调大内存到6g,还是如此,奈何机器资源有限,改压400w,结果数据与预期完全符合,此时水落石出,没有想到坑在RandomAccessFile这里,回过头来看这个类,虽然这个类的注释已经被看烂了,比较诡异的是jdk1.0就出的,但是作者未知,可能怕被人喷,嘿嘿嘿。

总结

1、代码不是复制粘特,光搜索谷歌百度,往往很多噪音。
2、高并发场景要多次严格压测,保证数据质量。
3、千万区分windows系统和linux系统,二者的文件系统完全不同,上述代码在windows完全没问题,但是linux就是状况百出。
4、怀疑精神,代码都是人写的,就会有bug,测试用例覆盖所有场景,测试各种可能性。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,378评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,356评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,702评论 0 342
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,259评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,263评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,036评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,349评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,979评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,469评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,938评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,059评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,703评论 4 323
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,257评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,262评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,485评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,501评论 2 354
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,792评论 2 345