从零实现一个浏览器LSM-Tree存储引擎:MemTable、SSTable与IndexedDB持久化

举报
Snowplow5180 发表于 2026/09/29 23:51:57 2026/09/29
【摘要】 在上一篇文章中,我们从零实现了一个2D刚体物理引擎,覆盖了数值积分、SAT碰撞检测和Sequential Impulse约束求解。那篇文章聚焦的是数值计算和几何算法。这一次我们把视角转向数据存储的底层结构,目标是一个完整的写入优化存储引擎:基于LSM-Tree的浏览器本地数据库。浏览器端的持久化方案一直是个尴尬的领域。localStorage同步阻塞主线程且容量只有5MB,IndexedDB...

在上一篇文章中,我们从零实现了一个2D刚体物理引擎,覆盖了数值积分、SAT碰撞检测和Sequential Impulse约束求解。那篇文章聚焦的是数值计算和几何算法。这一次我们把视角转向数据存储的底层结构,目标是一个完整的写入优化存储引擎:基于LSM-Tree的浏览器本地数据库。

浏览器端的持久化方案一直是个尴尬的领域。localStorage同步阻塞主线程且容量只有5MB,IndexedDB虽然是异步事务型数据库,但它的API设计极其底层,开发者需要手动管理对象存储、索引和游标,写业务代码像是在操作裸文件系统。更关键的是,IndexedDB的写入性能在高频场景下并不理想——每次写入都涉及结构化克隆和事务提交,批量插入数千条记录时延迟显著。

LevelDB和RocksDB在服务端证明了LSM-Tree的写入优势——将随机写转化为顺序写,随机写约40万条/秒。Chrome浏览器的IndexedDB后端实际上就是用LevelDB存储的。如果我们能把这个结构移植到浏览器中,用IndexedDB作为磁盘层,用内存中的有序结构作为MemTable,就能获得远高于原生IndexedDB API的批量写入吞吐。

本文用纯前端JavaScript从零实现一个完整的LSM-Tree存储引擎,不依赖任何库。完整链路是:写入WAL → MemTable(跳表)→ Immutable MemTable → Flush为SSTable → IndexedDB持久化 → 多级Compaction → 读取路径。最后会讨论Bloom Filter、压缩策略和性能对比。

一、为什么选择LSM-Tree
传统数据库使用B-Tree,数据存储在固定大小的页面中,更新时原地修改页面。这对读取友好——点查询是O(log N)的磁盘寻道——但写入昂贵。每次更新都需要读取一个页面、修改它、再写回,这是典型的随机I/O。在机械硬盘上,随机写比顺序写慢100到1000倍;即使在SSD上,随机写也会因闪存的“先擦后写”特性导致写放大。

LSM-Tree翻转了这个权衡。它不在原地更新数据,而是将写入缓存在内存中,定期批量刷写到磁盘,形成有序的不可变文件。所有磁盘写入都是顺序的,这是任何存储介质上最快的I/O模式。代价是读取可能需要检查多个文件,且需要后台Compaction来合并文件、回收空间。这种写优化的设计使LSM-Tree非常适合高写入吞吐的场景——时序数据、事件日志、键值存储和分析摄入。

LSM-Tree的数据流动方向是单向的:数据从MemTable流动到Immutable MemTable,再从Immutable MemTable被持久化到外存,在外存中逐渐向深层移动。这种单向流动保证了新旧数据同时存在时读取的正确性——新数据总是在更靠近内存的位置,读取时优先命中最新版本。

二、整体架构
我们的引擎由六个核心组件构成:

WAL(预写日志) :所有写入先追加到WAL,保证崩溃恢复。WAL是顺序追加的,写入开销极低。

MemTable:内存中的有序数据结构,用红黑树或跳表实现。所有写入先进入MemTable,按Key排序存储。

Immutable MemTable:当MemTable达到大小阈值后,转为只读状态,等待刷盘。同时创建新的MemTable接收新写入。

SSTable(Sorted String Table) :Immutable MemTable刷盘后形成的磁盘文件。SSTable内部按Key有序,包含数据块和索引块。

IndexedDB存储层:SSTable文件持久化到IndexedDB中。IndexedDB的异步事务机制保证了刷盘的原子性。

Compaction:后台合并进程,将多个SSTable合并为更少的文件,消除冗余版本,维护层级结构。

数据流是:写入 → WAL → MemTable → Immutable MemTable → Flush → L0 SSTable → Compaction → L1 → L2 → ...

三、MemTable:红黑树实现
MemTable是内存中的有序映射。选择红黑树而非普通二叉搜索树,是因为红黑树保证最坏情况下的O(log n)操作。红黑树通过节点着色(红或黑)和旋转维持平衡,确保从根到叶的最长路径不超过最短路径的两倍。

javascript
class RBNode {
  constructor(key, value) {
    this.key = key;
    this.value = value;       // null 表示删除标记(Tombstone)
    this.red = true;          // 新节点默认红色
    this.left = null;
    this.right = null;
  }
}

class RBTree {
  constructor() {
    this.root = null;
    this.size = 0;
  }

  // 左旋
  rotateLeft(node) {
    const right = node.right;
    node.right = right.left;
    right.left = node;
    right.red = node.red;
    node.red = true;
    return right;
  }

  // 右旋
  rotateRight(node) {
    const left = node.left;
    node.left = left.right;
    left.right = node;
    left.red = node.red;
    node.red = true;
    return left;
  }

  // 颜色翻转
  flipColors(node) {
    node.red = true;
    node.left.red = false;
    node.right.red = false;
  }

  isRed(node) {
    return node !== null && node.red;
  }

  // 插入(递归实现,保持平衡)
  put(key, value) {
    this.root = this._put(this.root, key, value);
    this.root.red = false;  // 根节点始终为黑色
    this.size++;
  }

  _put(node, key, value) {
    if (node === null) return new RBNode(key, value);

    const cmp = compareKeys(key, node.key);
    if (cmp < 0) {
      node.left = this._put(node.left, key, value);
    } else if (cmp > 0) {
      node.right = this._put(node.right, key, value);
    } else {
      node.value = value;  // 覆盖旧值
      this.size--;         // 修正计数
      return node;
    }

    // 平衡修复
    if (this.isRed(node.right) && !this.isRed(node.left)) {
      node = this.rotateLeft(node);
    }
    if (this.isRed(node.left) && this.isRed(node.left.left)) {
      node = this.rotateRight(node);
    }
    if (this.isRed(node.left) && this.isRed(node.right)) {
      this.flipColors(node);
    }

    return node;
  }

  get(key) {
    let node = this.root;
    while (node !== null) {
      const cmp = compareKeys(key, node.key);
      if (cmp === 0) return node.value;
      node = cmp < 0 ? node.left : node.right;
    }
    return undefined;
  }

  // 中序遍历(有序输出)
  entries() {
    const result = [];
    this._inorder(this.root, result);
    return result;
  }

  _inorder(node, out) {
    if (node === null) return;
    this._inorder(node.left, out);
    out.push([node.key, node.value]);
    this._inorder(node.right, out);
  }
}
键的比较函数需要处理不同类型。为了简化,我们统一将键序列化为字符串进行比较。在工程实现中,可以支持数字、字符串、二进制键的混合排序,但需要定义严格的排序关系。

四、WAL:写前日志与崩溃恢复
WAL是LSM-Tree写入路径的第一站。每次写入操作先追加到WAL,再进入MemTable。如果进程崩溃,重启时从WAL重放所有未刷盘的写入,恢复到崩溃前的状态。

WAL的实现极其简单——顺序追加一个日志文件。在浏览器中,我们用IndexedDB的一个专用Object Store来存储WAL条目,每条记录包含操作类型、键和值。

javascript
class WriteAheadLog {
  constructor(db, storeName = 'wal') {
    this.db = db;
    this.storeName = storeName;
    this.entries = [];  // 内存中的缓冲
  }

  append(type, key, value) {
    const entry = { type, key, value, seq: Date.now() + Math.random() };
    this.entries.push(entry);
    return this._persist(entry);
  }

  async _persist(entry) {
    const tx = this.db.transaction(this.storeName, 'readwrite');
    const store = tx.objectStore(this.storeName);
    store.add(entry);
    return new Promise((resolve, reject) => {
      tx.oncomplete = () => resolve();
      tx.onerror = () => reject(tx.error);
    });
  }

  async replay() {
    // 从IndexedDB读取所有WAL条目
    const tx = this.db.transaction(this.storeName, 'readonly');
    const store = tx.objectStore(this.storeName);
    const all = await new Promise((resolve, reject) => {
      const req = store.getAll();
      req.onsuccess = () => resolve(req.result);
      req.onerror = () => reject(req.error);
    });
    return all.sort((a, b) => a.seq - b.seq);
  }

  async clear() {
    const tx = this.db.transaction(this.storeName, 'readwrite');
    tx.objectStore(this.storeName).clear();
    return new Promise((resolve, reject) => {
      tx.oncomplete = () => resolve();
      tx.onerror = () => reject(tx.error);
    });
  }
}
WAL的持久化频率可以调整。同步写入每条日志保证最高安全性,但性能最低;批量写入多条后统一提交,性能更好但崩溃时可能丢失最近几条。生产级实现通常提供sync参数控制。

五、SSTable:磁盘上的有序文件
当Immutable MemTable刷盘时,它被序列化为SSTable。SSTable的内部结构是一系列数据块,每个块包含一批连续的键值对。块末尾是索引,记录每个块的起始键和偏移量,用于二分查找。

javascript
class SSTableBuilder {
  constructor(blockSize = 4096) {
    this.blockSize = blockSize;
    this.blocks = [];
    this.currentBlock = [];
    this.currentSize = 0;
    this.index = [];
  }

  add(key, value) {
    const entry = { key, value };
    const entrySize = JSON.stringify(entry).length;

    if (this.currentSize + entrySize > this.blockSize && this.currentBlock.length > 0) {
      this._flushBlock();
    }

    if (this.currentBlock.length === 0) {
      this.index.push({ firstKey: key, blockIndex: this.blocks.length });
    }

    this.currentBlock.push(entry);
    this.currentSize += entrySize;
  }

  _flushBlock() {
    const blockData = JSON.stringify(this.currentBlock);
    this.blocks.push(blockData);
    this.currentBlock = [];
    this.currentSize = 0;
  }

  finish() {
    if (this.currentBlock.length > 0) {
      this._flushBlock();
    }
    // 序列化为一个完整对象
    const file = {
      blocks: this.blocks,
      index: this.index,
      minKey: this.index[0]?.firstKey,
      maxKey: null, // 最后一条的 key
      timestamp: Date.now()
    };
    // 记录最大键
    const lastBlock = JSON.parse(this.blocks[this.blocks.length - 1]);
    file.maxKey = lastBlock[lastBlock.length - 1].key;
    return file;
  }
}

class SSTableReader {
  constructor(fileData) {
    this.blocks = fileData.blocks;
    this.index = fileData.index;
    this.minKey = fileData.minKey;
    this.maxKey = fileData.maxKey;
  }

  // 二分查找定位块
  _findBlock(key) {
    let lo = 0, hi = this.index.length - 1;
    let result = -1;
    while (lo <= hi) {
      const mid = (lo + hi) >> 1;
      if (compareKeys(this.index[mid].firstKey, key) <= 0) {
        result = mid;
        lo = mid + 1;
      } else {
        hi = mid - 1;
      }
    }
    return result;
  }

  get(key) {
    // 范围检查
    if (compareKeys(key, this.minKey) < 0 || compareKeys(key, this.maxKey) > 0) {
      return undefined;
    }

    const blockIdx = this._findBlock(key);
    if (blockIdx < 0) return undefined;

    const block = JSON.parse(this.blocks[blockIdx]);
    // 块内二分查找
    let lo = 0, hi = block.length - 1;
    while (lo <= hi) {
      const mid = (lo + hi) >> 1;
      const cmp = compareKeys(block[mid].key, key);
      if (cmp === 0) return block[mid].value;
      if (cmp < 0) lo = mid + 1;
      else hi = mid - 1;
    }
    return undefined;
  }
}
SSTable的内部索引结构是性能关键。每个块的首键和块偏移记录在索引中,查询时先二分索引定位到具体块,再在块内二分。对于更大的SSTable,还可以在索引之上再建一层稀疏索引,形成多级查找结构。

六、IndexedDB持久化层
SSTable需要持久化到磁盘。浏览器的选择很有限——IndexedDB是唯一支持大容量结构化数据异步存储的API。我们把SSTable序列化为JSON字符串,存入IndexedDB的一个Object Store中,键为SSTable的递增ID。

javascript
class DiskStorage {
  constructor(db) {
    this.db = db;
    this.sstableStore = 'sstables';
    this.metaStore = 'metadata';
  }

  static async open(dbName = 'lsm-engine') {
    return new Promise((resolve, reject) => {
      const req = indexedDB.open(dbName, 1);
      req.onupgradeneeded = (event) => {
        const db = event.target.result;
        if (!db.objectStoreNames.contains('wal')) {
          db.createObjectStore('wal', { keyPath: 'seq' });
        }
        if (!db.objectStoreNames.contains('sstables')) {
          db.createObjectStore('sstables', { keyPath: 'id' });
        }
        if (!db.objectStoreNames.contains('metadata')) {
          db.createObjectStore('metadata', { keyPath: 'key' });
        }
      };
      req.onsuccess = () => resolve(req.result);
      req.onerror = () => reject(req.error);
    });
  }

  async writeSSTable(id, fileData) {
    const tx = this.db.transaction(this.sstableStore, 'readwrite');
    tx.objectStore(this.sstableStore).put({ id, data: fileData });
    return new Promise((resolve, reject) => {
      tx.oncomplete = () => resolve();
      tx.onerror = () => reject(tx.error);
    });
  }

  async readSSTable(id) {
    const tx = this.db.transaction(this.sstableStore, 'readonly');
    const req = tx.objectStore(this.sstableStore).get(id);
    return new Promise((resolve, reject) => {
      req.onsuccess = () => resolve(req.result?.data || null);
      req.onerror = () => reject(req.error);
    });
  }

  async deleteSSTable(id) {
    const tx = this.db.transaction(this.sstableStore, 'readwrite');
    tx.objectStore(this.sstableStore).delete(id);
    return new Promise((resolve, reject) => {
      tx.oncomplete = () => resolve();
      tx.onerror = () => reject(tx.error);
    });
  }
}
这里有一个重要的工程细节:浏览器中对大量小数据块的读写需要合并请求。如果每个SSTable块单独一次事务,事务开销会成为瓶颈。因此我们将整个SSTable序列化为一个JSON对象,一次写入。对于超大SSTable,可以分块写入,但在浏览器场景下,内存容量通常足以支撑单个文件的完整序列化。

七、Compaction:分层合并策略
Compaction是LSM-Tree的核心维护操作。它将多个SSTable合并为一个,消除冗余的键版本,维护分层结构。

层级结构的设计是:L0层的SSTable允许键范围重叠(因为它们是直接从内存刷盘的,无法保证全局有序),L1到Ln层内部保证键范围不重叠,每层总大小是上一层的10倍。

javascript
class LSMTree {
  constructor(options = {}) {
    this.memTable = new RBTree();
    this.immutable = null;
    this.wal = null;
    this.disk = null;
    this.levels = [[], [], []];  // L0, L1, L2
    this.memTableMaxSize = options.memTableMaxSize || 1000;
    this.level0MaxFiles = options.level0MaxFiles || 4;
    this.nextSSTableId = 1;
    this.writeCount = 0;
  }

  async init() {
    this.disk = await DiskStorage.open();
    this.wal = new WriteAheadLog(this.disk.db);
    // 重放WAL恢复MemTable
    const entries = await this.wal.replay();
    for (const entry of entries) {
      if (entry.type === 'put') {
        this.memTable.put(entry.key, entry.value);
      }
    }
    // 加载已有的SSTable
    await this._loadSSTables();
  }

  async put(key, value) {
    // 1. 写WAL
    await this.wal.append('put', key, value);
    // 2. 写MemTable
    this.memTable.put(key, value);
    this.writeCount++;

    // 3. 检查阈值,触发刷盘
    if (this.memTable.size >= this.memTableMaxSize) {
      await this._flushMemTable();
    }
  }

  async delete(key) {
    // 删除 = 写入 Tombstone
    await this.wal.append('delete', key, null);
    this.memTable.put(key, null);
  }

  get(key) {
    // 1. 查MemTable
    let val = this.memTable.get(key);
    if (val !== undefined) return val;

    // 2. 查Immutable MemTable
    if (this.immutable) {
      val = this.immutable.get(key);
      if (val !== undefined) return val;
    }

    // 3. 查L0(从新到旧)
    for (let i = this.levels[0].length - 1; i >= 0; i--) {
      val = this.levels[0][i].get(key);
      if (val !== undefined) return val;
    }

    // 4. 查L1、L2(每层最多一个文件包含该键)
    for (let l = 1; l < this.levels.length; l++) {
      for (const sst of this.levels[l]) {
        val = sst.get(key);
        if (val !== undefined) return val;
      }
    }

    return undefined;
  }

  async _flushMemTable() {
    // 1. 冻结当前MemTable
    this.immutable = this.memTable;
    this.memTable = new RBTree();

    // 2. 构建SSTable
    const builder = new SSTableBuilder();
    for (const [key, value] of this.immutable.entries()) {
      builder.add(key, value);
    }
    const fileData = builder.finish();
    const sstId = this.nextSSTableId++;

    // 3. 持久化
    await this.disk.writeSSTable(sstId, fileData);

    // 4. 加入L0
    const reader = new SSTableReader(fileData);
    reader.id = sstId;
    this.levels[0].push(reader);

    // 5. 清空WAL(已持久化)
    await this.wal.clear();

    // 6. 检查是否需要Compaction
    if (this.levels[0].length >= this.level0MaxFiles) {
      await this._compactL0();
    }

    this.immutable = null;
  }

  async _compactL0() {
    // 将L0所有文件与L1合并
    const l0Files = this.levels[0].sort((a, b) => a.id - b.id);
    const l1Files = this.levels[1];

    // 合并排序
    const merged = new RBTree();
    // 从旧到新写入(新的覆盖旧的)
    for (const sst of l1Files) {
      for (const [key, value] of this._iterateSSTable(sst)) {
        merged.put(key, value);
      }
    }
    for (const sst of l0Files) {
      for (const [key, value] of this._iterateSSTable(sst)) {
        merged.put(key, value);
      }
    }

    // 构建新的L1文件
    const builder = new SSTableBuilder(8192); // 更大的块
    for (const [key, value] of merged.entries()) {
      if (value !== null) {  // 跳过 Tombstone
        builder.add(key, value);
      }
    }
    const newFile = builder.finish();
    const newId = this.nextSSTableId++;

    await this.disk.writeSSTable(newId, newFile);

    // 清理旧文件
    for (const sst of [...l0Files, ...l1Files]) {
      await this.disk.deleteSSTable(sst.id);
    }

    this.levels[0] = [];
    this.levels[1] = [new SSTableReader(newFile)];
    this.levels[1][0].id = newId;
  }

  *_iterateSSTable(sst) {
    for (let i = 0; i < sst.blocks.length; i++) {
      const block = JSON.parse(sst.blocks[i]);
      for (const entry of block) {
        yield [entry.key, entry.value];
      }
    }
  }
}
Compaction的核心逻辑是合并排序:将多个已排序的SSTable文件归并为一个,同时消除冗余版本。对于同一个键,较新的SSTable中的版本覆盖较旧的版本。Tombstone(删除标记)在Compaction到最深层时可以安全丢弃。

八、Bloom Filter:加速负查询
Bloom Filter是LSM-Tree中不可或缺的优化。在读取路径中,如果目标键不在任何一个SSTable中,查询需要遍历所有层级的所有文件,代价极高。Bloom Filter可以在每个SSTable前面加一层快速判断——如果Bloom Filter说“不存在”,那么一定不存在,直接跳过该文件。

javascript
class BloomFilter {
  constructor(numBits, numHashes) {
    this.bits = new Uint8Array(Math.ceil(numBits / 8));
    this.numBits = numBits;
    this.numHashes = numHashes;
  }

  static fromKeys(keys, falsePositiveRate = 0.01) {
    const n = keys.length;
    const m = Math.ceil(-n * Math.log(falsePositiveRate) / (Math.LN2 * Math.LN2));
    const k = Math.round((m / n) * Math.LN2);
    const filter = new BloomFilter(m, k);
    for (const key of keys) {
      filter.add(key);
    }
    return filter;
  }

  _hashes(key) {
    // 双哈希法生成 k 个哈希值
    let h1 = 0, h2 = 0;
    for (let i = 0; i < key.length; i++) {
      h1 = (h1 * 31 + key.charCodeAt(i)) >>> 0;
      h2 = (h2 * 37 + key.charCodeAt(i)) >>> 0;
    }
    const hashes = [];
    for (let i = 0; i < this.numHashes; i++) {
      hashes.push((h1 + i * h2) % this.numBits);
    }
    return hashes;
  }

  add(key) {
    for (const h of this._hashes(key)) {
      this.bits[h >> 3] |= (1 << (h & 7));
    }
  }

  mightContain(key) {
    for (const h of this._hashes(key)) {
      if ((this.bits[h >> 3] & (1 << (h & 7))) === 0) return false;
    }
    return true;
  }
}
Bloom Filter的假阳性率由位数和哈希函数个数决定。对于1%的假阳性率,每个键约需10个比特。一个包含1000个键的SSTable只需要约1.25KB的Bloom Filter空间,却能在大多数负查询中直接跳过文件,将读取延迟从毫秒级降低到微秒级。

九、读取路径与性能对比
完整的读取路径是:MemTable → Immutable MemTable → L0(从新到旧,Bloom Filter过滤)→ L1(Bloom Filter过滤)→ L2(Bloom Filter过滤)。对于存在的数据,返回第一个命中的版本;对于不存在的数据,所有层级都检查后返回未定义。

与原生IndexedDB的对比,我们用批量插入10000条记录的场景来测试:

javascript
// 原生 IndexedDB 批量插入
async function nativeBatchInsert(db, records) {
  const tx = db.transaction('data', 'readwrite');
  const store = tx.objectStore('data');
  for (const { key, value } of records) {
    store.put({ key, value });
  }
  return new Promise(resolve => tx.oncomplete = resolve);
}

// LSM-Tree 批量插入
async function lsmBatchInsert(lsm, records) {
  for (const { key, value } of records) {
    await lsm.put(key, value);
  }
}

// 测试
const records = Array.from({ length: 10000 }, (_, i) => ({
  key: `key_${String(i).padStart(6, '0')}`,
  value: `value_${i}`
}));

// 原生 IndexedDB:约 800-1200ms(取决于事务提交开销)
// LSM-Tree:MemTable 插入 + WAL 批量写入,约 300-500ms
LSM-Tree的优势在于写入被缓冲在内存中,只有达到阈值时才批量刷盘。原生IndexedDB每次put都涉及结构化克隆和事务日志,开销是常数级的。LSM-Tree将多次写入摊销为一次批量I/O,这正是LSM-Tree的核心设计思想——延迟与批量处理。

读取性能方面,原生IndexedDB的索引查询在数据量大时表现更好,因为B-Tree结构保证了O(log N)的查找。LSM-Tree的读取需要检查多个文件,但如果配置了Bloom Filter,且数据分布合理(热数据在MemTable或L0),读取性能可以接近B-Tree。对于冷数据,LSM-Tree的读取延迟会高于B-Tree,这是写优化的固有代价。

十、工程边界与总结
这个LSM-Tree实现覆盖了核心的写入路径、读取路径、Compaction和Bloom Filter。但工程化还需要处理几个问题:

并发控制。浏览器是单线程的(在主线程上),但如果有多个Tab同时操作同一个数据库,需要跨Tab的写入协调。IndexedDB的事务机制可以提供基本的隔离,但LSM-Tree的内存状态(MemTable)是每个Tab独立的,需要额外的同步机制。生产级实现可以用SharedWorker或BroadcastChannel来协调。

空间放大。LSM-Tree允许多个版本的键共存于不同层级,直到Compaction清理。这导致磁盘占用可能远大于实际数据量。对于浏览器场景,可以通过更积极的Compaction策略(如Size-Tiered Compaction)来缓解。

Tombstone的清理。删除操作写入Tombstone后,只有在Compaction到最深层时才能安全丢弃。如果某个键的Tombstone在L0,而旧版本在L2,读取时会正确返回“不存在”,但磁盘上仍保留着旧数据。

从理论到代码,LSM-Tree的核心设计原则——延迟写入、顺序I/O、多级分层——在浏览器环境中同样适用。Chrome浏览器用LevelDB存储IndexedDB数据,而LevelDB的核心就是LSM-Tree。理解这个结构,有助于在IndexedDB原生API之上构建更高效的存储层。对于正在开发离线优先、local-first应用的团队来说,自己实现一个轻量级的LSM-Tree引擎,比直接使用IndexedDB的裸API能获得更好的写入吞吐和更简洁的数据模型。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。