root检测方法小结


参考:https://blog.csdn.net/lintax/article/details/70988565

大多数应用都需要反root,不然不安全,信息可能被非法操作。


为什么要把root排除在外,才能说应用的隔离是安全的呢?

在Linux操作系统中,root的权限是最高的,也被称为超级权限的拥有者。
在系统中,每个文件、目录和进程,都归属于某一个用户,没有用户许可其它普通用户是无法操作的,但对root除外。

root用户的特权性还表现在:root可以超越任何用户和用户组来对文件或目录进行读取、修改或删除(在系统正常的许可范围内);对可执行程序的执行、终止;对硬件设备的添加、创建和移除等;也可以对文件和目录进行属主和权限进行修改,以适合系统管理的需要(因为root是系统中权限最高的特权用户);root是超越任何用户和用户组的,基于用户ID的权限机制的沙盒是隔离不了它的。

接下来了解下root的方式

通常可以分为2种:
1,不完全Root
2,完全Root
目前获取Android root 权限常用方法是通过各种系统漏洞,替换或添加SU程序到设备,获取Root权限,而在获取root权限以后,会装一个程序用以提醒用户是否给予程序最高权限,可以一定程度上防止恶意软件,通常会使用Superuser或者 SuperSU ,这种方法通常叫做“不完全Root”。
而 “完全ROOT”是指,替换设备原有的ROM,以实现取消secure设置。

root检测的方法

下面介绍下root检测的各种方法:
1,查看系统是否测试版

我们可以查看发布的系统版本,是test-keys(测试版),还是release-keys(发布版)。
可以先在adb shell中运行下命令查看:

root@android:/ # cat /system/build.prop | grep ro.build.tags
ro.build.tags=release-keys


这个返回结果“release-keys”,代表此系统是正式发布版。
在代码中的检测方法如下:

    public static boolean checkDeviceDebuggable(){
        String buildTags = android.os.Build.TAGS;
        if (buildTags != null && buildTags.contains("test-keys")) {
            Log.i(LOG_TAG,"buildTags="+buildTags);
            return true;
        }
        return false;
    }


若是非官方发布版,很可能是完全root的版本,存在使用风险。
可是在实际情况下,我遇到过某些厂家的正式发布版本,也是test-keys,可能大家对这个标识也不是特别注意吧。所以具体是否使用,还要多考虑考虑呢。也许能解决问题,也许会给自己带来些麻烦。
2,检查是否存在Superuser.apk

Superuser.apk是一个被广泛使用的用来root安卓设备的软件,所以可以检查这个app是否存在。
检测方法如下:

    public static boolean checkSuperuserApk(){
        try {
            File file = new File("/system/app/Superuser.apk");
            if (file.exists()) {
                Log.i(LOG_TAG,"/system/app/Superuser.apk exist");
                return true;
            }
        } catch (Exception e) { }
        return false;
    }


3,检查su命令

su是Linux下切换用户的命令,在使用时不带参数,就是切换到超级用户。通常我们获取root权限,就是使用su命令来实现的,所以可以检查这个命令是否存在。
有三个方法来测试su是否存在:
1)检测在常用目录下是否存在su

    public static boolean checkRootPathSU()
    {
        File f=null;
        final String kSuSearchPaths[]={"/system/bin/","/system/xbin/","/system/sbin/","/sbin/","/vendor/bin/"};
        try{
            for(int i=0;i<kSuSearchPaths.length;i++)
            {
                f=new File(kSuSearchPaths[i]+"su");
                if(f!=null&&f.exists())
                {
                    Log.i(LOG_TAG,"find su in : "+kSuSearchPaths[i]);
                    return true;
                }
            }
        }catch(Exception e)
        {
            e.printStackTrace();
        }
        return false;
    }

这个方法是检测常用目录,那么就有可能漏过不常用的目录。
所以就有了第二个方法,直接使用shell下的命令来查找。

2)使用which命令查看是否存在su
which是linux下的一个命令,可以在系统PATH变量指定的路径中搜索某个系统命令的位置并且返回第一个搜索结果。
这里,我们就用它来查找su。

    public static boolean checkRootWhichSU() {
        String[] strCmd = new String[] {"/system/xbin/which","su"};
        ArrayList<String> execResult = executeCommand(strCmd);
        if (execResult != null){
            Log.i(LOG_TAG,"execResult="+execResult.toString());
            return true;
        }else{
            Log.i(LOG_TAG,"execResult=null");
            return false;
        }
    }

 

其中调用了一个函数 executeCommand(),是执行linux下的shell命令。具体实现如下:

    public static ArrayList<String> executeCommand(String[] shellCmd){
        String line = null;
        ArrayList<String> fullResponse = new ArrayList<String>();
        Process localProcess = null;
        try {
            Log.i(LOG_TAG,"to shell exec which for find su :");
            localProcess = Runtime.getRuntime().exec(shellCmd);
        } catch (Exception e) {
            return null;
        }
        BufferedWriter out = new BufferedWriter(new OutputStreamWriter(localProcess.getOutputStream()));
        BufferedReader in = new BufferedReader(new InputStreamReader(localProcess.getInputStream()));
        try {
            while ((line = in.readLine()) != null) {
                Log.i(LOG_TAG,"–> Line received: " + line);
                fullResponse.add(line);
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
        Log.i(LOG_TAG,"–> Full response was: " + fullResponse);
        return fullResponse;
    }

 

然而,这个方法也存在一个缺陷,就是需要系统中存在which这个命令。我在测试过程中,就遇到有的Android系统中没有这个命令,所以,这也不是一个完全有保障的方法,倒是可以和上一个方法(在常用路径下查找)进行组合,能提升成功率。
这种查找命令的方式,还有一种缺陷,就是可能系统中存在su,但是已经失效的情况。例如,我曾经root过,后来又取消了,就可能出现这种情况:有su这个文件,但是当前设备不是root的。

3)执行su,看能否获取到root权限
由于上面两种查找方法都存在可能查不到的情况,以及有su文件与设备root的差异,所以,有这第三中方法:我们执行这个命令su。这样,系统就会在PATH路径中搜索su,如果找到,就会执行,执行成功后,就是获取到真正的超级权限了。
具体代码如下:

 public static synchronized boolean checkGetRootAuth()
    {
        Process process = null;
        DataOutputStream os = null;
        try
        {
            Log.i(LOG_TAG,"to exec su");
            process = Runtime.getRuntime().exec("su");
            os = new DataOutputStream(process.getOutputStream());
            os.writeBytes("exit\n");
            os.flush();
            int exitValue = process.waitFor();
            Log.i(LOG_TAG, "exitValue="+exitValue);
            if (exitValue == 0)
            {
                return true;
            } else
            {
                return false;
            }
        } catch (Exception e)
        {
            Log.i(LOG_TAG, "Unexpected error - Here is what I know: "
                    + e.getMessage());
            return false;
        } finally
        {
            try
            {
                if (os != null)
                {
                    os.close();
                }
                process.destroy();
            } catch (Exception e)
            {
                e.printStackTrace();
            }
        }
    }

 

这种检测su的方法,应该是最靠谱的,不过,也有个问题,就是在已经root的设备上,会弹出提示框,请求给app开启root权限。这个提示不太友好,可能用户会不喜欢。
如果想安静的检测,可以用上两种方法的组合;如果需要尽量安全的检测到,还是执行su吧。

4,执行busybox

Android是基于Linux系统的,可是在终端Terminal中操作,会发现一些基本的命令都找不到。这是由于Android系统为了安全,将可能带来风险的命令都去掉了,最典型的,例如su,还有find、mount等。对于一个已经获取了超级权限的人来讲,这是很不爽的事情,所以,便要想办法加上自己需要的命令了。一个个添加命令也麻烦,有一个很方便的方法,就是使用被称为“嵌入式Linux中的瑞士军刀”的Busybox。简单的说BusyBox就好像是个大工具箱,它集成压缩了 Linux 的许多工具和命令。
所以若设备root了,很可能Busybox也被安装上了。这样我们运行busybox测试也是一个好的检测方法。

   public static synchronized boolean checkBusybox()
    {
        try
        {
            Log.i(LOG_TAG,"to exec busybox df");
            String[] strCmd = new String[] {"busybox","df"};
            ArrayList<String> execResult = executeCommand(strCmd);
            if (execResult != null){
                Log.i(LOG_TAG,"execResult="+execResult.toString());
                return true;
            }else{
                Log.i(LOG_TAG,"execResult=null");
                return false;
            }
        } catch (Exception e)
        {
            Log.i(LOG_TAG, "Unexpected error - Here is what I know: "
                    + e.getMessage());
            return false;
        }
    }

 

5,访问/data目录,查看读写权限

在Android系统中,有些目录是普通用户不能访问的,例如 /data、/system、/etc 等。
我们就已/data为例,来进行读写访问。本着谨慎的态度,我是先写入一个文件,然后读出,查看内容是否匹配,若匹配,才认为系统已经root了。

public static synchronized boolean checkAccessRootData()
    {
        try
        {
            Log.i(LOG_TAG,"to write /data");
            String fileContent = "test_ok";
            Boolean writeFlag = writeFile("/data/su_test",fileContent);
            if (writeFlag){
                Log.i(LOG_TAG,"write ok");
            }else{
                Log.i(LOG_TAG,"write failed");
            }

            Log.i(LOG_TAG,"to read /data");
            String strRead = readFile("/data/su_test");
            Log.i(LOG_TAG,"strRead="+strRead);
            if(fileContent.equals(strRead)){
                return true;
            }else {
                return false;
            }
        } catch (Exception e)
        {
            Log.i(LOG_TAG, "Unexpected error - Here is what I know: "
                    + e.getMessage());
            return false;
        }
    }

 

上面的代码,调用了两个函数:writeFile()写文件,readFile()读文件,下面是具体实现:

//写文件
    public static Boolean writeFile(String fileName,String message){
        try{
            FileOutputStream fout = new FileOutputStream(fileName);
            byte [] bytes = message.getBytes();
            fout.write(bytes);
            fout.close();
            return true;
        }
        catch(Exception e){
            e.printStackTrace();
            return false;
        }
    }
//读文件
    public static String readFile(String fileName){
        File file = new File(fileName);
        try {
            FileInputStream fis= new FileInputStream(file);
            byte[] bytes = new byte[1024];
            ByteArrayOutputStream bos = new ByteArrayOutputStream();
            int len;
            while((len=fis.read(bytes))>0){
                bos.write(bytes, 0, len);
            }
            String result = new String(bos.toByteArray());
            Log.i(LOG_TAG, result);
            return result;
        } catch (Exception e) {
            e.printStackTrace();
            return null;
        }
    }

在前面的课程里,我和你多次提到过etcd数据存储在boltdb。那么boltdb是如何组织你的key-value数据的呢?当你读写一个key时,boltdb是如何工作的?今天我将通过一个写请求在boltdb中执行的简要流程,分析其背后的boltdb的磁盘文件布局,帮助你了解page、node、bucket等核心数据结构的原理与作用,搞懂boltdb基于B+ tree、各类page实现查找、更新、事务提交的原理,让你明白etcd为什么适合读多写少的场景。boltdb磁盘布局在介绍一个put写请求在boltdb中执行原理前,我先给你从整体上介绍下平时你所看到的etcd db文件的磁盘布局,让你了解下db文件的物理存储结构。boltdb文件指的是你etcd数据目录下的member/snap/db的文件, etcd的key-value、lease、meta、member、cluster、auth等所有数据存储在其中。etcd启动的时候,会通过mmap机制将db文件映射到内存,后续可从内存中快速读取文件中的数据。写请求通过fwrite和fdatasync来写入、持久化数据到磁盘。上图是我给你画的db文件磁盘布局,从图中的左边部分你可以看到,文件的内容由若干个page组成,一般情况下page size为4KB。page按照功能可分为元数据页(meta page)、B+ tree索引节点页(branch page)、B+ tree 叶子节点页(leaf page)、空闲页管理页(freelist page)、空闲页(free page)。文件最开头的两个page是固定的db元数据meta page,空闲页管理页记录了db中哪些页是空闲、可使用的。索引节点页保存了B+ tree的内部节点,如图中的右边部分所示,它们记录了key值,叶子节点页记录了B+ tree中的key-value和bucket数据。boltdb逻辑上通过B+ tree来管理branch/leaf page, 实现快速查找、写入key-value数据。boltdb API了解完boltdb的磁盘布局后,那么如果要在etcd中执行一个put请求,boltdb中是如何执行的呢? boltdb作为一个库,提供了什么API给client访问写入数据?boltdb提供了非常简单的API给上层业务使用,当我们执行一个put hello为world命令时,boltdb实际写入的key是版本号,value为mvccpb.KeyValue结构体。这里我们简化下,假设往key bucket写入一个key为r94,value为world的字符串,其核心代码如下:// 打开boltdb文件,获取db对象db,err := bolt.Open("db", 0600, nil)if err != nil { log.Fatal(err)}defer db.Close()// 参数true表示创建一个写事务,false读事务tx,err := db.Begin(true)if err != nil { return err}defer tx.Rollback()// 使用事务对象创建key bucketb,err := tx.CreatebucketIfNotExists([]byte("key"))if err != nil { return err}// 使用bucket对象更新一个keyif err := b.Put([]byte("r94"),[]byte("world")); err != nil { return err}// 提交事务if err := tx.Commit(); err != nil { return err}12345678910111213141516171819202122232425如上所示,通过boltdb的Open API,我们获取到boltdb的核心对象db实例后,然后通过db的Begin API开启写事务,获得写事务对象tx。通过写事务对象tx, 你可以创建bucket。这里我们创建了一个名为key的bucket(如果不存在),并使用bucket API往其中更新了一个key为r94,value为world的数据。最后我们使用写事务的Commit接口提交整个事务,完成bucket创建和key-value数据写入。看起来是不是非常简单,神秘的boltdb,并未有我们想象的那么难。然而其API简单的背后却是boltdb的一系列巧妙的设计和实现。一个key-value数据如何知道该存储在db在哪个page?如何快速找到你的key-value数据?事务提交的原理又是怎样的呢?接下来我就和你浅析boltdb背后的奥秘。核心数据结构介绍上面我们介绍boltdb的磁盘布局时提到,boltdb整个文件由一个个page组成。最开头的两个page描述db元数据信息,而它正是在client调用boltdb Open API时被填充的。那么描述磁盘页面的page数据结构是怎样的呢?元数据页又含有哪些核心数据结构?boltdb本身自带了一个工具bbolt,它可以按页打印出db文件的十六进制的内容,下面我们就使用此工具来揭开db文件的神秘面纱。下图左边的十六进制是执行如下bbolt dump命令,所打印的boltdb第0页的数据,图的右边是对应的page磁盘页结构和meta page的数据结构。$ ./bbolt dump ./infra1.etcd/member/snap/db 01一看上图中的十六进制数据,你可能很懵,没关系,在你了解page磁盘页结构、meta page数据结构后,你就能读懂其含义了。page磁盘页结构我们先了解下page磁盘页结构,如上图所示,它由页ID(id)、页类型(flags)、数量(count)、溢出页数量(overflow)、页面数据起始位置(ptr)字段组成。页类型目前有如下四种:0x01表示branch page,0x02表示leaf page,0x04表示meta page,0x10表示freelist page。数量字段仅在页类型为leaf和branch时生效,溢出页数量是指当前页面数据存放不下,需要向后再申请overflow个连续页面使用,页面数据起始位置指向page的载体数据,比如meta page、branch/leaf等page的内容。meta page数据结构第0、1页我们知道它是固定存储db元数据的页(meta page),那么meta page它为了管理整个boltdb含有哪些信息呢?如上图中的meta page数据结构所示,你可以看到它由boltdb的文件标识(magic)、版本号(version)、页大小(pagesize)、boltdb的根bucket信息(root bucket)、freelist页面ID(freelist)、总的页面数量(pgid)、上一次写事务ID(txid)、校验码(checksum)组成。meta page十六进制分析了解完page磁盘页结构和meta page数据结构后,我再结合图左边的十六进数据和你简要分析下其含义。上图中十六进制输出的是db文件的page 0页结构,左边第一列表示此行十六进制内容对应的文件起始地址,每行16个字节。结合page磁盘页和meta page数据结构我们可知,第一行前8个字节描述pgid(忽略第一列)是0。接下来2个字节描述的页类型, 其值为0x04表示meta page, 说明此页的数据存储的是meta page内容,因此ptr开始的数据存储的是meta page内容。正如你下图中所看到的,第二行首先含有一个4字节的magic number(0xED0CDAED),通过它来识别当前文件是否boltdb,接下来是两个字节描述boltdb的版本号0x2, 然后是四个字节的page size大小,0x1000表示4096个字节,四个字节的flags为0。第三行对应的就是meta page的root bucket结构(16个字节),它描述了boltdb的root bucket信息,比如一个db中有哪些bucket, bucket里面的数据存储在哪里。第四行中前面的8个字节,0x3表示freelist页面ID,此页面记录了db当前哪些页面是空闲的。后面8个字节,0x6表示当前db总的页面数。第五行前面的8个字节,0x1a表示上一次的写事务ID,后面的8个字节表示校验码,用于检测文件是否损坏。了解完db元数据页面原理后,那么boltdb是如何根据元数据页面信息快速找到你的bucket和key-value数据呢?这就涉及到了元数据页面中的root bucket,它是个至关重要的数据结构。下面我们看看它是如何管理一系列bucket、帮助我们查找、写入key-value数据到boltdb中。bucket数据结构如下命令所示,你可以使用bbolt buckets命令,输出一个db文件的bucket列表。执行完此命令后,我们可以看到之前介绍过的auth/lease/meta等熟悉的bucket,它们都是etcd默认创建的。那么boltdb是如何存储、管理bucket的呢?$ ./bbolt buckets ./infra1.etcd/member/snap/dbalarmauthauthRolesauthUsersclusterkeyleasemembersmembers_removedmeta123456789101112在上面我们提到过meta page中的,有一个名为root、类型bucket的重要数据结构,如下所示,bucket由root和sequence两个字段组成,root表示该bucket根节点的page id。注意meta page中的bucket.root字段,存储的是db的root bucket页面信息,你所看到的key/lease/auth等bucket都是root bucket的子bucket。type bucket struct { root pgid // page id of the bucket's root-level page sequence uint64 // monotonically incrementing, used by NextSequence()}1234上面meta page十六进制图中,第三行的16个字节就是描述的root bucket信息。root bucket指向的page id为4,page id为4的页面是什么类型呢? 我们可以通过如下bbolt pages命令看看各个page类型和元素数量,从下图结果可知,4号页面为leaf page。$ ./bbolt pages ./infra1.etcd/member/snap/dbID TYPE ITEMS OVRFLW======== ========== ====== ======0 meta 01 meta 02 free3 freelist 24 leaf 105 free123456789通过上面的分析可知,当bucket比较少时,我们子bucket数据可直接从meta page里指向的leaf page中找到。leaf pagemeta page的root bucket直接指向的是page id为4的leaf page, page flag为0x02, leaf page它的磁盘布局如下图所示,前半部分是leafPageElement数组,后半部分是key-value数组。leafPageElement包含leaf page的类型flags, 通过它可以区分存储的是bucket名称还是key-value数据。当flag为bucketLeafFlag(0x01)时,表示存储的是bucket数据,否则存储的是key-value数据,leafPageElement它还含有key-value的读取偏移量,key-value大小,根据偏移量和key-value大小,我们就可以方便地从leaf page中解析出所有key-value对。当存储的是bucket数据的时候,key是bucket名称,value则是bucket结构信息。bucket结构信息含有root page信息,通过root page(基于B+ tree查找算法),你可以快速找到你存储在这个bucket下面的key-value数据所在页面。从上面分析你可以看到,每个子bucket至少需要一个page来存储其下面的key-value数据,如果子bucket数据量很少,就会造成磁盘空间的浪费。实际上boltdb实现了inline bucket,在满足一些条件限制的情况下,可以将小的子bucket内嵌在它的父亲叶子节点上,友好的支持了大量小bucket。为了方便大家快速理解核心原理,本节我们讨论的bucket是假设都是非inline bucket。那么boltdb是如何管理大量bucket、key-value的呢?branch pageboltdb使用了B+ tree来高效管理所有子bucket和key-value数据,因此它可以支持大量的bucket和key-value,只不过B+ tree的根节点不再直接指向leaf page,而是branch page索引节点页。branch page flags为0x01。它的磁盘布局如下图所示,前半部分是branchPageElement数组,后半部分是key数组。branchPageElement包含key的读取偏移量、key大小、子节点的page id。根据偏移量和key大小,我们就可以方便地从branch page中解析出所有key,然后二分搜索匹配key,获取其子节点page id,递归搜索,直至从bucketLeafFlag类型的leaf page中找到目的bucket name。注意,boltdb在内存中使用了一个名为node的数据结构,来保存page反序列化的结果。下面我给出了一个boltdb读取page到node的代码片段,你可以直观感受下。func (n *node) read(p *page) { n.pgid = p.id n.isLeaf = ((p.flags & leafPageFlag) != 0) n.inodes = make(inodes, int(p.count)) for i := 0; i < int(p.count); i++ { inode := &n.inodes[i] if n.isLeaf { elem := p.leafPageElement(uint16(i)) inode.flags = elem.flags inode.key = elem.key() inode.value = elem.value() } else { elem := p.branchPageElement(uint16(i)) inode.pgid = elem.pgid inode.key = elem.key() } }12345678910111213141516171819从上面分析过程中你会发现,boltdb存储bucket和key-value原理是类似的,将page划分成branch page、leaf page,通过B+ tree来管理实现。boltdb为了区分leaf page存储的数据类型是bucket还是key-value,增加了标识字段(leafPageElement.flags),因此key-value的数据存储过程我就不再重复分析了。freelist介绍完bucket、key-value存储原理后,我们再看meta page中的另外一个核心字段freelist,它的作用是什么呢?我们知道boltdb将db划分成若干个page,那么它是如何知道哪些page在使用中,哪些page未使用呢?答案是boltdb通过meta page中的freelist来管理页面的分配,freelist page中记录了哪些页是空闲的。当你在boltdb中删除大量数据的时候,其对应的page就会被释放,页ID存储到freelist所指向的空闲页中。当你写入数据的时候,就可直接从空闲页中申请页面使用。下面meta page十六进制图中,第四行的前8个字节就是描述的freelist信息,page id为3。我们可以通过bbolt page命令查看3号page内容,如下所示,它记录了2和5为空闲页,与我们上面通过bbolt pages命令所看到的信息一致。$ ./bbolt page ./infra1.etcd/member/snap/db 3page ID: 3page Type: freelistTotal Size: 4096 bytesItem Count: 2Overflow: 025123456789下图是freelist page存储结构,pageflags为0x10,表示freelist类型的页,ptr指向空闲页id数组。注意在boltdb中支持通过多种数据结构(数组和hashmap)来管理free page,这里我介绍的是数组。Open原理了解完核心数据结构后,我们就很容易搞懂boltdb Open API的原理了。首先它会打开db文件并对其增加文件锁,目的是防止其他进程也以读写模式打开它后,操作meta和free page,导致db文件损坏。其次boltdb通过mmap机制将db文件映射到内存中,并读取两个meta page到db对象实例中,然后校验meta page的magic、version、checksum是否有效,若两个meta page都无效,那么db文件就出现了严重损坏,导致异常退出。Put原理那么成功获取db对象实例后,通过bucket API创建一个bucket、发起一个Put请求更新数据时,boltdb是如何工作的呢?根据我们上面介绍的bucket的核心原理,它首先是根据meta page中记录root bucket的root page,按照B+ tree的查找算法,从root page递归搜索到对应的叶子节点page面,返回key名称、leaf类型。如果leaf类型为bucketLeafFlag,且key相等,那么说明已经创建过,不允许bucket重复创建,结束请求。否则往B+ tree中添加一个flag为bucketLeafFlag的key,key名称为bucket name,value为bucket的结构。创建完bucket后,你就可以通过bucket的Put API发起一个Put请求更新数据。它的核心原理跟bucket类似,根据子bucket的root page,从root page递归搜索此key到leaf page,如果没有找到,则在返回的位置处插入新key和value。为了方便你理解B+ tree查找、插入一个key原理,我给你构造了的一个max degree为5的B+ tree,下图是key r94的查找流程图。那么如何确定这个key的插入位置呢?首先从boltdb的key bucket的root page里,二分查找大于等于r94的key所在page,最终找到key r9指向的page(流程1)。r9指向的page是个leaf page,B+ tree需要确保叶子节点key的有序性,因此同样二分查找其插入位置,将key r94插入到相关位置(流程二)。在核心数据结构介绍中,我和你提到boltdb在内存中通过node数据结构来存储page磁盘页内容,它记录了key-value数据、page id、parent及children的node、B+ tree是否需要进行重平衡和分裂操作等信息。因此,当我们执行完一个put请求时,它只是将值更新到boltdb的内存node数据结构里,并未持久化到磁盘中。事务提交原理那么boltdb何时将数据持久化到db文件中呢?当你的代码执行tx.Commit API时,它才会将我们上面保存到node内存数据结构中的数据,持久化到boltdb中。下图我给出了一个事务提交的流程图,接下来我就分别和你简要分析下各个核心步骤。首先从上面put案例中我们可以看到,插入了一个新的元素在B+ tree的叶子节点,它可能已不满足B+ tree的特性,因此事务提交时,第一步首先要调整B+ tree,进行重平衡、分裂操作,使其满足B+ tree树的特性。上面案例里插入一个key r94后,经过重平衡、分裂操作后的B+ tree如下图所示。在重平衡、分裂过程中可能会申请、释放free page,freelist所管理的free page也发生了变化。因此事务提交的第二步,就是持久化freelist。注意,在etcd v3.4.9中,为了优化写性能等,freelist持久化功能是关闭的。etcd启动获取boltdb db对象的时候,boltdb会遍历所有page,构建空闲页列表。事务提交的第三步就是将client更新操作产生的dirty page通过fdatasync系统调用,持久化存储到磁盘中。最后,在执行写事务过程中,meta page的txid、freelist等字段会发生变化,因此事务的最后一步就是持久化meta page。通过以上四大步骤,我们就完成了事务提交的工作,成功将数据持久化到了磁盘文件中,安全地完成了一个put操作。小结最后我们来小结下今天的内容。首先我通过一幅boltdb磁盘布局图和bbolt工具,为你解密了db文件的本质。db文件由meta page、freelist page、branch page、leaf page、free page组成。随后我结合bbolt工具,和你深入介绍了meta page、branch page、leaf page、freelist page的数据结构,帮助你了解key、value数据是如何存储到文件中的。然后我通过分析一个put请求在boltdb中如何执行的。我从Open API获取db对象说起,介绍了其通过mmap将db文件映射到内存,构建meta page,校验meta page的有效性,再到创建bucket,通过bucket API往boltdb添加key-value数据。添加bucket和key-value操作本质,是从B+ tree管理的page中找到插入的页和位置,并将数据更新到page的内存node数据结构中。真正持久化数据到磁盘是通过事务提交执行的。它首先需要通过一系列重平衡、分裂操作,确保boltdb维护的B+ tree满足相关特性,其次需要持久化freelist page,并将用户更新操作产生的dirty page数据持久化到磁盘中,最后则是持久化meta page。
最新发布
04-06
### 数据存储结构 BoltDB 是一种基于 Go 编写的键值数据库,其底层采用 B+ 树作为核心数据结构[^2]。整个数据库的内容被持久化到单一文件中,并通过内存映射(memory-mapping)技术加载到内存中以加速读取操作。 #### 文件布局 BoltDB 的文件分为多个页(Page),每一页大小固定,默认为 4KB 或者自定义配置的其他值。这些页构成了 BoltDB 的基本单元,用于存储元数据、索引和实际的数据内容。页面之间通过指针相互连接形成复杂的逻辑结构。 - **元数据页**:保存关于数据库的整体信息,例如根 Bucket 所在位置等。 - **叶子节点页**:存放具体的 Key 和 Value 对应关系。 - **分支节点页**:构建起多层次的导航体系以便快速检索目标 key。 ### B+树的应用 正如所提到,在 Boltdb 中 cursor 封装了 bucket 上的操作接口,而这个过程实际上依赖于内部维护的一个高效查询机制——即 B+ 树[^3]。每当执行 CRUD (Create, Read, Update, Delete) 操作时,都会涉及到对该棵树相应部分修改或访问: - 当新增一条记录时,如果当前叶节点已满,则触发分裂动作创建新的子节点并调整父级索引项; - 删除某个特定条目可能导致某些节点变得稀疏甚至空洞,此时需考虑合并临近兄弟节点或者重新分配空间; - 更新现有 entry 则相对简单些,只需找到对应的位置替换旧值即可。 由于采用了 B+ 树这种高度优化过的算法模型,使得即使面对海量规模的数据集也能保持较为稳定的性能表现。 ### 事务提交原理 尽管不像 MySQL 那样具备完整的 ACID 特性支持,但 BoltDB 同样实现了轻量版的事物管理功能来保障一致性与可靠性[^1]。以下是有关如何处理事物的关键要点: - **写入前准备**: 开始一个新的 write transaction 前先锁定全局互斥锁(lock),防止并发冲突发生. - **日志记录**: 跟踪所有的变更活动直至最终确认完成之前都暂存起来;一旦出现问题可随时回滚至初始状态. - **两阶段提交协议(Two-phase Commit Protocol)**: 类似传统分布式系统中的做法分成 prepare 和 commit 两个步骤来进行安全验证后再正式生效更改成果. - Prepare Phase: 确认所有必要的资源已经就绪并且能够满足即将发生的变动需求; - Commit Phase: 如果前面一切顺利则真正实施更新并将结果同步反映到磁盘上去. 最后值得注意的是,BoltDb 并未引入 Undo Log/Redo Log 这样的复杂概念而是依靠简单的 Copy-On-Write 技巧达成类似效果的同时简化整体架构设计思路. ```go // 示例代码展示开启事务的方式 func main() { db, err := bolt.Open("my.db", 0600, nil) if err != nil { log.Fatal(err) } defer db.Close() err = db.Update(func(tx *bolt.Tx) error { b := tx.Bucket([]byte("MyBucket")) if b == nil { _, err := tx.CreateBucketIfNotExists([]byte("MyBucket")) if err != nil { return fmt.Errorf("create bucket: %s", err) } } err := b.Put([]byte("answer"), []byte("42")) if err != nil { return err } return nil }) } ```
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值