长二进制串或字符串的分块存储探讨

(Owed by: 春夜喜雨 http://blog.csdn.net/chunyexiyu)

参考:样例代码使用豆包AI提示生成

1. 引言

在做数据处理过程中,经常面临处理字符数据量的不断增长,有时数据量可能达到GB以上,甚至几十GB的情况;
当需要把这些数据缓存到内存中时,如何合理的设定数据的存储结构,如何分配这么大的内存来存储数据,可能会是我们要面对的情况。

分块存储策略,是一个比较容易想到的策略,使用许多分块内存来满足数据的不断增长。

使用分块存储一块大内存,好处也比较明显,比如申请一个1GB的字符串,分块存储时,如果按照1MB来存储,申请1024个1MB的块即可。
而通常来说,申请1GB的困难程度,与申请1024个1MB块的困难程度,可能对于内存来说并不相同,通常我们认为申请1024个1MB块更容易一些,在内存碎块多时,也容易申请内存成功。

为了达成内存块的分配,会有多种分配块的方式来处理:
通常有不考虑记录顺序的-数据内存池结构;
还有需要考虑记录顺序的-连续数据块结构;

2. 考虑数据记录顺序的分块方式

需要考虑数据顺序,也就是当作连续存储内容;
这个内存池分配使用有所不同,连续存储内容的分配,要求分配池本身提供数据顺序,这种情况下,需要在分配池内确定数据顺序了。

要保证数据的顺序,就需要分配内存时,有序分配,采用类似这样的分配池:
std::vector blocks;
分配池依次分配内存放入blocks分配池种,数据放置时,也依序放置,存在准则:

  1. 同一个block中,前面的记录是先放入的,后面的记录后放入的;
  2. 不同的block之间,靠前的block记录是先放入的,靠后的block的记录是后放入的;

基于这两个准则,就可以明确了记录的数据顺序。
读取时,按照block之内从前往后读取,blocks之间,从前一个向后一个读取,就能保证数据按照放入顺序,依次被读取出来。

该种情况下,实现可以有两种:

  • 一种是固定长度block的方式,如果记录过程时,把记录截断存储;
  • 一种是支持非固定长度block的方式,通常记录采用固定长度,但遇到记录空间不足时,或记录过大时,采用非固定长度;

这两种情况下,所形成的结果也不一样:

  • 一种是存在记录截断,一种是不存在记录截断;
  • 一种是基本没有内存浪费(只有最后一个block未填满),一种是存在内存浪费(中间block也都存在未填满情况),因为存在分配了未使用的空间;

两种实现也各有优劣点。

3. 记录顺序存储-固定大小block的情况

对于固定大小block的情况:
如果采用固定大小block,那么无论block多大,都可能存在记录大小比它大的情况,就必然存在着数据的拆分。
另外采用固定大小block时,数据采用无间距形式,把前一个block的空间都填满后再用后一个block空间;
采用固定大小block时,一个读取位置可以直接推断出所在的blocks-index,所在的block的读取偏移位置offset;
readindex = pos / kFixedBlockSize;
readoffset = pos % kFixedBlockSize;
读取位置就可以直接推断出:blocks_[readindex] + readoffset

如果读取位置pos,读取长度len的数据时,
一方面计算出读取的位置,
另一方面还要考虑读取长度是不是全部在一个block中,如果不全部在一个block中,需要跨block读取内容;
整体代码实现形如:// 注:样例代码生成 by 豆包ai

#include 
#include 
#include 
#include 

class FixedBlockReader {
private:
    static const size_t kBlockSize = 1024;
    std::vector<char*> blocks_;
    size_t totalSize_;

public:
    FixedBlockReader() : totalSize_(0) {}
    ~FixedBlockReader() { for (char* block : blocks_) delete[] block; }
    void addData(const char* data, size_t size) {
        if (!data || size == 0) return;
        size_t remaining = size;
        const char* currentPos = data;
        size_t blocksNeeded = (remaining + kBlockSize - 1) / kBlockSize;
        for (size_t i = 0; i < blocksNeeded; ++i) {
            char* newBlock = new char[kBlockSize];
            if (!newBlock) throw std::runtime_error("内存分配失败");
            size_t bytesToCopy = (remaining > kBlockSize) ? kBlockSize : remaining;
            std::memcpy(newBlock, currentPos, bytesToCopy);
            currentPos += bytesToCopy;
            remaining -= bytesToCopy;
            blocks_.push_back(newBlock);
        }
        totalSize_ += size;
    }
    
    size_t readData(char* buffer, size_t pos, size_t len) const {
        if (pos >= totalSize_) return 0;
        if (pos + len > totalSize_) len = totalSize_ - pos;
        size_t bytesRead = 0;
        size_t remaining = len;
        size_t readIndex = pos / kBlockSize;
        size_t readOffset = pos % kBlockSize;
        for (size_t i = readIndex; i < blocks_.size() && remaining > 0; ++i) {
            const char* currentBlock = blocks_[i];
            size_t bytesInBlock = kBlockSize - readOffset;
            size_t bytesToRead = (bytesInBlock < remaining) ? bytesInBlock : remaining;
            std::memcpy(buffer + bytesRead, currentBlock + readOffset, bytesToRead);
            bytesRead += bytesToRead;
            remaining -= bytesToRead;
            readOffset = 0;
        }
        return bytesRead;
    }
    
    size_t getTotalSize() const { return totalSize_; }
};

int main() {
    try {
        FixedBlockReader reader;
        const char* data1 = "This is the first block of data. ";
        const char* data2 = "This is the second block, which contains more information. ";
        const char* data3 = "This is the third and final block.";
        reader.addData(data1, strlen(data1));
        reader.addData(data2, strlen(data2));
        reader.addData(data3, strlen(data3));
        char buffer[200];
        size_t bytesRead1 = reader.readData(buffer, 0, 30);
        buffer[bytesRead1] = '\0';
        std::cout << "读取数据1 (" << bytesRead1 << " 字节): " << buffer << std::endl;
        size_t bytesRead2 = reader.readData(buffer, 50, 40);
        buffer[bytesRead2] = '\0';
        std::cout << "读取数据2 (" << bytesRead2 << " 字节): " << buffer << std::endl;
        size_t bytesRead3 = reader.readData(buffer, 100, 200);
        buffer[bytesRead3] = '\0';
        std::cout << "尝试读取超出范围: " << bytesRead3 << " 字节" << std::endl;
    } catch (const std::exception& e) {
        std::cerr << "错误: " << e.what() << std::endl;
        return 1;
    }
    return 0;
}

4. 记录顺序存储-非固定大小block的情况

非固定大小的块的情况和leveldb的Arena情况有一点像,不过也不一样,因为Arena不考虑数据写入的次序,分配前后的Arena记录之间不保证下面这一点:

  • 不同的block之间,靠前的block记录是先放入的,靠后的block的记录是后放入的;
    但是该种情况需要保证的,那么就对于一旦有新分配block之后,前面的block就不可以再写入了。

这种情况,就需要每个block自己记录自身分配的量,使用的量,剩余的量;
另外放置数据时,发现最后一个block的空间够用就用,不够用就申请新的来用,申请时确保当前记录能放下;
实现的代码形如:// 注:样例代码生成 by 豆包ai

#include 
#include 
#include 
class VariableBlockReader {
private:
    static const size_t kDefaultBlockSize = 1024;
    struct Block { 
	char* data; 
	size_t allocated; 
	size_t used; 
	Block() : data(nullptr), allocated(0), used(0) {} 
	~Block() { delete[] data; } 
    };
    std::vector<Block> blocks_;
    size_t totalSize_;

public:
    VariableBlockReader() : totalSize_(0) {}
    ~VariableBlockReader() {}
    
    void addData(const char* data, size_t size) {
        if (!data || size == 0) return;     
        // 如果有块且数据可以完全放入最后一个块的剩余空间
        if (!blocks_.empty()) {
            Block& lastBlock = blocks_.back();
            size_t freeSpace = lastBlock.allocated - lastBlock.used;          
            // 如果数据可以完全放入剩余空间且剩余空间足够大
            if (size <= freeSpace && freeSpace >= kDefaultBlockSize / 4) {
                std::memcpy(lastBlock.data + lastBlock.used, data, size);
                lastBlock.used += size;
                totalSize_ += size;
                return;  // 数据已完全添加,直接返回
            }
        }
        // 创建新块,大小为数据大小或默认块大小中的较大值
        size_t blockSize = (size > kDefaultBlockSize) ? size : kDefaultBlockSize;
        Block newBlock;
        newBlock.data = new char[blockSize];
        if (!newBlock.data) throw std::runtime_error("内存分配失败");
        newBlock.allocated = blockSize;
        // 复制数据到新块
        std::memcpy(newBlock.data, data, size);
        newBlock.used = size;
        // 添加新块并更新总大小
        blocks_.push_back(std::move(newBlock));
        totalSize_ += size;
    }
    
    size_t readData(char* buffer, size_t pos, size_t len) const {
        if (pos >= totalSize_) return 0;
        if (pos + len > totalSize_) len = totalSize_ - pos;
        size_t bytesRead = 0;
        size_t remaining = len;
        size_t currentPos = pos;
        size_t blockIndex = 0;
        while (blockIndex < blocks_.size() && currentPos >= blocks_[blockIndex].used) { 
	currentPos -= blocks_[blockIndex].used; 
	++blockIndex; 
        }
        for (size_t i = blockIndex; i < blocks_.size() && remaining > 0; ++i) {
            const Block& block = blocks_[i];
            size_t bytesInBlock = block.used - currentPos;
            size_t bytesToRead = (bytesInBlock < remaining) ? bytesInBlock : remaining;
            std::memcpy(buffer + bytesRead, block.data + currentPos, bytesToRead);
            bytesRead += bytesToRead;
            remaining -= bytesToRead;
            currentPos = 0;
        }
        return bytesRead;
    }   
    size_t getTotalSize() const { return totalSize_; }
    void printBlockInfo() const {
        std::cout << "块数量: " << blocks_.size() << ", 总大小: " << totalSize_ << " 字节" << std::endl;
        for (size_t i = 0; i < blocks_.size(); ++i) {
            const Block& block = blocks_[i];
            std::cout << "块 " << i << ": 分配=" << block.allocated << " 字节, 使用=" << block.used 
                      << " 字节, 剩余=" << (block.allocated - block.used) << " 字节" << std::endl;
        }
    }
};

5. 记录非顺序存储-分块方式与典型样例

存储的数据放入次序不关注,只关注使用内存时,能申请到合适内存;
如果需要顺序,使用外部的其它结构来记录数据顺序,或特定数据的顺序。

这块的数据池,不同数据块之间,放入数据次序不固定,对外表现是一个内存池的特点,也就是一个内存分配池的作用。实现的方式有许多,基于大小采用多个不同的分配池,也或使用一个分配池,适配不同大小。

对于内存池这种类型的分配方式,这块不做细的介绍,因为公开的实现有许多,就举一个简答的例子。

比较简单的例子是Leveldb的Arena内存池分配机制,Arena本质是作为数据存储池作用。
Arena为MemTable的内容提供数据分配池,MemTable通过skiplist来确定数据的键值排序,并不通过Arena确定数据顺序。
Arena分配的机制时,通过分配块来满足外部使用内存的需求,关注内存分配的总量,但不关注分配的内存块的连续性,分配主要关注的:

  1. 申请使用大小bytes(apply-length)
  2. 分配剩余大小alloc_bytes_remaining_(avaliable-length)
  3. 常量分配额度大小kBlockSize(fixed-allocate-length)

Arena形成的分配策略是:

  • 如果申请一块超大内存使用时:大于avaliable-length 并且 大于fixed-allocate-length/4
    则新申请一块超大内存,供外部使用;avaliable-length保持不变,继续留给后面使用;
  • 如果申请一块中型大小内存:大于avaliable-length,小于fixed-allocate-length/4
    则按照fixed-alloacte-length申请一块内存,供外部使用,多余部分作为available-length供后续申请使用;
    之前的availiable-length就丢弃了,因为小于fixed-allocate-length/4,丢弃也不算太可惜。
  • 如果申请一块小内存:小于available-length
    则直接使用已有的空间数据来分配,available-length相应调小

Arena分配策略的实现代码:// 注:样例代码取自leveldb源码

inline char* Arena::Allocate(size_t bytes) {
  // The semantics of what to return are a bit messy if we allow
  // 0-byte allocations, so we disallow them here (we don't need
  // them for our internal use).
  assert(bytes > 0);
  if (bytes <= alloc_bytes_remaining_) {
    char* result = alloc_ptr_;
    alloc_ptr_ += bytes;
    alloc_bytes_remaining_ -= bytes;
    return result;
  }
  return AllocateFallback(bytes);
}
char* Arena::AllocateFallback(size_t bytes) {
  if (bytes > kBlockSize / 4) {
    // Object is more than a quarter of our block size.  Allocate it separately
    // to avoid wasting too much space in leftover bytes.
    char* result = AllocateNewBlock(bytes);
    return result;
  }

  // We waste the remaining space in the current block.
  alloc_ptr_ = AllocateNewBlock(kBlockSize);
  alloc_bytes_remaining_ = kBlockSize;

  char* result = alloc_ptr_;
  alloc_ptr_ += bytes;
  alloc_bytes_remaining_ -= bytes;
  return result;
}

6. 综述

综上,无论采用哪种方式,使用分配池的多块存储大块内容的方式,都可以算作对大块内存申请下的一种优化。这种把一整块内存申请化为多个blocks的形式:

  • 一方面支持内存大小可以持续增长,每次申请的块都不大,算是对内存友好;
  • 另一方面对于增长的过程中,不存在需要把原数据copy到新空间的问题,算是对CPU友好,减少了许多数据copy过程;

总之,使用Arena这种分块分配内存方式,是对内存与CPU都友好型的一种大内存申请使用优化。

(Owed by: 春夜喜雨 http://blog.csdn.net/chunyexiyu)

你可能感兴趣的:(算法,C/C++,分块存储,内存池,存储池,Arena)