weken.news
redisupstashline-bot數據分析debug

Redis capped list 存不住使用率歷史:LINE Bot 月度統計改成寫入當下聚合的實作紀錄

直接回答

Redis 的 capped list(LPUSH 加 LTRIM)保留的是「最近 N 筆」不是「最近 N 天」,所以它答得出「剛剛發生什麼」,永遠答不出「上個月有多少人用」。我的 500 筆訊息記錄實際只涵蓋 7 天,1000 筆指令記錄涵蓋 60 天,而且使用量越大可見歷史越短。解法不是調高上限,是在事件發生的當下就累加進月度計數器,一個月 7 個 key,永不裁切。

週末哥 ·

我想知道我的 LINE Bot 每個月有多少人用、總共用了幾次。

打開資料庫查,才發現這個問題我答不出來。

系統裡有五條記錄流,全部是 Redis 的 capped list:LPUSH 寫進去,LTRIM 砍掉超出上限的部分。訊息記錄上限 500 筆、指令記錄 1000 筆、公開動態牆 500 筆、錯誤記錄 200 筆、AI 呼叫記錄 500 筆。

看起來很合理。實際量下去才發現問題。

我對每條 list 取頭尾兩筆比對時間戳:

const len = await cmd(['llen', key]);
const head = await cmd(['lindex', key, 0]);
const tail = await cmd(['lindex', key, String(len - 1)]);

500 筆的訊息記錄,最舊那筆是 7 天前。1000 筆的指令記錄,最舊那筆是 60 天前。

更早的紀錄不是「查起來比較麻煩」,是已經被 Redis 永久刪掉了,撈不回來。

真正讓我改變做法的是這件事:這個窗口會越來越短。

現在每天約 70 則訊息,500 筆等於 7 天。如果使用量翻倍,同一個上限就只剩 3 天半。

Redis capped list 保留的是「最近 N 筆」不是「最近 N 天」。每天 70 則訊息時,500 筆上限等於 7 天;使用量翻倍,同一個上限只剩 3 天半。使用者用得越多,你能看到的歷史越短。

也就是說,這個系統的設計是:產品越成功,數據越看不見。

我第一個反應是把上限調高。想了三秒就否決了。

筆數上限跟時間範圍本來就是兩件事,調高只是延後撞牆的時間,寫入速度一變快又縮回去。而且真的把每一則訊息、每一次 AI 呼叫、每一筆錯誤都永久保存,會做出一座沒人會去翻的垃圾山。

正確的問法不是「怎麼保留更多明細」,是「我到底要保留什麼」。

我把現有資料分成三類:

第一類,會流失而且必須救:訊息量、使用次數、使用人數、功能別分布、最後使用時間。這些要看跨月趨勢。

第二類,會流失但不用救:錯誤記錄、AI 延遲、訊息原文、寄信記錄。這些的用途是「出事的時候回頭查最近幾天」,滾動剛好夠用,永久保存反而讓每次查都要翻垃圾。

第三類,本來就不會流失:我的專案裡剛好有兩個當初就寫對的例子,種子數量跟商品查詢次數,都是分月累加的計數器,從四月到今天一筆沒少。

第三類就是答案。要永久保存的不是原始明細,是算好的數字。

改法是在事件發生的當下就累加,一個月一組 key:

return pipeline([
  ['incr', `usage:count:${month}`],
  ['zincrby', `usage:users:${month}`, 1, uid],
  ['zincrby', `usage:action:${month}`, 1, a],
  ['set', `usage:last:${uid}`, new Date().toISOString()],
  ['sadd', 'usage:months', month],
  ['sadd', `usage:dau:${day}`, uid],
  ['expire', `usage:dau:${day}`, 7776000],
]);

七個指令,Upstash 的 pipeline 端點打包成一次 HTTP 往返。

zset 的成員數就是當月使用人數,不用另外算。日資料設 90 天到期,月資料永久保留,一個月只多 7 個 key,存十年都不會爆。

還有一個小技巧:SADD 在成員原本不存在時回傳 1,已存在時回傳 0。把當日活躍名單的 SADD 放進 pipeline,用它的回傳值判斷「這個人今天第一次出現」,才去累加活躍天數。省掉一次先讀再寫。

const sadd = Array.isArray(res) ? res[5] : null;
if (sadd && sadd.result === 1) {
  return pipeline([['zincrby', `usage:days:${month}`, 1, uid]]);
}

程式碼改動集中在一個函式裡。我的專案已經有一個 logEvent 被 16 個地方呼叫,計數器塞進它內部就一次覆蓋,另外只補 4 個沒走這個函式的分支。

接著是一個看起來很小、其實會決定數字意義的問題:一次使用怎麼算?

我原本的規則是「一則訊息成功觸發功能算 1 次」。使用者看到規則後直接回我:報名流程走五步,只能算一次。

他是對的。查三次庫存是三次獨立查詢,算 3 次沒問題;但報名走五步、上傳丟五張照片再回商品名,那是同一件事被拆成很多則訊息,算成六次就是灌水。

用 Redis 的 SET NX EX 當票夾解掉:

const res = await pipeline([['set', key, '1', 'nx', 'ex', 1800]]);
const isNew = Array.isArray(res) && res[0] && res[0].result === 'OK';
if (!isNew) await pipeline([['expire', key, 1800]]);
return isNew;

key 是「使用者加流程名稱」。第一步拿到票才計次,後面的步驟拿不到票,只把 30 分鐘窗口往後延。使用者中途去忙別的再回來接,也不會被算成兩次。

實測:查三次庫存記 3 次,報名 4 步記 1 次,上傳 4 則訊息記 1 次,多步驟的脆文生成 3 步記 1 次。

多步驟流程用 SET key 1 NX EX 1800 當票夾收斂成一次使用:第一步拿到票才計次,後續步驟只延長 30 分鐘窗口。實測上傳流程 4 則訊息記 1 次、報名流程 4 步記 1 次,單次指令仍維持一則一次。

最後是我做錯的那一段。

計數器上線後,舊資料只剩滾動清單裡那 60 天。我把還在清單裡的當月事件抓出來,依時間順序重播一次,套用完全相同的規則寫進計數器,讓當月變成完整的一個月。

邊界取「最早的一筆即時計數時間」,只重播早於它的事件,確保不會重複計算。這部分是對的。

錯的是資料來源。

儀表板做好之後,使用者看了一眼就說:這個功能次數怎麼這麼多?只有我在用啊。

我回去查原始資料。那個功能在指令記錄裡是 0 筆,一次都沒有。那些次數全部來自公開動態牆,而且是 3 個不同的人寫的。

原因是:那面動態牆是多支 bot 共用的。主要的 bot、另一支在群組自動記錄的 bot、還有第三支工作坊的 bot,全部往同一個 feed 寫。我回填的時候把整份 feed 當成主 bot 的使用紀錄,於是把別支 bot 的 14 筆紀錄算成了使用量。

多支 bot 共用的公開 feed 不等於單一產品的使用紀錄。回填前要確認事件的來源,不是只看事件類型;同一個 type 可能來自三支不同的服務,混進去就是使用量灌水。

修法我沒有整批重寫,因為即時計數已經開始跑了,重寫會蓋掉它。改成用同一份 log、同一個邊界重算「含」與「不含」兩個版本,只把差額反向寫回去。連帶受影響的每人次數、每日活躍名單、活躍天數也一起重算,不是只改總數。

其中兩個細節值得記下來:每日活躍名單只在「新版那天沒有他」而且「他不是上線後才活動」的情況下才移除;最後使用時間只在「目前存的值等於我當初寫進去的值」時才修正。這兩個條件是為了不要蓋掉上線後的真實資料。

修完之後我做了 28 項逐條驗證。

不是掃過去看順不順眼,是把每個數字回去對原始記錄重算:每人次數加總要等於總次數、功能別加總要等於總次數、不做收斂的指令計數要一筆不差等於原始筆數、每人活躍天數要等於他實際出現在幾天的名單裡、不能有 0 或負數。

過程中我的稽核腳本還跳出一個「有 12 個人統計完全看不到」的警報。回去查才發現是我腳本的 key 比對寫錯,另一個服務用 profile:seed: 開頭的命名空間被我一起抓進來了。重查之後兩個方向都是 0 誤差。那是假警報,不是系統問題。

差一點就把它當成發現報出去。

三件事值得帶走。

capped list 跟月度統計是兩件事,筆數上限怎麼調都只是延後,要跨月的數字就必須在寫入的當下聚合。

使用者對自己天天在用的功能是一手事實。他說「這個只有我在用」的時候,正確反應是回去查原始資料,不是先解釋。

還有,共用的 feed 不等於你的使用紀錄。回填之前先問這筆事件是誰寫的。

常見問題

Redis list 設上限 500 筆可以保留多久的資料?
取決於寫入速度,不是天數。我的 LINE Bot 每天約 70 則訊息,500 筆上限實際只涵蓋 7 天;1000 筆的指令記錄涵蓋 60 天。關鍵陷阱是:使用者用得越多,你能看到的歷史越短。這跟直覺相反,也是很多人以為自己有歷史資料、真的要查才發現沒有的原因。
把 LTRIM 的上限調高就能保留歷史嗎?
只是延後,不是解決。筆數上限跟時間範圍本來就是兩件事,寫入速度一變快,涵蓋天數又縮回去。正確做法是把兩個需求拆開:明細維持滾動(用途是出事回頭查最近幾天),統計數字在寫入當下就累加進月度 key(用途是看跨月趨勢)。
月度使用率統計要存哪些 Redis key?
我實際用 7 個:總次數用 INCR、每人次數用 ZINCRBY(zset 成員數就是當月使用人數)、功能別分布用 ZINCRBY、每人活躍天數用 ZINCRBY、當日活躍名單用 SADD(設 90 天到期)、每人最後使用時間用 SET、有哪些月份用 SADD。全部打包成一次 pipeline 請求,一次 HTTP 往返寫完。
多步驟流程(報名、上傳)的使用次數應該怎麼算?
一整趟算一次,不是一步算一次。我用 SET key value NX EX 1800 當票夾:同一個人同一個流程,第一步拿到票才計次,後面的步驟只延長 30 分鐘窗口不再計次。實測上傳流程 4 則訊息記 1 次、報名流程 4 步記 1 次。單次指令則維持一則一次,查三次庫存就是 3 次。
回填歷史使用率數據要注意什麼?
先確認事件的來源,不要只看事件類型。我把公開動態牆整份當成單一產品的使用紀錄回填,結果混進了另一支 bot 在群組自動寫的 14 筆紀錄,使用量直接灌水。回填時也要套用跟即時計數完全相同的規則,否則兩段數字意思不一樣,接起來的趨勢圖是假的。