利用Redis實現排隊需求
近期在項目中做了一個用戶排隊等待接入客服的需求,此文記錄自己的實現思路與過程,以及一些考慮的異常。
需求
大部分人都有排隊等待接入客服的經歷,所以需求不難理解:“存在一個在線客服列表,用戶發起接入請求時,從客服列表中選擇空閒的進行配對接入,如果沒有可用客服則用戶進入等待隊列,每當出現客服空閒時接入等待隊列中的第一個用戶”。
設計
這部分主要描述自己從需求中理解的實體與關係以及選擇它們的存儲方式。
實體
從這個需求中分解出來的實體有以下幾種:
- 客服
- 用戶
- 房間
客服接待用戶所處的“空間”稱爲房間,主要作爲客服-用戶溝通的載體
- 等待隊列
所有沒能進入房間的用戶形成的隊列,是一個先來先服務(FIFO)的隊列
房間這個實體是虛擬出來的,可以讓用戶與客服直接產生聯繫,這樣就不需要房間實體。
關係
上述實體之間的關係:
- 客服-房間
房間與客服綁定,伴隨客服存在,客服接待用戶時房間處於佔用狀態
- 用戶-房間
用戶被接入時,用戶與房間處於暫時綁定關係且與客服產生間接關係,構成關係:
客服-房間-用戶
- 用戶-等待隊列
沒能被接入的用戶都處於等待隊列中
存儲
確定實體與關係後,接着考慮如何存儲這些實體與關係,有如下幾種選擇:
- 內存
列內存這個選項主要是給小白看,因爲曾經在一個項目中見到有人這麼做的,所以我認爲有必要強調一下選內存方案的致命缺點:
- 無法水平擴展,在Node.js中意味只能啓一個進程,即一個線程
- 應用重啓會導致實體與關係丟失
- Redis
這個場景用Redis做存儲,只能用完美來形容
- MySQL
這個場景用MySQL做存儲,理論上可行,但實體與關係會頻繁進行讀寫,所以在性能方面會遠遜於Redis
- Redis + MySQL
這個方案主要是考慮可以用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
定義這幾個結構後,對其進行合理操作即可實現用戶接入與排隊,下面對操作過程做一個簡要描述:
- 客服登錄系統,爲其分配roomId,即在redis中設置一個servicerId對應roomId的
key-value
,同時會巧用key的ttl做客服掉線處理 - 客服輪詢自己的房間狀態,可以通過S2R、R2U判斷房間內是否有用戶接入
- 用戶發起接入請求,將用戶放置到匹配隊列,同時設置U2R的數據爲
''
,這裏同樣可以利用key的ttl做用戶掉線判斷 - 用戶加入隊列後觸發一次匹配接入,隨後查詢用戶U2R的數據可得到接入狀態
- U2R查到的roomId不爲空則用戶接入成功,進入房間
- U2R查到的roomId爲空則用戶出於排隊中,持續輪詢直到接入成功
上述過程中的匹配接入步驟:
- 利用
keys prefix:xxx
方式從S2R中尋找在線客服- 使用
mget
從S2R中獲取客服房間- 使用
mget
從R2U中獲取房間用戶,挑選一個空閒房間準備接入- 從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操作:
- 從隊列取用戶,Sorted-Set的zrem操作
- 設置u2r的數據,set操作
- 設置r2u的數據,set操作
由於這三個操作依賴外部參數且它們之間也有參數依賴,所以無法藉助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觸發時機優化
參考前面關於match觸發時機的分析
- 優化數據結構,減少IO頻率
比如使用
keys
命令搜索在線客服這種,redis中key很多的情況下性能好不到哪去
- 使用lua腳本替換分佈式鎖
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"