Bun 生产环境实践 [0] 小游戏服务器

1,110 阅读9分钟

最近 Tap-to-Earn 在Telegram上大火,其代表作品 Hamster Kombat 就是这类游戏中的一颗新星。

近日接到新的需求,需要开发的应用与 Hamster Kombat 类似,运行在Telegram Mini App平台,但是游戏模式和打地鼠一样,玩家需要在一轮游戏中尽可能打中更多的地鼠以获取更高的分数,为了实现反作弊防刷分我需要把游戏逻辑和分数结算都做到服务器上,为此本文将使用 BunElysia.jsuWebSocket、Redis 等技术和库来实现小游戏服务端,并在实际生产环境中使用。

!!请注意,这只是我在生产环境中的应用,要是学我操作导致事故不要找我!!概不负责!!

Article Elysia uses uWebSocket which Bun uses under the hood with the same API. Elysia.js WebSocket Source code

为什么选择 Bun

兼容性&包管理

Bun作为包管理器速度更快,兼容Node.js

Article Node.js-compatible package manager.

⚡️ 25x faster — Switch from npm install to bun install in any Node.js project to make your installations up to 25x faster.

运行时速度

Bun作为一个新兴的JavaScript运行时,在各项Benchmark性能测试中表现都优于Node.js。在游戏服务器这种需要处理并发请求和实时通信的场景中,Bun的高性能特性很重要。

Article ⚡️ 7x more throughput — Bun's WebSockets are fast. For a simple chatroom on Linux x64, Bun can handle 7x more requests per second than Node.js + "ws".

开发体验

不需要 ts-nodenodemon 等工具,直接运行 .ts,使用 --watch 参数可以实现快速的热重载,提高开发效率

Article Watch mode

项目设计

由于我本是个前端,对游戏服务端少有涉猎,所以我的做法不一定都是正确高效的,以下内容全凭我的写代码经验作出的方案。

文中所有代码均是写文章时处理简化后的代码,不代表线上代码质量。所有信息已脱敏

本项目支持多实例启动,使用 Redis 作为共享状态存储,允许多个实例访问相同的数据。并使用分布式锁机制,确保每个玩家只能同时玩一局游戏。使用事件驱动,通过 Redis 的发布/订阅功能来广播游戏中的事件、局内状态变更到每个实例。

每个实例都是无状态的,每个连接都基于 Redis 中存储的数据。这意味着任何实例都可以处理来自任何玩家的WebSocket 消息。

通过这些设计和实现,项目能够在多个实例上运行,同时保持数据一致性和游戏逻辑的正确性。每个实例可以独立处理玩家请求和游戏逻辑,而共享状态和事件机制确保了跨实例的协调。

流程

  • 使用 Elysia.js 框架,使用 /game/wam 路径作为 WebSocket 连接端点。
  • 启动时,订阅 Redis Channel 接收各种事件,创建 Event Emitter
  • 当新的客户端连接时,从请求头解析并验证 tgInitData 获取 用户 ID,尝试获取当前游戏进度(如果存在)。开始监听事件,并将消息发到客户端。
  • 解析并处理客户端命令
  • 客户端断开时,清理监听器。

游戏逻辑

游戏的需求是一个类似打地鼠的游戏,假设在30秒内,每2秒在3x4的宫格中随机于(x,y)位置上的出现一批地鼠,玩家点击地鼠后获得分数(基于地鼠类型),游戏结束后结算分数。

游戏逻辑很简单,只需要一个以固定 tick 运行的循环,在固定的间隔向客户端发送生成的地鼠位置信息,然后监听用户击中的格子XY位置就能实现。

为了尽快完成需求,我打算为每个用户都创建了一个游戏循环,它只会在一个服务端实例中运行,如果服务端突然挂了那么这局游戏就gg了。未来应该可以通过共享逻辑帧循环来进一步优化性能(如果甲方愿意加钱,我还是很乐意去实现的)。

核心功能实现

主要依赖的库有:

bun add ioredis tseep elysia

创建端点

const redis = getRedisClient();
const listenerMap: Map<any, [string, any][]> = new Map();

// reusePort 允许端口重用,以支持多个实例运行
export const app = new Elysia({
  serve: {
    reusePort: true,
  },
})
  .ws("/game/wam", {
    body: BodySchemas,
    // 这里大概是有个 elyisa 的bug,所以用 t.Any 了... BodySchemas 我导出到前端项目中使用了,确保传入的数据没问题即可。其他情况抛异常
    response: t.Any(),
    async message(ws, message) {
      const tgInitData = ws.data.query["tg-init-data"] ?? "";
      const initData = verifyInitData(tgInitData);
      // 拿到用户 id
      const playerId = initData.user.id;
      
      // 获取游戏channel key,并组成几个有用的函数
      const game = await GameLoop.tryRetireGame(playerId, redis);
      
      // 根据客户端命令类型,进行不同的操作
      switch (message.type) {
        case "state":
          // 将游戏状态给客户端
          ws.send({
            type: "game-state",
            ...(await game.getState()),
          });
          return;
        case "settings":
          // 将游戏设置参数给客户端
          ws.send({
            type: "settings",
            ...(await game.getSettings()),
          });
          return;
        case "start":
          try {
            // 尝试在此实例上启动游戏循环,tgInitData传入是因为结算时要调用另一个接口。。。。后端是php写的
            await GameLoop.launchGame(playerId, tgInitData, redis);
          } catch (err) {
            ws.send({
              type: "game-exception",
              message: err.message,
            });
          }
          // 然后将游戏的设置参数传回去
          ws.send({
            type: "settings",
            ...(await game.getSettings()),
          });
          return;
        case "giant-mole":
          // 玩家点了某个地鼠,传来了 xy 坐标
          game.giantMole(message.x, message.y);
          return;
      }
    },
    async open(ws) {
      // ... 此处省略获取解析 tgInitData 的代码,这部分代码应该可以写到 elysia 的 derive 里
      const playerId = initData.user.id;
      // 设置了一些监听器,数据从 redis channel 中来的,比如 onGameStateMsg。中的实现就是判断过来的消息是不是 playerId 的,如果是则 ws.send 给客户端
      gameOutgoingEvent
        .on("game-state", onGameStateMsg)
        .on("deliver-mole", onDeliverMoleMsg)
        .on("game-exception", onGameExceptionMsg);

      listenerMap.set(ws.id, [
        ["game-state", onGameStateMsg],
        ["deliver-mole", onDeliverMoleMsg],
        ["game-exception", onGameExceptionMsg],
      ]);
    },
    close(ws) {
      // 清理监听器
      const listeners = listenerMap.get(ws.id);
      console.info("clear listeners", ws.id, listeners?.length);
      if (listeners && listeners.length) {
        listeners.forEach(([event, listener]) => {
          gameOutgoingEvent.removeListener(event as any, listener);
        });
      }
      listenerMap.delete(ws.id);
    },
  });

其中 GameLoop.tryRetireGame 大概长这样

  static async tryRetireGame(playerId: number, redis: Redis) {
    // 获取 key 前缀,和 playerId 进行拼接
    const redisPrefix = GameLoop.getPrefix(playerId);
    return {
      // 给channel发送用户点击某个宫格的消息,由实际运行游戏循环的实例去处理
      async giantMole(x: number, y: number) {
        await redis.publish(
          gameInternalEventChannel,
          JSON.stringify({
            x,
            y,
            type: "giant-mole",
            playerId: playerId,
          } satisfies InternalChannelGiantMoleMessage),
        );
      },
      // 从redis获取游戏设置参数
      async getSettings() {
        return JSON.parse(
          (await redis.get(`${redisPrefix}:settings`)) ?? "",
        ) as GameWAM.GameSettings;
      },
      // 从redis获取游戏状态
      async getState() {
        return JSON.parse(
          (await redis.get(`${redisPrefix}:state`)) ?? "",
        ) as GameWAM.GameState;
      },
    };
  }

至于 gameOutgoingEvent 大概长这样

// 其实就是订阅redis channel中的消息,然后通过tseep分发给本实例中
export interface OutgoingChannelDeliverMoleMessage {
  type: "deliver-mole";
  playerId: number;
  events: GameWAM.DeliverMoleEvent[];
}

export interface OutgoingChannelGameStateMessage extends GameWAM.GameState {
  type: "game-state";
  playerId: number;
}

export interface OutgoingChannelGameExceptionMessage
  extends GameWAM.GameExceptionEvent {
  type: "game-exception";
  playerId: number;
}

export type OutgoingChannelMessage =
  | OutgoingChannelGameStateMessage
  | OutgoingChannelDeliverMoleMessage
  | OutgoingChannelGameExceptionMessage;

export const gameOutgoingEvent = new EventEmitter<{
  "game-state": (msg: OutgoingChannelGameStateMessage) => void;
  "deliver-mole": (msg: OutgoingChannelDeliverMoleMessage) => void;
  "game-exception": (msg: OutgoingChannelGameExceptionMessage) => void;
}>();

export const gameOutgoingEventChannel = `common:channel:game-outgoing`;

const subRedis = getRedisClient();
await subRedis.subscribe(gameOutgoingEventChannel, (err) => {
  console.info("game loop outgoing channel subscribed");
  if (err) console.warn(err);
});
subRedis.on("message", (channel, message) => {
  if (channel !== gameOutgoingEventChannel) return;
  const msg = JSON.parse(message) as OutgoingChannelMessage;
  gameOutgoingEvent.emit(msg.type, msg as any);
});

启动/恢复游戏

  static async launchGame(playerId: number, tgInitData: string, redis: Redis) {
    // 用玩家id拼接 redis key 前缀
    const redisPrefix = GameLoop.getPrefix(playerId);
    try {
      const initState = {
         ...一系列数据
      } satisfies GameWAM.GameState;

      const initSettings = {
         ...一系列数据
      } satisfies GameWAM.GameSettings;
      
      // 锁的ttl,不用很长,在游戏循环中会不断去延长锁的时间。以免程序异常退出后,锁长时间得不到释放,玩家没法继续玩游戏。
      const ttl = Math.ceil(initSettings.duration / 1000 + 10);
      // 如果存在锁,说明已经有正在进行中的游戏,则抛出异常。
      if (!(await GameLoop.acquireLock(playerId, redis, 5))) {
        // game is already running
        throw new GameAlreadyRunningException();
      }
      
      // 不管有没有吧,先把之前缓存的数据都删了
      const keys = await redis.keys(`${redisPrefix}:*`);
      const pipe = redis.pipeline();
      for (const key of keys) {
        pipe.expire(key, ttl);
      }
      await pipe.exec();

      // 存入初始游戏状态和设置
      await redis.set(
        `${redisPrefix}:state`,
        JSON.stringify(initState),
        "EX",
        ttl,
      );
      await redis.set(
        `${redisPrefix}:settings`,
        JSON.stringify(initSettings),
        "EX",
        ttl,
      );

      // 将实例暂存。退出循环后删除实例
      const instance = new GameLoop(playerId, redis, initState, initSettings);
      this.instances.push(instance);

      // 启动游戏循环
      instance.loop().catch((err) => {
        console.warn(err);
        instance.stop().catch(console.warn);
      });

      console.info("player", playerId, "new game", initSettings.startTime);
    } catch (err) {
      // ...一系列错误处理
    }
    return {};
  }

其中 acquireLock 如下:

  private static async acquireLock(
    playerId: number,
    redis: Redis,
    duration: number,
  ) {
    const lockKey = `${GameLoop.getPrefix(playerId)}:lock`;
    const acquired = await redis.set(lockKey, "locked", "NX");
    await redis.expire(lockKey, duration);
    return acquired === "OK";
  }

游戏逻辑

整个loop函数很长,大概如下

  async loop(): Promise<void> {
    // 将玩家点击的格子的坐标和时间存储
    this.onGiantMoleListener = (msg: InternalChannelGiantMoleMessage) => {
      if (msg.playerId !== this.playerId) return;
      const { x, y } = msg;
      this.giantMoleIds.push({ date: Date.now(), xy: [x, y] });
    };
    gameInternalEvent.on("giant-mole", this.onGiantMoleListener);
    
    // 游戏循环正文
    while (!this.stopped) {
      const now = Date.now();
      // 游戏总时长
      const elapsed = now - this.settings.startTime;

      // 从redis获取当前状态
      const stateStr = await this.redis.get(`${this.prefix}:state`);
      if (!stateStr) return;
      this.state = JSON.parse(stateStr) as GameWAM.GameState;

      // 更新lock
      if (differenceInMilliseconds(now, this._lastTickTime) > 1000) {
        const lockKey = `${GameLoop.getPrefix(this.playerId)}:lock`;
        await this.redis.set(lockKey, "locked", "EX", 5);
        this._lastTickTime = now;
      }

      // 处理玩家点击格子,将通过坐标和时间找到地鼠
      if (this.giantMoleIds.length) {
        const giantMoles = this.giantMoleIds
          .splice(0, this.giantMoleIds.length)
          .map(({ date, xy: [x, y] }) => {
            // 从出现过的地鼠中找到位置一样的
            const matches = this.state.appearedMoles.filter((mole) => {
              return mole.x === x && mole.y === y;
            });
            // 找到点击时间和地鼠出现时间最接近的那一个
            const index = closestIndexTo(
              date,
              matches.map((mole) => mole.deliveryTime),
            );
            if (index === undefined) {
              console.warn("giant mole not found", x, y);
              return;
            }
            const mole = matches.at(index);
            if (
              differenceInMilliseconds(date, mole?.deliveryTime ?? 0) > 5000
            ) {
              // 如果点击时间和地鼠出现时间相差太长,则不算数
              return;
            }
            return mole;
          })
          .filter((mole) => !!mole);
          
        // 将之前打到的地鼠和有效的刚打到的地鼠合并
        const collectedMoleIds = [
          ...this.state.collectedMoleIds,
          ...giantMoles,
        ];
        // 去重
        const deduped = collectedMoleIds.reduce(
          (acc, cur) => {
            if (!acc.some((m) => m.id === cur.id)) {
              acc.push(cur);
            }
            return acc;
          },
          [] as typeof collectedMoleIds,
        );
        this.state.collectedMoleIds = deduped;
        this.state.syncTime = Date.now();
        // 然后更新redis中的数据
        const newState = JSON.stringify(this.state);
        await this.redis.set(`${this.prefix}:state`, newState);
        // 并向channel中发送状态更新事件
        await this.redis.publish(
          gameOutgoingEventChannel,
          JSON.stringify({
            ...this.state,
            playerId: this.playerId,
            type: "game-state",
          } satisfies OutgoingChannelGameStateMessage),
        );
      }
      
      // 先等打中的地鼠机算完,再判断是否游戏结束了
      if (this.state.isOver) break;

      // 每n秒 发一批地鼠给客户端
      if (
        differenceInMilliseconds(now, this.state.lastTickTime) >=
          this.settings.tickInterval ||
        this.state.appearedMoles.length === 0
      ) {
        const tmpState = structuredClone(this.state);
        {
          const xyPool: [number, number][] = [];
          const deliver = () => {
            // 省略几十行代码,用于生成地鼠坐标等信息
            return { ... }
          };
          // 批次随机数量
          const count =
            Math.floor(
              (this.settings.maxCount - this.settings.minCount) * Math.random(),
            ) + this.settings.minCount;
          const events = Array.from({ length: count }, deliver);
          if (this.stopped || !this.checkGameAlive()) return;
          // 添加到已出现的地鼠列表中,并将新的地鼠传给广播出去
          tmpState.appearedMoles.push(...events);
          await this.redis.publish(
            gameOutgoingEventChannel,
            JSON.stringify({
              events,
              playerId: this.playerId,
              type: "deliver-mole",
            } satisfies OutgoingChannelDeliverMoleMessage),
          );
        }

        // sync state
        tmpState.lastTickTime = now;
        
        // 游戏时长到了,游戏结束
        if (elapsed > this.settings.duration) {
          tmpState.isOver = true;
          tmpState.tips = tips;
          tmpState.percentage = Number(percentage);
        }
        
        // 同步状态到redis,广播游戏状态更新事件
        tmpState.syncTime = Date.now();
        const updatedState = JSON.stringify(tmpState);
        if (this.stopped || !this.checkGameAlive()) return;
        await this.redis.set(`${this.prefix}:state`, updatedState);
        await this.redis.publish(
          gameOutgoingEventChannel,
          JSON.stringify({
            ...tmpState,
            playerId: this.playerId,
            type: "game-state",
          } satisfies OutgoingChannelGameStateMessage),
        );
        this.state = tmpState;
      }

      await sleep(100);
    }

    // 循环退出,游戏结束
    const { collectedMoleIds, appearedMoles, ...state } = this.state;
    await this.stop();
  }

以上就是核心内容了,代码有点乱,时间紧任务重。其实有大量可以优化的地方

部署

本文不提供源代码。通过 pm2 或是 docker 进行部署

pm2 start bun --time -i 4 -- src/server.ts

docker 就得写个 Dockerfile 了

或是直接像这样运行:

bun src/server.ts

bun src/server.ts

bun src/server.ts

哈哈哈

Benchmark

使用 k6 进行测试

脚本如下

import ws from "k6/ws";
import { check } from "k6";
import exec from "k6/execution";

const url = "ws://localhost:3001/game/wam";
const params = {
  tags: { myTag: "myValue" },
};

// eslint-disable-next-line import/no-anonymous-default-export
export default function () {
  let gameover = false;
  ws.connect(
    url + "?tg-init-data=" + exec.scenario.iterationInTest,
    params,
    function (socket) {
      socket.on("open", function () {
        console.log(
          `${exec.scenario.iterationInTest} WebSocket connection opened`,
        );
        socket.send(JSON.stringify({ type: "start" }));
      });

      socket.on("message", function (message) {
        console.log(`${exec.scenario.iterationInTest} Received message`);
        const msg = JSON.parse(message);
        if (msg.type === "game-state" && msg.isOver) {
          gameover = true;
          socket.close();
        }
      });

      socket.on("close", function () {
        check(gameover, { "game over normally": (value) => value === true });
        console.log(
          exec.scenario.iterationInTest,
          "WebSocket connection closed",
        );
      });
    },
  );
}

环境:

MacBook Pro M2 - 24 GB

Bun v1.1.23

本地 Docker 启动 Redis,默认配置

启动一个游戏服务端实例,游戏设置一局游戏30s,每2s投放一批地鼠,逻辑帧间隔100ms

很不幸,我在mac上只支持一个实例,除非我把它跑在docker里。Article Linux only — Windows and macOS ignore the reusePort option. This is an operating system limitation with SO_REUSEPORT, unfortunately.

测试命令: k6 run -u 1000 -d 1s ./benchmark/benchmark.js

image.png

我对k6也不是很熟悉。。不知道它表现的咋样。

总结

总体而言还不错,小游戏在线上正常运行一两周了,用户数目前不多,大概两三万,有多少人同时玩我还真没统计过。

目前 Bun 还不是特别成熟,以上代码在 Node 环境也是能跑的。如果你要在生产环境中用请务必做好准备!

node:cluster 也可以多实例运行,Bun 目前还不支持 node:cluster 但允许 portReuse

做完这个需求也让我学到了很多,我很高兴能在生产环境中把 Bun 跑起来,希望未来能在更多的服务中用上它。也希望更多的人了解 Bun,学习 Bun,共建良好的社区氛围。

相关链接

Bun Roadmap

Bun 功能需求排行