標籤:需要 exp 情況 _id 處理 組件 分頁 事務 fse
問題描述
需要根據某類資料在動態時間段內的統計值對這些資料進行排名。例如按過去24小時內點贊數排名的文章,每隔一小時計算一次結果。以下描述均針對這個例子展開。
解決思路
針對這種問題,我的第一反應是直接通過 mysql
一張資料表記錄所有資料的每一條統計值改變的行為,例如記下每個文章在哪個時間點被誰點贊。排序結果直接通過 select + where + order_by + limit
。簡單粗暴,但效率低下,擴充性差,而且當資料量很多時,會導致資料庫查詢效率低下。
那麼為了提高效率, mysql
換成 redis
如何?將這些統計值改變的記錄移到 redis
的 zset
中,為每個文章建立一條 zset
, zset
的每一條 member
代表一條點贊記錄, field
為文章id, score
記錄點贊時間。利用 zcount
查詢每個文章有效時間段內的點贊總數。 redis
查詢也快,看起來可行,但當文章基數和點贊數很大時, zset
中成員量暴增,而且對於到期資料怎麼處理?而且把點贊量換成點擊量呢?就要記錄每個使用者對每篇文章的點擊行為了,這個方案是否合理?
仔細想一下這個問題,其實我們只需要關注文章的點贊數量,而不是每條點贊行為。因此可以考慮利用 redis
記錄文章在每個時間點內的點贊數量,並且定期刪掉到期的資料,例如記錄每條文章每個小時內的點贊數。這樣就可以知道過去24小時內這個文章的點贊總數了。具體的方案下面將展開。
資料結構
需要兩個 redis
資料結構,分別存放
過程
和
結果
,因為要定期到期一些資料,所以要通過 過程
記錄資料統計值的記錄,同時 結果
記錄所有有效 過程
內的總體排序結果。
例如目前時間9:00,就需要知道當前9點到昨天9點的所有點贊數量,也就不需要昨天8點的點贊數了,所以結果裡面需要加上今天9點的資料,減去昨天8點的資料。而隨著時間往後推移,昨天9點到今天9點的資料也會依次被減掉,所以需要記錄有效時間段內每個時間點的資料,這就是過程的作用了。結果的作用顯而易見了,記錄結果並支援排序。
過程利用 hash
結構存放在有效期間內每個時間節點的所有文章的統計值,例如建立: last_1, last_2, last_3 ……
等24個 key
(因為是統計過去24小時內),每個 hash key
內記錄該節點到下個節點的時間段內被點贊的文章( field
)及其點贊數( value
) 為什麼採用 hash
結構而不是 list
或 set
,因為在定時更新統計資料過程中,需要先擷取當前最新,再加一或減一, hash
的 hincrby
方法支援原子操作,可以在一個事務內完成這兩個操作。試想在 list
結構下,對同一個文章的多個並發點贊,可能導致資料錯誤。
結果利用 zset
結構存放所有文章的統計總數,也就是排序結果。包括所有文章( member
)及其統計總數( value
) zset
支援記錄 score
以及按 score
排序,也可以支援分頁擷取資料。
想法實現
基於以上過程和結果的redis結構,需要處理統計資料的更新,以及排序結果的定時更新兩個關鍵流程。
以時間點為單位記錄統計資料,當有更新時,只需要更新最近的時間點。而且要及時處理到期時間點。 關於到期時間點的清理時機的選擇,想過兩種方式,寫一個定時器定時清理,或找准已有的請求時機觸發清理。定時清理直觀準確,如果項目中本來就有定時器組件,當然可以利用起來。我選擇第二種方式,在統計資料更新時,如果要新增時間點了,就清理掉到期時間點。
排序結果只通過一個 zset
結構記錄,注意及時清理 score
為0的資料。
from datetime import datetime, timedelta
import redis
TIME_SLOT = ‘slot_{name}_{timestamp}‘ # hash,時間點,到前一個時間點的時間段內,所有資料的變化值
STATS_RESULTS = ‘stats_{name}‘ # 統計結果,周期計算的統計值排序結果
LAST_SLOT = ‘last_slot_{name}‘ # 記錄最新的時間節點
redis_client = redis.Redis(host=‘127.0.0.1‘, port=6380)
class DyStats(object):
def __init__(self, stats_name, period, interval):
"""
:param stats_name: 被統計值名稱, 必須唯一
:param period: 統計參數的有效期間, 比如7天, 24小時
:param interval: 定時計算的周期, 如每隔一小時,每隔一天計算一次
"""
assert interval in [1, 2, 3, 4, 6, 8, 12] or (interval >= 24 and interval % 24 == 0)
assert period >= interval and period % interval == 0
self.stats_name = stats_name
self.period = period # 以小時為單位
self.interval = interval # 以小時為單位
def incr_stats(self, target_id, amount=1):
"""
統計數量加一
"""
last_slot = self._last_slot
redis_client.hincrby(TIME_SLOT.format(name=self.stats_name, timestamp=last_slot), target_id, amount)
redis_client.zincrby(STATS_RESULTS.format(name=self.stats_name), target_id, amount)
def get_stats_list(self, offset, limit, withscores=False):
"""
擷取排名結果
"""
if withscores:
return [(int(i), s) for i, s in redis_client.zrevrange(STATS_RESULTS.format(name=self.stats_name),
offset, offset + limit, withscores)]
else:
return [int(i) for i in redis_client.zrevrange(STATS_RESULTS.format(name=self.stats_name),
offset, offset + limit, withscores)]
def remove_all_expired_slots(self):
"""
刪除所有的到期節點
"""
slot_keys = redis_client.keys(TIME_SLOT[:11].format(name=self.stats_name) + ‘_*‘)
now = datetime.now()
for key in slot_keys:
key = int(key.decode()[(6 + len(self.stats_name)):])
if key < (datetime(year=now.year, month=now.month, day=now.day, hour=now.hour) -
timedelta(hours=self.period + self.interval)).timestamp():
self.remove_expired_slot(key)
def remove_expired_slot(self, timestamp):
"""
移除到期的時間節點
"""
# 記錄下要刪除的節點中所有的統計值
slot_values = redis_client.hgetall(TIME_SLOT.format(name=self.stats_name, timestamp=timestamp))
# 刪除到期節點
deleted = redis_client.delete(TIME_SLOT.format(name=self.stats_name, timestamp=timestamp))
# 減去統計結果中的到期值
if deleted:
for key in slot_values.keys():
value = redis_client.zincrby(STATS_RESULTS.format(name=self.stats_name),
key.decode(), -int(slot_values[key].decode()))
# 刪除統計結果中score為0的成員,這裡可能出現incr操作,導致value>0,可能造成統計資料不準
# zremrangebyscore貌似能解決問題,但需要遍曆zset中所有member,代價太大
# 考慮到排序問題對資料嚴格準確性要求不高,可以容忍
if value <= 0:
redis_client.zrem(STATS_RESULTS.format(name=self.stats_name), key.decode())
@property
def _last_slot(self):
"""
最新slot
"""
last_slot = redis_client.get(LAST_SLOT.format(name=self.stats_name))
last_slot = int(last_slot.decode()) if last_slot is not None else None
if last_slot is None:
last_slot = self._set_first_slot()
# last_slot一到期就刪除所有已到期的slot
if datetime.fromtimestamp(last_slot) + timedelta(hours=self.interval) <= datetime.now():
self.remove_all_expired_slots()
# 設定最新時間槽
while datetime.fromtimestamp(last_slot) + timedelta(hours=self.interval) <= datetime.now():
last_slot = (datetime.fromtimestamp(last_slot) + timedelta(hours=self.interval)).timestamp()
redis_client.set(LAST_SLOT.format(name=self.stats_name), int(last_slot))
return int(last_slot)
def _set_first_slot(self):
"""
設定初始slot 的timestamp
"""
now = datetime.now()
first_slot = datetime(year=now.year, month=now.month, day=now.day, hour=now.hour).timestamp()
redis_client.set(LAST_SLOT.format(name=self.stats_name), int(first_slot))
return int(first_slot)
總結
因為對redis還不是特別熟悉,最近也在看《redis實戰》,感覺還沒有完全利用好redis特性,本文的解決方案後續也許還有很多可以最佳化的點。 例如 remove_expired_slot()
方法中,刪除節點時,如何處理並發情況下統計資料一致性的問題,是否可以通過 zremrangebyscore
或者事務解決?能否利用其它更簡潔結構處理這類問題? 算是自己在redis學習過程中一篇小小的記錄!
用redis實現動態時間段內統計排序