what???領導讓我實現一個redis Zset多維度排行榜

一:背景

  • 實現一個多維度的排行榜(已自然周爲一個週期),考慮得分和時間維度。當得分一樣時,獲得此排名越早的排名越靠前
  • 需要監聽原始數據,這裏分爲三個動作:收到、已讀、通過。根據三個動作進行各項數據指標的統計
    • 用戶當前自然周收到、查看、標記的數量
    • 根據三個動作等進行多條件過濾,準備出各個條件下的文案提示

二:方案設計

  • 針對自然周的定義,可以參考雪花算法的實現。通過設計一個固定不可變基準開始日期A,來將某個日期B化爲距離基準日A的週數X來作爲週期數來表示
  • 針對排行榜的實現,我們可以採用Redis的ZSet來實現。key:固定標識 + 固定基準日A + 距離固定基準日A的週數X  value:用戶id  score:可以參考雪花算法的實現
    • 因爲score要承擔兩個維度:得分和時間,所以採用64位的long來進行數據整合
    • score 64位:首位可以默認0,用來做保留位
    • score 64位:得分可以佔位23位,代表最大得分:8388608(2^23)
    • score 64位:當前時間距離基準日C的時間戳(毫秒)可以佔位40位,代表可以持續34年(2^40)。因爲排名是倒序排的,所以這個基準日C的選擇得是距離今年34後的時間作爲基準日C。這樣計算時間戳差值Y的時候,就可以差值Y越大,排名越靠前
    • 這樣一個得分就拼接完成了:0 (標識位)+ 00000 00000 000 (真正得分位)+ 00000 00000 00000 00000 00000 00000 00000 00000(時間戳差值C)。因爲真正的得分權重要比時間戳高,所以真正得分位靠前
  • 針對得分的賦值,可以考慮樂觀鎖 + ZADD + LUA來實現,避免覆蓋更新,導致score不正確
  • 針對監聽原始數據,可以考慮觀察者模式 + 線程隔離實現。基於開閉原則,高內聚低耦合,使業務更加明浪
  • 針對三個動作的數據源進行多個條件進行過濾,得出屬於每個用戶的個性化文案,可以考慮責任鏈實現。基於開閉原則,每個過濾條件一個實現類,當條件新增,減少或者變更時可以靈活的只更改當前過濾實現類就可以,能做到影響程度最低,複用程度高,耦合程度低。

三:具體實現

redis Zset Score的實現:

基礎score格式準備:

/**
 * 得分位 最大得分8388608
 */
private static final int SCORE = 23;

/**
 * 時間戳:34年
 */
private static final int TIMESTAMP = 40;

/**
 * 得分佔位最大值
 */
private static final long SCORE_MAX_SIZE = ~(-1L << SCORE);

/**
 * 時間戳佔位最大值
 */
private static final long TIME_STAMP_MAX_SIZE = ~(-1L << TIMESTAMP);
/**
 * 獲取真實score
 * @param redisScore redis存儲得分
 * @return
 */
public static BigDecimal getRealScore(Long redisScore) {
    if (redisScore == null) {
        return BigDecimal.ZERO;
    }
    long score = getRedisRealScore(redisScore);
    return new BigDecimal(score).divide(BigDecimal.TEN, 2, BigDecimal.ROUND_HALF_UP);
}
/**
 * 獲取redis真實score(擴大10倍)
 * @param redisScore redis存儲得分
 * @return
 */
public static long getRedisRealScore(Long redisScore) {
    if (redisScore == null) {
        return 0;
    }
    return redisScore >> TIMESTAMP & SCORE_MAX_SIZE;
}
/**
 * 計算 時間戳
 * @param redisScore redis存儲得分
 * @return
 */
public static long genTimeStamp(Long redisScore) {
    if (redisScore == null) {
        return 0;
    }
    return getFixedEndTimeStamp() - (redisScore & TIME_STAMP_MAX_SIZE);
}
/**
 * 計算增加 value 值
 * @param score 得分
 * @param betweenMs 相差毫秒
 */
public static Number incScoreValue(long score, long betweenMs) {
    return ((score & SCORE_MAX_SIZE) << TIMESTAMP) | (betweenMs & TIME_STAMP_MAX_SIZE);
}
/**
 * 獲取固定時間(基準起始值,千萬不要改動)
 *
 * @return
 */
public static DateTime getFixedStartTime() {
    return DateUtil.parse("2020-09-07 00:00:00", DatePattern.NORM_DATETIME_PATTERN);
}
/**
 * 獲取固定時間戳(基準結束值,千萬不要改動)
 *
 * @return
 */
public static long getFixedEndTimeStamp() {
    return DateUtil.offset(getFixedStartTime(), DateField.YEAR, 34).getTime();
}

redis調用:

// 當前時間
Date now = new Date();
// 相差秒數
long betweenMs = fixedEndTimeStamp - currentTime.getTime();
// 獲取該行業該期數對應的排行榜key
String weekRankingKey = MessageFormat.format("WEEK_RANKING:{0}:{2}", getFixedStartTime().toString(DatePattern.PURE_DATE_PATTERN), getFixedPeriod(now));
incrScore(weekRankingKey, String.valueOf(bUid), rankingScore, betweenMs);
/**
 * 設置多維度登封值
 *
 * @param key       zset key
 * @param value     zset value
 * @param getScore  此次獲取的分數
 * @param betweenMs 與固定時間相差毫秒數
 * @return
 */
private boolean incrScore(String key, String value, long getScore, long betweenMs) {
    Long oldScore = null;
    long newScore;
    long totalScore;

    do {
        Double zScore = redisClient.zscore(key, value);
        if (zScore != null) {
            oldScore = zScore.longValue();
            long redisRealScore = getRedisRealScore(oldScore);
            totalScore = redisRealScore + getScore;
        } else {
            totalScore =  getScore;
        }
        // 生成新值
        newScore = incScoreValue(totalScore, betweenMs).longValue();
    } while (!compareAndSetScore(key, value, oldScore, newScore));

    return true;
}
private static String LUA_SCRIPT = "if ( (ARGV[2] == '' or ARGV[2] == nil)  and ((not (redis.call('zscore', KEYS[1], ARGV[1]))) ) or redis.call('zscore', KEYS[1], ARGV[1]) == ARGV[2]) \n" +
            "    then \n" +
            "redis.call('zadd',KEYS[1],ARGV[3],ARGV[1])\n" +
            "            redis.call('EXPIRE', KEYS[1], tonumber(ARGV[4]))\n" +
            "            return 1\n" +
            "            else\n" +
            "            return 0\n" +
            "    end";
}
/**
 * 1個月
 */
public static final int EXPIRE_ONE_MONTH = 60 * 60 * 24 * 30;
/**
 * CAS 設置score
 *
 * @param key
 * @param value
 * @param oldScore
 * @param newScore
 * @return
 */
private boolean compareAndSetScore(String key, String value, Long oldScore, long newScore) {
    Long execute = 0L;
    try {
        execute = redisClient.execute(workCallback -> {
            List<String> args = new ArrayList<>();
            args.add(value);
            args.add(Convert.toStr(oldScore, ""));
            args.add(Convert.toStr(newScore, ""));
            args.add(String.valueOf(EXPIRE_ONE_MONTH));
            return (Long) workCallback.eval(LUA_SCRIPT, Lists.newArrayList(key), args);
        });
    } catch (Exception e) {
        log.error("compareAndSetScore Exception", e);
    }

    return execute == 1L;
}

觀察者模式 + 線程隔離 監聽

可以採用java自帶的Observer和Observable來實現

public class ActionObservable extends Observable {

    private ActionObservable() {
    }

    private static volatile ActionObservable actionObservable = null;

    public static ActionObservable getInstance() {
        if (actionObservable == null) {
            synchronized (ActionObservable.class) {
                if (actionObservable == null) {
                    actionObservable = new ActionObservable();
                }
            }
        }
        return actionObservable;
    }

    /**
     * 初始化訂閱者
     */
    public void initLoginObserver() {
        addObserver(new RankingObserver());
        addObserver(new OwnerTitleObserver());
    }

    public void loginNoticeAll(Dto dto) {
        setChanged();
        notifyObservers(dto);
    }
}
public abstract class AbsObserver implements Observer {

    private final Logger log = Logger.getLogger(AbsObserver.class);

    private static ThreadPoolExecutor threadPoolExecutor;

    static {
        int nThreads = Runtime.getRuntime().availableProcessors() * 2 + 1;
        ThreadPoolExecutor.CallerRunsPolicy callerRunsPolicy = new ThreadPoolExecutor.CallerRunsPolicy();
        threadPoolExecutor = new ThreadPoolExecutor(nThreads, nThreads, 0L, TimeUnit.MILLISECONDS, new ArrayBlockingQueue<Runnable>(2048), callerRunsPolicy);
    }

    @Override
    public void update(Observable o, Object arg) {
        if (o instanceof ActionObservable) {
            if (arg instanceof Dto) {
                final Dto param = (Dto) arg;
                try {
                    threadPoolExecutor.execute(() -> change(param));
                } catch (Exception e) {
                    log.error("AbsObserver-change Exception param: " + JSON.toJSONString(param), e);
                }
            }
        }
    }

    /**
     * 接受訂閱消息後執行
     *
     * @param dto
     */
    protected abstract void change(Dto dto);

}
@Slf4j
public class OwnerTitleObserver extends AbsObserver {
    
    @Override
    protected void change(Dto dto) {
        // 個性化文案統計
    }

}
@Slf4j
public class RankingObserver extends AbsObserver {
    
    @Override
    protected void change(Dto dto) {
         // 進行排名
    }

}

針對三個動作的數據源進行多個條件進行責任鏈過濾

責任鏈底層抽象

@Slf4j
public abstract class AbsFilter<T> {

    /**
     * 下一個處理鏈
     */
    protected AbsFilter nextFilter;

    public AbsFilter setNextFilter(AbsFilter nextFilter) {
        return this.nextFilter = nextFilter;
    }

    public void filter(T param) {
        if (param == null) {
           return;
        }
        int order = getOrder();
        if (order == 1) {
            boolean isDeal = handlerFirstBefore(param);
            if (!isDeal) {
                return;
            }
        }
        boolean handlerRes = handler(param);
        if (handlerRes) {
            if (nextFilter != null) {
                // 調用下一個鏈
                nextFilter.filter(param);
            }
        } else {
            handlerAfterFalse(param);
        }
    }

    /**
     * 處理邏輯
     *
     * @param param
     * @return
     */
    abstract protected boolean handler(T param);

    /**
     * 前置處理(只處理一次)
     * @param param
     * @return 是否繼續處理
     */
    protected boolean handlerFirstBefore(T param) {
        // 可以進行參數校驗
        ValidateUtils.validate(param);
        return true;
    }

    /**
     * 後置處理(只處理handler返回false的, 只處理一次)
     * @param param
     * @return
     */
    protected void handlerAfterFalse(T param) {}

    /**
     * 自定義排序 越小越靠前 從1開始
     * @return
     */
    protected abstract int getOrder();

}

責任鏈業務底層抽象

@Slf4j
public abstract class AbsOwnerTitleFilter extends AbsFilter<Dto> {

    @Override
    protected boolean handlerFirstBefore(Dto param) {
        super.handlerFirstBefore(param);
        return false;
    }

    protected void commonDeal(Dto dto) {
        // 公用處理
        // 進行個性化文案保存
    }

    @Override
    protected int getOrder() {
        return getCurrentOwnerTitleEnum().getOrder();
    }
    
    /**
     * 獲取當前代表的個性化稱號文案枚舉 可以自定義,包含文案,排序,類型等字段
     *
     * @return
     */
    protected abstract OwnerTitleEnum getCurrentOwnerTitleEnum();

    @Override
    protected void handlerAfterFalse(Dto dto) {
        commonDeal(param);
    }
}

具體的過濾條件調用(示例)

@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class OwnerTitleSevenFilter extends AbsOwnerTitleFilter {

    @Override
    protected boolean handler(Dto dto) {
        // 進行業務處理,返回false則鏈路完成,不在進行下一鏈路調用,否則繼續調用下一鏈路
        return false;
    }

    @Override
    protected OwnerTitleEnum getCurrentOwnerTitleEnum() {
        return OwnerTitleEnum.SENEN;
    }
}

調用入口可以進行調用

private OwnerTitleOneFilter ownerTitleOneFilter = Singleton.get(OwnerTitleOneFilter.class);
// 構造方法初始化
private RankingServiceImpl() {
    // 責任鏈過濾
    ownerTitleOneFilter
            .setNextFilter(ownerTitleTwoFilter)
            .setNextFilter(ownerTitleThreeFilter)
            .setNextFilter(ownerTitleFourFilter)
            .setNextFilter(ownerTitleFiveFilter)
            .setNextFilter(ownerTitleSixFilter)
            .setNextFilter(ownerTitleSevenFilter);
}

總結:

此次需求主要挑戰在於

  • redis zset的多維度排序。可以參考其他框架的實現,比如這次就複用了雪花算法的一些思想,因此多看源碼,我們看的更多的是思想和架構,以便能夠在其他地方複用,而不是隻是背。
  • 設計模式的有效使用,可以大大降低系統的耦合度。我們不想寫過多if else的原因很簡單,是爲了代碼清晰和可擴展性強,畢竟我們都不想在一個屎山一樣的代碼中進行編輯,更多的是新寫一個類進行我們自己的代碼編輯,也能降低錯誤的發生。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章