🎯 核心挑战 — 5大难题
挑战1:Feed流 — 首页刷到什么内容?
打开微博/小红书,首页显示的内容是怎么来的?这就是Feed流的核心问题:给用户看什么?
类比理解:报纸主动送上门=推模式,你去报摊自己挑=拉模式。推模式省事但可能推的内容你不喜欢,拉模式精准但要自己费劲。
推模式 vs 拉模式
推模式(写扩散)
作者发帖
→
写入所有粉丝的收件箱
→
粉丝刷Feed直接读
拉模式(读扩散)
作者发帖
→
写入发帖者时间线
→
粉丝刷Feed时实时拉取关注人的帖子
| 对比维度 |
推模式 |
拉模式 |
推拉结合 |
| 写入开销 |
大:1帖×N粉丝=N次写入 |
小:1帖=1次写入 |
中等:活跃用户推、不活跃拉 |
| 读取开销 |
小:直接读自己收件箱 |
大:实时聚合N个关注人 |
较小:活跃部分预计算 |
| 写延迟 |
高(粉丝多时) |
低 |
中等 |
| 读延迟 |
低 |
高 |
较低 |
| 适用场景 |
粉丝量中等、读多 |
关注量多、写多 |
混合场景 |
| 典型应用 |
朋友圈 |
Twitter |
微博/小红书 |
✅ 实战最优解:推拉结合 — 普通用户发帖用推模式(粉丝不多,写入量可控),大V发帖用拉模式(粉丝太多,写不完),活跃粉丝推到收件箱(经常看的保证体验),不活跃粉丝按需拉取(偶尔看的省存储)。
Redis Timeline代码实现
// Feed流服务 — 推拉结合方案
@Service
public class FeedService {
@Autowired
private RedisTemplate<String, String> redisTemplate;
@Autowired
private UserFollowRepository followRepo;
@Autowired
private RocketMQTemplate mqTemplate;
// 大V粉丝数阈值:超过此值用拉模式
private static final long BIG_V_THRESHOLD = 10000;
// 推模式推送的活跃粉丝数上限
private static final int ACTIVE_PUSH_LIMIT = 1000;
/**
* 发帖后Feed分发 — 推拉结合
*/
public void distributePost(Post post) {
String authorId = post.getAuthorId();
long followerCount = followRepo.countFollowers(authorId);
// 第1步:写入作者自己的发帖时间线(所有模式都需要)
String authorTimelineKey = "timeline:user:" + authorId;
redisTemplate.opsForZSet().add(
authorTimelineKey, post.getId(), post.getCreatedAt()
);
if (followerCount <= BIG_V_THRESHOLD) {
// 普通用户:推模式 — 给所有粉丝的收件箱写入
pushToFollowers(post);
} else {
// 大V:推拉结合 — 只推给活跃粉丝,其他按需拉
pushToActiveFollowers(post);
}
}
/**
* 推模式:写入所有粉丝的收件箱
*/
private void pushToFollowers(Post post) {
List<String> followerIds = followRepo.findFollowerIds(post.getAuthorId());
// 异步批量写入,不阻塞发帖
Lists.partition(followerIds, 100).forEach(batch -> {
FeedPushMessage msg = new FeedPushMessage();
msg.setPostId(post.getId());
msg.setAuthorId(post.getAuthorId());
msg.setTargetUserIds(batch);
msg.setTimestamp(post.getCreatedAt());
mqTemplate.asyncSend("feed-push", msg);
});
}
/**
* 推拉结合:只推给活跃粉丝
*/
private void pushToActiveFollowers(Post post) {
// 查询最近7天活跃的粉丝
List<String> activeFollowers = followRepo.findActiveFollowerIds(
post.getAuthorId(), ACTIVE_PUSH_LIMIT
);
for (String followerId : activeFollowers) {
String inboxKey = "feed:inbox:" + followerId;
redisTemplate.opsForZSet().add(
inboxKey, post.getId(), post.getCreatedAt()
);
}
// 不活跃粉丝:不推,刷Feed时实时拉取
}
/**
* 获取用户Feed流
*/
public List<Post> getFeed(String userId, long maxScore, int count) {
String inboxKey = "feed:inbox:" + userId;
// 第1步:从收件箱获取帖子ID(推模式部分)
Set<String> postIds = redisTemplate.opsForZSet()
.reverseRangeByScore(inboxKey, 0, maxScore, 0, count);
// 第2步:补充拉模式部分(大V的帖子)
List<String> bigVIds = followRepo.findBigVFollowing(userId);
for (String bigVId : bigVIds) {
String timelineKey = "timeline:user:" + bigVId;
Set<String> bigVPostIds = redisTemplate.opsForZSet()
.reverseRangeByScore(timelineKey, 0, maxScore, 0, 20);
postIds.addAll(bigVPostIds);
}
// 第3步:合并排序 + 分页
List<Post> posts = postRepo.findByIds(postIds);
posts.sort(Comparator.comparingLong(Post::getCreatedAt).reversed());
return posts.stream().limit(count).collect(Collectors.toList());
}
}
挑战2:点赞评论 — 100万赞怎么高效计数?
一篇爆款文章可能有上百万赞。如果每次点赞都写数据库,数据库扛不住。
类比理解:统计点赞数就像计票——你不需要记住每张票是谁投的,只需要知道总数。但如果要做排行榜,就需要记录"谁投了谁"。
用户点赞
→
Redis计数器+1
→
异步写DB(持久化)
查询点赞数
→
直接读Redis(O(1))
// 点赞服务 — 高并发计数方案
@Service
public class LikeService {
@Autowired
private RedisTemplate<String, String> redisTemplate;
@Autowired
private RocketMQTemplate mqTemplate;
// 点赞计数Key: like:count:{postId}
// 点赞去重Set: like:set:{postId}
// 点赞排行榜: like:ranking:{topicId}
/**
* 点赞 — 幂等设计(同一用户对同一帖子只能赞一次)
*/
public boolean like(String postId, String userId) {
String setKey = "like:set:" + postId;
// 用Set去重:同一个用户只能赞一次
Long added = redisTemplate.opsForSet().add(setKey, userId);
if (added > 0) {
// 第一次点赞,计数器+1
String countKey = "like:count:" + postId;
redisTemplate.opsForValue().increment(countKey);
// 排行榜:用Sorted Set,score=点赞数
String authorId = postRepo.findAuthorId(postId);
String rankingKey = "like:ranking:" + postRepo.getTopicId(postId);
redisTemplate.opsForZSet().incrementScore(rankingKey, postId, 1);
// 异步写入DB(持久化)
LikeMessage msg = new LikeMessage(postId, userId, System.currentTimeMillis());
mqTemplate.asyncSend("like-persist", msg);
return true;
}
// 已经赞过了,幂等返回
return false;
}
/**
* 取消点赞
*/
public boolean unlike(String postId, String userId) {
String setKey = "like:set:" + postId;
Long removed = redisTemplate.opsForSet().remove(setKey, userId);
if (removed > 0) {
String countKey = "like:count:" + postId;
redisTemplate.opsForValue().decrement(countKey);
// 排行榜分数-1
String rankingKey = "like:ranking:" + postRepo.getTopicId(postId);
redisTemplate.opsForZSet().incrementScore(rankingKey, postId, -1);
return true;
}
return false;
}
/**
* 获取点赞数 — 直接读Redis,O(1)极快
*/
public long getLikeCount(String postId) {
String countKey = "like:count:" + postId;
String count = redisTemplate.opsForValue().get(countKey);
return count == null ? 0 : Long.parseLong(count);
}
/**
* 判断用户是否已赞
*/
public boolean hasLiked(String postId, String userId) {
String setKey = "like:set:" + postId;
return Boolean.TRUE.equals(redisTemplate.opsForSet().isMember(setKey, userId));
}
/**
* 获取热门帖子排行榜 Top N
*/
public List<String> getTopPosts(String topicId, int topN) {
String rankingKey = "like:ranking:" + topicId;
// 从高到低取TopN
Set<String> topPostIds = redisTemplate.opsForZSet()
.reverseRange(rankingKey, 0, topN - 1);
return new ArrayList<>(topPostIds);
}
}
💡 为什么用Redis Set而不是DB做去重?:100万赞 = 100万次DB查询检查是否已赞,数据库肯定扛不住。Redis Set的SISMEMBER命令是O(1)的,100万次查询也不到1秒。
挑战3:审核系统 — 内容合规检查
用户发的内容不能直接展示,必须先过审核。就像报社有编辑部,有问题的稿件打回修改。
类比理解:审核就像编辑审稿——先过机器审核(敏感词检测=拼写检查、AI图片识别=版面审查),有问题的再交给人工审核(主编终审)。
用户提交内容
→
机审(敏感词+AI)
→
通过→直接发布
|
疑似→人工审核
|
违规→打回/封禁
DFA敏感词算法
敏感词过滤最常用的是DFA(Deterministic Finite Automaton,确定有限状态自动机)算法。把所有敏感词构建成一棵字典树,然后逐字匹配。就像查字典——从第一个字母开始找,一直找下去。
// DFA敏感词过滤 — 字典树实现
public class DFAFilter {
// 字典树的根节点
private final Map root = new HashMap<>();
// 敏感词结束标记
private static final String END_FLAG = "isEnd";
/**
* 初始化:加载敏感词库,构建字典树
* 比如敏感词:["傻瓜", "傻子", "笨蛋"]
* 构建的字典树:
* 傻 → 瓜 → {isEnd: true}
* → 子 → {isEnd: true}
* 笨 → 蛋 → {isEnd: true}
*/
public void init(Set<String> sensitiveWords) {
root.clear();
for (String word : sensitiveWords) {
addWord(word);
}
}
/**
* 向字典树中添加一个敏感词
*/
@SuppressWarnings("unchecked")
private void addWord(String word) {
Map currentNode = root;
for (char c : word.toCharArray()) {
// 如果当前字符不在字典树中,新建节点
if (!currentNode.containsKey(c)) {
currentNode.put(c, new HashMap<>());
}
// 移动到下一个节点
currentNode = (Map) currentNode.get(c);
}
// 标记这个词结束
currentNode.put(END_FLAG, "true");
}
/**
* 过滤文本 — 检测并替换敏感词
* 返回过滤后的文本和检测到的敏感词列表
*/
public FilterResult filter(String text) {
List<String> foundWords = new ArrayList<>();
StringBuilder result = new StringBuilder(text);
for (int i = 0; i < text.length(); i++) {
int length = checkWord(text, i);
if (length > 0) {
String word = text.substring(i, i + length);
foundWords.add(word);
// 替换为 ***
for (int j = i; j < i + length; j++) {
result.setCharAt(j, '*');
}
i += length - 1; // 跳过已匹配的字符
}
}
return new FilterResult(result.toString(), foundWords);
}
/**
* 从位置begin开始检查是否匹配敏感词
* @return 匹配到的敏感词长度,0表示未匹配
*/
@SuppressWarnings("unchecked")
private int checkWord(String text, int begin) {
Map currentNode = root;
int wordLength = 0;
int matchLength = 0;
for (int i = begin; i < text.length(); i++) {
char c = text.charAt(i);
if (!currentNode.containsKey(c)) {
// 字符不在字典树中,匹配结束
break;
}
wordLength++;
currentNode = (Map) currentNode.get(c);
if ("true".equals(currentNode.get(END_FLAG))) {
// 找到一个完整的敏感词
matchLength = wordLength;
}
}
return matchLength;
}
}
// 使用示例
DFAFilter filter = new DFAFilter();
filter.init(Set.of("傻瓜", "笨蛋", "违规词"));
FilterResult result = filter.filter("你是个傻瓜和笨蛋");
// result.filteredText = "你是个**和**"
// result.foundWords = ["傻瓜", "笨蛋"]
挑战4:热搜榜 — 实时统计搜索热词Top50
热搜就像报纸的头版头条——最多人关注的新闻排最前面。但热搜是实时的,每秒都在变化。
类比理解:热搜=头版头条。哪个话题被最多人搜索,就排在热搜最前面。但不是简单按搜索次数排——还要考虑时间衰减(昨天的热搜今天应该降温)和反作弊(防止水军刷热搜)。
// 热搜服务 — Redis Sorted Set + 滑动窗口
@Service
public class HotSearchService {
@Autowired
private RedisTemplate<String, String> redisTemplate;
// 热搜Key: hotsearch:realtime
private static final String HOT_KEY = "hotsearch:realtime";
// 时间窗口Key前缀: hotsearch:window:{minute}
private static final String WINDOW_PREFIX = "hotsearch:window:";
// 搜索去重Key: hotsearch:dedup:{keyword}:{userId}
private static final String DEDUP_PREFIX = "hotsearch:dedup:";
/**
* 用户搜索 — 记录搜索词
* 1. 去重:同一用户对同一词短时间内只算1次
* 2. 滑动窗口:按分钟统计
* 3. 聚合:实时更新总榜
*/
public void recordSearch(String keyword, String userId) {
// 第1步:去重 — 同一用户5分钟内重复搜索同一词只算1次
String dedupKey = DEDUP_PREFIX + keyword + ":" + userId;
Boolean isNew = redisTemplate.opsForValue()
.setIfAbsent(dedupKey, "1", 5, TimeUnit.MINUTES);
if (!Boolean.TRUE.equals(isNew)) {
// 5分钟内已经搜过这个词了,不重复计数
return;
}
// 第2步:滑动窗口 — 当前分钟的搜索计数+1
long currentMinute = System.currentTimeMillis() / 60_000;
String windowKey = WINDOW_PREFIX + currentMinute;
redisTemplate.opsForZSet().incrementScore(windowKey, keyword, 1);
redisTemplate.expire(windowKey, 2, TimeUnit.HOURS);
// 第3步:更新总榜(带权重衰减)
updateHotRanking(keyword, 1);
}
/**
* 更新热搜排行 — 带时间衰减
* 衰减公式:新分数 = 旧分数 × 衰减因子 + 本次增量
* 越久没被搜索的词,分数越低
*/
private void updateHotRanking(String keyword, double increment) {
// 衰减因子:0.95(每次更新时旧分值乘以0.95)
double decayFactor = 0.95;
Double currentScore = redisTemplate.opsForZSet()
.score(HOT_KEY, keyword);
double newScore = (currentScore == null ? 0 : currentScore * decayFactor) + increment;
redisTemplate.opsForZSet().add(HOT_KEY, keyword, newScore);
}
/**
* 获取热搜榜 Top N
*/
public List<HotSearchItem> getTopHot(int topN) {
Set<ZSetOperations.TypedTuple<String>> tuples =
redisTemplate.opsForZSet()
.reverseRangeWithScores(HOT_KEY, 0, topN - 1);
List<HotSearchItem> result = new ArrayList<>();
int rank = 1;
for (ZSetOperations.TypedTuple<String> tuple : tuples) {
HotSearchItem item = new HotSearchItem();
item.setRank(rank++);
item.setKeyword(tuple.getValue());
item.setHotScore(tuple.getScore());
result.add(item);
}
return result;
}
/**
* 定时衰减 — 每分钟执行,所有热搜词分数 × 衰减因子
* 这样长时间没被搜索的词会逐渐掉出榜单
*/
@Scheduled(cron = "0 * * * * ?")
public void decayAllScores() {
Set<String> keywords = redisTemplate.opsForZSet()
.reverseRange(HOT_KEY, 0, -1);
for (String keyword : keywords) {
Double score = redisTemplate.opsForZSet()
.score(HOT_KEY, keyword);
if (score != null) {
double newScore = score * 0.99;
if (newScore < 0.1) {
// 分数太低,直接移除
redisTemplate.opsForZSet().remove(HOT_KEY, keyword);
} else {
redisTemplate.opsForZSet().add(HOT_KEY, keyword, newScore);
}
}
}
}
}
挑战5:推荐算法基础 — 协同过滤
推荐算法解决的核心问题:给用户看他可能喜欢的内容。
类比理解:你喜欢看A作者的文章,推荐系统就给你推A作者的其他文章、或者跟A风格类似的文章。就像书店店员——你买了《三体》,他就推荐《银河帝国》。
基于用户的协同过滤(User-CF)
用户A喜欢帖子1,2,3
+
用户B喜欢帖子1,2,4
→
A和B相似→给A推荐帖子4
基于物品的协同过滤(Item-CF)
帖子1和帖子2被很多人同时喜欢
→
帖子1和帖子2相似
→
喜欢1的人也推荐2
| 对比维度 |
User-CF(基于用户) |
Item-CF(基于物品) |
| 核心思想 |
找跟你相似的人,推荐他喜欢的 |
找跟你喜欢的相似的物品,推荐它 |
| 类比 |
你的朋友喜欢什么你就看什么 |
你喜欢的书旁边摆了什么书你就看什么 |
| 计算量 |
用户多了计算量大(用户相似度矩阵) |
物品相对稳定,可离线计算 |
| 适用场景 |
用户数少、兴趣变化快(新闻推荐) |
物品数少、用户数多(电商推荐) |
| 典型应用 |
微博热搜、新闻推荐 |
淘宝商品推荐、Netflix电影推荐 |
// 简单的Item-CF推荐实现思路
@Service
public class SimpleItemCFService {
@Autowired
private UserBehaviorRepository behaviorRepo;
/**
* 推荐帖子 — 基于物品的协同过滤
*
* 步骤:
* 1. 找出用户喜欢过的帖子列表
* 2. 对每个喜欢的帖子,找相似的帖子(被同一批人喜欢)
* 3. 按相似度排序,推荐TopN
*
* 简化版:用"共同点赞用户数"衡量相似度
*/
public List<String> recommend(String userId, int topN) {
// 1. 获取用户喜欢过的帖子
List<String> likedPostIds = behaviorRepo.findLikedPostIds(userId);
// 2. 计算相似帖子
Map<String, double> candidateScores = new HashMap<>();
for (String likedPostId : likedPostIds) {
// 找跟这个帖子相似的其他帖子
// 相似度 = 共同点赞用户数 / 总点赞用户数
List<SimilarPost> similarPosts = behaviorRepo
.findSimilarPosts(likedPostId, 20);
for (SimilarPost sp : similarPosts) {
// 排除用户已经看过的
if (!likedPostIds.contains(sp.getPostId())) {
candidateScores.merge(sp.getPostId(),
sp.getSimilarity(), Double::sum);
}
}
}
// 3. 按分数排序,取TopN
return candidateScores.entrySet().stream()
.sorted(Map.comparingByValue().reversed())
.limit(topN)
.map(Map.Entry::getKey)
.collect(Collectors.toList());
}
}
🔑 面试加分:实际生产中,推荐系统远比这个复杂。还需要考虑:冷启动(新用户/新帖子怎么推荐)、多样性(不能只推一类内容)、实时性(用户新行为要立刻影响推荐)、AB测试(新旧算法对比)。这里只是入门级别的理解。
🔄 关键流程 — 完整代码实现
流程1:发帖+审核+Feed推送完整代码
@RestController
@RequestMapping("/api/posts")
public class PostController {
@Autowired
private PostService postService;
@Autowired
private AuditService auditService;
@Autowired
private FeedService feedService;
/**
* 发布帖子 — 完整流程
*/
@PostMapping("/publish")
public Result<PostVO> publish(@RequestBody PublishRequest req,
@RequestHeader("X-User-Id") String userId) {
// 第1步:创建帖子
Post post = new Post();
post.setId(SnowflakeIdGenerator.nextId());
post.setAuthorId(userId);
post.setContent(req.getContent());
post.setTitle(req.getTitle());
post.setImages(req.getImages());
post.setStatus(PostStatus.PENDING_REVIEW); // 待审核
post.setCreatedAt(System.currentTimeMillis());
postService.save(post);
// 第2步:提交审核(异步)
AuditResult textResult = auditService.auditText(post.getContent());
if (textResult.isViolation()) {
// 明确违规,直接拒绝
post.setStatus(PostStatus.REJECTED);
postService.save(post);
return Result.fail("内容违规,发布失败");
}
if (textResult.isSuspicious()) {
// 疑似违规,进入人工审核
auditService.submitToManualReview(post);
return Result.success("提交成功,等待人工审核");
}
// 第3步:审核通过,发布
post.setStatus(PostStatus.PUBLISHED);
postService.save(post);
// 第4步:Feed分发
feedService.distributePost(post);
// 第5步:建立搜索索引(异步)
searchService.indexPost(post);
return Result.success(PostVO.from(post));
}
}
// ========== 审核服务 ==========
@Service
public class AuditService {
@Autowired
private DFAFilter dfaFilter;
@Autowired
private ImageAuditClient imageAuditClient;
/**
* 文本审核 — DFA敏感词检测
*/
public AuditResult auditText(String content) {
FilterResult filterResult = dfaFilter.filter(content);
if (!filterResult.getFoundWords().isEmpty()) {
if (filterResult.getFoundWords().size() >= 3) {
// 3个以上敏感词,直接拒绝
return AuditResult.violation(filterResult.getFoundWords());
} else {
// 1-2个敏感词,可能误判,进入人工审核
return AuditResult.suspicious(filterResult.getFoundWords());
}
}
return AuditResult.pass();
}
/**
* 图片审核 — 调用AI审核服务
*/
public AuditResult auditImages(List<String> imageUrls) {
for (String url : imageUrls) {
ImageAuditResult result = imageAuditClient.audit(url);
if (result.isViolation()) {
return AuditResult.violation(List.of("图片内容违规"));
}
}
return AuditResult.pass();
}
}
流程2:点赞+计数代码
// 点赞接口 + MQ异步持久化
@RestController
@RequestMapping("/api/like")
public class LikeController {
@Autowired
private LikeService likeService;
@PostMapping("/{postId}")
public Result<LikeVO> like(@PathVariable String postId,
@RequestHeader("X-User-Id") String userId) {
boolean success = likeService.like(postId, userId);
long count = likeService.getLikeCount(postId);
return Result.success(new LikeVO(success, count));
}
@DeleteMapping("/{postId}")
public Result<LikeVO> unlike(@PathVariable String postId,
@RequestHeader("X-User-Id") String userId) {
boolean success = likeService.unlike(postId, userId);
long count = likeService.getLikeCount(postId);
return Result.success(new LikeVO(success, count));
}
}
// ========== MQ消费者:点赞数据异步持久化到DB ==========
@RocketMQMessageListener(topic = "like-persist", consumerGroup = "like-consumer")
public class LikePersistConsumer implements RocketMQListener<LikeMessage> {
@Autowired
private LikeRepository likeRepo;
@Override
public void onMessage(LikeMessage msg) {
LikeRecord record = new LikeRecord();
record.setPostId(msg.getPostId());
record.setUserId(msg.getUserId());
record.setCreatedAt(new Date(msg.getTimestamp()));
likeRepo.save(record);
}
}
流程3:热搜更新代码
// 搜索接口 — 记录搜索词 + 返回结果
@RestController
@RequestMapping("/api/search")
public class SearchController {
@Autowired
private HotSearchService hotSearchService;
@Autowired
private PostSearchService postSearchService;
/**
* 搜索帖子
*/
@GetMapping
public Result<PageResult<PostVO>> search(
@RequestParam String keyword,
@RequestParam(defaultValue = "1") int page,
@RequestHeader(value = "X-User-Id", required = false) String userId) {
// 记录搜索词到热搜
if (userId != null) {
hotSearchService.recordSearch(keyword, userId);
}
// 从ES搜索帖子
PageResult<PostVO> result = postSearchService.search(keyword, page);
return Result.success(result);
}
/**
* 获取热搜榜
*/
@GetMapping("/hot")
public Result<List<HotSearchItem>> getHotSearch(
@RequestParam(defaultValue = "50") int topN) {
List<HotSearchItem> hotList = hotSearchService.getTopHot(topN);
return Result.success(hotList);
}
}
💥 踩坑实录 — 4个真实事故
坑1:明星发帖Feed写入风暴 — 1000万粉丝
现象:某明星发一条微博,需要写入1000万粉丝的收件箱,数据库写入QPS暴增,服务几乎不可用。
原因:推模式下,1条帖子 × 1000万粉丝 = 1000万次Redis/DB写入,即使异步也要好几分钟才能写完。
解决方案:异步 + 分批 + 大V走拉模式
// Feed写入优化 — 大V异步分批写入
public void pushToFollowersOptimized(Post post) {
long followerCount = followRepo.countFollowers(post.getAuthorId());
if (followerCount > 10_000_000) {
// 超大V:完全拉模式,不推
// 只写入作者时间线
writeToAuthorTimeline(post);
} else if (followerCount > 10_000) {
// 中等V:推给活跃粉丝,其他按需拉
pushToActiveFollowers(post);
} else {
// 普通用户:全量推,分批异步
List<String> followerIds = followRepo.findFollowerIds(post.getAuthorId());
// 分批100人,每批发一条MQ消息
Lists.partition(followerIds, 100).forEach(batch -> {
mqTemplate.asyncSend("feed-push",
new FeedPushMessage(post, batch));
});
}
}
✅ 关键:1000万粉丝的大V,根本不能用推模式。只用拉模式——粉丝刷Feed时实时拉取大V的时间线。活跃粉丝(最近7天有登录)可以适当推,保证体验。
坑2:点赞数和实际不一致 — Redis和DB不同步
现象:页面上显示10万赞,但数据库里只有8万条记录。
原因:点赞先写Redis(快),再异步写DB(慢)。如果MQ消费延迟或丢失,Redis和DB数据就不一致了。
解决方案:定期对账 + 补偿机制
// 点赞数对账 — 每天凌晨3点执行
@Scheduled(cron = "0 0 3 * * ?")
public void reconcileLikeCount() {
// 扫描所有帖子
List<Post> posts = postRepo.findAllPublished();
for (Post post : posts) {
// Redis中的点赞数
long redisCount = likeService.getLikeCount(post.getId());
// DB中的点赞数
long dbCount = likeRepo.countByPostId(post.getId());
if (redisCount != dbCount) {
log.warn("点赞数不一致: postId={}, redis={}, db={}",
post.getId(), redisCount, dbCount);
// 以DB为准,修正Redis
String countKey = "like:count:" + post.getId();
redisTemplate.opsForValue().set(countKey, String.valueOf(dbCount));
}
}
}
坑3:敏感词误杀 — 正常内容被拦截
现象:用户发"我今天考了杯具成绩"被拦截,因为"杯具"在敏感词库中(谐音"悲剧")。
原因:敏感词库太严格,缺少白名单机制。有些词本身有正常含义,不应一刀切。
解决方案:白名单 + 人工复核
// 改进后的审核逻辑
public AuditResult auditText(String content) {
FilterResult result = dfaFilter.filter(content);
if (result.getFoundWords().isEmpty()) {
return AuditResult.pass();
}
// 检查白名单:如果命中敏感词但上下文合法,放行
List<String> realViolations = new ArrayList<>();
for (String word : result.getFoundWords()) {
if (whitelistService.isInWhitelist(word, content)) {
// 白名单中且上下文合法,不算违规
continue;
}
realViolations.add(word);
}
if (realViolations.isEmpty()) {
return AuditResult.pass();
} else if (realViolations.size() <= 2) {
// 少量疑似,人工复核
return AuditResult.suspicious(realViolations);
} else {
// 多数违规,直接拒绝
return AuditResult.violation(realViolations);
}
}
坑4:热搜被刷 — 水军刷搜索词
现象:某个本来不热的话题突然冲上热搜第一,后来发现是水军批量搜索刷上去的。
原因:热搜只看搜索次数,没有反作弊机制。水军用脚本搜索10万次,就能把任何词刷上热搜。
解决方案:反作弊 + 权重衰减
// 热搜反作弊机制
public void recordSearchWithAntiCheat(String keyword, String userId) {
// 1. 用户维度去重(同用户5分钟内只算1次)
String dedupKey = "hotsearch:dedup:" + keyword + ":" + userId;
if (!redisTemplate.opsForValue()
.setIfAbsent(dedupKey, "1", 5, TimeUnit.MINUTES)) {
return;
}
// 2. IP维度限流(同IP每分钟最多10次搜索)
String ipRateKey = "hotsearch:ip:" + getCurrentIp();
Long ipCount = redisTemplate.opsForValue().increment(ipRateKey);
if (ipCount == 1) {
redisTemplate.expire(ipRateKey, 1, TimeUnit.MINUTES);
}
if (ipCount > 10) {
// 同IP搜索过于频繁,疑似水军
return;
}
// 3. 用户权重(新注册/低活跃用户搜索权重低)
double weight = getUserWeight(userId);
// 老用户weight=1.0,新用户weight=0.1
// 4. 带权重更新热搜
updateHotRanking(keyword, weight);
}
private double getUserWeight(String userId) {
User user = userRepo.findById(userId);
if (user == null) return 0.1;
// 注册不到7天的新用户权重低
if (user.getAgeInDays() < 7) return 0.1;
// 从未发过帖的用户权重低
if (user.getPostCount() == 0) return 0.3;
return 1.0;
}