巧用 Redis Hyperloglog,輕鬆統計 UV 數據

原文首發於微信公衆號「 Doocs開源社區」,一個助力開發者成長的公衆號。

若是你正在開發一個基於「事件」的應用程序,該應用程序能夠處理來自不一樣用戶的許多請求,那麼你很大可能但願可以計算滑動窗口或指定時間範圍內不一樣的用戶操做。java

計數不一樣用戶行爲的最快方法之一是寫一個相似 SELECT COUNT(DISTINCT user) 的 SQL。可是,若是實時數據的量達到了上百萬條,這可能會很昂貴。你可能會想到另外一種方法,就是將用戶保存在一個 Redis set 集合中,由於 set 自然具有去重的功能。redis

可是,這種解決方案也帶來了它固有的問題。若是一個統計不一樣用戶記錄的應用程序運行有多個實例,那麼咱們須要具備巨大 RAM 大小的內存緩存解決方案。若是要處理 1000 萬個不一樣的記錄,每一個記錄分配 10 字節,那麼僅在一個時間範圍內咱們就至少須要 100MB 的內存。所以,這不是內存有效的解決方案。apache

在本文中,我想向你展現如何經過在 Redis Cache 服務器中分配少於 2MB 的內存來處理一百萬個不一樣的用戶記錄。緩存

咱們都知道,Redis 有好幾種數據結構,好比:String、BitMap、Set、Sorted Set 等。在這裏我想特別強調一下Hyperloglog,由於它最適合經過減小內存消耗來統計不一樣的用戶操做。bash

redis-data

Hyper LogLog

Hyper LogLog 計數器的名稱是具備自描述性的。 你能夠僅僅使用loglog(Nmax)+ O(1)位來估計基數爲 Nmax 的集合的基數。服務器

Redis Hyperloglog 操做

要進行 Redis Hyperloglog 的操做,咱們可使用如下三個命令:微信

  • PFADD
  • PFCOUNT
  • PFMERGE

咱們用一個實際的例子來解釋這些命令。好比,有這麼個場景,用戶登陸到系統,咱們須要在一小時內統計不一樣的用戶。 所以,咱們須要一個 key,例如 USER:LOGIN:2019092818。 換句話說,咱們要統計在 2019 年 09 月 28 日下午 18 點至 19 點之間發生用戶登陸操做的非重複用戶數。對於未來的時間,咱們也須要使用對應的 key 進行表示,好比 2019111100、201911110一、2019111102 等。數據結構

咱們假設,用戶 A、B、C、D、E 和 F 在下午 18 點至 19 點之間登陸了系統。url

127.0.0.1:6379> pfadd USER:LOGIN:2019092818 A
(integer) 1
127.0.0.1:6379> pfadd USER:LOGIN:2019092818 B C D E F
(integer) 1
127.0.0.1:6379>

當進行計數時,你會獲得預期的 6。spa

127.0.0.1:6379> pfcount USER:LOGIN:2019092818
(integer) 6

若是 A 和 B 在這個時間內屢次登陸系統,你也將獲得相同的結果,由於咱們僅保留不一樣的用戶。

127.0.0.1:6379> pfadd USER:LOGIN:2019092818 A B
(integer) 0
127.0.0.1:6379> pfcount USER:LOGIN:2019092818
(integer) 6

若是用戶 A~F 和另一個其餘用戶 G 在下午 19 點至下午 20 點之間登陸系統:

127.0.0.1:6379> pfadd USER:LOGIN:2019092819 A B C D E F G
(integer) 1
127.0.0.1:6379> pfcount USER:LOGIN:2019092819
(integer) 7

如今,咱們有兩個鍵 USER:LOGIN:2019092818 和 USER:LOGIN:2019092819,若是咱們想知道在 18 點到 20 點(2 小時)之間有多少不一樣的用戶登陸到系統中,咱們能夠直接使用pfcount命令對兩個鍵進行合併計數:

127.0.0.1:6379> pfcount USER:LOGIN:2019092818 USER:LOGIN:2019092819
(integer) 7

若是咱們須要保留鍵值而避免一遍又一遍地計數,那麼咱們能夠將鍵合併爲一個鍵 USER:LOGIN:2019092818-19,而後直接對該鍵進行pfcount操做,以下所示。

127.0.0.1:6379> pfmerge USER:LOGIN:2019092818-19 USER:LOGIN:2019092818 USER:LOGIN:2019092819
OK
127.0.0.1:6379> pfcount USER:LOGIN:2019092818-19
(integer) 7

接下來,咱們寫個程序,比較使用 SET、Hyperloglog 兩種方式存儲不一樣用戶登陸行爲的內存佔用。

import redis.clients.jedis.Jedis;

public class RedisTest {
    
    private static final int NUM = 1000000;  // 100萬用戶

    private static final String SET_KEY = "SET:USER:LOGIN:2019082811";
    private static final String PF_KEY = "PF:USER:LOGIN:2019082811";

    public static void main(String[] args) {
        Jedis client = new Jedis();
        for (int i = 0; i < NUM; ++i) {
            System.out.println(i);
            client.sadd(SET_KEY, "USER" + i);
            client.pfadd(PF_KEY, "USER" + i);
        }
    }
}

咱們看一下結果,對於 100 萬用戶,Set 能夠精確存儲,而 Hyperloglog 則稍有誤差,多出了 7336,偏差率大概是在 0.7%。而在內存佔用上,Set 消耗了 10888895B≈10MB,Hyperloglog 只消耗了 10481B≈10KB 的內存,幾乎是 Set 的 1/1000。

127.0.0.1:6379> scard SET:USER:LOGIN:2019082811
(integer) 1000000
127.0.0.1:6379> pfcount PF:USER:LOGIN:2019082811
(integer) 1007336

127.0.0.1:6379> debug object SET:USER:LOGIN:2019082811
Value at:00007FD74F841940 refcount:1 encoding:hashtable serializedlength:10888895 lru:9308508 lru_seconds_idle:53
127.0.0.1:6379> debug object PF:USER:LOGIN:2019082811
Value at:00007FD74F7A5940 refcount:1 encoding:raw serializedlength:10481 lru:9308523 lru_seconds_idle:50
serializedlength 參數表示該 key 存儲的內容所佔用的內存字節數。

滑動窗口的不一樣計數

要在滑動窗口中計算不一樣的用戶,咱們須要指定一個較小的粒度,在這種狀況下,分鐘級的就足夠了,咱們將用戶行爲保存在格式爲 yyyyMMddHHmm 的鍵中,例如 USER:LOGIN:201909281820。

當咱們要統計最後 5 分鐘的不一樣用戶操做時,只須要將 5 個鍵進行合併計算便可:

127.0.0.1:6379> pfcount 201909281821 201909281822 201909281823 201909281824 201909281825

(integer) 6

由此看來,統計最近一小時咱們須要 60 個鍵,統計最近一天須要 1440 個鍵,最近 7 天則須要 10080 個鍵。 咱們擁有的鍵越多,合併它們時就須要耗費更多的時間進行計算。 所以,咱們應該減小鍵的數量,不只要保留具備 yyyyMMddHHmm 格式的鍵,還應保留小時、日和月的時間間隔,並使用 yyyyMM,yyyyMMdd,yyyyMMddHH。

使用這些新鍵,pfcount 操做能夠花費更少的時間,例如:

若是你要計算用戶最近一天的操做而且僅使用分鐘鍵,你須要合併全部 1440 個鍵。可是,若是你在分鐘鍵以外還使用小時鍵,則只須要 60 個分鐘鍵和 24 個小時鍵,所以咱們只須要 84 個鍵。

package utils;

import org.apache.commons.lang3.time.DateUtils;

import java.text.SimpleDateFormat;
import java.util.*;
import java.util.stream.Collectors;


public class HLLUtils {
    private static String TIME_FORMAT_MONTH_DAY = "MMdd";
    private static String TIME_FORMAT_DAY_MINUTES = "MMddHHmm";
    private static String TIME_FORMAT_DAY_HOURS = "MMddHH";
    private static SimpleDateFormat FORMAT_MONTH_DAY = new SimpleDateFormat(TIME_FORMAT_MONTH_DAY);
    private static SimpleDateFormat FORMAT_DAY_HOURS = new SimpleDateFormat(TIME_FORMAT_DAY_HOURS);
    private static SimpleDateFormat FORMAT_DAY_MINUTES = new SimpleDateFormat(TIME_FORMAT_DAY_MINUTES);

    /**
     * 獲取兩個日期之間的鍵
     *
     * @param d1 日期1
     * @param d2 日期2
     * @return 鍵列表
     */
    public static List<String> parse(Date d1, Date d2) {
        List<String> list = new ArrayList<>();
        if (d1.compareTo(d2) == 0) {
            return list;
        }
        
        long delta = d2.getTime() - d1.getTime(); 
        
        if (delta == 0) {
            return list;
        }
        
        if (delta < DateUtils.MILLIS_PER_HOUR) {   // 若時間差小於 1 小時
        
            int minutesDiff = (int) (delta / DateUtils.MILLIS_PER_MINUTE);
            Date date1Increment = d1;
            while (d2.compareTo(date1Increment) > 0 && minutesDiff > 0) {
                list.add(FORMAT_DAY_MINUTES.format(date1Increment));
                date1Increment = DateUtils.addMinutes(date1Increment, 1);
            }
           
        } else if (delta < DateUtils.MILLIS_PER_DAY) {  // 若時間差小於 1 天
        
            Date dateLastPortionHour = DateUtils.truncate(d2, Calendar.HOUR_OF_DAY);
            list.addAll(parse(dateLastPortionHour, d2));
            long delta2 = dateLastPortionHour.getTime() - d1.getTime();
            int hoursDiff = (int) (delta2 / DateUtils.MILLIS_PER_HOUR);
            Date date1Increment = DateUtils.addHours(dateLastPortionHour, -1 * hoursDiff);
            while (dateLastPortionHour.compareTo(date1Increment) > 0 && hoursDiff > 0) {
                list.add(FORMAT_DAY_HOURS.format(date1Increment));
                date1Increment = DateUtils.addHours(date1Increment, 1);
            }
            list.addAll(parse(d1, DateUtils.addHours(dateLastPortionHour, -1 * hoursDiff)));
        } else {
            Date dateLastPortionDay = DateUtils.truncate(d2, Calendar.DAY_OF_MONTH);
            list.addAll(parse(dateLastPortionDay, d2));
            long delta2 = dateLastPortionDay.getTime() - d1.getTime();
   
            int daysDiff = (int) (delta2 / DateUtils.MILLIS_PER_DAY); // 若時間差小於 1 個月
            
            Date date1Increment = DateUtils.addDays(dateLastPortionDay, -1 * daysDiff);
            while (dateLastPortionDay.compareTo(date1Increment) > 0 && daysDiff > 0) {
                list.add(FORMAT_MONTH_DAY.format(date1Increment));
                date1Increment = DateUtils.addDays(date1Increment, 1);
            }
            list.addAll(parse(d1, DateUtils.addDays(dateLastPortionDay, -1 * daysDiff)));
        }
        return list;
    }

    /**
     * 獲取從 date 往前推 minutes 分鐘的鍵列表
     *
     * @param date    特定日期
     * @param minutes 分鐘數
     * @return 鍵列表
     */
    public static List<String> getLastMinutes(Date date, int minutes) {
        return parse(DateUtils.addMinutes(date, -1 * minutes), date);
    }

    /**
     * 獲取從 date 往前推 hours 個小時的鍵列表
     *
     * @param date  特定日期
     * @param hours 小時數
     * @return 鍵列表
     */
    public static List<String> getLastHours(Date date, int hours) {
        return parse(DateUtils.addHours(date, -1 * hours), date);
    }

    /**
     * 獲取從 date 開始往前推 days 天的鍵列表
     *
     * @param date 特定日期
     * @param days 天數
     * @return 鍵列表
     */
    public static List<String> getLastDays(Date date, int days) {
        return parse(DateUtils.addDays(date, -1 * days), date);
    }

    /**
     * 爲keys列表添加前綴
     *
     * @param keys   鍵列表
     * @param prefix 前綴符號
     * @return 添加了前綴的鍵列表
     */
    public static List<String> addPrefix(List<String> keys, String prefix) {
        return keys.stream().map(key -> prefix + key).collect(Collectors.toList());
    }
}

咱們來看一下兩個日期之間計算出的樣本鍵列表。 你可能已經意識到了,鍵的數量應該儘量少,這樣合併鍵進行統計時代價將會比較小。 所以,咱們不只要將時間範圍劃分爲分鐘,並且還要劃分爲小時、天、月等。

  • BEGIN=201909281800&END=201909281920
[USER:LOGIN:09281900, USER:LOGIN:09281901, USER:LOGIN:09281902, USER:LOGIN:09281903, USER:LOGIN:09281904, USER:LOGIN:09281905, USER:LOGIN:09281906, USER:LOGIN:09281907, USER:LOGIN:09281908, USER:LOGIN:09281909, USER:LOGIN:09281910, USER:LOGIN:09281911, USER:LOGIN:09281912, USER:LOGIN:09281913, USER:LOGIN:09281914, USER:LOGIN:09281915, USER:LOGIN:09281916, USER:LOGIN:09281917, USER:LOGIN:09281918, USER:LOGIN:09281919, USER:LOGIN:092818]
  • BEGIN=20190928191100&END=20190930163800
[USER:LOGIN:09301600, USER:LOGIN:09301601, USER:LOGIN:09301602, USER:LOGIN:09301603, USER:LOGIN:09301604, USER:LOGIN:09301605, USER:LOGIN:09301606, USER:LOGIN:09301607, USER:LOGIN:09301608, USER:LOGIN:09301609, USER:LOGIN:09301610, USER:LOGIN:09301611, USER:LOGIN:09301612, USER:LOGIN:09301613, USER:LOGIN:09301614, USER:LOGIN:09301615, USER:LOGIN:09301616, USER:LOGIN:09301617, USER:LOGIN:09301618, USER:LOGIN:09301619, USER:LOGIN:09301620, USER:LOGIN:09301621, USER:LOGIN:09301622, USER:LOGIN:09301623, USER:LOGIN:09301624, USER:LOGIN:09301625, USER:LOGIN:09301626, USER:LOGIN:09301627, USER:LOGIN:09301628, USER:LOGIN:09301629, USER:LOGIN:09301630, USER:LOGIN:09301631, USER:LOGIN:09301632, USER:LOGIN:09301633, USER:LOGIN:09301634, USER:LOGIN:09301635, USER:LOGIN:09301636, USER:LOGIN:09301637, USER:LOGIN:093000, USER:LOGIN:093001, USER:LOGIN:093002, USER:LOGIN:093003, USER:LOGIN:093004, USER:LOGIN:093005, USER:LOGIN:093006, USER:LOGIN:093007, USER:LOGIN:093008, USER:LOGIN:093009, USER:LOGIN:093010, USER:LOGIN:093011, USER:LOGIN:093012, USER:LOGIN:093013, USER:LOGIN:093014, USER:LOGIN:093015, USER:LOGIN:0929, USER:LOGIN:092820, USER:LOGIN:092821, USER:LOGIN:092822, USER:LOGIN:092823, USER:LOGIN:09281911, USER:LOGIN:09281912, USER:LOGIN:09281913, USER:LOGIN:09281914, USER:LOGIN:09281915, USER:LOGIN:09281916, USER:LOGIN:09281917, USER:LOGIN:09281918, USER:LOGIN:09281919, USER:LOGIN:09281920, USER:LOGIN:09281921, USER:LOGIN:09281922, USER:LOGIN:09281923, USER:LOGIN:09281924, USER:LOGIN:09281925, USER:LOGIN:09281926, USER:LOGIN:09281927, USER:LOGIN:09281928, USER:LOGIN:09281929, USER:LOGIN:09281930, USER:LOGIN:09281931, USER:LOGIN:09281932, USER:LOGIN:09281933, USER:LOGIN:09281934, USER:LOGIN:09281935, USER:LOGIN:09281936, USER:LOGIN:09281937, USER:LOGIN:09281938, USER:LOGIN:09281939, USER:LOGIN:09281940, USER:LOGIN:09281941, USER:LOGIN:09281942, USER:LOGIN:09281943, USER:LOGIN:09281944, USER:LOGIN:09281945, USER:LOGIN:09281946, USER:LOGIN:09281947, USER:LOGIN:09281948, USER:LOGIN:09281949, USER:LOGIN:09281950, USER:LOGIN:09281951, USER:LOGIN:09281952, USER:LOGIN:09281953, USER:LOGIN:09281954, USER:LOGIN:09281955, USER:LOGIN:09281956, USER:LOGIN:09281957, USER:LOGIN:09281958, USER:LOGIN:09281959]

實例

其實,有了上面生成 key 的方法,咱們即可以很輕鬆地在實際場景中應用 Redis 的 HyperLoglog 進行數據統計,好比咱們要統計今後刻開始往前推一小時、一天、一週的 UV。

代碼實現以下:

import redis.clients.jedis.Jedis;
import utils.JedisUtils;

import java.util.Date;
import java.util.List;

import static utils.HLLUtils.addPrefix;
import static utils.HLLUtils.getLastDays;
import static utils.HLLUtils.getLastHours;

public class UVCounter {
    private Jedis client = JedisUtils.getClient();

    private static final String PREFIX = "USER:LOGIN:";

    public UVCounter() {

    }

    /**
     * 獲取周UV
     *
     * @return UV數
     */
    public long getWeeklyUV() {
        List<String> suffixKeys = getLastDays(new Date(), 7);
        List<String> keys = addPrefix(suffixKeys, PREFIX);
        return client.pfcount(keys.toArray(new String[0]));
    }

    /**
     * 獲取日UV
     *
     * @return UV數
     */
    public long getDailyUV() {
        List<String> suffixKeys = getLastHours(new Date(), 24);
        List<String> keys = addPrefix(suffixKeys, PREFIX);
        return client.pfcount(keys.toArray(new String[0]));
    }

    /**
     * 獲取小時UV
     * 
     * @return UV數
     */
    public long getHourlyUV() {
        List<String> suffixKeys = getLastHours(new Date(), 1);
        List<String> keys = addPrefix(suffixKeys, PREFIX);
        return client.pfcount(keys.toArray(new String[0]));
    }
}

怎麼樣,你學會了嗎?

相關文章
相關標籤/搜索