背景
我们有一个比较老旧的项目,是使用Pomelo框架来作为基础来实现的, 一直以来,我们对应上线新的业务就需要停服更新这件事情都耿耿于怀, 也一直在思考应该如何改进, 最根本的解决方案是去掉Pomelo这个老旧的框架, 使用HTTP协议替换长连接. 但受限于业务和代码,需要改的实在是太多了, 开发成本和测试的成本的非常高, 所以我们只能把目光转到另外的一些方面
这是抽象出来的一个架构图, 其中Connector进程是前端服务器用于维持和客户端的连接, 在业务上负责登录
Logic进程是后端服务器,负责除了登录外的其他业务
如何实现无感重启
业务Connector进程主要是负责登录业务,而登录业务变化的频率并不高, 如果我们更新业务的时候不重启Connector进程的话, 玩家是感觉不到重启的, 如果登录业务有变化,我们再退化成重启更新业务也是无可厚非的.
那是不是只重启Logic进程就能实现无感重启呢? 答案是否定的, 因为Logic进程其实是有状态的, 他的状态来自 channelService , 我们可以把channelService理解成是一个关系, 是玩家和前端服务器的关系,如下图
当我们需要给指定的玩家发送通知(响应在长连接中也是通知)时,需要找到我们玩家的所在的长连接是在哪一个Connector服务器上, 这时候
ChannlService就承担了这个关系.
我们看一段Pomelo的源代码
存储都在进程里
发消息也是从内存中来的
那么如果我们重启了Logic进程,岂不是UID和SID的关系也就没了, 那玩家就收不到通知了, 这个无感重启也就没有意义了
官方推荐 global-channel-service
库的地址: github.com/NetEase/pom…
文档描述
但我们项目受限于开发时间很长, 如果改成global-channel会有大量的修改和测试工作,所有我们并没有采用这种方式
多组Logic进程
我们想到的第一个方法是多组Logic进程,通过流量切换的方式,让多个版本的Logic进程共存,这样如果我们的Logic进程不下线,那么就不会出现收不到通知的情况.
我们设想的是, 当流量慢慢的从LogicV1转移到LogicV2后,我们再把V1版本的Logic下线,正常情况下我们的业务基本上是周更的方式, 所有基本上不会出现玩家一周在线的情况(就算有也是极少的,是可以忽略的)
共享channel关系
在上一个解决方案中, 我们只提到了正常的周更的情况, 当然除了周更还会有一些平时的热更, 比如我们发现线上的一个错误, 这时候我们就需要把最新的代码热更到线上去, 这时候就会出现一个问题, 新版本Logic的玩家人还不多又面临着下线老版本Logic, 这会导致一部分玩家收不到通知, 为了保险起见, 一般来说就会选择停服更新的方法,要么就是线上问题修正延迟,要么就停服,损失玩家体验. 为了更多的实现无感重启, 我们想到了第三种解决方案.
共享关系, 类似于官方推荐的global-channel我们可以把关系的存储放到第三方redis里来, 而在合适的时间将redis的关系重新加载到进程里来(例如启动新版本的进程后)
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的具体实现代码,一共两部分
- 原始方法的拦截,并把关系放到redis中去
- 从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的方案,但因为业务改动太大而放弃了.