索引和二进制格式存储

接下来我们就开始实现一个最简单的保存在内存内的哈希索引,这在 Node 环境中非常简单,只需要用一个 Map 就能实现了。但这个 Map 内的值存什么还需要多考虑一点。我们需要先设计好如何以二进制格式来存储我们的数据。

TIP

Node.js 提供了三种风格的接口,分别是异步调用返回一个 promise,同步调用,以及传入回调函数的 callback 形式,为了方便,只要有同步调用的我们都统一采用同步调用的方式。

对于写入,我们还是使用之前的 fs.appendFileSync 接口,除了字符串数据以外,它也支持传入一个 buffer。而读取的话,我们需要使用到 fs.readSync 接口,它的签名是下面这样的,你可以在指定的位置,读取指定 length 长度的数据块,将其复制到你所传入的 buffer 对象中,同时可以指定从 buffer 对象 offset 的偏移量位置开始写入:

export function readSync(
  fd: number,
  buffer: NodeJS.ArrayBufferView,
  offset: number,
  length: number,
  position: ReadPosition | null
): number;
export function readSync(
  fd: number,
  buffer: NodeJS.ArrayBufferView,
  offset: number,
  length: number,
  position: ReadPosition | null
): number;

对于其中的 fd,我们可以通过 fs.openSync 来获取文件的描述符。

这意味着在读取数据时我们不光要知道从那个地方开始读,最好还能知道读取多大的数据块出来,那么为了方便,我们写入前就干脆也把需要写入的大小也记录到索引里面:

type Offset = number;
type Size = number; // how many bytes
type Index = [Offset, Size];
type Offset = number;
type Size = number; // how many bytes
type Index = [Offset, Size];

同时,这次我们也提供对数据类型的支持,对于提交过来的不同的数据类型,我们针对性的将其转换成二进制格式,为了简单一点,这里我们只支持三种类型,分别是数字,布尔值和字符串:

const enum DataType {
  Boolean,
  Number,
  String,
}
const enum DataType {
  Boolean,
  Number,
  String,
}

这里使用到了 const enum,他们在编译后会被完全抹去,三种类型分别会用 0,1,2 代替:

// before
const a = DataType.Boolean;

// after
const a = 0; /* Boolean */
// before
const a = DataType.Boolean;

// after
const a = 0; /* Boolean */

然后我们可以针对这三个类型编写一个序列化的函数,同时,为了在读取时针对性的进行反序列化,我们还需要将它的类型信息也存进去,规则是下面这样的:

  • 每一个数据都用第一个字节来存储它的类型信息,0,1,2 分别对应布尔值,数字和字符串;
  • 布尔值,用一个字节来表示(主要是 Node 最少得分配一个字节),1 为 true,0 为 false;
  • 数字,因为 Node 中的普通数字实际上都是双精度浮点数,我们就将其都作为 double 处理,占用 8 个字节(64 位),对于可能溢出的大数字先不管;
  • 除了布尔和数字,其他都当做字符串处理,采用 UTF-8 编码。
const serialize = (val: boolean | number | string): Buffer => {
  let buf: Buffer;
  let type: Buffer;
  let data: Buffer;

  switch (typeof val) {
    case 'boolean': {
      type = Buffer.alloc(1, DataType.Boolean);
      data = Buffer.alloc(1, val ? 1 : 0);
      break;
    }
    case 'number': {
      type = Buffer.alloc(1, DataType.Number);
      data = Buffer.allocUnsafe(8);
      data.writeDoubleBE(val);
      break;
    }
    case 'string': {
      type = Buffer.alloc(1, DataType.String);
      data = Buffer.from(val);
      break;
    }
    default: {
      type = Buffer.alloc(1, DataType.String);
      data = Buffer.from(val);
    }
  }

  buf = Buffer.concat([type, data]);
  return buf;
};
const serialize = (val: boolean | number | string): Buffer => {
  let buf: Buffer;
  let type: Buffer;
  let data: Buffer;

  switch (typeof val) {
    case 'boolean': {
      type = Buffer.alloc(1, DataType.Boolean);
      data = Buffer.alloc(1, val ? 1 : 0);
      break;
    }
    case 'number': {
      type = Buffer.alloc(1, DataType.Number);
      data = Buffer.allocUnsafe(8);
      data.writeDoubleBE(val);
      break;
    }
    case 'string': {
      type = Buffer.alloc(1, DataType.String);
      data = Buffer.from(val);
      break;
    }
    default: {
      type = Buffer.alloc(1, DataType.String);
      data = Buffer.from(val);
    }
  }

  buf = Buffer.concat([type, data]);
  return buf;
};

注意数字的部分用到了 buffer.writeDoubleBEBE 的意思是 Big-Endian,中文叫大端序,还有一种叫小端序,对于字节序这里不赘述,可以自行搜索。具体使用什么样的字节序这里没什么讲究,但最重要的是在序列化和反序列化的时候一定要使用相同的字节序,这里我们默认全部使用大端序。

这样呢反序列化也很简单:

const deserialize = (buf: Buffer): boolean | number | string => {
  const type = buf[0];

  switch (type) {
    case DataType.Boolean: {
      return buf[1] === 1;
    }
    case DataType.Number: {
      return buf.readDoubleBE(1);
    }
    case DataType.String: {
      return buf.slice(1).toString();
    }
    default:
      return buf.slice(1).toString();
  }
};
const deserialize = (buf: Buffer): boolean | number | string => {
  const type = buf[0];

  switch (type) {
    case DataType.Boolean: {
      return buf[1] === 1;
    }
    case DataType.Number: {
      return buf.readDoubleBE(1);
    }
    case DataType.String: {
      return buf.slice(1).toString();
    }
    default:
      return buf.slice(1).toString();
  }
};

这样整体的思路就确定了:

  • 启动时先获取数据库文件 fd,开启 repl 环境;
  • 对于 set 命令把 key/value 交给 set 函数,进行序列化,先更新索引,再写入文件;
  • 对于 get 命令解析出 key,交给 get 函数,先从索引中查找,如果有的话根据偏移位置和数据大小,然后直接读取出来并进行反序列化然后返回。

我们据此可以重构一下之前的 getset 函数:

const get = (key: string) => {
  const index = map.get(key);

  if (index) {
    const [offset, size] = index;
    const buffer = Buffer.alloc(size);
    fs.readSync(fd, buffer, 0, size, offset);
    return deserialize(buffer);
  }

  return null;
};

const set = (key: string, value: string) => {
  let buffer: Buffer;
  if (value === 'true' || value === 'false') {
    // boolean
    buffer = serialize(JSON.parse(value));
  } else if (/^-?\d+$/.test(value)) {
    // number
    buffer = serialize(parseFloat(value));
  } else {
    buffer = serialize(value);
  }

  const size = buffer.byteLength;
  const offset = fs.statSync(dbFile).size;
  map.set(key, [offset, size]);
  fs.appendFileSync(dbFile, buffer);
};
const get = (key: string) => {
  const index = map.get(key);

  if (index) {
    const [offset, size] = index;
    const buffer = Buffer.alloc(size);
    fs.readSync(fd, buffer, 0, size, offset);
    return deserialize(buffer);
  }

  return null;
};

const set = (key: string, value: string) => {
  let buffer: Buffer;
  if (value === 'true' || value === 'false') {
    // boolean
    buffer = serialize(JSON.parse(value));
  } else if (/^-?\d+$/.test(value)) {
    // number
    buffer = serialize(parseFloat(value));
  } else {
    buffer = serialize(value);
  }

  const size = buffer.byteLength;
  const offset = fs.statSync(dbFile).size;
  map.set(key, [offset, size]);
  fs.appendFileSync(dbFile, buffer);
};

可以使用 ts-node db.ts 来直接启动并进行测试。

问题

目前来看这个简单的方案似乎还是可行的,现实中某些数据库其实也是这么做的,但要求所有的键必须都可以放入内存中,而数据值可以使用比可用内存更多的空间,因为可以在硬盘上通过一次硬盘查找操作来加载所需的部分。我们甚至还可以加入缓存功能,对于同一个 key 被反复读取的话将其缓存在内存里,这样下一次请求过来就可以从缓存中查找到并返回,就完全不需要再来一次磁盘 I/O 了。但它还存在几个问题:

  • 如果有海量多的键,那就很麻烦了。虽然可以在硬盘上维护一个映射,但很可惜硬盘上的映射很难表现优秀。它需要大量的随机访问 I/O,并且哈希冲突的处理也需要很烦琐的逻辑;
  • 范围查询效率很低,只能一个一个单独查找每一个键;
  • 内存中的映射会在进程退出时丢失,或许可以在退出时先写入到硬盘,下次启动时再恢复,不过总之服务的重启可能比较痛苦;
  • 每次 append 虽然快,但是会造成很多的存储空间浪费,尤其是在一个键多次写入的时候,我们或许可以用新值覆盖旧值?实际上追加属于顺序写入操作,通常会比随机写入快得多,另外我们还可以通过合并压缩等方案来优化存储,但这个问题我们以后再细说;
  • 因为存储的数据大小是未知的,我们在索引中还需要维护这个数据,总感觉不太优雅,应该还有更好的解决方案。

之后我们就讲一讲怎么一步步解决上面这些问题的。

最后附上完整的代码:

点击展开
import repl from 'repl';
import fs from 'fs';

const dbFile = './data.db';

const map = new Map<string, Index>();
const fd = fs.openSync(dbFile, 'a+'); // `a+` flag means open file for reading and appending.

type Offset = number;
type Size = number; // how many bytes
type Index = [Offset, Size];

const enum DataType {
  Boolean,
  Number,
  String,
}

const get = (key: string) => {
  const index = map.get(key);

  if (index) {
    const [offset, size] = index;
    const buffer = Buffer.alloc(size);
    fs.readSync(fd, buffer, 0, size, offset);
    return deserialize(buffer);
  }

  return null;
};

const set = (key: string, value: string) => {
  let buffer: Buffer;
  if (value === 'true' || value === 'false') {
    // boolean
    buffer = serialize(JSON.parse(value));
  } else if (/^-?\d+$/.test(value)) {
    // number
    buffer = serialize(parseFloat(value));
  } else {
    buffer = serialize(value);
  }

  const size = buffer.byteLength;
  const offset = fs.statSync(dbFile).size;
  map.set(key, [offset, size]);
  fs.appendFileSync(dbFile, buffer);
};

const deserialize = (buf: Buffer): boolean | number | string => {
  const type = buf[0];

  switch (type) {
    case DataType.Boolean: {
      return buf[1] === 1;
    }
    case DataType.Number: {
      return buf.readDoubleBE(1);
    }
    case DataType.String: {
      return buf.slice(1).toString();
    }
    default:
      return buf.slice(1).toString();
  }
};

const serialize = (val: boolean | number | string): Buffer => {
  let buf: Buffer;
  let type: Buffer;
  let data: Buffer;

  switch (typeof val) {
    case 'boolean': {
      type = Buffer.alloc(1, DataType.Boolean);
      data = Buffer.alloc(1, val ? 1 : 0);
      break;
    }
    case 'number': {
      type = Buffer.alloc(1, DataType.Number);
      data = Buffer.allocUnsafe(8);
      data.writeDoubleBE(val);
      break;
    }
    case 'string': {
      type = Buffer.alloc(1, DataType.String);
      data = Buffer.from(val);
      break;
    }
    default: {
      type = Buffer.alloc(1, DataType.String);
      data = Buffer.from(val);
    }
  }

  buf = Buffer.concat([type, data]);
  return buf;
};

repl.start({
  prompt: 'db.js >> ',
  eval: async (evalCmd, _, __, callback) => {
    const cmd = evalCmd.trim();
    if (cmd.startsWith('set')) {
      const [, key, value] = cmd.split(' ');
      set(key, value);
      return callback(null, value);
    }
    if (cmd.startsWith('get')) {
      const [, key] = cmd.split(' ');
      const value = get(key);
      return callback(null, value);
    }
    return callback(null, `Unrecognized command.`);
  },
});
import repl from 'repl';
import fs from 'fs';

const dbFile = './data.db';

const map = new Map<string, Index>();
const fd = fs.openSync(dbFile, 'a+'); // `a+` flag means open file for reading and appending.

type Offset = number;
type Size = number; // how many bytes
type Index = [Offset, Size];

const enum DataType {
  Boolean,
  Number,
  String,
}

const get = (key: string) => {
  const index = map.get(key);

  if (index) {
    const [offset, size] = index;
    const buffer = Buffer.alloc(size);
    fs.readSync(fd, buffer, 0, size, offset);
    return deserialize(buffer);
  }

  return null;
};

const set = (key: string, value: string) => {
  let buffer: Buffer;
  if (value === 'true' || value === 'false') {
    // boolean
    buffer = serialize(JSON.parse(value));
  } else if (/^-?\d+$/.test(value)) {
    // number
    buffer = serialize(parseFloat(value));
  } else {
    buffer = serialize(value);
  }

  const size = buffer.byteLength;
  const offset = fs.statSync(dbFile).size;
  map.set(key, [offset, size]);
  fs.appendFileSync(dbFile, buffer);
};

const deserialize = (buf: Buffer): boolean | number | string => {
  const type = buf[0];

  switch (type) {
    case DataType.Boolean: {
      return buf[1] === 1;
    }
    case DataType.Number: {
      return buf.readDoubleBE(1);
    }
    case DataType.String: {
      return buf.slice(1).toString();
    }
    default:
      return buf.slice(1).toString();
  }
};

const serialize = (val: boolean | number | string): Buffer => {
  let buf: Buffer;
  let type: Buffer;
  let data: Buffer;

  switch (typeof val) {
    case 'boolean': {
      type = Buffer.alloc(1, DataType.Boolean);
      data = Buffer.alloc(1, val ? 1 : 0);
      break;
    }
    case 'number': {
      type = Buffer.alloc(1, DataType.Number);
      data = Buffer.allocUnsafe(8);
      data.writeDoubleBE(val);
      break;
    }
    case 'string': {
      type = Buffer.alloc(1, DataType.String);
      data = Buffer.from(val);
      break;
    }
    default: {
      type = Buffer.alloc(1, DataType.String);
      data = Buffer.from(val);
    }
  }

  buf = Buffer.concat([type, data]);
  return buf;
};

repl.start({
  prompt: 'db.js >> ',
  eval: async (evalCmd, _, __, callback) => {
    const cmd = evalCmd.trim();
    if (cmd.startsWith('set')) {
      const [, key, value] = cmd.split(' ');
      set(key, value);
      return callback(null, value);
    }
    if (cmd.startsWith('get')) {
      const [, key] = cmd.split(' ');
      const value = get(key);
      return callback(null, value);
    }
    return callback(null, `Unrecognized command.`);
  },
});