尧图网站建设 尧图网络
  • 首页
  • 关于我们
  • 服务项目
  • 案例展示
  • 建站流程
  • 资讯中心
  • 联系我们
首页/资讯中心/详情

从零实现高并发内存池:三层缓存架构设计与C++实战

从零实现高并发内存池:三层缓存架构设计与C++实战
📅 发布时间:2026/7/16 4:53:09

1. 项目概述:为什么我们需要一个自己的高并发内存池?

如果你写过C++服务端程序,或者参与过任何对性能有要求的C++项目,大概率都遇到过内存管理的痛点。标准库的new/delete或malloc/free在单线程、低频次申请的场景下表现尚可,但一旦进入高并发、高频次小内存分配的场景,它们就会迅速成为性能瓶颈,甚至导致系统不稳定。这个项目,就是带你从零开始,亲手打造一个能应对这种挑战的“高并发内存池”。

简单来说,内存池的核心思想就是“空间换时间”和“专事专办”。与其让每个线程都去竞争一个全局的内存堆,不如预先分配一大块内存,然后由每个线程管理自己的“小仓库”(Thread Cache),大部分内存申请和释放都在线程本地无锁完成。只有当本地仓库空了或者满了,才需要去访问全局的“中央仓库”(Central Cache)或“大仓库”(Page Cache),并且这些全局访问也通过精巧的设计来减少锁的竞争。这听起来是不是很像现实世界里的物流仓储系统?每个快递员(线程)有自己的配送车(Thread Cache),车上的包裹(小内存块)可以快速存取;车空了就去区域分拣中心(Central Cache)补货,区域中心没货了才向总仓(Page Cache)调拨。这样,大部分时间快递员都在独立工作,互不干扰,效率自然就上去了。

这个项目不仅是一个优秀的C++练手项目,能让你深入理解内存管理、数据结构、多线程同步等核心知识,更是面试中展示你系统编程能力的绝佳素材。接下来,我会带你一步步拆解它的设计思路、核心实现,并分享我在实现过程中踩过的坑和总结的经验。

2. 整体架构设计:三层缓存模型详解

一个成熟的高并发内存池,通常会采用类似tcmalloc、jemalloc的设计,采用三层缓存结构来平衡速度、空间利用率和并发能力。我们的项目也遵循这一经典范式。

2.1 三层结构的分工与协作

我们的内存池主要由三个部分组成:Thread Cache、Central Cache和Page Cache。它们的关系和职责如下:

  1. Thread Cache(线程缓存):

    • 定位:每个线程独享的本地缓存。这是内存分配的第一站,也是速度最快的一环。
    • 数据结构:本质上是一个哈希桶(std::vector或数组),每个桶挂着一个自由链表(Free List),用于管理特定大小范围的内存块。例如,第一个桶管理8字节的内存块,第二个桶管理16字节的,以此类推,通常按照8字节、16字节、24字节...直到256字节(或更大)进行对齐划分。
    • 关键特性:无锁操作。因为每个线程只访问自己的Thread Cache,所以申请和释放内存时不需要加锁,这是高并发性能的基石。
  2. Central Cache(中心缓存):

    • 定位:所有线程共享的缓存。它是Thread Cache的“后勤补给中心”。
    • 数据结构:同样是一个哈希桶,桶的划分规则与Thread Cache完全一致。但每个桶里挂的不再是单个内存块,而是由多个内存块组成的“Span”对象链表。一个Span代表一大块连续的内存页(例如4KB或8KB),它被切分成多个符合该桶大小要求的小块。
    • 关键特性:桶锁。Central Cache是共享资源,所以每个桶(或者说每个大小类)需要一把独立的锁(桶锁),这样不同大小的内存申请释放不会互相阻塞,减少了锁的竞争范围。
  3. Page Cache(页缓存):

    • 定位:整个内存池最底层的大内存管理者。它以“页”(Page,如4KB)为单位向系统申请和释放内存,并负责将这些大内存块组织成Span,提供给Central Cache。
    • 数据结构:一个哈希映射或数组,下标是Span包含的页数(如1页,2页,...,128页),值是该页数对应的Span双向链表。它还负责Span的合并,将释放回来的、物理地址相邻的小Span合并成大Span,以减少内存碎片。
    • 关键特性:全局锁。Page Cache的访问频率相对较低,且其数据结构(合并操作)需要全局视图,因此通常用一把大锁保护。但通过设计,可以尽量减少线程进入Page Cache的次数。

协作流程举例: 当一个线程需要申请一个24字节的内存时:

  1. 线程首先访问自己的Thread Cache中管理24字节(假设对齐后是24字节)的自由链表。
  2. 如果链表不为空,直接弹出头节点返回,过程无锁,极快。
  3. 如果链表为空,线程就会向Central Cache申请一批(比如5-10个)24字节的内存块。
  4. Central Cache在对应的24字节桶上加锁,查找是否有可用的Span及其内存块。如果有,则转移一批给Thread Cache,然后释放桶锁。
  5. 如果Central Cache也空了,它会向Page Cache申请一个新的Span(比如一个4KB的页,能切成170个24字节的块)。
  6. Page Cache加全局锁,查找或分配一个合适的Span,切好并返回给Central Cache,Central Cache再将其挂到对应桶上,并切分出部分块给Thread Cache。
  7. 线程最终从Thread Cache拿到内存块。

释放过程则是反向的,优先还到Thread Cache,当Thread Cache的缓存块过多时,再批量归还给Central Cache,Central Cache的Span所有块都归还后,再将整个Span还给Page Cache进行合并。

2.2 关键设计决策:对齐规则、哈希桶与自由链表

对齐规则(Size Class): 这是内存池高效管理不同大小内存的核心。我们不能为每一个字节大小都维护一个自由链表,那样哈希桶会太大。常见的策略是采用分段对齐。例如:

  • 8字节对齐: [1, 8] -> 8字节, [9, 16] -> 16字节, [17, 24] -> 24字节 ... 直到128字节。
  • 然后可以增大对齐步长,比如[129, 256] -> 256字节。
  • 超过256字节的申请,可能直接走Page Cache或系统调用,因为大内存分配不频繁,且碎片化问题不突出。 我们需要一个函数RoundUp(size_t bytes)来将用户申请的大小向上对齐到最近的标准大小类。

自由链表(Free List)的实现: 这是Thread Cache和Central Cache中管理内存块的数据结构。它不是一个std::list,而是我们自己用“嵌入指针”实现的单链表。

  • 原理:每一块分配出去的内存,在返还给内存池后,这块内存本身的前8个字节(在64位系统下)可以用来存储一个指针,指向下一块空闲内存。这样,我们不需要额外的数据结构来管理空闲块,节省了空间。
  • 操作:Push(void* obj)将对象头插到链表;Pop()从链表头弹出一个对象。这些操作就是简单的指针赋值,效率极高。

Span对象的设计: Span是Central Cache和Page Cache管理内存的基本单位。它需要记录以下信息:

struct Span { PAGE_ID _pageId = 0; // 起始页号(将地址右移12位得到,用于合并时快速判断是否相邻) size_t _n = 0; // 这个Span包含多少页 Span* _next = nullptr; // 用于Central/Page Cache中的链表 Span* _prev = nullptr; size_t _useCount = 0; // 已被分配给Thread Cache的内存块计数,为0时表示整个Span空闲 void* _freeList = nullptr; // 指向Span内切分出来的自由链表头 size_t _objSize = 0; // 每个小块的大小(属于哪个Size Class) };

_pageId是关键,它通过将内存地址除以页大小(如4096)得到,是一个整数。Page Cache通过比较不同Span的_pageId和_n,就能判断它们物理地址是否连续,从而进行合并。

3. 核心模块实现与源码解析

接下来,我们深入到代码层面,看看每个模块如何实现。我会用伪代码和关键片段来说明,并解释为什么这么做。

3.1 Thread Cache的实现:无锁快车道

Thread Cache的核心是线程局部存储(TLS)。在C++中,我们可以使用thread_local关键字来让每个线程拥有自己的实例。

class ThreadCache { public: // 申请内存 void* Allocate(size_t size); // 释放内存 void Deallocate(void* ptr, size_t size); private: FreeList _freeLists[NFREELISTS]; // 哈希桶,NFREELISTS是对齐后大小类的数量 // 当自由链表为空时,从Central Cache获取内存 void* FetchFromCentralCache(size_t index, size_t size); // 当自由链表过长时,归还部分内存到Central Cache void ListTooLong(FreeList& list, size_t size); }; // 线程局部指针 static thread_local ThreadCache* pTLSThreadCache = nullptr;

Allocate流程:

  1. 根据size计算对齐后的索引index。
  2. 访问_freeLists[index]。
  3. 如果链表不为空,Pop()一个对象返回。这是最常见、最快的路径。
  4. 如果为空,调用FetchFromCentralCache(index, size)。

FetchFromCentralCache流程:

  1. 这里涉及一个“慢启动”的批量申请策略。不是一次只申请一个块,也不是一次性申请太多。我们维护一个每次申请数量的上限_maxSize,初始值较小(如1)。
  2. 调用CentralCache::GetInstance()->FetchRangeObj(start, end, batchNum, size),尝试从Central Cache获取batchNum(不超过_maxSize)个对象。
  3. 如果实际获取到的数量小于batchNum,说明Central Cache也比较紧张,本次获取是“慢”的,下次可以少申请点(_maxSize递减)。
  4. 如果获取到的数量等于batchNum,说明获取顺利,下次可以尝试申请更多(_maxSize递增),但不超过一个阈值。这是一种简单的自适应负载策略。
  5. 将获取到的这批对象,除了第一个直接返回给用户,其余的通过Push放入Thread Cache的自由链表中,以备下次快速分配。

注意:thread_local变量的初始化需要小心。我们通常会在第一次调用ThreadCache::GetInstance()(一个静态方法)时,检查pTLSThreadCache是否为空,如果为空则创建一个新的ThreadCache对象。同时,在线程退出时,需要有一个机制(如注册析构回调)将ThreadCache中未释放的内存归还给Central Cache,避免内存泄漏。这在某些场景下(如使用线程池)尤为重要。

3.2 Central Cache的实现:共享资源与桶锁

Central Cache是单例模式,因为整个进程只需要一个。

class CentralCache { public: static CentralCache* GetInstance(); // 获取一个范围的内存块给Thread Cache [start, end] size_t FetchRangeObj(void*& start, void*& end, size_t batchNum, size_t size); // 接收从Thread Cache归还的一串内存块 void ReleaseListToSpans(void* start, size_t size); private: SpanList _spanLists[NFREELISTS]; // 每个桶是一个Span的双向链表 // 每个桶配一把锁,减少竞争 static std::mutex _mtx[NFREELISTS]; };

FetchRangeObj流程:

  1. 根据size找到对应的桶_spanLists[index]。
  2. 给这个桶加锁(std::unique_lock<std::mutex> lock(_mtx[index]))。
  3. 遍历该桶下的Span链表,找到一个有空闲块的Span(_freeList不为空)。
  4. 从这个Span的_freeList中,批量取出最多batchNum个对象,更新Span的_useCount。
  5. 如果取出后该Span的_freeList为空,可以将其从链表中移除(或标记为满),优化后续查找。
  6. 释放桶锁。
  7. 将获取到的内存块链表通过start和end输出参数返回。

ReleaseListToSpans流程:

  1. 这是Thread Cache批量归还内存的入口。传入的start是一串通过嵌入指针连接起来的内存块链表。
  2. 根据内存块地址和size,需要找到它属于哪个Span。这是难点!我们需要一个从内存块地址到其所属Span的映射。
  3. 地址映射设计:这是整个内存池的一个关键设计。我们利用Page Cache的页号映射。对于一个给定的内存地址ptr,我们通过(PAGE_ID)ptr >> PAGE_SHIFT得到它的页号。Page Cache维护一个巨大的映射数组std::unordered_map<PAGE_ID, Span*>或Span* _idSpanMap[MAX_PAGES],记录每一页属于哪个Span。这样,通过地址就能快速找到Span。
  4. 找到Span后,给对应的Central Cache桶加锁。
  5. 将这串内存块头插到该Span的_freeList中,并减少_useCount。
  6. 如果_useCount减为0,说明这个Span的所有块都归还了,此时可以调用PageCache::GetInstance()->ReleaseSpanToPageCache(span),将这个完整的Span还给Page Cache进行合并。
  7. 释放桶锁。

实操心得:Central Cache的桶锁竞争是需要关注的点。如果项目中对某一特定大小的内存块申请极其频繁,对应的桶锁可能会成为热点。在实际项目中,可以进一步优化,比如使用更细粒度的锁,或者尝试无锁队列。但在大多数场景下,桶锁的设计已经能很好地分散竞争。

3.3 Page Cache的实现:大内总管与合并算法

Page Cache同样是单例,它直接使用VirtualAlloc(Windows)或mmap/brk(Linux)等系统调用向操作系统申请以页为单位的大块内存。

class PageCache { public: static PageCache* GetInstance(); // Central Cache申请一个Span Span* NewSpan(size_t n); // Central Cache归还一个Span void ReleaseSpanToPageCache(Span* span); // 根据地址获取Span (供Central Cache使用) Span* MapObjectToSpan(void* obj); private: SpanList _spanLists[NPAGES]; // 下标是页数,管理空闲Span std::unordered_map<PAGE_ID, Span*> _idSpanMap; // 页号到Span的映射 std::mutex _mtx; // 全局一把锁 // 向系统申请内存的封装函数 void* SystemAlloc(size_t kpage); };

NewSpan流程:

  1. 加全局锁。
  2. 检查_spanLists[n]链表是否为空(是否有恰好n页的空闲Span)。
  3. 如果有,直接取出一个返回。
  4. 如果没有,则向后遍历_spanLists[n+1],_spanLists[n+2]... 直到找到第一个非空链表(假设找到m页的Span,m>n)。
  5. 将这个m页的Span拆分成一个n页的Span和一个(m-n)页的Span。
  6. 将n页的Span返回给Central Cache,将(m-n)页的Span挂到对应的_spanLists[m-n]链表中。
  7. 如果向后找遍了都没有,则调用SystemAlloc向系统申请一个较大的内存块(比如128页),将其组织成一个128页的大Span,然后递归地调用NewSpan来切分。
  8. 在返回Span之前,需要对这个Span进行“切块”:根据Central Cache请求时附带的对象大小size,将这个Span的起始地址_pageId << PAGE_SHIFT切分成一个个size大小的块,并用嵌入指针连接起来,形成_freeList。同时,要更新_idSpanMap,将这个Span包含的每一页都映射到它自身。
  9. 释放全局锁。

ReleaseSpanToPageCache流程(合并是核心):

  1. 加全局锁。
  2. 得到要归还的Span的起始页号id和页数n。
  3. 向前合并:计算前一个Span的页号prevId = id - 1。通过_idSpanMap查找prevId对应的Span,如果该Span存在、空闲、且与当前Span物理连续,则将它们合并。合并后,从原链表中移除前一个Span,更新当前Span的起始页号和页数。
  4. 向后合并:计算后一个Span的起始页号nextId = id + n。同样查找并尝试合并。
  5. 合并完成后,将最终得到的大Span插入到_spanLists[最终页数]链表中。
  6. 更新_idSpanMap中这个新Span所覆盖的所有页的映射关系。
  7. 释放全局锁。

踩坑记录:_idSpanMap的设计和更新是极易出错的地方。在NewSpan切分Span时,不仅要更新新Span的映射,还要注意原大Span剩余部分的映射需要更新为新的小Span。在ReleaseSpanToPageCache合并时,被合并的小Span的映射需要清除,合并后的大Span需要建立新的映射。任何一步遗漏都会导致后续MapObjectToSpan查找失败,引发致命错误。建议为_idSpanMap的更新编写独立的辅助函数并进行严格测试。

4. 项目难点、调试技巧与性能优化

实现一个能稳定运行的内存池,远比理解原理要复杂。下面分享几个关键的难点和调试技巧。

4.1 难点一:指针与整型的转换与运算

内存池中充斥着大量的指针运算和类型转换,必须非常小心。

// 计算对象所属的Span:先将指针转为char*进行字节运算,再右移得到页号 PAGE_ID id = ((PAGE_ID)obj >> PAGE_SHIFT); Span* span = PageCache::GetInstance()->MapObjectToSpan((void*)id); // 注意:这里id是页号,不是地址! // 从Span的页号计算起始地址 void* ptr = (void*)(span->_pageId << PAGE_SHIFT); // 自由链表的嵌入指针操作 void* next = *(void**)obj; // 读取obj头部的指针 *(void**)obj = _freeList; // 将obj头部指向当前链表头

注意事项:确保PAGE_SHIFT的值(如12,对应4KB页)与系统内存对齐和你的设计一致。在64位系统下,指针是8字节,PAGE_ID应使用size_t或uintptr_t。

4.2 难点二:多线程环境下的调试

内存池的Bug常常在多线程压力测试下才暴露出来,比如数据竞争、死锁。

  • 使用ThreadSanitizer (TSan):在Linux下使用-fsanitize=thread编译,可以检测数据竞争。这是发现锁使用不当或无锁数据结构问题的利器。
  • 死锁排查:确保锁的获取顺序一致。例如,如果函数A先锁桶1再锁桶2,那么所有其他需要同时锁这两个桶的函数都必须遵循同样的顺序。
  • 压力测试:编写多线程测试程序,让大量线程随机申请和释放不同大小的内存,并验证没有内存泄漏(使用Valgrind的memcheck)和内存损坏(使用AddressSanitizer)。
  • 日志输出:在关键路径(如向系统申请内存、Span合并)添加简单的日志,但注意日志输出本身也可能影响并发行为,最好能开关控制。

4.3 性能优化点

  1. Size Class的优化:对齐规则直接影响内存利用率(内部碎片)。可以调研tcmalloc或jemalloc的size class设计,它们通常有更精细的划分,在8-256字节区间内可能使用更小的增长因子,以减少浪费。
  2. Thread Cache的批量大小:FetchFromCentralCache中的慢启动算法参数(初始值、增长因子、最大值)需要根据实际负载调整。可以通过测试找到适合你应用场景的参数。
  3. Central Cache的锁优化:如果发现某个size class的锁竞争激烈,可以考虑使用更高效的锁(如自旋锁std::atomic_flag)或尝试无锁结构,但这会大大增加实现复杂度。
  4. Page Cache的全局锁:Page Cache的访问频率较低,一把大锁通常可以接受。但如果项目需要极致性能,可以考虑使用分层或分片(Sharding)的Page Cache,将不同页号范围的Span用不同的锁管理。
  5. 避免False Sharing:ThreadCache的_freeLists数组可能很大,确保每个线程的ThreadCache实例在内存中独立对齐(例如使用alignas(64)),避免多个线程的缓存行(Cache Line)伪共享,影响性能。

5. 测试、验证与集成到现有项目

5.1 如何测试你的内存池

  1. 单元测试:

    • 单线程正确性:测试连续申请释放、随机大小申请释放、交错申请释放等场景,确保分配的内存可写,且归还后不会重复分配已释放的内存。
    • 对齐检查:验证RoundUp函数是否正确。
    • Span合并:专门测试Page Cache的合并逻辑,构造前后Span空闲的场景,验证是否能正确合并成大Span。
  2. 多线程压力测试:

    void ThreadFunc() { std::vector<void*> ptrs; for (int i = 0; i < 100000; ++i) { size_t size = rand() % 256 + 1; // 申请1-256字节 void* ptr = ConcurrentAlloc(size); memset(ptr, 0, size); // 写操作,触发潜在错误 ptrs.push_back(ptr); if (ptrs.size() > 100) { // 随机释放一些 int idx = rand() % ptrs.size(); ConcurrentFree(ptrs[idx], size); // 注意:这里需要记录大小 ptrs[idx] = ptrs.back(); ptrs.pop_back(); } } // 清理剩余内存 for (auto p : ptrs) ConcurrentFree(p, 0); } // 启动多个线程执行ThreadFunc

    使用Valgrind的--tool=memcheck --leak-check=full检查内存泄漏。

  3. 性能对比测试:

    • 编写相同的测试用例,分别使用你的内存池和系统的malloc/free。
    • 使用std::chrono高精度计时器。
    • 对比指标:总耗时、每秒操作数(Ops)、不同线程数下的扩展性。
    • 通常,在高并发小内存分配场景下,你的内存池应该有数倍甚至数十倍的性能提升。

5.2 替换系统的 new/delete

要让你的项目使用自定义的内存池,你需要重载全局的operator new和operator delete。

void* operator new(size_t size) { return ConcurrentAlloc(size); } void operator delete(void* ptr) noexcept { ConcurrentFree(ptr, 0); // 需要想办法获取size,见下文 } // 同样需要重载 new[], delete[], 以及带nothrow的版本

最大的挑战:operator delete不知道要释放的内存块大小!而我们的ConcurrentFree需要这个信息来确定属于哪个自由链表。解决方案:

  1. 存储大小信息:在分配内存时,多分配一点空间,在返回给用户的指针前面,存储这块内存的大小(或对齐后的size class索引)。释放时,通过(char*)ptr - 4(假设32位系统)读取大小。这是常见做法,但有额外开销。
  2. 查询映射:像Central Cache释放时一样,通过MapObjectToSpan找到所属的Span,Span里记录了_objSize。这需要PageCache::MapObjectToSpan函数非常高效。这通常是我们采用的方法,因为它没有额外的存储开销,但依赖高效的页号映射。

5.3 集成注意事项

  • 初始化:确保内存池在程序开始时正确初始化(单例的获取会触发初始化)。
  • 线程退出:如前所述,处理好thread_local的ThreadCache析构,将剩余内存归还。
  • 与第三方库兼容:某些第三方库可能内部使用自己的内存管理或特定的对齐要求。全局替换new/delete可能导致兼容性问题。在生产环境中,有时更安全的做法是只在你自己的模块内使用内存池的ConcurrentAlloc/ConcurrentFree接口,而不是全局替换。

实现一个高并发内存池是一个系统工程,它几乎涵盖了C++系统编程的所有难点:指针、内存、数据结构、多线程、锁、性能优化。通过这个项目,你获得的不只是一个工具,更是对计算机系统如何工作的深刻理解。当你看到自己写的内存池在压力测试下稳稳超越malloc时,那种成就感是无与伦比的。在实际编码中,从最简单的单线程固定大小内存池开始,逐步增加对齐、Thread Cache、Central Cache、Page Cache和合并功能,步步为营,调试和测试贯穿始终,这才是最终成功的保证。

相关新闻

  • Python agentc-core 包完全指南:功能、安装、语法与实战案例
  • Linux系统下oneAPI环境配置与MKL库编译实战指南
  • OpenSandbox 再进化:Credential Vault 让真实密钥不再进入沙箱

最新新闻

  • 度申相机DVP2 SDK 实战:从环境搭建到多语言应用开发
  • 排查Windows局域网共享失败:从网络发现到SMB协议的深度诊断
  • 2026年7月最新亨得利官方服务项目及价格查询|热线及详细地址权威信息公示 - 亨得利官方博客
  • Java循环控制进阶:while与do..while的实战抉择与break/continue的精准调控
  • 2026 年现阶段,宜春比较好的太阳能板热水器加工厂推荐几家,别再烧煤!这套系统如何帮你省下每月几百块电费? - 企业推荐管【认证】
  • 2026刑事纠纷律所口碑推荐强势出炉 零套路不踩坑 高性价比之选看这篇就够 - myqiye

日新闻

  • Toon Boom Harmony 高效工作流:从节点视图到镜头标记的实战技巧
  • 真力时售后维修电话,为您提供专业腕表保养与故障修复服务权威公示(2026年7月最新) - 亨得利官方服务中心
  • 【C++】类和对象--构造函数进阶(初始化列表与explicit)

周新闻

  • IX9104 PCIe5.0 高速交换芯片@ACP#完整规格 + 应用场景总结
  • Unity游戏集成Coze智能体:实现NPC智能对话与知识库联动
  • SAP EPIC 建行回单查询:从标准类CL_EPIC_EXAMPLE_CN_CCB_GHTD到Z类的5处关键修改

月新闻

  • 2026年6月公司网站搭建最新热门渠道测评:四大低成本/零代码平台对比+避坑
  • 【Linux】Linux arm 编译QT程序,出现expected “}“报错
  • 【MATLAB例程】四基站二维AOA定位与距离辅助增强对比仿真。基于角度观测和测距修正的固定目标平面定位精度分析

关于尧图

  • 公司简介
  • 团队介绍
  • 企业文化
  • 荣誉资质

服务项目

  • 定制开发
  • 电商建站
  • UI 设计
  • 运维服务

快速链接

  • 案例展示
  • 建站流程
  • 常见问题
  • 资讯中心

联系方式

  • 📍北京市朝阳区互联网产业园 A 座 10 层
  • 📞400-888-8888
  • ✉️contact@rkmt.cn
  • 🕐周一至周日 9:00-21:00

© 2024 北京尧图网络科技有限公司 版权所有 | 京 ICP 备 XXXXXXXX 号