V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
firebroo
V2EX  ›  C

多线程环境内存数据安全持久化到磁盘

  •  
  •   firebroo · 2018-11-08 15:33:21 +08:00 · 4094 次点击
    这是一个创建于 2199 天前的主题,其中的信息可能已经有所发展或是发生改变。

    问题

    最近一个项目,对计算效率时实性要求比较高,我把所有数据都放到内存里面操作计算,这个时候如果进程被意外终止(人为 kill 或者说 oom kill 都可能),数据就会全部丢失,所以需要定时的把内存数据备份到磁盘(数据可能几十 M,也可能十几个 G ),这样进程重启就可以读取备份文件恢复数据到内存,丢失几分钟的数据还是可以接受的;还有一个需求是需要有个触发器线程,会在一定情况触发去同步内存数据到硬盘。

    思考

    总的来说就是一个定时线程,一个触发器线程,有几率会出现多线程写一个文件情况,多线程写文件比较好解决,首先想到的就是给文件上锁,确实可以解决多线程环境写的问题,但是无法解决在写的时候,进程被意外 kill,写到一半操作被终止,数据就会被损坏,这个时候比较尴尬的就是直接在原文件基础操作的,数据完全被损坏,进程重启找不到完整的数据去恢复,于是想到每次备份的时候写到不同的文件里面,这个时候面临的问题就是如果数据很大,就会产生 n 个备份文件,极端情况也无法接受,毕竟磁盘也是钱阿。所以抛出的问题就是多线程环境内存数据安全持久化到磁盘。

    解决方案

    寻找一种原子级别的备份操作,备份成功则数据更新,备份失败保留原始数据,由于是原子操作,也不存在多线程的竞争问题

    具体实现

    首先将内存数据写入一个随机的 tmp 文件,然后使用 rename 函数将 tmp 文件更新为备份文件名字,rename 的 manpage

    If newpath already exists it will be atomically replaced (subject to a few conditions; see ERRORS below), so that there is no point at which another process attempting to access newpath will find it missing.

    重点就是只要操作系统不 crash,rename 操作就是原子的。

    bool 
    concurrent_safe_backup()
    {
        ofstream ofs; 
        std::string tmp = "tmp";
    
        static default_random_engine e(time(0));
        //random filename
        tmp += to_string(e());
    
        //open
        ofs.open(tmp);
        if (!ofs) {
            return false;
        }
    
        std::string contentString = "data";
        ofs << contentString;
        ofs.close();
    
        int ret = rename(tmp.c_str(), "memory.bak");
        if (ret == -1) {
            return false;
        }
        //done
        return true;
    }
    
    第 1 条附言  ·  2018-11-09 14:53:46 +08:00

    经过大神指点,参考redis实现,最后我修改代码大概是这样,调用fflush和fsync强制数据刷入磁盘。

    bool
    concurrent_safe_backup()
    {
        ofstream ofs;
        std::string tmp("tmp");
    
        //random filename
        static default_random_engine e(time(0));
        tmp += to_string(e());
    
        //open
        FILE* fp = fopen(tmp.c_str(), "w");
        if (!fp) {
            return false;
        }
    
        std::string contentString("dataaaaaa\n");
        fwrite(contentString.c_str(), 1, contentString.length(), fp);
    
        /* Make sure data will not remain on the OS's output buffers */
        if (fflush(fp) == EOF) return false;
        if (fsync(fileno(fp)) == -1) return false;
        if (fclose(fp) == EOF) return false;
    
        /* Use RENAME to make sure the DB file is changed atomically only
         * if the generate DB file is ok. */
        int ret = rename(tmp.c_str(), "memory.bak");
        if (ret == -1) {
            return false;
        }
        //done
        return true;
    }
    
    44 条回复    2018-11-12 13:38:00 +08:00
    polythene
        1
    polythene  
       2018-11-08 15:54:08 +08:00
    Write Ahead Log?
    feverzsj
        2
    feverzsj  
       2018-11-08 16:09:25 +08:00
    把中间数据提交到数据库不就可以了
    muntoya
        3
    muntoya  
       2018-11-08 16:14:21 +08:00   ❤️ 1
    fork 以后在子进程里安心写磁盘就行了,子进程的内存保持不变,redis 就这么做的
    huhu3312
        4
    huhu3312  
       2018-11-08 16:19:01 +08:00
    天池大赛啊。。。。key-value 数据库复赛
    yzmm
        5
    yzmm  
       2018-11-08 16:20:38 +08:00
    hi...
    iiusky
        6
    iiusky  
       2018-11-08 16:23:31 +08:00
    hi,二狗
    petelin
        7
    petelin  
       2018-11-08 17:24:20 +08:00
    推荐看下#3 的方式去写数据. 可以保证内存数据不变.另外你可以创建文件名是 data_{utcnano}.txt 的文件不就不会覆盖之前的了吗. 写失败的你就删了就得了.
    ooo3o
        8
    ooo3o  
       2018-11-08 17:35:17 +08:00
    先分线程各自写一个, 再跑一个慢慢归并.
    firebroo
        9
    firebroo  
    OP
       2018-11-08 18:00:13 +08:00 via Android
    @huhu3312 看了下,还真的是。。不过我是实际场景遇到
    firebroo
        10
    firebroo  
    OP
       2018-11-08 18:04:28 +08:00 via Android
    @muntoya 问题不在于内存变不变。。而是解决写的时候写操作被 kill -9 中断导致写文件损坏
    firebroo
        11
    firebroo  
    OP
       2018-11-08 18:05:54 +08:00 via Android
    @petelin 离题。。我文章里面写明了多个文件会消耗磁盘,pass 掉
    leavan
        12
    leavan  
       2018-11-08 18:29:15 +08:00
    一般来说如果是追加到文件末尾的话,即使 kill 掉也只会导致文件末尾没写上...
    feverzsj
        13
    feverzsj  
       2018-11-08 18:31:40 +08:00
    你让 sqlite 之类的嵌入式数据库帮你代理读写就可以了,你想原子就原子,你想质子就质子
    firebroo
        14
    firebroo  
    OP
       2018-11-08 18:59:53 +08:00
    @leavan 这里的数据具有,不能增量写,只能全量重新写入。
    firebroo
        15
    firebroo  
    OP
       2018-11-08 19:00:26 +08:00
    @firebroo 具有完整性
    firebroo
        16
    firebroo  
    OP
       2018-11-08 19:07:40 +08:00
    @feverzsj 数据库场景不合适,十几个 G 数据全量变化更新,不然我就不把数据放内存里面了,再次也用 redis 这种内存数据库,测试 redis 没满足性能需要
    lihongjie0209
        17
    lihongjie0209  
       2018-11-08 19:09:53 +08:00
    重新发明一个数据库
    feverzsj
        18
    feverzsj  
       2018-11-08 19:14:07 +08:00
    @firebroo 了解,你原来根本搞不清楚问题在哪,写入完整性本来就是依赖副本交换实现的,十几 G 根本不算大,sqlite 完全能胜任
    429839446
        19
    429839446  
       2018-11-08 19:15:34 +08:00 via Android
    可以看微信开源的 mmkv ?
    firebroo
        20
    firebroo  
    OP
       2018-11-08 19:36:27 +08:00
    @feverzsj 你没测试就说可以胜任我的场景,我说的是数据有完整性,不能单一的一条条更新,必须整体更新,不是写入完整性。
    feverzsj
        21
    feverzsj  
       2018-11-08 19:37:45 +08:00
    @firebroo 知不知道什么叫事务性啊?我根本不需要了解你那些小儿科的场景应用
    firebroo
        22
    firebroo  
    OP
       2018-11-08 19:43:12 +08:00
    @429839446 可以可以,就是类似这种,mmkv 这样的实现就完全丢失不了数据了。
    xylophone21
        23
    xylophone21  
       2018-11-08 19:47:34 +08:00
    @muntoya 说到问题的关键了,但感觉楼主还在一些不重要的细枝末节上出不来,或者没找到重点,不解释。
    firebroo
        24
    firebroo  
    OP
       2018-11-08 19:47:46 +08:00
    @feverzsj 我讨论 redis 这种内存数据库是如何实现安全持久化内存数据到磁盘的,然后自己实现个类似的,你让我把这种事情交给 redis 去搞,你是扛精。。?
    feverzsj
        25
    feverzsj  
       2018-11-08 19:53:15 +08:00
    @firebroo 我根本没提过 redis,你说说你想实现的东西,有什么不能用 sqlite 实现的? sqlite 能在保证事务性的基础上达到良好的 io 效率,你自己用标注库 io 能做到?
    firebroo
        26
    firebroo  
    OP
       2018-11-08 19:59:32 +08:00 via Android
    @feverzsj redis 和 sqlite 有差吗,就是把这件事交给数据库去完成,某些场景确实需要,不然微信为啥有 mmkv 这种轮子不用 sqlite
    feverzsj
        27
    feverzsj  
       2018-11-08 20:03:31 +08:00
    @firebroo mmkv 是非常低层次的 kvstore,根本不管你数据死活,sqlite 是完整的关系型数据库,根本不是一个层面的
    liuxu
        28
    liuxu  
       2018-11-08 20:10:16 +08:00
    能不能写到 ramdisk 呢,然后一个线程定期刷到硬盘呢,记录好日志,这样进程即使死了 ramdisk 数据依然不会丢失,重启进程分析日志继续写入到硬盘
    firebroo
        29
    firebroo  
    OP
       2018-11-08 20:14:24 +08:00 via Android
    @feverzsj 不抬杠了,太累,下次我用 sqlite
    firebroo
        30
    firebroo  
    OP
       2018-11-08 20:18:59 +08:00 via Android
    @xylophone21 科普一下,我确实没有看懂 3 楼回复,redis 的实现没看过
    firebroo
        31
    firebroo  
    OP
       2018-11-08 20:32:04 +08:00
    @muntoya
    @xylophone21 原来是你好像真的没看懂问题是啥。。我刚去翻了下 redis 持久化的源码
    ```c
    /* Use RENAME to make sure the DB file is changed atomically only
    * if the generate DB file is ok. */
    if (rename(tmpfile,filename) == -1) {
    serverLog(LL_WARNING,"Error moving temp append only file on the final destination: %s", strerror(errno));
    unlink(tmpfile);
    return C_ERR;
    }
    ```
    msg7086
        32
    msg7086  
       2018-11-09 09:11:26 +08:00
    没看懂问题是啥。
    写临时文件然后 rename 算是基本操作,rsync 之类的软件也是这么跑的。
    值得注意的是你这里写完临时文件以后需要 flush 一下底层的文件系统,确保文件内容已经刷入磁盘了,再 rename 比较好。
    Monad
        33
    Monad  
       2018-11-09 10:03:33 +08:00
    @msg7086 #32 只考虑 kill -9 进程的话 操作系统应该可以保证落盘的吧
    firebroo
        34
    firebroo  
    OP
       2018-11-09 10:22:17 +08:00 via Android
    @msg7086 我 rename 之前已经 close 了 tmp 文件,会自动 flush 吧
    msg7086
        35
    msg7086  
       2018-11-09 10:29:22 +08:00
    @Monad @firebroo kill -9 不影响。主要是考虑断电的问题。
    firebroo
        36
    firebroo  
    OP
       2018-11-09 10:38:42 +08:00 via Android
    @msg7086 真实环境,会可能断电。。
    msg7086
        37
    msg7086  
       2018-11-09 10:44:25 +08:00
    @firebroo 会断电的话还是查一下文件系统的文档吧,看看什么时候强刷。保险起见可以手动强刷再 RENAME。
    firebroo
        38
    firebroo  
    OP
       2018-11-09 10:46:53 +08:00
    @msg7086 fflush:是把 C 库中的缓冲调用 write 函数写到磁盘[其实是写到内核的缓冲区]。fsync:是把内核缓冲刷到磁盘上。 这个吧?学习了。
    no1xsyzy
        39
    no1xsyzy  
       2018-11-09 11:10:27 +08:00
    @firebroo #31 https://github.com/antirez/redis/blob/2f8f29aa0e63a198aa628296ce617214b3ae1575/src/aof.c#L1540
    Redis 实现 rewriteAppendOnlyFileBackground() 的时候用了 fork() 而在子进程中断开监听端口,调用 rewriteAppendOnlyFile() 进行写入。
    不过 Redis 是单线程的,多线程的话 fork 要处理锁,因为这时候系铃人可能消失了,解不了铃。
    no1xsyzy
        40
    no1xsyzy  
       2018-11-09 11:21:11 +08:00
    @firebroo #14 全量写入也可以追加文件末尾啊,虽然又碰上数据量过大的问题了。有没有想过上磁带?
    或者保证只有 2 份文件存在。写 A 不碰 B,写 B 不碰 A。
    firebroo
        41
    firebroo  
    OP
       2018-11-09 13:52:00 +08:00
    @no1xsyzy 啊哈,上磁带是啥意思?
    no1xsyzy
        42
    no1xsyzy  
       2018-11-09 16:04:31 +08:00
    @firebroo 磁带+RAID 0,单位容量价格极低,顺序读写速度堪比 SSD,缺点是基本只能顺序,随机查找 IOPS 极低,但你这个情景用磁带做持久,之后用磁带还原状态,也就顺序……要是 VPS 上跑就当我没说
    firebroo
        43
    firebroo  
    OP
       2018-11-09 16:08:54 +08:00
    @no1xsyzy 擦。。感觉太复杂,不是 vps,线上环境。
    pythonCoder
        44
    pythonCoder  
       2018-11-12 13:38:00 +08:00
    爬山心得
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   2891 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 33ms · UTC 06:26 · PVG 14:26 · LAX 22:26 · JFK 01:26
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.