详解Java社区项目对帖子和评论进行点赞的实现方法

190 阅读7分钟

Java社区项目实现发帖、评论的方法见 详解Java社区项目实现发帖、评论的方法(仿百度贴吧)Java社区项目,用户可以发帖,评论,仿百度贴吧,评论共二级评论, - 掘金

场景描述:每个用户都可以对帖子和评论进行点赞,设置点赞表,记录点赞用户id,点赞目标id,点赞目标类型;帖子表和评论表中有点赞数。后端提供接口:“点赞或取消点赞”,判断点赞表中有无当前点赞记录,如果没有就是点赞操作,插入新数据,并根据点赞目标类型在Redis中插入今日帖子/评论点赞数差值,使用Hash存储,key是前缀加帖子/评论标识加年月日,field是帖子/评论id,value加1。如果有就是取消点赞操作,删除数据库对应数据并把Redis中对应value减1即可。点赞数查询时需要查MySQL与Redis中今日点赞数差值之和。点赞状态的判断就是点赞表中有没有对应数据。使用XXL-JOB设置定时任务,每日凌晨将点赞数差值同步到数据库中,然后删除Redis中对应的key。

点赞功能实现方案详解

下面是完整的点赞系统实现,结合数据库、Redis和定时任务,确保高并发下的性能和数据一致性。

系统架构设计

graph TD
    A[客户端] --> B[API接口]
    B --> C[点赞服务]
    C --> D[MySQL数据库]
    C --> E[Redis缓存]
    F[定时任务] --> E
    F --> D

数据库设计

点赞记录表

CREATE TABLE `like_record` (
  `id` BIGINT NOT NULL AUTO_INCREMENT,
  `user_id` BIGINT NOT NULL COMMENT '用户ID',
  `target_id` BIGINT NOT NULL COMMENT '点赞目标ID',
  `target_type` TINYINT NOT NULL COMMENT '目标类型:0-帖子, 1-评论',
  `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_user_target` (`user_id`, `target_id`, `target_type`),
  KEY `idx_target` (`target_id`, `target_type`)
) COMMENT='点赞记录表';

更新帖子表和评论表

-- 帖子表添加点赞数字段
ALTER TABLE `post` ADD COLUMN `like_count` INT NOT NULL DEFAULT 0 COMMENT '点赞数';

-- 评论表添加点赞数字段
ALTER TABLE `comment` ADD COLUMN `like_count` INT NOT NULL DEFAULT 0 COMMENT '点赞数';

Redis数据结构设计

点赞数差值存储

使用Hash结构存储每日点赞数差值:

  • Key格式:like:daily:{targetType}:{date}
    • targetType: 0(帖子)或1(评论)
    • date: 当前日期(格式: yyyyMMdd)
  • Field: 目标ID(帖子ID或评论ID)
  • Value: 当日点赞数差值(可正可负)

示例:

Key: like:daily:0:20230615
Field: 12345 (帖子ID)
Value: +5 (当日新增5个点赞)

Key: like:daily:1:20230615
Field: 67890 (评论ID)
Value: -2 (当日取消2个点赞)

核心服务实现

点赞/取消点赞服务

@Service
@RequiredArgsConstructor
public class LikeService {
    private final LikeRecordMapper likeRecordMapper;
    private final RedisTemplate<String, String> redisTemplate;
    private final StringRedisTemplate stringRedisTemplate;

    @Transactional
    public LikeResult toggleLike(Long userId, Long targetId, Integer targetType) {
        // 1. 检查是否已点赞
        LikeRecord existing = likeRecordMapper.selectOne(
            new QueryWrapper<LikeRecord>()
                .eq("user_id", userId)
                .eq("target_id", targetId)
                .eq("target_type", targetType)
        );

        String date = LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE);
        String redisKey = "like:daily:" + targetType + ":" + date;

        if (existing == null) {
            // 点赞操作
            LikeRecord record = new LikeRecord();
            record.setUserId(userId);
            record.setTargetId(targetId);
            record.setTargetType(targetType);
            likeRecordMapper.insert(record);
            
            // Redis操作: 增加差值
            incrementRedisDelta(redisKey, targetId, 1);
            
            return new LikeResult(true, getLikeCount(targetId, targetType));
        } else {
            // 取消点赞操作
            likeRecordMapper.deleteById(existing.getId());
            
            // Redis操作: 减少差值
            incrementRedisDelta(redisKey, targetId, -1);
            
            return new LikeResult(false, getLikeCount(targetId, targetType));
        }
    }

    private void incrementRedisDelta(String key, Long targetId, int delta) {
        stringRedisTemplate.opsForHash().increment(key, targetId.toString(), delta);
        // 设置过期时间: 2天(确保定时任务能处理)
        stringRedisTemplate.expire(key, 2, TimeUnit.DAYS);
    }

    public int getLikeCount(Long targetId, Integer targetType) {
        // 1. 获取基础点赞数(数据库中的值)
        int baseCount = getBaseLikeCount(targetId, targetType);
        
        // 2. 获取Redis中今日的点赞数差值
        int todayDelta = getTodayDelta(targetId, targetType);
        
        return baseCount + todayDelta;
    }

    private int getBaseLikeCount(Long targetId, Integer targetType) {
        if (targetType == 0) {
            return postMapper.selectLikeCount(targetId);
        } else {
            return commentMapper.selectLikeCount(targetId);
        }
    }

    private int getTodayDelta(Long targetId, Integer targetType) {
        String date = LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE);
        String key = "like:daily:" + targetType + ":" + date;
        
        Object value = redisTemplate.opsForHash().get(key, targetId.toString());
        return value != null ? Integer.parseInt(value.toString()) : 0;
    }
}

定时任务实现(XXL-JOB)

@Component
@RequiredArgsConstructor
public class LikeSyncJob {
    private final RedisTemplate<String, String> redisTemplate;
    private final PostMapper postMapper;
    private final CommentMapper commentMapper;
    
    @XxlJob("likeSyncJobHandler")
    public void execute() {
        // 1. 获取前一天的日期
        LocalDate yesterday = LocalDate.now().minusDays(1);
        String dateStr = yesterday.format(DateTimeFormatter.BASIC_ISO_DATE);
        
        // 2. 处理帖子点赞同步
        syncForTargetType(0, dateStr);
        
        // 3. 处理评论点赞同步
        syncForTargetType(1, dateStr);
    }
    
    private void syncForTargetType(int targetType, String date) {
        String key = "like:daily:" + targetType + ":" + date;
        
        // 获取所有field-value对
        Map<Object, Object> entries = redisTemplate.opsForHash().entries(key);
        
        if (entries.isEmpty()) {
            redisTemplate.delete(key);
            return;
        }
        
        // 按目标ID分组处理
        Map<Long, Integer> deltaMap = new HashMap<>();
        entries.forEach((field, value) -> {
            Long targetId = Long.parseLong(field.toString());
            int delta = Integer.parseInt(value.toString());
            deltaMap.put(targetId, delta);
        });
        
        // 批量更新数据库
        if (targetType == 0) {
            postMapper.batchUpdateLikeCount(deltaMap);
        } else {
            commentMapper.batchUpdateLikeCount(deltaMap);
        }
        
        // 删除Redis key
        redisTemplate.delete(key);
    }
}

Mapper批量更新方法

// PostMapper.java
@Update("<script>" +
        "UPDATE post SET like_count = like_count + CASE id " +
        "<foreach item='item' index='key' collection='deltaMap.entrySet()'>" +
        "WHEN #{key} THEN #{item.value} " +
        "</foreach>" +
        "END " +
        "WHERE id IN " +
        "<foreach item='key' collection='deltaMap.keySet()' open='(' separator=',' close=')'>" +
        "#{key}" +
        "</foreach>" +
        "</script>")
void batchUpdateLikeCount(@Param("deltaMap") Map<Long, Integer> deltaMap);

// CommentMapper.java (类似实现)

点赞状态查询优化

使用Redis缓存点赞状态

@Service
public class LikeStatusService {
    private final LikeRecordMapper likeRecordMapper;
    private final RedisTemplate<String, String> redisTemplate;
    
    // 获取用户点赞状态
    public boolean isLiked(Long userId, Long targetId, Integer targetType) {
        String cacheKey = getCacheKey(userId, targetId, targetType);
        
        // 1. 尝试从缓存获取
        Boolean cached = redisTemplate.opsForValue().getBit(cacheKey, 0);
        if (cached != null) {
            return cached;
        }
        
        // 2. 查询数据库
        boolean exists = likeRecordMapper.exists(
            new QueryWrapper<LikeRecord>()
                .eq("user_id", userId)
                .eq("target_id", targetId)
                .eq("target_type", targetType)
        );
        
        // 3. 更新缓存
        redisTemplate.opsForValue().setBit(cacheKey, 0, exists);
        redisTemplate.expire(cacheKey, 7, TimeUnit.DAYS);
        
        return exists;
    }
    
    private String getCacheKey(Long userId, Long targetId, Integer targetType) {
        return "like:status:" + userId + ":" + targetType + ":" + targetId;
    }
    
    // 当点赞状态变化时清除缓存
    public void clearCache(Long userId, Long targetId, Integer targetType) {
        String cacheKey = getCacheKey(userId, targetId, targetType);
        redisTemplate.delete(cacheKey);
    }
}

高并发优化策略

1. Redis管道化操作

private void incrementRedisDeltaPipeline(String key, Long targetId, int delta) {
    redisTemplate.executePipelined((RedisCallback<Object>) connection -> {
        StringRedisConnection stringRedisConn = (StringRedisConnection) connection;
        stringRedisConn.hIncrBy(key, targetId.toString(), delta);
        stringRedisConn.expire(key, 172800); // 2天
        return null;
    });
}

2. 热点数据缓存

@Cacheable(value = "likeCount", key = "#targetType + ':' + #targetId")
public int getLikeCount(Long targetId, Integer targetType) {
    // 原始实现
}

3. 延迟双删策略

@CacheEvict(value = "likeCount", key = "#targetType + ':' + #targetId")
public void evictLikeCountCache(Long targetId, Integer targetType) {
    // 第一次删除缓存
    
    // 执行数据库更新操作
    
    // 延迟删除(确保数据库主从同步完成)
    executor.schedule(() -> {
        cacheManager.getCache("likeCount").evict(targetType + ":" + targetId);
    }, 500, TimeUnit.MILLISECONDS);
}

前端交互实现

点赞组件(Vue 3)

<template>
  <div class="like-container" @click="toggleLike">
    <div :class="['like-icon', { 'liked': isLiked }]">
      <i class="fas fa-heart"></i>
    </div>
    <div class="like-count">{{ displayCount }}</div>
  </div>
</template>

<script setup>
import { ref, computed } from 'vue';
import { useUserStore } from '@/stores/user';
import { toggleLikeApi } from '@/api/like';

const props = defineProps({
  targetId: Number,
  targetType: Number, // 0: 帖子, 1: 评论
  initialLiked: Boolean,
  initialCount: Number
});

const emit = defineEmits(['update:likeStatus']);

const userStore = useUserStore();
const isLiked = ref(props.initialLiked);
const actualCount = ref(props.initialCount);
const isLoading = ref(false);

// 显示计数(优化大数字展示)
const displayCount = computed(() => {
  if (actualCount.value > 1000) {
    return (actualCount.value / 1000).toFixed(1) + 'k';
  }
  return actualCount.value;
});

// 点赞/取消点赞操作
const toggleLike = async () => {
  if (!userStore.isAuthenticated || isLoading.value) return;
  
  isLoading.value = true;
  
  try {
    // 乐观更新
    const wasLiked = isLiked.value;
    const oldCount = actualCount.value;
    
    isLiked.value = !wasLiked;
    actualCount.value = wasLiked ? oldCount - 1 : oldCount + 1;
    
    // 发送API请求
    const response = await toggleLikeApi({
      targetId: props.targetId,
      targetType: props.targetType
    });
    
    // 确保UI与服务器一致
    isLiked.value = response.liked;
    actualCount.value = response.count;
    emit('update:likeStatus', response.liked);
    
  } catch (error) {
    // 出错时回滚
    isLiked.value = props.initialLiked;
    actualCount.value = props.initialCount;
    console.error('点赞操作失败', error);
  } finally {
    isLoading.value = false;
  }
};
</script>

<style scoped>
.like-container {
  display: flex;
  align-items: center;
  cursor: pointer;
}

.like-icon {
  color: #8e8e8e;
  font-size: 18px;
  transition: all 0.3s ease;
}

.like-icon.liked {
  color: #ff2d55;
  animation: pulse 0.5s;
}

.like-count {
  margin-left: 6px;
  font-size: 14px;
  color: #8e8e8e;
}

@keyframes pulse {
  0% { transform: scale(1); }
  50% { transform: scale(1.2); }
  100% { transform: scale(1); }
}
</style>

安全与防护

1. 频率限制

@PostMapping("/toggle")
@RateLimiter(key = "like:${#userId}", limit = 10, period = "60s")
public Result<LikeResult> toggleLike(@RequestBody LikeRequest request) {
    // ...
}

2. 请求验证

@Data
public class LikeRequest {
    @NotNull(message = "目标ID不能为空")
    @Positive(message = "无效的目标ID")
    private Long targetId;
    
    @NotNull(message = "目标类型不能为空")
    @Range(min = 0, max = 1, message = "无效的目标类型")
    private Integer targetType;
}

3. 权限校验

@PreAuthorize("isAuthenticated()")
@PostMapping("/toggle")
public Result<LikeResult> toggleLike(@RequestBody LikeRequest request) {
    // 获取当前用户ID
    Long userId = SecurityUtils.getCurrentUserId();
    // ...
}

系统监控与告警

监控指标

  1. 点赞操作QPS
  2. 点赞/取消点赞比例
  3. Redis内存使用情况
  4. 定时任务执行时长
  5. 数据库更新延迟

异常告警

  1. Redis连接失败
  2. 数据库更新异常
  3. 定时任务执行失败
  4. 点赞操作成功率下降
graph LR
    A[应用服务器] -->|发送指标| B[Prometheus]
    B --> C[Grafana仪表盘]
    B --> D[Alertmanager]
    D --> E[邮件告警]
    D --> F[短信告警]
    D --> G[Slack通知]

总结与扩展

方案优势

  1. 高性能:Redis处理高并发点赞请求,减轻数据库压力
  2. 最终一致性:通过定时任务保证数据最终一致
  3. 可扩展性:水平扩展Redis集群应对更高并发
  4. 容错机制:Redis持久化保证数据安全
  5. 实时反馈:客户端乐观更新提升用户体验

扩展方向

  1. 点赞排行榜:使用Redis ZSET实现实时点赞排行榜
    ZINCRBY post:likes:ranking 1 {postId}
    
  2. 点赞消息通知:集成消息队列发送实时通知
  3. 反作弊系统:检测异常点赞行为
  4. 数据分析:分析用户点赞行为模式
  5. 多级缓存:增加本地缓存减少Redis访问

此方案完整实现了场景描述中的点赞系统要求,通过MySQL、Redis和定时任务的结合,在保证数据一致性的同时提供了高性能的点赞服务,适合高并发的社区场景。