[date: 2019-03-23 16:16] [visits: 69]

利用Redis实现排队需求

近期在项目中做了一个用户排队等待接入客服的需求,此文记录自己的实现思路与过程,以及一些考虑的异常。

需求

大部分人都有排队等待接入客服的经历,所以需求不难理解:“存在一个在线客服列表,用户发起接入请求时,从客服列表中选择空闲的进行配对接入,如果没有可用客服则用户进入等待队列,每当出现客服空闲时接入等待队列中的第一个用户”。

设计

这部分主要描述自己从需求中理解的实体与关系以及选择它们的存储方式。

实体

从这个需求中分解出来的实体有以下几种:

客服接待用户所处的“空间”称为房间,主要作为客服-用户沟通的载体

所有没能进入房间的用户形成的队列,是一个先来先服务(FIFO)的队列

房间这个实体是虚拟出来的,可以让用户与客服直接产生联系,这样就不需要房间实体。

关系

上述实体之间的关系:

房间与客服绑定,伴随客服存在,客服接待用户时房间处于占用状态

用户被接入时,用户与房间处于暂时绑定关系且与客服产生间接关系,构成关系:客服-房间-用户

没能被接入的用户都处于等待队列中

存储

确定实体与关系后,接着考虑如何存储这些实体与关系,有如下几种选择:

列内存这个选项主要是给小白看,因为曾经在一个项目中见到有人这么做的,所以我认为有必要强调一下选内存方案的致命缺点:

这个场景用Redis做存储,只能用完美来形容

这个场景用MySQL做存储,理论上可行,但实体与关系会频繁进行读写,所以在性能方面会远逊于Redis

这个方案主要是考虑可以用MySQL存储实体之间的历史关系数据(咨询历史、排队历史等),而用户、客服这类实体是系统外部已存在的,所以不在这个系统内考虑它们的存储

思路

目前没保存实体之间的历史关系数据,所以只用到了Redis,首先定义几个存放在Redis的结构:

S2R(servicer-to-room): key-value,客服ID对应的房间ID
R2U(room-to-user): key-value,房间ID对应的用户ID
U2R(user-to-room): key-value,用户ID对应的房间ID
Q: Sorted-Set,用户排队的队列

// 上述的Redis结构在存储时会对key添加前缀,主要为了避免冲突以及方便`keys`命令搜索key

定义这几个结构后,对其进行合理操作即可实现用户接入与排队,下面对操作过程做一个简要描述:

  1. 客服登录系统,为其分配roomId,即在redis中设置一个servicerId对应roomId的key-value,同时会巧用key的ttl做客服掉线处理
  2. 客服轮询自己的房间状态,可以通过S2R、R2U判断房间内是否有用户接入
  3. 用户发起接入请求,将用户放置到匹配队列,同时设置U2R的数据为'',这里同样可以利用key的ttl做用户掉线判断
  4. 用户加入队列后触发一次匹配接入,随后查询用户U2R的数据可得到接入状态
  5. U2R查到的roomId不为空则用户接入成功,进入房间
  6. U2R查到的roomId为空则用户出于排队中,持续轮询直到接入成功

上述过程中的匹配接入步骤:

  1. 利用keys prefix:xxx方式从S2R中寻找在线客服
  2. 使用mget从S2R中获取客服房间
  3. 使用mget从R2U中获取房间用户,挑选一个空闲房间准备接入
  4. 从Q中取用户,设置U2R与R2U完成匹配

在此方案下每次匹配接入需要查询所有客服的状态并挑选出空闲房间,有一定的IO开销且对Redis会造成一定压力,所以要考虑不同匹配接入触发时机会的利弊,自己想到的方案有如下几种:

此方案适用于咨询人数少排队出现频率低的情况,不适合咨询人数多客服少的情景

使用定时器触发匹配的优点是让系统压力分布平均,缺点是定时器的间隔不好把握,太短的话系统压力大,太长的话用户响应时间长(可结合第一种方案优化)

这个方案是一个较优的选择,不会有多余的匹配操作,缺点是客服空闲事件不好捕捉

前面三个方案都是自动接入,这个方案是由客服主动接入,属于产品经理决定的内容

实现

已有的代码实现依赖所处项目环境,就不贴出来了,这里将重要流程用代码表现一下,并且添加一些注释说明。

const redis = {}; // 表示对redis操作的对象
const timeout = 15; // 断线的超时时间,如用户的排队掉线,咨询过程中掉线等

exports.applyRoomByUserId = applyRoomByUserId; // 用户排队请求
exports.markUserAliveById = markUserAliveById; // 用户接入后心跳

exports.assignRoomByServicerId = assignRoomByServicerId; // 给客服分配房间
exports.roomStateByServicerId = roomStateByServicerId; // 客服房间状态
exports.markServicerAliveById = markServicerAliveById; // 客服心跳

// 根据用户id申请房间,即请求接入
function applyRoomByUserId(userId) {
    if (onlineServicerCount() <= 0) {
        return 'no online servicer';
    }

    markUserAliveById(userId);
    enqueue(userId);
    match();

    let roomId = roomIdByUserId(userId);
    if (roomId) {
        return `in room: ${roomId}`; // 用户在房间内
    }

    let index = queueIndexByUserId(userId);
    if (index !== -1) {
        return `in queue: ${index}`; // 在等待队列中
    }

    // 因为这里很多操作都是异步的
    // 在并发情况下,这里有可能出现既不在房间也不队列的情况,按用户在队首处理
    return 'in queue: 0';
}

// 在线客服数量
function onlineServicerCount() {
    return redis.keys('prefix-s2r:*').length;
}

// 标记用户alive,用户排队与咨询过程轮询调用该方法
function markUserAliveById(userId) {
    let u2rKey = `prefix-u2r:${userId}`;
    let r2uKey = `prefix-r2u:${redis.get(u2rKey)}`;

    if (!redis.exists(u2rKey)) {
        // 标记用户存活,等待分配房间
        return redis.set(u2rKey, '');
    }

    // 标记用户存活且在房间中
    redis.expire(u2rKey, timeout);
    redis.expire(r2uKey, timeout);
}

function roomIdByUserId(userId) {
    return redis.get(`prefix-u2r:${userId}`);
}

// 用户在队列中的位置
function queueIndexByUserId(userId) {
    let rank = redis.zrank('prefix-q', userId);
    return rank === null ? -1 : rank;
}

// 加入排队
function enqueue(userId) {
    if (redis.get(`prefix-u2r:${userId}`)) {
        // 用户已经在房间中
        return;
    }

    // 出现重复入队时NX可以避免更新
    return redis.sadd('prefix-q', 'NX', Date.now(), userId);
}

function assignRoomByServicerId(servicerId) {
    let roomId = Math.random().toString(16).substr(2);
    return redis.set(`prefix-s2r:${servicerId}`, roomId, 'EX', timeout);
}

function roomStateByServicerId(servicerId) {
    let roomId = redis.get(`prefix-s2r:${servicerId}`);
    let userId = redis.get(`prefix-r2u:${roomId}`);

    return {
        roomId,
        userId
    };
}

function markServicerAliveById(servicerId) {
    return redis.expire(`prefix-s2r:${servicerId}`);
}

function match() {
    if (redis.zcard('prefix-q') <= 0) {
        // 队列为空
        return;
    }

    let servicerKeys = redis.keys('prefix-s2r:*');
    if (servicerKeys.length === 0) {
        // 没有客服
        return;
    }

    let rooms = redis.mget(...servicerKeys);
    let users = redis.mget(...(rooms.map(item => `prefix-r2u:${item}`)));

    let idleServicerId = ''; // 根据servicerKeys、rooms、users可以筛选出空闲客服

    // 分布式锁
    redis.lockCallWithKey('match:lock', () => { 
        // 根据idleServicerId查询客服相关信息,比如是否在线,房间是否有用户等
        // 从等待队列中取用户
        // 根据u2r的ttl判断用户是否在线
        // 若能达成匹配,则设置ru2、u2r的数据
    });
}

代码中的所有操作为了便于理解都没有体现异步,但实际情况所有的redis操作都是异步,需要考虑执行顺序的可能异常

上述代码match方法中的核心逻辑并不复杂,但由于代码较多故没有一一写出来,这里解释一下为什么要用锁以及锁的scope选择,如果对分布式锁不熟悉,可以参考这篇相关文章

上述场景进行一次匹配的操作涉及到三个redis操作:

由于这三个操作依赖外部参数且它们之间也有参数依赖,所以无法借助redis.multi完成原子操作,因此在这里选择使用分布式锁避免逻辑异常。

锁的scope可以用表锁与行锁做比喻,表与行就是锁的scope,在上述例子中锁的scope是全局,即同时只有一个“人”可以进入到匹配操作内部。

用全局scope的锁会让匹配操作的TPS下降,考虑用idleServicerId做锁的scope行不行?答案是不行,因为两个servicer可能同时争夺一个用户,分析如下:

客服A、B同时进入到匹配操作的内部,并且同时使用zrange获取了队列中的第一个等待用户U,接着用redis.multi执行上述三个操作,最终用户U只会被一个客服接入成功

看起来是可以的,当为什么答案是不行呢?因为客服A、B接入产生了覆盖,比如客服A接入了用户U,正在沟通中,这时客服B的接入会导致A-U之间的连接被莫名中断

如果要使用idleServicerId作为锁的scope,可以选择使用zpopmin命令(redis-5.0)替换zrange命令,这样不同客服不会争夺同一个用户,缺点是重启可能导致队列用户丢失,这个缺点大部分情况是可接受的。

优化

以自己的能力,觉得优化可以从以下几点下功夫:

参考前面关于match触发时机的分析

比如使用keys命令搜索在线客服这种,redis中key很多的情况下性能好不到哪去

lua脚本的内容如下:

local u2rPrefix = ARGV[1]
local r2uPrefix = ARGV[2]
local s2rPrefix = ARGV[3]
local qPrefix = ARGV[4]
local servicerId = ARGV[5]

if redis.call("zcard", qPrefix) == 0 then
    return nil
end

local roomId = redis.call("get", s2rKey..servicerId)
if roomId == nil or redis.call("get", r2uPrefix..roomId) != nil then
    return nil
end

local userId = redis.call("zrange", qPrefix, 0, 0)

redis.call("set", r2uPrefix..roomId, userId, 'EX', 15)
redis.call("set", u2rPrefix..userId, roomId, 'EX', 15)
redis.call("zrem", qPrefix, userId)

return "ok"

Have good day, bye~