李锋镝的博客

  • 首页
  • 时间轴
  • 说说
  • 读书观影
  • 系列
  • AI工具集
  • 论坛
  • 友圈
    • 友链
    • 朋友圈
    • 游客中心
  • 留言
    • 留言
    • 走心评论
  • 关于
    • 关于本站
    • Now
    • 每日心情
    • 网站地图
    • 网站统计
    • 赞助
  • 星球穿梭
  • 🚇开往
静亦定 动亦定
惟坚韧者始能遂其志
  1. 首页
  2. 后端
  3. 正文

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

2025年10月29日 288 1 0
本文最后更新于 2025年10月29日,距今已 374 天,其中的信息可能已经发生变化,请注意甄别。

在业务开发中,排行榜是一个高频需求——游戏中的战力榜、电商的销量榜、内容平台的点赞榜、社交产品的热度榜……看似简单的“排序展示”,背后却藏着数据量与实时性的博弈。很多团队初期用“数据库排序”快速上线,却在用户量突破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任务延迟,及时发现瓶颈。

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

本作品采用 知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议 进行许可
分享到

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

也可使用浏览器菜单中的「分享」功能

微信扫一扫分享

标签: JAVA Redis SQL 排序 排行榜 架构
最后更新:2025年10月29日

岁月同一天 11 月 7 日

回望过去的今天,你在写什么

  • AI
    1 年前 2025年11月7日
    DeepSeek本地部署全攻略:从环境搭建到高级应用,打造专属 AI 助手

    DeepSeek 作为国产开源大模型的佼佼者,凭借出色的推理能力、代码生成效率和中文支持,成为很多开发者本地部署的首选。…

相关文章
  • 设计模式总结2019年12月13日
  • ThreadPoolExecutor如何实现线程复用及超时销毁2021年6月18日
  • 是时候优雅的和NullPointException说再见了2022年7月15日
  • 记一次Apollo配置中心+Spring配置自动刷新导致的内存泄露问题2026年7月31日
  • 我要狠狠的反驳“公司禁止使用 Lombok ”的观点!2021年3月3日

李锋镝

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

打赏 点赞
< 上一篇
下一篇 >
1234567891112131415161718192021222324252627282930313233343536373839404142434446474849505152535455575859606162636465666769727476777879808182858687909293949596979899
取消回复
…

文章评论

还没有评论,快来抢沙发吧~

一个人有两个我,一个在黑暗里醒着,一个在光明中睡着。

听点儿音乐吧 朋友~
文章目录
最新 热点 随机
最新 热点 随机
给服务器加了WAF 又一个逆天改命的人:小小徐 AI的生成能力等于生产力吗? NGINX严重漏洞(CVE-2026-15176、CVE-2026-93479、CVE-2026-97205) Kratos-plus v1.1.27更新说明 php2shell远程执行漏洞修复(CVE-2026-109618 和 CVE-2026-109619)
给主题增加了游客中心功能心气没了,干什么都不得劲我觉得是时候公布我的导航网站了给服务器加了WAF游本昌去世关于主题加载速度优化的一点儿小演进
居家办公了~ 玉楼春·尊前拟把归期说 居住证可算是申请通过了…… MySQL语法总结 AI Agent 扩展双雄:MCP 与 Agent Skill 深度拆解——从设计哲学到实战落地 Taste Skill 说明与使用
最近评论
fairy 发布于 17 小时前(11月06日) 机器人快来我这里吧!
李锋镝 发布于 17 小时前(11月06日) 都是机器人,防不住
李锋镝 发布于 17 小时前(11月06日) 是的,太烦了
李锋镝 发布于 17 小时前(11月06日) 可以同一台服务器
fairy 发布于 19 小时前(11月06日) 这流量,好厉害啊。我一年都没那么多
标签聚合
数据库 SpringBoot MySQL AI 架构 JVM JAVA Theme Redis 分布式 Spring AI编程 Claude 多线程 ElasticSearch 日常 K8s SQL IDEA WordPress
友情链接
  • 瓦匠个人小站
  • lijie blog
  • Mr.Sun的博客
  • 志文工作室
  • Honesty
  • 韩小韩博客
  • 知向前端
  • Serendipity
  • sssr7844的博客
  • 哥斯拉
  • 韩情脉脉
  • 九仞之行
  • 搬砖日记
  • 老张博客
  • 彬红茶日记
  • 刘永博客
  • 懋和道人
  • 林羽凡
  • 皮皮社

COPYRIGHT © 2016-2026 lifengdi.com. ALL RIGHTS RESERVED.

lifengdi.com 十年之约虫洞

Domain age badge for lifengdi.com

Theme Kratos-plus By Dylan Li

津ICP备2024022503号-3

京公网安备11011502039375号