把Pomelo的后端服务器改成无状态的服务器

132 阅读7分钟

背景

我们有一个比较老旧的项目,是使用Pomelo框架来作为基础来实现的, 一直以来,我们对应上线新的业务就需要停服更新这件事情都耿耿于怀, 也一直在思考应该如何改进, 最根本的解决方案是去掉Pomelo这个老旧的框架, 使用HTTP协议替换长连接. 但受限于业务和代码,需要改的实在是太多了, 开发成本和测试的成本的非常高, 所以我们只能把目光转到另外的一些方面

image.png 这是抽象出来的一个架构图, 其中Connector进程是前端服务器用于维持和客户端的连接, 在业务上负责登录 Logic进程是后端服务器,负责除了登录外的其他业务

如何实现无感重启

业务Connector进程主要是负责登录业务,而登录业务变化的频率并不高, 如果我们更新业务的时候不重启Connector进程的话, 玩家是感觉不到重启的, 如果登录业务有变化,我们再退化成重启更新业务也是无可厚非的. 那是不是只重启Logic进程就能实现无感重启呢? 答案是否定的, 因为Logic进程其实是有状态的, 他的状态来自 channelService , 我们可以把channelService理解成是一个关系, 是玩家和前端服务器的关系,如下图

image.png 当我们需要给指定的玩家发送通知(响应在长连接中也是通知)时,需要找到我们玩家的所在的长连接是在哪一个Connector服务器上, 这时候ChannlService就承担了这个关系. 我们看一段Pomelo的源代码

image.png存储都在进程里

image.png 发消息也是从内存中来的

那么如果我们重启了Logic进程,岂不是UID和SID的关系也就没了, 那玩家就收不到通知了, 这个无感重启也就没有意义了

官方推荐 global-channel-service

库的地址: github.com/NetEase/pom…

image.png 文档描述

但我们项目受限于开发时间很长, 如果改成global-channel会有大量的修改和测试工作,所有我们并没有采用这种方式

多组Logic进程

我们想到的第一个方法是多组Logic进程,通过流量切换的方式,让多个版本的Logic进程共存,这样如果我们的Logic进程不下线,那么就不会出现收不到通知的情况.

image.png 我们设想的是, 当流量慢慢的从LogicV1转移到LogicV2后,我们再把V1版本的Logic下线,正常情况下我们的业务基本上是周更的方式, 所有基本上不会出现玩家一周在线的情况(就算有也是极少的,是可以忽略的)

共享channel关系

在上一个解决方案中, 我们只提到了正常的周更的情况, 当然除了周更还会有一些平时的热更, 比如我们发现线上的一个错误, 这时候我们就需要把最新的代码热更到线上去, 这时候就会出现一个问题, 新版本Logic的玩家人还不多又面临着下线老版本Logic, 这会导致一部分玩家收不到通知, 为了保险起见, 一般来说就会选择停服更新的方法,要么就是线上问题修正延迟,要么就停服,损失玩家体验. 为了更多的实现无感重启, 我们想到了第三种解决方案. 共享关系, 类似于官方推荐的global-channel我们可以把关系的存储放到第三方redis里来, 而在合适的时间将redis的关系重新加载到进程里来(例如启动新版本的进程后)

image.png

V1进程把关系不断的同步到Redis来, 当我们V2进程启动后从Redis中拉取最新的关系并放到内存中,这就实现了关系的共享,因为基本的逻辑是没有变化的,所以业务代码不需要修改

当然,什么时候从Redis加载到内存也同样是一个值得考虑的问题, 我这里不仅仅在服务器启动时从Redis加载了一次,另外我在服务器上线提供服务的时候也会从Redis加载一次到内存

下面是实现的具体代码 app.js 实现启动logic进程时加载一个新的RedisChannel的中间件

app.configure('production|development', 'logic', function () {
    var serverId = app.get('serverId');
    var serverInfo = serverId.split("-");
    if (serverInfo && serverInfo.length > 2) {
        var serverNumber = serverInfo[2];
        var keyPrefix = 'logic:channel:'+ serverNumber + ':';
        var generalConfig = GeneralConfigMan.getInstance().getConfig();
        var localRedisConfig = generalConfig.localRedis;
        app.load('redisChannel', require('./app/components/RedisChannel.js'), {
            redis: {
              host: localRedisConfig.host,
              port: localRedisConfig.port,
              db: localRedisConfig.db
            },
            keyPrefix: keyPrefix
        });
    }
});

下面是RedisChannel的具体实现代码,一共两部分

  1. 原始方法的拦截,并把关系放到redis中去
  2. 从redis中拉取数据并存储到内存中去
const IORedis = require('ioredis');
const GeneralConfigMan = require("../../app/common/model/GeneralConfigMan");

module.exports = function(app, opts) {
  return new RedisChannelComponent(app, opts);
};

class RedisChannelComponent {
  constructor(app, opts) {
    this.app = app;
    this.redisConfig = opts.redis || {
      host: '127.0.0.1',
      port: 6379
    };
    this.keyPrefix = opts.keyPrefix || 'pomelo:';
    this.wrappedChannels = new Map();
    this.isRestoring = false;
    this.originalMethods = {};

    //是否刷新过缓存了
    this.isRefreshed = false;
    this.app.set("redisChannel", this);
  }

  /**
   * 组件初始化方法
   */
  start(cb) {
    try {
      this.redis = new IORedis(this.redisConfig);
      this.channelService = this.app.get('channelService');
      
      if (!this.channelService) {
        console.error('channelService not found in app');
        return cb(new Error('channelService not found in app'));
      }
      
      // 先捕获原始方法,再进行包装
      this.captureOriginalMethods();
      this.wrapChannelServiceMethods();
      
      // 从Redis恢复状态
      this.restoreFromRedis()
        .then(() => {
          console.log('Channel state restored from Redis successfully');
          cb();
        })
        .catch(err => {
          console.error('Failed to restore channel state:', err);
          cb(err);
        });
    } catch (err) {
      console.error('Error starting RedisChannelComponent:', err);
      cb(err);
    }
  }

  afterStart(cb) {
    console.log('Redis channel component started successfully');
    cb();
  }

  stop(force, cb) {
    if (this.redis) {
      this.redis.quit().then(() => {
        console.log('Redis connection closed');
        cb();
      }).catch(err => {
        console.error('Error closing Redis connection:', err);
        cb(err);
      });
    } else {
      cb();
    }
  }

  /**
   * 捕获原始方法 - 保存独立的函数引用
   */
  captureOriginalMethods() {
    // 明确捕获函数引用,而不是对象引用
    if (typeof this.channelService.createChannel === 'function') {
      this.originalMethods.createChannel = this.channelService.createChannel;
    } else {
      throw new Error('createChannel method not found or not a function');
    }

    if (typeof this.channelService.destroyChannel === 'function') {
      this.originalMethods.destroyChannel = this.channelService.destroyChannel;
    } else {
      throw new Error('destroyChannel method not found or not a function');
    }

    if (typeof this.channelService.getChannel === 'function') {
      this.originalMethods.getChannel = this.channelService.getChannel;
    } else {
      throw new Error('getChannel method not found or not a function');
    }

    console.log('Original channel service methods captured successfully');
  }

  /**
   * 包装 channelService 的方法
   */
  wrapChannelServiceMethods() {
    // 使用局部变量保存原始方法的引用,避免 this 问题
    const origCreateChannel = this.originalMethods.createChannel;
    const origDestroyChannel = this.originalMethods.destroyChannel;
    const origGetChannel = this.originalMethods.getChannel;
    
    // 保存组件实例的引用,避免闭包中的 this 问题
    const self = this;

    // 替换 createChannel 方法
    this.channelService.createChannel = function(name) {
      // 调用原始方法,保留原始上下文
      const channel = origCreateChannel.call(this, name);
      
      // 如果是恢复过程中,跳过Redis操作
      if (!self.isRestoring) {
        // 异步记录到Redis
        self.redis.sadd(`${self.keyPrefix}channels`, name)
          .then(() => {
            console.log(`Channel created in Redis: ${name}`);
          })
          .catch(err => {
            console.error(`Failed to record channel creation in Redis: ${name}`, err);
          });
      }
      
      // 拦截新创建的channel的方法
      if (channel && !self.wrappedChannels.has(channel)) {
        self.wrapChannelMethods(channel, name);
        self.wrappedChannels.set(channel, true);
      }
      
      return channel;
    };
    
    // 替换 destroyChannel 方法
    this.channelService.destroyChannel = function(name) {
      // 调用原始方法,保留原始上下文
      const result = origDestroyChannel.call(this, name);
      
      if (!self.isRestoring) {
        // 异步从Redis删除
        Promise.all([
          self.redis.del(`${self.keyPrefix}channel:${name}`),
          self.redis.srem(`${self.keyPrefix}channels`, name)
        ])
        .then(() => {
          console.log(`Channel destroyed in Redis: ${name}`);
        })
        .catch(err => {
          console.error(`Failed to remove channel from Redis: ${name}`, err);
        });
      }
      
      return result;
    };
    
    // 替换 getChannel 方法
    this.channelService.getChannel = function(name, create) {
      // 调用原始方法,保留原始上下文
      const channel = origGetChannel.call(this, name, create);
      // 如果 channel 存在且是新创建的
      if (channel) {
        // 这是新创建的 channel,需要同步到 Redis
        self.redis.sismember(`${self.keyPrefix}channels`, name)
        .then(exists => {
          if (!exists) {
            // 只有在 Redis 中不存在时才添加
            return self.redis.sadd(`${self.keyPrefix}channels`, name)
              .then(() => {
                console.log(`Channel added to Redis via getChannel: ${name}`);
              });
          } else {
            console.log(`Channel ${name} already exists in Redis, skipping addition`);
          }
        })
        .catch(err => {
          console.error(`Failed to check/add channel in Redis via getChannel: ${name}`, err);
        });
      }
      if (channel && !self.wrappedChannels.has(channel)) {
        self.wrapChannelMethods(channel, name);
        self.wrappedChannels.set(channel, true);
      }
      return channel;
    };

    console.log('Channel service methods wrapped successfully');
  }

  /**
   * 包装 Channel 对象的方法
   */
  wrapChannelMethods(channel, channelName) {
    // 捕获原始方法的引用
    const originalAdd = channel.add;
    const originalLeave = channel.leave;
    
    // 保存组件实例的引用
    const self = this;
    
    // 替换 add 方法
    channel.add = function(uid, sid) {
      uid = Number(uid);
      if (!self.isRestoring) {
        if (channel.getMember && channel.getMember(uid)) {
          console.log(`User ${uid} already exists in channel ${channelName}, remove`);
          //如果用户已经存在,先移除,再添加
          originalLeave.call(this, uid, sid);
        }
      }
      // 先调用原始方法确保功能完整性
      const result = originalAdd.call(this, uid, sid);
      
      // 如果不是恢复过程,记录到Redis
      if (!self.isRestoring) {
        self.redis.hset(`${self.keyPrefix}channel:${channelName}`, uid, sid)
          .then(() => {
            console.log(`User added to channel in Redis: ${channelName}, ${uid}, ${sid}`);
          })
          .catch(err => {
            console.error(`Failed to add user to channel in Redis: ${channelName}, ${uid}`, err);
          });
      }
      
      return result;
    };
    
    // 替换 leave 方法
    channel.leave = function(uid, sid) {
      uid = Number(uid);
      // 先调用原始方法
      const result = originalLeave.call(this, uid, sid);
      
      // 如果不是恢复过程,从Redis删除
      if (!self.isRestoring) {
        self.redis.hdel(`${self.keyPrefix}channel:${channelName}`, uid)
          .then(() => {
            console.log(`User removed from channel in Redis: ${channelName}, ${uid}`);
          })
          .catch(err => {
            console.error(`Failed to remove user from channel in Redis: ${channelName}, ${uid}`, err);
          });
      }
      
      return result;
    };

    console.log(`Channel ${channelName} methods wrapped successfully`);
  }

  /**
   * 从Redis恢复channel状态
   */
  async restoreFromRedis() {
    try {
      // 设置恢复标记,避免递归操作
      this.isRestoring = true;
      
      // 获取所有通道名称
      const channels = await this.redis.smembers(`${this.keyPrefix}channels`);
      
      console.log(`Found ${channels.length} channels to restore from Redis`);
      
      for (const channelName of channels) {
        // 获取该通道的所有用户数据
        const users = await this.redis.hgetall(`${this.keyPrefix}channel:${channelName}`);
        
        // 使用原始方法创建频道(通过channelService调用)
        const channel = this.originalMethods.createChannel.call(this.channelService, channelName);
        
        // 包装新创建的channel
        if (!this.wrappedChannels.has(channel)) {
          this.wrapChannelMethods(channel, channelName);
          this.wrappedChannels.set(channel, true);
        }
        
        // 恢复用户
        const userCount = Object.keys(users).length;
        if (userCount > 0) {
          console.log(`Restoring channel ${channelName} with ${userCount} users`);
          
          for (let [uid, sid] of Object.entries(users)) {
            uid = Number(uid);
            if(channel.getMember && channel.getMember(uid)){
              console.log(`User ${uid} already exists in channel ${channelName}, remove user`);
              //先退出
              channel.__proto__.leave.call(channel, uid, sid);
            }
            try {
              // 使用原始的add方法添加用户
              const result = channel.__proto__.add.call(channel, uid, sid);
              console.log(`Restored user: ${channelName}, ${uid}, ${sid}`, result ? 'success' : 'failed');
            } catch (err) {
              console.error(`Failed to restore user ${uid} to channel ${channelName}:`, err);
              // 如果原始方法失败,尝试使用当前方法
              try {
                if(channel.getMember && channel.getMember(uid)){
                //console.log(`User ${uid} already exists in channel ${channelName}, skipping add`);
                channel.leave(uid, sid);
              }
                channel.add(uid, sid);
              } catch (err2) {
                console.error(`Also failed with current add method:`, err2);
              }
            }
          }
        } else {
          console.log(`Restored empty channel: ${channelName}`);
        }
      }
      
      // 恢复完成,清除标记
      this.isRestoring = false;
      console.log('Channel state restoration completed');
    } catch (err) {
      this.isRestoring = false;
      console.error('Error restoring from Redis:', err);
      throw err;
    }
  }

  //刷新redis数据
  async refreshRedisData() {
    console.log(this.app.get("isCurrentWorkLogicVersion"));
   if (this.app.get("isCurrentWorkLogicVersion")()) {
    //满足条件,刷新redis数据
    if (!this.isRefreshed) {
      this.isRefreshed = true;
      console.log("刷新redis数据");
      setTimeout(async () => {
        await this.restoreFromRedis();
      }, 0.5 * 1000);
    }
   }
  }
}

这样就实现了数据的共享,并且基本上不会涉及到业务的修改 在这个解决方案中在线的Logic只有一个版本. 当然我觉得最好的方案应该是global-channel的方案,但因为业务改动太大而放弃了.