李锋镝的博客

  • 首页
  • 时间轴
  • 评论区显眼包🔥
  • 左邻右舍
  • 博友圈
  • 关于我
    • 关于我
    • 另一个网站
    • 我的导航站
    • 网站地图
    • 赞助
  • 留言
  • 🚇开往
Destiny
自是人生长恨水长东
  1. 首页
  2. 后端
  3. 正文

从万级到千万级:排行榜系统的6种实现方案深度解析(含原理、优化与实战)

2025年10月29日 184点热度 1人点赞 0条评论

在业务开发中,排行榜是一个高频需求——游戏中的战力榜、电商的销量榜、内容平台的点赞榜、社交产品的热度榜……看似简单的“排序展示”,背后却藏着数据量与实时性的博弈。很多团队初期用“数据库排序”快速上线,却在用户量突破10万、100万后遭遇性能雪崩;也有团队盲目上分布式方案,导致架构复杂度过高,运维成本激增。

本文将基于业务场景演进,从“单机小数据”到“分布式大数据”,详细拆解6种排行榜实现方案的底层原理、实操步骤、性能瓶颈与优化技巧,并结合真实案例说明“不同阶段该选哪种方案”,帮你避开从设计到落地的所有坑。

一、先明确核心诉求:排行榜设计的3个关键维度

在选择方案前,必须先理清业务的核心诉求——不同诉求对应完全不同的技术选型,这是避免“过度设计”或“设计不足”的关键。

1. 数据规模:你的排行榜要存多少用户?

  • 小数据(万级以下):如内部管理系统的部门业绩榜、小型工具的用户积分榜;
  • 中数据(十万-百万级):如中型电商的单品销量榜、区域游戏的战力榜;
  • 大数据(千万级以上):如国民级游戏的全服榜、头部电商的全平台销量榜。

2. 实时性要求:排名多久更新一次?

  • 非实时(分钟/小时级延迟):如日销量榜、周热度榜,用户可接受“数据延迟1小时”;
  • 准实时(秒-分钟级延迟):如游戏实时战力榜、直播平台的礼物榜,用户希望“几分钟内看到排名变化”;
  • 实时(秒级更新):如秒杀活动的实时销量榜、社交平台的实时话题榜,需“用户操作后立即更新排名”。

3. 业务复杂度:排名规则有多复杂?

  • 简单规则:仅按单一维度排序(如“按分数降序”“按销量降序”);
  • 复杂规则:多维度加权(如“热度=点赞数0.6 + 评论数0.3 + 分享数*0.1”)、时间衰减(如“旧内容的分数随时间降低”)、特殊规则(如“相同分数按达成时间排序”)。

二、方案一:数据库直接排序(万级以下,非实时)

这是最直观的方案——直接用SQL对数据库表进行排序,适合数据量小、实时性要求低的场景(如内部系统的业绩榜、个人项目的积分榜)。

1. 基础实现:SQL排序+LIMIT分页

(1)表结构设计

核心是“用户ID+排序字段”,需为排序字段建立索引(否则会全表扫描,性能极差):

-- 用户分数表(适合简单排行榜)
CREATE TABLE `user_score` (
  `id` BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键',
  `user_id` VARCHAR(64) NOT NULL COMMENT '用户唯一标识',
  `score` DOUBLE NOT NULL COMMENT '排序分数(如战力、销量)',
  `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '分数更新时间',
  UNIQUE KEY `uk_user_id` (`user_id`) COMMENT '用户ID唯一,避免重复',
  KEY `idx_score_update_time` (`score` DESC, `update_time` ASC) COMMENT '按分数降序、更新时间升序(相同分数按时间排序)'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT '用户分数表(用于排行榜)';
  • 索引设计:score DESC + update_time ASC 覆盖排序需求,避免“Using filesort”(文件排序);
  • 唯一键:user_id 唯一,确保一个用户只占一行,更新分数时用UPDATE而非INSERT。

(2)核心查询代码

@Service
public class DbRankingService {
    @Autowired
    private JdbcTemplate jdbcTemplate;

    /**
     * 获取Top N排行榜(前100名)
     */
    public List<UserScoreDTO> getTopRanking(int topN) {
        // SQL:走索引idx_score_update_time,避免全表扫描
        String sql = "SELECT user_id, score FROM user_score " +
                     "ORDER BY score DESC, update_time ASC " +
                     "LIMIT ?";
        // 映射结果(避免ORM框架的额外开销,小数据场景JDBC更轻量)
        return jdbcTemplate.query(sql, new Object[]{topN}, 
            (rs, rowNum) -> new UserScoreDTO(
                rs.getString("user_id"),
                rs.getDouble("score")
            )
        );
    }

    /**
     * 获取用户个人排名(含分数)
     */
    public UserRankDTO getUserRank(String userId) {
        // 1. 先查当前用户的分数
        Double userScore = jdbcTemplate.queryForObject(
            "SELECT score FROM user_score WHERE user_id = ?",
            new Object[]{userId},
            Double.class
        );
        if (userScore == null) {
            return new UserRankDTO(userId, 0, 0.0); // 无数据,排名0
        }

        // 2. 查“分数比当前用户高”的用户数量 = 排名(+1是因为排名从1开始)
        Long rank = jdbcTemplate.queryForObject(
            "SELECT COUNT(*) FROM user_score " +
            "WHERE score > ? OR (score = ? AND update_time < ?)",
            new Object[]{userScore, userScore, 
                jdbcTemplate.queryForObject("SELECT update_time FROM user_score WHERE user_id = ?", 
                    new Object[]{userId}, String.class)},
            Long.class
        );

        return new UserRankDTO(userId, rank + 1, userScore);
    }
}

2. 底层原理:索引如何加速排序?

数据库排序的性能瓶颈在于“是否走索引”:

  • 走索引:idx_score_update_time 是“有序索引”,数据库直接按索引顺序读取数据,无需额外排序,执行时间通常在10ms以内(万级数据);
  • 全表扫描:若未建索引,数据库需读取所有数据到内存,用“快速排序”算法排序,10万条数据执行时间会超过1秒,100万条数据可能超时。

3. 实操优化:避开3个常见坑

(1)深分页性能问题(LIMIT 10000, 100)

当需要查询“第10001-10100名”时,用LIMIT 10000, 100会导致数据库先扫描前10100条数据,再丢弃前10000条,性能极差:

-- 错误:深分页,10万条数据时执行时间可能超过500ms
SELECT user_id, score FROM user_score ORDER BY score DESC LIMIT 10000, 100;

-- 优化:用“主键+索引”定位,避免全表扫描
-- 前提:记录上次查询的最后一条数据的score和update_time
SELECT user_id, score FROM user_score 
WHERE score < ? OR (score = ? AND update_time > ?)
ORDER BY score DESC, update_time ASC 
LIMIT 100;

原理:通过条件过滤直接定位到“上次查询的位置”,只扫描100条数据,执行时间降至10ms以内。

(2)索引维护成本

当数据更新频繁(如每秒100次score更新),索引会频繁分裂和重组,导致数据库CPU和IO占用升高。解决方案:

  • 非实时场景:将“实时更新”改为“批量更新”,如每10分钟更新一次score;
  • 索引选择:若更新频率极高,可放弃索引,改用“定时任务+缓存”(方案二)。

(3)数据量上限

当数据量超过10万条时,即使走索引,排序和分页的性能也会明显下降:

  • 10万条数据:LIMIT 100 执行时间约50ms;
  • 100万条数据:LIMIT 100 执行时间约200ms,已接近用户可感知的延迟(200ms是体验临界点)。

4. 优缺点总结

优点 缺点
实现简单,无需额外组件 数据量>10万时性能骤降
代码维护成本低,SQL易于调试 深分页性能差,需额外优化
天然支持持久化,数据不丢失 高并发下数据库压力大,易成为瓶颈

三、方案二:缓存+定时任务(十万-百万级,准实时)

当数据量增长到十万级,数据库排序的性能已无法满足高并发查询(如每秒1000次排行榜查询),此时需引入缓存(如Redis)存储排序结果,用定时任务异步更新缓存,降低数据库压力。

1. 核心思路:“定时全量更新+缓存查询”

  • 更新链路:定时任务(如每1分钟)从数据库查询Top N数据,全量写入Redis;
  • 查询链路:用户查询排行榜时,直接从Redis读取,无需访问数据库。

2. 基础实现:Spring Scheduled + Redis

(1)定时任务更新缓存

@Service
@EnableScheduling // 启用定时任务
public class CacheRankingService {
    @Autowired
    private UserScoreDao userScoreDao;
    @Autowired
    private StringRedisTemplate redisTemplate;
    // 分布式锁:避免多实例定时任务并发执行(如部署3台机器,只让1台执行更新)
    @Autowired
    private RedissonClient redissonClient;

    // 缓存Key:排行榜Top 1000(按业务需求调整Top N大小)
    private static final String RANKING_KEY = "ranking:top:1000";
    // 定时任务间隔:1分钟(可根据实时性需求调整,如5分钟、30秒)
    private static final long UPDATE_INTERVAL = 60 * 1000;

    /**
     * 定时更新缓存(分布式环境下确保唯一执行)
     */
    @Scheduled(fixedRate = UPDATE_INTERVAL)
    public void updateRankingCache() {
        // 1. 分布式锁:key=定时任务名称,过期时间=更新间隔*2(避免死锁)
        RLock lock = redissonClient.getLock("lock:ranking:update");
        try {
            // 尝试获取锁,最多等待10秒,100秒后自动释放
            if (lock.tryLock(10, 100, TimeUnit.SECONDS)) {
                // 2. 从数据库查询Top 1000(用分页查询,避免一次性加载过多数据)
                List<UserScoreDTO> topList = userScoreDao.queryTopN(1000);

                // 3. 全量更新Redis缓存(先删除旧数据,再写入新数据,避免脏读)
                redisTemplate.delete(RANKING_KEY);
                if (!topList.isEmpty()) {
                    // 用Redis List存储,顺序即排名(index 0是第1名)
                    List<String> rankValues = topList.stream()
                        .map(dto -> dto.getUserId() + ":" + dto.getScore()) // 格式:userId:score
                        .collect(Collectors.toList());
                    redisTemplate.opsForList().rightPushAll(RANKING_KEY, rankValues);
                }
                log.info("排行榜缓存更新完成,共{}条数据", topList.size());
            }
        } catch (InterruptedException e) {
            log.error("获取分布式锁失败", e);
        } finally {
            // 释放锁(确保锁一定被释放)
            if (lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }

    /**
     * 从缓存查询Top N排行榜
     */
    public List<UserScoreDTO> getTopRanking(int topN) {
        // 1. 从Redis List获取前topN条数据
        List<String> rankValues = redisTemplate.opsForList().range(RANKING_KEY, 0, topN - 1);
        if (rankValues == null || rankValues.isEmpty()) {
            // 缓存未命中,降级查询数据库(避免返回空)
            return userScoreDao.queryTopN(topN);
        }

        // 2. 解析数据(userId:score)
        return rankValues.stream()
            .map(value -> {
                String[] parts = value.split(":");
                return new UserScoreDTO(parts[0], Double.parseDouble(parts[1]));
            })
            .collect(Collectors.toList());
    }
}

(2)数据库DAO层优化:分批查询避免OOM

当Top N是1000时,一次性查询1000条数据没问题;但如果是10万条,一次性加载到内存会导致OOM,需分批查询:

@Repository
public class UserScoreDao {
    @Autowired
    private JdbcTemplate jdbcTemplate;

    /**
     * 分批查询Top N数据(避免OOM)
     */
    public List<UserScoreDTO> queryTopN(int topN) {
        List<UserScoreDTO> result = new ArrayList<>(topN);
        int batchSize = 1000; // 每批查询1000条
        int offset = 0;

        while (offset < topN) {
            int limit = Math.min(batchSize, topN - offset);
            String sql = "SELECT user_id, score FROM user_score " +
                         "ORDER BY score DESC, update_time ASC " +
                         "LIMIT ?, ?";
            List<UserScoreDTO> batch = jdbcTemplate.query(sql, 
                new Object[]{offset, limit},
                (rs, rowNum) -> new UserScoreDTO(
                    rs.getString("user_id"),
                    rs.getDouble("score")
                )
            );
            if (batch.isEmpty()) {
                break; // 无更多数据,退出循环
            }
            result.addAll(batch);
            offset += batchSize;
        }

        return result;
    }
}

3. 进阶优化:解决3个核心问题

(1)缓存更新的“增量优化”

全量更新的问题:当数据量达100万时,每次查询1000条数据需扫描大量行,数据库压力大。解决方案:增量更新——记录上次更新的“最大score”,只查询比这个score大的用户:

// 新增:记录上次更新的最大score(用Redis存储)
private static final String LAST_MAX_SCORE_KEY = "ranking:last:max:score";

public void updateRankingCache() {
    // ... 省略分布式锁 ...
    // 1. 获取上次更新的最大score(初始为0)
    String lastMaxScoreStr = redisTemplate.opsForValue().get(LAST_MAX_SCORE_KEY);
    double lastMaxScore = lastMaxScoreStr == null ? 0.0 : Double.parseDouble(lastMaxScoreStr);

    // 2. 增量查询:只查score > lastMaxScore 的用户(减少数据库扫描行数)
    List<UserScoreDTO> newTopList = userScoreDao.queryIncremental(lastMaxScore, 1000);
    if (!newTopList.isEmpty()) {
        // 3. 合并旧缓存和新数据(保持Top 1000)
        List<UserScoreDTO> oldTopList = getTopRanking(1000);
        oldTopList.addAll(newTopList);
        // 去重+排序+截取Top 1000
        List<UserScoreDTO> mergedList = oldTopList.stream()
            .collect(Collectors.toMap(
                UserScoreDTO::getUserId, 
                Function.identity(), 
                (a, b) -> b.getScore() > a.getScore() ? b : a // 相同用户保留高分
            ))
            .values().stream()
            .sorted((a, b) -> {
                if (b.getScore() != a.getScore()) {
                    return Double.compare(b.getScore(), a.getScore());
                }
                return a.getUpdateTime().compareTo(b.getUpdateTime()); // 相同分数按时间排序
            })
            .limit(1000)
            .collect(Collectors.toList());

        // 4. 更新缓存和上次最大score
        redisTemplate.delete(RANKING_KEY);
        // ... 写入缓存 ...
        // 更新上次最大score(取合并后列表的第一个元素的score)
        double newMaxScore = mergedList.get(0).getScore();
        redisTemplate.opsForValue().set(LAST_MAX_SCORE_KEY, String.valueOf(newMaxScore));
    }
}

(2)缓存穿透与击穿

  • 缓存穿透:查询“不存在的用户排名”,导致请求穿透到数据库。解决方案:用布隆过滤器过滤不存在的userId,或缓存“空结果”(如排名0);
  • 缓存击穿:缓存过期时,大量请求同时查询同一用户的排名,导致数据库压力骤增。解决方案:用互斥锁(如Redis的SETNX),只让一个请求去数据库查询,其他请求等待。

(3)多实例缓存一致性

当部署多台应用服务器时,本地缓存(如Caffeine)会导致“不同实例缓存不一致”。解决方案:

  • 优先用Redis缓存(分布式共享),避免本地缓存;
  • 若用本地缓存,需在Redis缓存更新后,通过消息队列(如RabbitMQ)通知所有实例刷新本地缓存。

4. 优缺点总结

优点 缺点
减轻数据库压力,查询性能高(Redis响应<10ms) 数据有延迟(取决于定时任务间隔)
实现相对简单,无需复杂组件 增量更新实现复杂,需处理数据合并
支持多实例共享缓存 高并发更新时,定时任务可能成为瓶颈

四、方案三:Redis有序集合(百万级,准实时)

当数据量达百万级,且需要“实时更新排名”(如用户分数变化后立即更新排名),Redis的有序集合(ZSet) 是最优选择。ZSet底层基于“跳表+哈希表”,支持O(logN)的插入、删除和排序,天生适合做排行榜。

1. ZSet底层原理:为什么适合排序?

ZSet的核心是“键(member)+分数(score)”,底层用两种结构实现:

  • 哈希表:存储“member→score”的映射,快速获取某个member的score(O(1));
  • 跳表:按score排序存储member,支持快速插入、删除和范围查询(O(logN))。

跳表的优势:相比红黑树,跳表的插入、删除逻辑更简单,且支持“范围查询”(如获取score在100-200之间的member),这是红黑树难以高效实现的。

2. 基础实现:ZSet核心API

(1)核心操作代码

@Service
public class RedisZSetRankingService {
    @Autowired
    private StringRedisTemplate redisTemplate;

    // ZSet的Key(排行榜名称,如“game:ranking:202405”表示2024年5月的游戏榜)
    private static final String RANKING_ZSET_KEY = "game:ranking:202405";

    /**
     * 1. 添加/更新用户分数(实时更新)
     * - 若用户已存在:更新score
     * - 若用户不存在:新增用户
     */
    public void updateUserScore(String userId, double score) {
        // ZADD:添加member到ZSet,score为排序依据
        // 第三个参数“NX”表示“仅新增”,“XX”表示“仅更新”,这里用默认(新增或更新)
        redisTemplate.opsForZSet().add(RANKING_ZSET_KEY, userId, score);
    }

    /**
     * 2. 获取Top N排行榜(按score降序,取前N名)
     */
    public List<UserRankDTO> getTopRanking(int topN) {
        // ZREVRANGE:按score降序获取指定范围的member(0=第1名,topN-1=第N名)
        // withScores=true:同时返回score
        Set<ZSetOperations.TypedTuple<String>> typedTuples = 
            redisTemplate.opsForZSet().reverseRangeWithScores(RANKING_ZSET_KEY, 0, topN - 1);

        if (typedTuples == null || typedTuples.isEmpty()) {
            return Collections.emptyList();
        }

        // 解析结果,生成排名(index+1=排名)
        List<UserRankDTO> result = new ArrayList<>(topN);
        int rank = 1;
        for (ZSetOperations.TypedTuple<String> tuple : typedTuples) {
            result.add(new UserRankDTO(
                tuple.getValue(), // userId
                rank++,           // 排名(从1开始)
                tuple.getScore()   // score
            ));
        }
        return result;
    }

    /**
     * 3. 获取用户个人排名(含并列排名处理)
     */
    public UserRankDTO getUserRank(String userId) {
        // 3.1 获取用户的score(判断是否存在)
        Double score = redisTemplate.opsForZSet().score(RANKING_ZSET_KEY, userId);
        if (score == null) {
            return new UserRankDTO(userId, 0, 0.0); // 无数据,排名0
        }

        // 3.2 获取“score > 当前用户score”的用户数量 = 排名(+1是因为排名从1开始)
        // ZCOUNT:统计score在指定区间的member数量
        Long higherCount = redisTemplate.opsForZSet().count(
            RANKING_ZSET_KEY, 
            ScoreRange.fromString(String.valueOf(score + 1e-9)) // 避免浮点精度问题,+1e-9
                .toPositiveInfinity()
        );

        // 3.3 处理并列排名(获取与当前用户score相同的用户数量)
        Long sameScoreCount = redisTemplate.opsForZSet().count(
            RANKING_ZSET_KEY, 
            ScoreRange.fromString(String.valueOf(score - 1e-9))
                .toString(String.valueOf(score + 1e-9))
        );

        return new UserRankDTO(
            userId,
            higherCount + 1, // 最终排名
            score,
            sameScoreCount.intValue() // 并列人数(可选)
        );
    }

    /**
     * 4. 按分数区间查询用户(如获取score在1000-2000之间的用户)
     */
    public List<UserRankDTO> getUsersByScoreRange(double minScore, double maxScore) {
        // ZREVRANGEBYSCORE:按score降序获取区间内的member
        Set<ZSetOperations.TypedTuple<String>> typedTuples = 
            redisTemplate.opsForZSet().reverseRangeByScoreWithScores(
                RANKING_ZSET_KEY, 
                minScore, 
                maxScore
            );

        if (typedTuples == null) {
            return Collections.emptyList();
        }

        // 解析结果(排名需要单独计算)
        return typedTuples.stream()
            .map(tuple -> {
                Long rank = redisTemplate.opsForZSet().reverseRank(RANKING_ZSET_KEY, tuple.getValue()) + 1;
                return new UserRankDTO(
                    tuple.getValue(),
                    rank.intValue(),
                    tuple.getScore()
                );
            })
            .collect(Collectors.toList());
    }
}

(2)关键API解析

API 用途 时间复杂度
ZADD key score member 添加/更新member的score O(logN)
ZREVRANGE key start stop [WITHSCORES] 按score降序获取区间member O(logN + K),K=返回数量
ZREVRANK key member 获取member的排名(降序,从0开始) O(logN)
ZSCORE key member 获取member的score O(1)
ZCOUNT key min max 统计score在[min,max]的member数量 O(logN)

3. 实操优化:避开ZSet的5个坑

(1)score的精度问题(double类型陷阱)

double类型有精度丢失风险,比如0.1 + 0.2 = 0.30000000000000004,导致分数计算错误。解决方案:将score转为整数存储,如乘以100(保留2位小数)或1000(保留3位小数):

// 错误:直接用double累加
double score = 0.1;
score += 0.2; // 变成0.30000000000000004

// 正确:用long存储整数(乘以100,保留2位小数)
long scoreInCent = 10; // 0.10
scoreInCent += 20; // 30 → 0.30
// 使用时转为double:scoreInCent / 100.0
redisTemplate.opsForZSet().add(RANKING_ZSET_KEY, userId, scoreInCent / 100.0);

(2)ZSet的内存占用优化

百万级member的ZSet会占用大量内存,需优化存储:

  • userId精简:用整数ID(如Long)代替字符串ID(如“user_123”),每个member可节省10-20字节;
  • 分数压缩:如用整数存储(见上文),避免double的额外开销;
  • 过期清理:历史排行榜(如“202404月榜”)可设置过期时间(EXPIRE key 2592000,30天过期),释放内存。

内存占用估算:每个ZSet元素(Long userId + double score)约占用24字节,100万元素约需24MB,1000万元素约需240MB,单机Redis完全可承受。

(3)并列排名的正确处理

ZSet的ZREVRANK会给相同score的member分配不同排名(按插入顺序),但业务中通常需要“并列排名”(如两个用户都是90分,排名都是第1)。解决方案:

  • 先查“分数高于当前用户”的数量(ZCOUNT key score+1 +inf),这是排名的起始值;
  • 再查“分数等于当前用户”的数量(ZCOUNT key score-eps score+eps,eps是精度值),即并列人数。

(4)大数量分页查询优化

当需要查询“第10000-10100名”时,ZREVRANGE 10000 10100的性能会下降(需遍历到第10000个元素)。解决方案:用ZREVRANGEBYSCORE按分数区间查询,而非按索引:

// 错误:深分页,性能差
Set<String> users = redisTemplate.opsForZSet().reverseRange(RANKING_ZSET_KEY, 10000, 10100);

// 正确:先获取第10000名的score,再查分数<=该score的用户
Double scoreOf10000 = redisTemplate.opsForZSet()
    .score(RANKING_ZSET_KEY, 
           redisTemplate.opsForZSet().reverseRange(RANKING_ZSET_KEY, 10000, 10000).iterator().next()
    );
// 查分数<=scoreOf10000的用户,取前100
Set<String> users = redisTemplate.opsForZSet()
    .reverseRangeByScore(RANKING_ZSET_KEY, 0, scoreOf10000, 0, 100);

(5)ZSet的持久化与高可用

单机Redis的ZSet存在数据丢失风险,需配置持久化和高可用:

  • 持久化:开启RDB+AOF混合持久化(RDB做全量备份,AOF做增量备份),确保重启后数据不丢失;
  • 高可用:搭建Redis主从复制(1主2从)+哨兵(Sentinel),主节点故障时自动切换到从节点,避免排行榜不可用。

4. 优缺点总结

优点 缺点
实时更新排名,响应速度快(O(logN)) 单机Redis内存有限(千万级以上需分片)
支持复杂查询(分数区间、并列排名) score精度需特殊处理,避免double陷阱
无需数据库,高并发下性能稳定 持久化和高可用配置复杂

五、方案四:分片+Redis集群(千万级,高并发)

当数据量突破千万级,单机Redis的内存和性能会达到瓶颈(如1000万元素的ZSet约需240MB内存,但若每个userId是长字符串,可能达数GB),此时需用Redis集群+分片实现水平扩展。

1. 核心思路:分片存储,分布式排序

将排行榜按“用户ID分片”,每个分片对应一个Redis节点,查询时:

  • 单分片查询:若只需某个分片的Top N(如“华东区排行榜”),直接查询对应分片;
  • 全局排行榜:聚合所有分片的Top N数据,再排序得到全局Top N(如“全服排行榜”)。

2. 分片策略选择:哈希分片 vs 范围分片

(1)哈希分片(推荐)

按用户ID的哈希值分配到不同分片,实现负载均衡:

@Service
public class ShardedRankingService {
    @Autowired
    private RedissonClient redissonClient;

    // 分片数量:16(需与Redis集群节点数匹配,避免数据倾斜)
    private static final int SHARD_COUNT = 16;
    // 排行榜前缀(每个分片的Key=前缀+分片ID)
    private static final String RANKING_SHARD_PREFIX = "game:ranking:shard:";

    /**
     * 1. 计算用户ID对应的分片(哈希分片)
     */
    private int getShardId(String userId) {
        // 一致性哈希:避免分片扩容时数据迁移过多(Redisson已内置)
        // 简化版:用userId的哈希值取模,实际推荐用一致性哈希
        return Math.abs(userId.hashCode()) % SHARD_COUNT;
    }

    /**
     * 2. 添加/更新用户分数(按分片存储)
     */
    public void updateUserScore(String userId, double score) {
        int shardId = getShardId(userId);
        String shardKey = RANKING_SHARD_PREFIX + shardId;
        // Redisson的RScoredSortedSet:封装ZSet操作,支持分布式
        RScoredSortedSet<String> zSet = redissonClient.getScoredSortedSet(shardKey);
        zSet.add(score, userId);
    }

    /**
     * 3. 获取单分片的Top N(如“分片0的Top 100”)
     */
    public List<UserRankDTO> getShardTopRanking(int shardId, int topN) {
        if (shardId < 0 || shardId >= SHARD_COUNT) {
            throw new IllegalArgumentException("分片ID无效");
        }
        String shardKey = RANKING_SHARD_PREFIX + shardId;
        RScoredSortedSet<String> zSet = redissonClient.getScoredSortedSet(shardKey);
        // 按score降序取前N名
        Collection<String> topUsers = zSet.valueRangeReversed(0, topN - 1);

        List<UserRankDTO> result = new ArrayList<>(topN);
        int rank = 1;
        for (String userId : topUsers) {
            Double score = zSet.getScore(userId);
            result.add(new UserRankDTO(userId, rank++, score));
        }
        return result;
    }

    /**
     * 4. 获取全局Top N(聚合所有分片的Top N,再排序)
     */
    public List<UserRankDTO> getGlobalTopRanking(int topN) {
        // 1. 并行查询所有分片的Top N(提高效率)
        ExecutorService executor = Executors.newFixedThreadPool(SHARD_COUNT);
        List<Future<List<UserRankDTO>>> futures = new ArrayList<>(SHARD_COUNT);
        for (int i = 0; i < SHARD_COUNT; i++) {
            int shardId = i;
            futures.add(executor.submit(() -> getShardTopRanking(shardId, topN)));
        }

        // 2. 聚合所有分片的结果
        List<UserRankDTO> allCandidates = new ArrayList<>(SHARD_COUNT * topN);
        for (Future<List<UserRankDTO>> future : futures) {
            try {
                allCandidates.addAll(future.get());
            } catch (InterruptedException | ExecutionException e) {
                log.error("查询分片排行榜失败", e);
            }
        }
        executor.shutdown();

        // 3. 去重+排序+截取全局Top N
        return allCandidates.stream()
            .collect(Collectors.toMap(
                UserRankDTO::getUserId,
                Function.identity(),
                (a, b) -> b.getScore() > a.getScore() ? b : a // 相同用户保留高分
            ))
            .values().stream()
            .sorted((a, b) -> Double.compare(b.getScore(), a.getScore()))
            .limit(topN)
            .collect(Collectors.toList());
    }
}

(2)范围分片(适合按业务分区)

按用户ID的范围分配分片(如userId以1-10000开头的用户分到分片0,10001-20000分到分片1),适合“按区域/等级分区”的排行榜(如“新手区排行榜”“老区排行榜”)。

3. Redis集群部署:Redisson vs Redis Cluster

(1)Redisson分片(简化版集群)

Redisson支持客户端分片,无需搭建Redis Cluster,适合中小团队:

  • 配置多个Redis节点,Redisson客户端自动按分片策略分配数据;
  • 支持动态扩容:新增分片时,需手动迁移数据(或用一致性哈希减少迁移量)。

(2)Redis Cluster(推荐,大规模场景)

Redis官方集群方案,支持自动分片(16384个Slot)、主从复制和故障转移:

  • Slot分配:每个Redis节点负责一部分Slot,用户ID的哈希值映射到Slot,再由Slot找到对应的节点;
  • 全局排行榜优化:用Redis Cluster的ASK/MOVED命令,自动路由到目标节点,聚合查询时无需客户端手动分片;
  • 动态扩容:支持在线添加节点,自动迁移Slot和数据,无需停机。

4. 跨分片查询的解决方案

全局排行榜需要聚合所有分片的数据,这是分片方案的核心难点,有两种优化思路:

(1)预计算全局Top N

  • 定时任务(如每5分钟)查询所有分片的Top 1000,聚合后存入“全局排行榜ZSet”;
  • 用户查询全局排行榜时,直接读取这个预计算的ZSet,避免实时聚合的性能开销。

(2)增量聚合

  • 每个分片维护“分片Top 100”,并将这些数据同步到一个“聚合节点”;
  • 聚合节点实时维护全局Top 1000,用户查询时直接从聚合节点获取。

5. 优缺点总结

优点 缺点
水平扩展能力强,支持千万级以上数据 跨分片查询复杂,全局排行榜需预计算
高并发下性能稳定,单分片负载低 架构复杂,需维护Redis集群和分片策略
支持业务分区(如区域榜、等级榜) 数据迁移和扩容成本高

六、方案五:预计算+分层缓存(读多写少,高访问量)

当排行榜“更新频率低但访问量极高”(如日销量榜、周热度榜,每天更新一次,但每秒有1000次查询),需用“预计算+分层缓存”进一步提升性能,减少Redis压力。

1. 核心思路:“本地缓存+Redis+预计算”三层架构

  • 预计算:每天凌晨用离线任务计算排行榜结果,避免实时计算;
  • 分层缓存:本地缓存(如Caffeine)→ Redis → 数据库,优先从上层缓存读取,减少下层压力。

2. 基础实现:Caffeine本地缓存 + Redis + 定时预计算

(1)预计算任务(离线计算排行榜)

@Service
public class PrecomputeRankingService {
    @Autowired
    private UserScoreDao userScoreDao;
    @Autowired
    private StringRedisTemplate redisTemplate;
    // 本地缓存(Caffeine:高性能本地缓存,命中率高于Guava Cache)
    private final LoadingCache<String, Integer> localRankCache = Caffeine.newBuilder()
        .maximumSize(100_000) // 本地缓存最大10万条(存储热点用户的排名)
        .expireAfterWrite(30, TimeUnit.MINUTES) // 30分钟过期
        .build(this::loadRankFromRedis); // 缓存未命中时,从Redis加载

    // Redis哈希表Key:存储用户ID→排名(日销量榜)
    private static final String RANKING_HASH_KEY = "ranking:daily:hash";
    // Redis List Key:存储Top 1000排行榜
    private static final String RANKING_TOP_LIST_KEY = "ranking:daily:top1000";

    /**
     * 1. 每日凌晨1点预计算排行榜(离线任务)
     */
    @Scheduled(cron = "0 0 1 * * ?") // 每天1点执行
    public void precomputeDailyRanking() {
        log.info("开始预计算每日排行榜");
        long start = System.currentTimeMillis();

        // 1. 从数据库查询所有用户的分数,排序后生成排名
        List<UserScoreDTO> allUsers = userScoreDao.queryAllSorted(); // 按score降序查询所有用户
        Map<String, Integer> userIdToRank = new HashMap<>(allUsers.size());
        List<String> top1000 = new ArrayList<>(1000);

        int rank = 1;
        double lastScore = -1;
        int sameScoreCount = 1;
        for (int i = 0; i < allUsers.size(); i++) {
            UserScoreDTO user = allUsers.get(i);
            // 处理并列排名
            if (i > 0 && user.getScore() == lastScore) {
                sameScoreCount++;
            } else {
                rank = i + 1;
                lastScore = user.getScore();
                sameScoreCount = 1;
            }
            // 存储用户→排名映射
            userIdToRank.put(user.getUserId(), rank);
            // 记录Top 1000
            if (i < 1000) {
                top1000.add(user.getUserId() + ":" + user.getScore());
            }
        }

        // 2. 写入Redis(哈希表存储所有用户排名,List存储Top 1000)
        redisTemplate.delete(RANKING_HASH_KEY);
        redisTemplate.delete(RANKING_TOP_LIST_KEY);
        // 批量写入哈希表(效率高于多次HSET)
        redisTemplate.opsForHash().putAll(RANKING_HASH_KEY, userIdToRank);
        // 写入Top 1000列表
        redisTemplate.opsForList().rightPushAll(RANKING_TOP_LIST_KEY, top1000);

        // 3. 清空本地缓存(触发重新加载)
        localRankCache.invalidateAll();

        log.info("预计算每日排行榜完成,耗时{}ms,共{}个用户", 
            System.currentTimeMillis() - start, allUsers.size());
    }

    /**
     * 2. 从本地缓存获取用户排名(优先)
     */
    public Integer getUserRank(String userId) {
        try {
            // 本地缓存命中:直接返回(O(1),响应<1ms)
            return localRankCache.get(userId);
        } catch (Exception e) {
            log.error("本地缓存获取排名失败,降级到Redis", e);
            // 本地缓存失效,直接从Redis获取
            return loadRankFromRedis(userId);
        }
    }

    /**
     * 3. 从Redis加载用户排名(本地缓存未命中时调用)
     */
    private Integer loadRankFromRedis(String userId) {
        Object rankObj = redisTemplate.opsForHash().get(RANKING_HASH_KEY, userId);
        if (rankObj == null) {
            // Redis未命中,降级到数据库(仅在预计算失败时触发)
            return userScoreDao.getUserRank(userId);
        }
        return (Integer) rankObj;
    }

    /**
     * 4. 从Redis获取Top 1000排行榜(本地缓存不存Top列表,避免内存占用过高)
     */
    public List<UserScoreDTO> getTop1000Ranking() {
        List<String> topList = redisTemplate.opsForList().range(RANKING_TOP_LIST_KEY, 0, 999);
        if (topList == null || topList.isEmpty()) {
            return Collections.emptyList();
        }
        return topList.stream()
            .map(value -> {
                String[] parts = value.split(":");
                return new UserScoreDTO(parts[0], Double.parseDouble(parts[1]));
            })
            .collect(Collectors.toList());
    }
}

(2)Caffeine本地缓存的优势

  • 高命中率:采用“Window TinyLfu”淘汰策略,适合热点数据(如前10万用户的排名查询);
  • 低延迟:本地内存访问,响应时间<1ms,远快于Redis的网络请求(10-20ms);
  • 线程安全:支持并发访问,无需额外加锁。

3. 优化技巧:热点数据与缓存更新

(1)热点用户的本地缓存优先

将查询频率高的“热点用户”(如Top 1000用户)优先存入本地缓存,减少Redis请求:

// 预计算时,将Top 1000用户的排名直接写入本地缓存
for (int i = 0; i < Math.min(1000, allUsers.size()); i++) {
    UserScoreDTO user = allUsers.get(i);
    localRankCache.put(user.getUserId(), userIdToRank.get(user.getUserId()));
}

(2)缓存更新的通知机制

当预计算任务更新Redis后,需通知所有应用实例刷新本地缓存,避免“本地缓存不一致”:

  • 用消息队列(如RabbitMQ)发送“缓存更新通知”;
  • 应用实例收到通知后,调用localRankCache.invalidateAll()清空本地缓存。

4. 优缺点总结

优点 缺点
访问性能极高(本地缓存<1ms) 数据实时性差(每日更新,无法实时反映变化)
大幅减轻Redis和数据库压力 预计算任务资源消耗大(全量查询所有用户)
适合读多写少场景,扩展性强 本地缓存占用内存,多实例需同步更新

七、方案六:实时计算+流处理(千万级,实时)

当业务需要“秒级实时更新”且数据量极大(如社交平台的实时话题榜、秒杀活动的实时销量榜),传统方案已无法满足,需用流处理框架(如Flink) 实时计算排名。

1. 核心架构:Kafka + Flink + Redis/TiDB

  • 数据源:用户行为数据(如点赞、购买、得分)实时写入Kafka;
  • 实时计算:Flink消费Kafka数据,实时计算用户分数和排名;
  • 结果存储:将实时排名写入Redis(供查询)或TiDB(分布式数据库,供持久化)。

2. 基础实现:Flink实时计算排行榜

(1)Flink状态管理:存储用户分数

Flink用MapState存储每个用户的当前分数,支持实时更新:

public class UserScoreProcessFunction extends ProcessFunction<UserAction, Tuple2<String, Double>> {
    // 存储用户ID→当前分数的状态(Flink State:故障恢复时数据不丢失)
    private MapState<String, Double> userScoreState;

    @Override
    public void open(Configuration parameters) throws Exception {
        // 初始化MapState(用RocksDB作为State后端,支持大状态)
        MapStateDescriptor<String, Double> stateDesc = new MapStateDescriptor<>(
            "userScoreState",
            BasicTypeInfo.STRING_TYPE_INFO,
            BasicTypeInfo.DOUBLE_TYPE_INFO
        );
        userScoreState = getRuntimeContext().getMapState(stateDesc);
    }

    /**
     * 处理每条用户行为数据(如“用户A购买了商品,得分+10”)
     */
    @Override
    public void processElement(UserAction action, Context ctx, Collector<Tuple2<String, Double>> out) throws Exception {
        // 1. 计算新增分数(根据行为类型:购买+10,点赞+1,评论+2)
        double addScore = getScoreByActionType(action.getType());

        // 2. 更新用户当前分数(从State读取旧分数,累加后写入)
        String userId = action.getUserId();
        double oldScore = userScoreState.getOrDefault(userId, 0.0);
        double newScore = oldScore + addScore;
        userScoreState.put(userId, newScore);

        // 3. 输出用户ID和新分数(供下游计算排名)
        out.collect(Tuple2.of(userId, newScore));
    }

    /**
     * 根据行为类型获取分数
     */
    private double getScoreByActionType(ActionType type) {
        switch (type) {
            case PURCHASE: return 10.0;
            case LIKE: return 1.0;
            case COMMENT: return 2.0;
            default: return 0.0;
        }
    }
}

(2)Flink窗口与排名计算

用“滑动窗口”实时维护Top N排名(如每5秒更新一次Top 1000):

public class RealTimeRankingJob {
    public static void main(String[] args) throws Exception {
        // 1. 创建Flink执行环境
        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        // 启用Checkpoint(每10秒一次,故障恢复时恢复State)
        env.enableCheckpointing(10000);
        env.getCheckpointConfig().setCheckpointStorage("hdfs:///flink/checkpoints/");

        // 2. 从Kafka读取用户行为数据
        Properties kafkaProps = new Properties();
        kafkaProps.setProperty("bootstrap.servers", "kafka:9092");
        kafkaProps.setProperty("group.id", "flink-ranking-group");
        DataStream<UserAction> actionStream = env.addSource(
            new FlinkKafkaConsumer<>("user-actions", new UserActionSchema(), kafkaProps)
        );

        // 3. 实时计算用户分数(调用上文的ProcessFunction)
        DataStream<Tuple2<String, Double>> scoreStream = actionStream
            .keyBy(UserAction::getUserId) // 按用户ID分组
            .process(new UserScoreProcessFunction());

        // 4. 全局窗口计算Top 1000(每5秒更新一次)
        DataStream<List<UserRankDTO>> rankingStream = scoreStream
            .keyBy(t -> "global") // 全局分组(计算全服排名)
            .window(TumblingProcessingTimeWindows.of(Time.seconds(5))) // 5秒滚动窗口
            .process(new TopNRankingProcessFunction(1000)); // 计算Top 1000

        // 5. 将排名结果写入Redis(供查询)
        rankingStream.addSink(new RedisRankingSink());

        // 6. 执行任务
        env.execute("Real-Time Ranking Job");
    }
}

/**
 * 计算窗口内的Top N排名
 */
class TopNRankingProcessFunction extends ProcessWindowFunction<
    Tuple2<String, Double>, // 输入:(userId, score)
    List<UserRankDTO>,       // 输出:Top N排名列表
    String,                  // Key类型:全局分组(固定为"global")
    TimeWindow               // 窗口类型
> {
    private final int topN;

    public TopNRankingProcessFunction(int topN) {
        this.topN = topN;
    }

    @Override
    public void process(String key, Context context, 
                       Iterable<Tuple2<String, Double>> elements, 
                       Collector<List<UserRankDTO>> out) {
        // 1. 收集窗口内所有用户的分数(去重,保留最高分)
        Map<String, Double> userIdToScore = new HashMap<>();
        for (Tuple2<String, Double> element : elements) {
            String userId = element.f0;
            double score = element.f1;
            // 保留用户的最高分
            userIdToScore.put(userId, Math.max(userIdToScore.getOrDefault(userId, 0.0), score));
        }

        // 2. 排序并截取Top N
        List<UserRankDTO> topList = userIdToScore.entrySet().stream()
            .sorted((a, b) -> Double.compare(b.getValue(), a.getValue()))
            .limit(topN)
            .map(entry -> new UserRankDTO(
                entry.getKey(),
                // 计算排名(从1开始)
                (int) userIdToScore.entrySet().stream()
                    .filter(e -> e.getValue() > entry.getValue())
                    .count() + 1,
                entry.getValue()
            ))
            .collect(Collectors.toList());

        // 3. 输出Top N排名
        out.collect(topList);
    }
}

(3)结果写入Redis:供业务查询

class RedisRankingSink extends RichSinkFunction<List<UserRankDTO>> {
    private StringRedisTemplate redisTemplate;

    @Override
    public void open(Configuration parameters) {
        // 初始化Redis连接(实际项目中用连接池)
        RedisStandaloneConfiguration config = new RedisStandaloneConfiguration("redis", 6379);
        redisTemplate = new StringRedisTemplate(new JedisConnectionFactory(config));
        redisTemplate.afterPropertiesSet();
    }

    @Override
    public void invoke(List<UserRankDTO> value, Context context) {
        // 写入Redis ZSet(实时排行榜)
        String zSetKey = "ranking:real-time:top1000";
        redisTemplate.delete(zSetKey);
        for (UserRankDTO rankDTO : value) {
            redisTemplate.opsForZSet().add(zSetKey, rankDTO.getUserId(), rankDTO.getScore());
        }
    }
}

3. 关键技术点:确保实时性与可靠性

(1)Flink State后端选择

  • 内存State:适合小状态(如10万用户),速度快但故障后数据丢失;
  • RocksDB State:适合大状态(千万级用户),数据持久化到磁盘,故障后可恢复,支持增量Checkpoint。

(2)窗口策略选择

  • 滚动窗口:固定时间间隔更新(如每5秒),适合“周期性更新”的排行榜;
  • 滑动窗口:重叠时间窗口(如每1秒更新,窗口大小5秒),适合“近实时”的排行榜,更新更频繁。

(3)Exactly-Once语义

确保用户行为数据不重复计算、不丢失:

  • Kafka消费者开启“自动提交offset”,配合Flink的Checkpoint,实现offset与State的一致性;
  • RedisSink用“原子操作”(如ZADD覆盖旧数据),避免重复写入。

4. 优缺点总结

优点 缺点
秒级实时更新,满足高实时性需求 架构复杂,需维护Kafka、Flink、Redis集群
可处理千万级以上数据,支持复杂计算逻辑 运维成本高,需专业流处理团队
故障可恢复,数据可靠性高(Checkpoint) 资源消耗大(Flink需大量CPU和内存)

八、方案对比与选型指南

方案 数据量 实时性 复杂度 运维成本 适用场景
数据库直接排序 万级以下 低(分钟级) 低 低 内部系统、个人项目、小流量应用
缓存+定时任务 十万-百万级 中(分钟级) 中 中 中小型电商、区域游戏、可接受延迟的排行榜
Redis有序集合 百万级 高(秒级) 中 中 大型游戏、高频更新的排行榜(如战力榜)
分片+Redis集群 千万级以上 高(秒级) 高 高 超大型游戏全服榜、头部电商全平台销量榜
预计算+分层缓存 百万-千万级 中(小时级) 高 中 读多写少的排行榜(如日销量榜、周热度榜)
实时计算+流处理 千万级以上 实时(秒级) 极高 极高 社交平台实时话题榜、秒杀实时销量榜

选型优先级:

  1. 先看数据量:万级选方案一,十万-百万级选方案二/三,千万级以上选方案四/六;
  2. 再看实时性:非实时选方案一/二/五,准实时选方案三/四,实时选方案六;
  3. 最后看团队能力:小团队优先方案一/二/三,有分布式经验选方案四,有流处理经验选方案六。

九、实战案例:游戏排行榜的演进之路

以一款游戏的战力榜为例,看如何从初期到成熟期选择方案:

  1. 初期(内测,1万用户):用方案一(数据库直接排序),快速上线,无需额外组件;
  2. 中期(公测,10万用户):切换到方案二(缓存+定时任务),1分钟更新一次,减轻数据库压力;
  3. 成熟期(正式运营,100万用户):升级到方案三(Redis ZSet),实时更新战力,支持玩家实时查看排名;
  4. 爆发期(全服1000万用户):扩容到方案四(分片+Redis集群),按服务器分片,同时维护“单服榜”和“全服榜”;
  5. 巅峰期(多端运营,5000万用户):引入方案六(Flink实时计算),支持跨服战实时战力榜,秒级更新。

十、总结:没有最好,只有最适合

排行榜的设计没有“银弹”,关键是匹配业务需求与技术成本:

  • 小业务别过度设计,数据库+简单缓存就能满足需求;
  • 中业务优先用Redis ZSet,平衡性能与复杂度;
  • 大业务需提前规划架构,分片、流处理等技术要尽早储备。

最后,无论选择哪种方案,都要做好性能测试与监控:

  • 性能测试:用JMeter压测排行榜查询QPS,确保峰值时响应时间<100ms;
  • 监控指标:数据库SQL执行时间、Redis ZSet操作耗时、缓存命中率、Flink任务延迟,及时发现瓶颈。

希望本文能帮你在实际业务中找到最适合的排行榜方案,避开所有坑!

除非注明,否则均为李锋镝的博客原创文章,转载必须以链接形式标明本文链接

本文链接:https://www.lifengdi.com/hou-duan/4543

相关文章

  • 从3秒到30毫秒!SpringBoot树形结构深度优化指南:不止于O(n)算法的全链路提速方案
  • 缓存架构实战指南:6大核心缓存技术深度解析与落地方案
  • SpringBoot整合GraphQL入门教程
  • 九种常用的UML图总结
  • 数据库更新如何实现乐观锁
本作品采用 知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议 进行许可
标签: JAVA Redis SQL 排序 排行榜 架构
最后更新:2025年10月29日

李锋镝

既然选择了远方,便只顾风雨兼程。

打赏 点赞
< 上一篇
下一篇 >

文章评论

1 2 3 4 5 6 7 8 9 11 12 13 14 15 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 46 47 48 49 50 51 52 53 54 55 57 58 60 61 62 63 64 65 66 67 69 72 74 76 77 78 79 80 81 82 85 86 87 90 92 93 94 95 96 97 98 99
取消回复

寻寻觅觅,冷冷清清,凄凄惨惨戚戚。乍暖还寒时候,最难将息。三杯两盏淡酒,怎敌他、晚来风急!雁过也,正伤心,却是旧时相识。
满地黄花堆积,憔悴损,如今有谁堪摘?守着窗儿,独自怎生得黑!梧桐更兼细雨,到黄昏、点点滴滴。这次第,怎一个愁字了得!

那年今日(01月25日)

  • 1979年:中国左翼文学运动开创者之一郑伯奇逝世
  • 1949年:日本帝国时期的政治家牧野伸显逝世
  • 1924年:第一届奥林匹克冬季运动会在夏蒙尼开幕
  • 1911年:中国第一部专门刑法典颁布
  • 1504年:意大利艺术家米开朗基罗完成大卫雕像
  • 更多历史事件
最新 热点 随机
最新 热点 随机
AI时代,个人技术博客的出路在哪里? 什么是Meta Server? 千万级大表新增字段实战指南:告别锁表与业务中断 在 SQL 中做范围查询时,使用 BETWEEN AND 和直接用 >/=/ 深度解析 Disruptor:无锁队列的高性能实现与实践 精通Linux根目录:核心文件夹深度解析与实战指南
玩博客的人是不是越来越少了?准备入手个亚太的ECS,友友们有什么建议吗?AI时代,个人技术博客的出路在哪里?使用WireGuard在Ubuntu 24.04系统搭建VPNWordPress实现用户评论等级排行榜插件WordPress网站换了个字体,差点儿把样式换崩了
基于Java8的Either类 居家办公了~ 祝大家六一儿童节快乐~~~ IntelliJ IDEA 2020.3.x永久白嫖(Windows/Mac) 看病难~取药难~~ 睡觉睡不踏实
标签聚合
WordPress K8s 分布式 AI编程 多线程 设计模式 JAVA ElasticSearch Redis SpringBoot JVM SQL AI docker IDEA 架构 MySQL 日常 数据库 Spring
友情链接
  • Blogs·CN
  • Honesty
  • Mr.Sun的博客
  • 临窗旋墨
  • 哥斯拉
  • 彬红茶日记
  • 志文工作室
  • 懋和道人
  • 搬砖日记
  • 旧时繁华
  • 林羽凡
  • 瓦匠个人小站
  • 皮皮社
  • 知向前端
  • 蜗牛工作室
  • 韩小韩博客
  • 风渡言

COPYRIGHT © 2026 lifengdi.com. ALL RIGHTS RESERVED.

域名年龄

Theme Kratos Made By Dylan

津ICP备2024022503号-3

京公网安备11011502039375号