一:背景
- 實現一個多維度的排行榜(已自然周爲一個週期),考慮得分和時間維度。當得分一樣時,獲得此排名越早的排名越靠前
- 需要監聽原始數據,這裏分爲三個動作:收到、已讀、通過。根據三個動作進行各項數據指標的統計
- 用戶當前自然周收到、查看、標記的數量
- 根據三個動作等進行多條件過濾,準備出各個條件下的文案提示
二:方案設計
- 針對自然周的定義,可以參考雪花算法的實現。通過設計一個固定不可變基準開始日期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的原因很簡單,是爲了代碼清晰和可擴展性強,畢竟我們都不想在一個屎山一樣的代碼中進行編輯,更多的是新寫一個類進行我們自己的代碼編輯,也能降低錯誤的發生。