ioredis Sentinel 实现就近访问

跨机房 redis 访问性能堪忧

线上服务运行结果显示跨机房相对不跨机房,一个 redis 长连接上的 QPS 会低一个数量级: 50 vs 640 。这是因为 Redis 的请求-响应是串行的,网络时延会对 QPS 造成巨大的响应。因此,一定要尽量连接距离更近的 Redis 实例。

ioredis 支持按角色(Role)进行连接

引用自 ioredis

ioredis 保证即使故障转移(failover)后你连接的结点依然是 master 。当故障转移发生,ioredis 会向 sentinels 询问新的 master 结点并连接,而不是尝试重连失效的结点(恢复可用后它会降级为 slave)。故障转移期间发送的所有命令将放入队列,当新连接建立后再执行,不会丢失命令。

可以指定 role 选项为 slave 以连接 slave 结点,ioredis 将尝试连接指定 master 的一个随机 slave 结点,并且保证连接的结点总是 slave 角色。当连接的结点因为故障转移而提升为 master,ioredis 将从该结点断开连接并询问 sentinels 获取另一个 slave 结点进行连接。

ioredis guarantees that the node you connected to is always a master even after a failover. When a failover happens, instead of trying to reconnect to the failed node (which will be demoted to slave when it's available again), ioredis will ask sentinels for the new master node and connect to it. All commands sent during the failover are queued and will be executed when the new connection is established so that none of the commands will be lost.

It's possible to connect to a slave instead of a master by specifying the option role with the value of slave, and ioredis will try to connect to a random slave of the specified master, with the guarantee that the connected node is always a slave. If the current node is promoted to master due to a failover, ioredis will disconnect from it and ask the sentinels for another slave node to connect to.

ioredis 会随机选择一个 Slave

引用自 ioredis/lib/connectors/sentinel_connector.js

SentinelConnector.prototype.resolveSlave = function (client, callback) {
  client.sentinel('slaves', this.options.name, function (err, result) {
    client.disconnect();
    if (err) {
      return callback(err);
    }
    var selectedSlave;
    if (Array.isArray(result)) {
      var availableSlaves = [];
      for (var i = 0; i < result.length; ++i) {
        var slave = utils.packObject(result[i]);
        if (slave.flags && !slave.flags.match(/(disconnected|s_down|o_down)/)) {
          availableSlaves.push(slave);
        }
      }
      selectedSlave = _.sample(availableSlaves);
    }
    callback(null, selectedSlave ? { host: selectedSlave.ip, port: selectedSlave.port } : null);
  });
};

判断本地还是异地的算法

按 IP 地址进行推断,前 N 段一样则认为是本地。

如 N 取值为 3,本机 IP 为 11.22.33.1,则 11.22.33.123 由于前 3 段(11.22.33)与本机相同被认定为是本地,而 11.22.99.1 由于前 3 段(11.22.99)与本机不同被认定为是异地。

redis 至少要求 2.8.12

redis 2.8.12 实现了一个特性:当 failover (redis 角色变化)后,断开所有 client 的连接。

缺少这个特性会导致 failover 发生后与原 master 连接还是保持的,后继请求返回 READONLY 错误。

可以设置 reconnectOnError 选项,判断错误类型为 READONLY 后触发重连。

但 PUB/SUB 不会出现 READONLY 错误,所以还是要升级到 2.8.12 以上。

在 <2.4.0 的 ioredis 上实现优先选择本地 Slave

相关讨论

rfc - a preferred slave list in a sentinel setup · Issue #38 · luin/ioredis

preferredSlaves 选项已经在 2.4.0 版实现,下面的代码在旧版本 ioredis 的基础上实现,仅供参考,不建议使用。

主要的通过替换 SentinelConnector.prototype.resolveSlave, SentinelConnector.prototype.resolveMaster, SentinelConnector.prototype.check 实现:

  • 从到 sentinel 的连接上取得本机地址
  • 从 sentinel 取出 slaves 列表
  • 将 slaves 列表分为本地列表和异地列表
  • 优先从本地列表随机选择一个 slave

具体实现如下

var Redis     = require('ioredis'),
    utils     = require('ioredis/lib/utils'),
    _         = require('lodash'),
    net       = require('net'),
    assert    = require('assert');


/**
 * A SentinelConnector.prototype.resolveSlave replacement, prefer local slave.
 *
 * @param client redis client.
 * @param callback function (err, slave) called when done.
 *                 slave with a extra boolean field "local_node" to indicate slave is in local network.
 *
 */
function resolveSlavePreferLocal (client, callback) {
    var self = this;
    client.sentinel('slaves', this.options.name, function (err, result) {
        if (err) {
            client.disconnect();
            return callback(err);
        }
        var localIP = client.stream.localAddress;
        client.disconnect();

        var localIPSegments = new Array(4);
        if (net.isIPv4(localIP)) {
            localIPSegments = localIP.split('.');
        }

        var selectedSlave;
        var local_node = false;
        if (Array.isArray(result)) {
            var localSlaves = [];
            var remoteSlaves = [];
            for (var i = 0; i < result.length; ++i) {
                var slave = utils.packObject(result[i]);
                if (slave.flags && !slave.flags.match(/(disconnected|s_down|o_down)/)) {
                    if (net.isIPv4(slave.ip)) {
                        var slaveIpSegments = slave.ip.split('.');
                        if (localIPSegments[0] === slaveIpSegments[0] &&
                            localIPSegments[1] === slaveIpSegments[1] &&
                            localIPSegments[2] === slaveIpSegments[2]) {
                            localSlaves.push(slave);
                            continue;
                        }
                    }

                    remoteSlaves.push(slave);
                }
            }
            selectedSlave = _.sample(localSlaves.length ? localSlaves : remoteSlaves);
            local_node = Boolean(localSlaves.length);
        }

        console.warn('redis(' + JSON.stringify({name: self.options.name, db: self.options.db, sentinels: self.options.sentinels}) + ') resolve slave to' + (local_node ? ' local' : '') + ': ' + selectedSlave.ip + ':' + selectedSlave.port);

        callback(null, selectedSlave ? { host: selectedSlave.ip, port: selectedSlave.port, local_node: local_node } : null);
    });
};

/**
 * Prefer connect to local slave.
 *
 * @param client redis client.
 *
 * @return is this change successful.
 */
function preferLocalSlave(client) {
    if (client.options.role === 'slave' && client.connector.resolveSlave) {
        if (client.options.lazyConnect && client.status == 'wait') {
            client.connector.resolveSlave = resolveSlavePreferLocal;
            return true;
        }

        console.warn('redis client(' + JSON.stringify({name: client.options.name, db: client.options.db, sentinels: client.options.sentinels}) + ') status unexpected');
    }

    return false;
}

用法如下

var options = {name: "data", sentinels: sentinels, db: 0, role: "slave", lazyConnect: true}
var client = new Redis(options);
if (preferLocalSlave(client)) {
    console.warn("prefer local slave on redis sentinel(" + JSON.stringify(options) + ")");
}

值得注意的是必须指定 lazyConnect: true ,这样才能通过替换 client 中的方法实现功能。

在 <2.4.0 的 ioredis 上实现优先选择本地结点

假设 redis 是以 1 Master + 1 Slave 方式进行跨机房部署,那么我们希望实现优先连接本地结点(忽略其角色),连接成功后该连接可能是 Master 也可能是 Slave,我们把它当 Slave 用准没错。

preferredSlaves 选项已经在 2.4.0 版实现,下面的代码依赖之前的 ioredis 版本代码,仅供参考,不建议使用。

实现逻辑:

  • 按上一节的实现获取 slave
  • 如果 slave 在本地,则使用该 slave,否则尝试连接 master
  • 如果 master 在本地,则使用该 master,否则使用前面的 slave。

具体实现如下

/**
 * A SentinelConnector.prototype.resolveMaster replacement, indicate the resolved node is local node.
 *
 * @param client redis client.
 * @param callback function (err, master) called when done.
 *                 master with a extra boolean field "local_node" to indicate master is in local network.
 *
 */
function resolveMasterPreferLocal (client, callback) {
    client.sentinel('get-master-addr-by-name', this.options.name, function (err, result) {
        if (err) {
            client.disconnect();
            return callback(err);
        }

        var localIP = client.stream.localAddress;
        client.disconnect();

        var localIPSegments = new Array(4);
        if (net.isIPv4(localIP)) {
            localIPSegments = localIP.split('.');
        }

        var local_node = false;
        if (Array.isArray(result)) {
            var ip = result[0];
            if (net.isIPv4(ip)) {
                var ipSegments = ip.split('.');
                if (localIPSegments[0] === ipSegments[0] &&
                    localIPSegments[1] === ipSegments[1] &&
                    localIPSegments[2] === ipSegments[2]) {
                    local_node = true;
                }
            }
        }

        callback(null, Array.isArray(result) ? { host: result[0], port: result[1], local_node: local_node } : null);
    });
};

/**
 * A SentinelConnector.prototype.resolve replacement, prefer resolve to local node.
 *
 * @param endpoint sentinel endpoint to connect.
 * @param callback function (err, node) called when done.
 *                 node with a extra boolean field "local_node" to indicate node is in local network.
 *
 */
function resolvePreferLocal(endpoint, callback) {
    assert(this.options.role === 'slave');

    var client = new Redis({
        port: endpoint.port,
        host: endpoint.host,
        retryStrategy: null,
        enableReadyCheck: false,
        connectTimeout: this.options.connectTimeout
    });

    var self = this;
    this.resolveSlave(client, function (slave_err, slave) {
        if (slave_err || !slave ||!slave.local_node) {
            if (slave_err) {
                console.error('redis(' + JSON.stringify({name: self.options.name, db: self.options.db, sentinels: self.options.sentinels}) + ') resolve slave error(' + slave_err.toString() + ')');
            }
            client = new Redis({
                port: endpoint.port,
                host: endpoint.host,
                retryStrategy: null,
                enableReadyCheck: false,
                connectTimeout: self.options.connectTimeout
            });
            return self.resolveMaster(client, function (master_err, master) {
                if (master_err) {
                    console.error('redis(' + JSON.stringify({name: self.options.name, db: self.options.db, sentinels: self.options.sentinels}) + ') resolve master error(' + master_err.toString() + ')');
                }
                if (!master_err && master && master.local_node) {
                    console.warn('redis(' + JSON.stringify({name: self.options.name, db: self.options.db, sentinels: self.options.sentinels}) + ') resolve slave to local master: ' + master.host + ':' + master.port);
                    return callback(null, master);
                } else if (slave || master) {
                    return callback(null, slave || master);
                }

                return callback(slave_err || master_err, null);
            });
        } else {
            return callback(null, slave);
        }
    });
}

/**
 * A SentinelConnector.prototype.check replacement, enable connect local master when connect to slave.
 */
function checkPreferLocal(info) {
    return true;
}

/**
 * Prefer connect to local redis node, slave first.
 *
 * @param client redis client.
 *
 * @return is this change successful.
 */
function preferLocal(client) {
    if (client.options.role === 'slave' && client.connector.resolveSlave) {
        if (client.options.lazyConnect && client.status == 'wait') {
            if (client.connector.resolveSlave != resolveSlavePreferLocal) {
                preferLocalSlave(client);
            }

            client.connector.resolveMaster = resolveMasterPreferLocal;
            client.connector.resolve = resolvePreferLocal;
            client.connector.check = checkPreferLocal;
            return true;
        }

        console.warn('redis client(' + JSON.stringify({name: client.options.name, db: client.options.db, sentinels: client.options.sentinels}) + ') status unexpected');
    }

    return false;
}

用法如下

var options = {name: "data", sentinels: sentinels, db: 0, role: "slave", lazyConnect: true}
var client = new Redis(options);
if (preferLocal(client)) {
    console.warn("prefer local on redis sentinel(" + JSON.stringify(options) + ")");
}

在 2.4.x 的 ioredis 上实现优先选择本地结点(使用 preferredSlaves)

具体实现以及用法请参考 gist Connect redis with Minimum Distance First(MDF) algorithm ,使用 preferredSlaves 选项实现,要求 ioredis 版本至少为 2.4.0 。在 ioredis 上做了一层封装,使用 ioredis 的方式需要改变,没有侵入 ioredis 的代码。

实现逻辑如下:

  • 使用 preferredSlaves 优先连接本地 slave
  • 如果 slave 在本地,则使用该 slave 连接;否则尝试连接 master
  • 如果 master 在本地,则使用该 master 连接,否则使用前面的 slave 连接。

这导致当本地无 slave 而连上本地 master 后,总是重连 master。

在 2.4.x 的 ioredis 上实现优先选择本地结点(不使用 preferredSlaves)

具体实现以及用法请参考 github 仓库 https://github.com/tangxinfa/ioredis_sentinel_connector ,要求 ioredis 版本为 2.4.x。

通过替换 SentinelConnector.prototype.resolveSlave 及 SentinelConnector.prototype.check 方法实现,主要的实现逻辑:

  • 优先连接本地 slave
  • 如果 slave 在本地,则使用该 slave 连接;否则尝试连接 master
  • 如果 master 在本地,则使用该 master 连接,否则使用前面的 slave 连接。