Java 点赞功能设计与实现
·
Java 点赞功能设计与实现完整指南
本文详细介绍如何从零设计和实现一个完整的点赞/取消点赞功能,包含数据库设计、API 开发、防刷限制、性能优化等完整实战。
一、概述
1.1 功能需求
- 用户点赞:登录用户对内容(视频/文章)进行点赞
- 取消点赞:再次点击取消点赞(toggle 模式)
- 点赞计数:实时更新内容的点赞总数
- 点赞状态:查询当前用户是否已点赞
- 频率限制:每个用户每小时最多点赞 30 次
- 游客点赞:未登录用户也可点赞(仅增加计数)
1.2 技术栈
| 层级 | 技术 | 说明 |
|---|---|---|
| 框架 | Spring Boot + MyBatis Plus | 开发框架 |
| 数据库 | MySQL | 关系型数据库 |
| Mapper | MyBatis XML + MyBatis Plus BaseMapper | 数据访问 |
| 限流 | 自定义注解 @Limit |
防刷控制 |
| 验证 | @Validated |
参数校验 |
| 接口文档 | Swagger / Knife4j (@Api) |
API 文档 |
1.3 核心流程
用户点击点赞按钮
↓
请求到达 Controller
↓
@Limit 限流检查
↓
解析 Token 获取用户身份
↓
┌─── 有 Token(登录用户) ───┐ ┌─── 无 Token(游客) ───┐
│ Service.niceDetail() │ │ 视频点赞数 +1 │
│ 查询 t_nice_detail │ └─────────────────────────┘
└───────────┬───────────────┘
↓
是否有该点赞记录?
├── 是 → 删除记录 → 视频点赞数 -1
└── 否 → 新增记录 → 视频点赞数 +1
二、数据库设计
2.1 点赞记录表
-- 点赞明细表:记录每个用户对每个内容的点赞关系
CREATE TABLE `t_nice_detail` (
`id` INT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`consumer_id` BIGINT DEFAULT NULL COMMENT '用户ID(点赞者)',
`video_id` INT DEFAULT NULL COMMENT '内容ID(被点赞的视频/文章)',
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '点赞时间',
`update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_consumer_video` (`consumer_id`, `video_id`),
KEY `idx_consumer_id` (`consumer_id`),
KEY `idx_video_id` (`video_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='点赞明细表';
2.2 内容点赞数表
-- 视频/内容表:包含点赞数字段(在原表追加)
-- 假设已有 t_video 表,追加 give_up 字段
ALTER TABLE `t_video`
ADD COLUMN `give_up` INT DEFAULT 0 COMMENT '点赞数';
设计原则:点赞数预存在内容表中以避免每次 COUNT 查询。通过
t_nice_detail表记录明细,通过t_video.give_up字段缓存总数。两者通过事务保持一致。
2.3 关键索引说明
| 索引 | 作用 |
|---|---|
uk_consumer_video |
防止重复点赞(唯一约束) |
idx_consumer_id |
查询用户的所有点赞记录 |
idx_video_id |
查询某内容的所有点赞用户 |
三、实体与 Mapper 层
3.1 点赞记录实体
import com.baomidou.mybatisplus.annotation.*;
import com.fasterxml.jackson.annotation.JsonIgnore;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
/**
* 点赞明细实体
*/
@Data
@TableName("t_nice_detail")
public class NiceDetail {
@TableId(value = "id", type = IdType.AUTO)
@JsonIgnore
@ApiModelProperty(hidden = true)
private Integer id;
/** 用户ID */
private Long consumerId;
/** 内容ID(视频/文章) */
private Integer videoId;
}
3.2 视频实体(含点赞数)
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
@Data
@TableName("t_video")
public class Video {
@TableId(value = "id", type = IdType.AUTO)
private Long id;
/** 其他视频字段... */
/** 点赞数 */
private Long giveUp;
}
3.3 DAO 接口
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Param;
import org.springframework.stereotype.Repository;
/**
* 点赞明细 DAO
*/
@Repository
public interface NiceDetailDao extends BaseMapper<NiceDetail> {
/**
* 根据主键删除
*/
int deleteByPrimaryKey(Integer id);
/**
* 查询单条点赞记录
*/
NiceDetail findNiceDetail(NiceDetail niceDetail);
/**
* 选择性插入(仅插入非空字段)
*/
int insertSelective(@Param("record") NiceDetail record);
/**
* 增加点赞数(用于游客点赞)
*/
Integer insertGiveUp(Integer GiveUp);
}
3.4 MyBatis XML Mapper
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.wwtx.chinesemedicine.service.dao.NiceDetailDao">
<resultMap id="BaseResultMap" type="com.wwtx.chinesemedicine.service.models.domain.NiceDetail">
<id column="id" property="id" jdbcType="INTEGER"/>
<result property="consumerId" column="consumer_id"/>
<result property="videoId" column="video_id" jdbcType="INTEGER"/>
</resultMap>
<sql id="Base_Column_List">
id, consumer_id, video_id
</sql>
<!-- 根据主键删除 -->
<delete id="deleteByPrimaryKey" parameterType="java.lang.Integer">
DELETE FROM t_nice_detail
WHERE id = #{id,jdbcType=INTEGER}
</delete>
<!-- 查询单条点赞记录 -->
<select id="findNiceDetail"
resultMap="BaseResultMap"
parameterType="com.wwtx.chinesemedicine.service.models.domain.NiceDetail">
SELECT <include refid="Base_Column_List"/>
FROM t_nice_detail
WHERE consumer_id = #{consumerId,jdbcType=VARCHAR}
AND video_id = #{videoId,jdbcType=INTEGER}
</select>
<!-- 选择性插入 -->
<insert id="insertSelective"
parameterType="com.wwtx.chinesemedicine.service.models.domain.NiceDetail">
INSERT INTO t_nice_detail
<trim prefix="(" suffix=")" suffixOverrides=",">
<if test="record != null">
consumer_id,
video_id,
</if>
</trim>
<trim prefix="values (" suffix=")" suffixOverrides=",">
<if test="record != null">
#{record.consumerId,jdbcType=VARCHAR},
#{record.videoId,jdbcType=INTEGER},
</if>
</trim>
</insert>
<!-- 增加点赞数(游客) -->
<update id="insertGiveUp"
parameterType="com.wwtx.chinesemedicine.service.models.domain.Video">
UPDATE t_video
SET give_up = give_up + 1
WHERE id = #{param2}
</update>
</mapper>
3.5 MyBatis Plus 视频 Mapper
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Repository
public interface VideoDao extends BaseMapper<Video> {
/**
* 批量查询点赞数
*/
List<Video> selectByIds(@Param("giveUp") List<String> giveUp);
}
四、Service 层
4.1 接口定义
public interface NiceDetailService {
/**
* 插入点赞记录
*/
Integer insertNiceDetail(NiceDetail niceDetail);
/**
* 删除点赞记录
*/
Integer deleteNiceDetail(Integer id);
/**
* 查询点赞记录
*/
NiceDetail findNiceDetail(NiceDetail niceDetail);
/**
* 点赞/取消点赞(核心业务)
*/
JsonResult niceDetail(NiceDetail niceDetail);
/**
* 游客点赞数加一
*/
Integer addGiveUp(Integer GiveUp);
}
4.2 核心实现(Toggle 模式)
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@Service("nicedetailService")
public class NiceDetailServiceImpl implements NiceDetailService {
@Autowired
private NiceDetailDao nicedetailDao;
@Autowired
private VideoDao videoDao;
@Override
public Integer insertNiceDetail(NiceDetail niceDetail) {
return nicedetailDao.insertSelective(niceDetail);
}
@Override
public Integer deleteNiceDetail(Integer id) {
return nicedetailDao.deleteByPrimaryKey(id);
}
@Override
public NiceDetail findNiceDetail(NiceDetail niceDetail) {
return nicedetailDao.findNiceDetail(niceDetail);
}
/**
* 点赞核心逻辑(toggle)
*
* 1. 查询是否有该用户的点赞记录
* 2. 有记录 → 删除记录 → 点赞数 -1
* 3. 无记录 → 新增记录 → 点赞数 +1
*/
@Override
public JsonResult niceDetail(NiceDetail niceDetail) {
System.out.println("点赞模块:" + niceDetail.toString());
// 1. 查询是否有该用户的点赞记录
NiceDetail existingRecord = nicedetailDao.findNiceDetail(niceDetail);
if (existingRecord != null) {
// 已点赞 → 取消点赞
System.out.println("有该记录,执行取消点赞");
// 删除点赞记录
nicedetailDao.deleteByPrimaryKey(existingRecord.getId());
// 根据点赞记录找到视频
Video video = videoDao.selectById(existingRecord.getVideoId());
// 视频点赞数减一
video.setGiveUp(video.getGiveUp() - 1);
// 更新视频
videoDao.updateById(video);
Long result = video.getGiveUp();
System.out.println("取消点赞,当前点赞数:" + result);
} else {
// 未点赞 → 添加点赞
System.out.println("没有记录,执行添加点赞");
// 添加点赞记录
nicedetailDao.insertSelective(niceDetail);
// 重新查询(获取自增ID)
NiceDetail newRecord = nicedetailDao.findNiceDetail(niceDetail);
// 根据点赞记录找到视频
Video video = videoDao.selectById(newRecord.getVideoId());
// 视频点赞数加一
video.setGiveUp(video.getGiveUp() + 1);
// 更新视频
videoDao.updateById(video);
Long result = video.getGiveUp();
System.out.println("添加点赞,当前点赞数:" + result);
}
return JsonResult.success();
}
@Override
public Integer addGiveUp(Integer videoId) {
return nicedetailDao.insertGiveUp(videoId);
}
}
4.3 查询用户点赞状态
/**
* 批量查询用户对多个内容的点赞状态
*/
public Map<Integer, Boolean> checkLikeStatus(Long userId, List<Integer> videoIds) {
if (userId == null || videoIds == null || videoIds.isEmpty()) {
return Collections.emptyMap();
}
// 查询用户点赞过的视频ID
LambdaQueryWrapper<NiceDetail> wrapper = Wrappers.lambdaQuery();
wrapper.eq(NiceDetail::getConsumerId, userId);
wrapper.in(NiceDetail::getVideoId, videoIds);
List<NiceDetail> likedList = nicedetailDao.selectList(wrapper);
// 转为 Map<videoId, true>
Set<Integer> likedSet = likedList.stream()
.map(NiceDetail::getVideoId)
.collect(Collectors.toSet());
// 返回所有视频的点赞状态
return videoIds.stream()
.collect(Collectors.toMap(
id -> id,
likedSet::contains
));
}
五、Controller 层
5.1 点赞 / 取消点赞接口
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import javax.servlet.http.HttpServletRequest;
import java.io.Serializable;
/**
* 点赞控制器
*/
@RestController
@RequestMapping("api/nicedetail")
@Api(tags = "点赞")
@Validated
@CrossOrigin
public class NiceDetailController {
@Autowired
private NiceDetailService niceDetailService;
@Autowired
private HttpServletRequest httprequest;
@Autowired
private VideoService videoService;
/**
* 处理用户点赞行为
*
* 点赞做了限制,一个用户每小时只能点 30 次
*/
@PostMapping("/niceDetail{userid,consumerId}")
@ResponseBody
@ApiOperation("点赞")
@Limit(
limitType = LimitType.SECONDS,
period = 3600,
limitParamTypes = {LimitParamType.PARAM, LimitParamType.IP},
count = 30,
params = "niceDetail.videoId"
)
public Serializable niceDetail(NiceDetail niceDetail) {
// 获取请求头中的 Authorization 令牌
String authorization = httprequest.getHeader("Authorization");
Long uid = null;
// 如果有 Token,解析用户 ID
if (authorization != null) {
uid = JWTUtil.getAppUID(authorization);
}
// 登录用户:执行点赞业务
if (uid != null) {
niceDetail.setConsumerId(uid);
return niceDetailService.niceDetail(niceDetail);
}
// 游客:仅增加点赞计数
else {
// 游客点赞数加一
Integer videoId = niceDetail.getVideoId();
return niceDetailService.addGiveUp(videoId);
}
}
}
5.2 其他点赞相关接口
/**
* 取消点赞(游客取消)
*/
@Limit(
limitType = LimitType.SECONDS,
period = 3600,
limitParamTypes = {LimitParamType.PARAM, LimitParamType.IP},
count = 30,
params = "id"
)
@ApiOperation("游客取消点赞")
@DeleteMapping("unlikeVideoGiveUp/{id}")
public JsonResult unlikeVideo(Long id) {
videoService.guest(id);
return JsonResult.success();
}
/**
* 查询用户点赞状态
*/
@GetMapping("/likeStatus")
@ApiOperation("查询点赞状态")
public Map<String, Boolean> getLikeStatus(@RequestParam Long userId,
@RequestParam List<Integer> videoIds) {
Map<Integer, Boolean> statusMap =
niceDetailService.checkLikeStatus(userId, videoIds);
// 转换为 String key 的 Map
return statusMap.entrySet().stream()
.collect(Collectors.toMap(
e -> String.valueOf(e.getKey()),
Map.Entry::getValue
));
}
/**
* 查询视频的点赞总数
*/
@GetMapping("/count/{videoId}")
@ApiOperation("查询点赞总数")
public JsonResult getLikeCount(@PathVariable Integer videoId) {
Video video = videoService.getById(videoId);
if (video == null) {
return JsonResult.error("视频不存在");
}
return JsonResult.success(video.getGiveUp());
}
5.3 使用 MyBatis Plus 简化 Mapper
// 使用 MyBatis Plus 的 LambdaQueryWrapper
// 可以完全替代 XML 中的 findNiceDetail 方法
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
public NiceDetail findNiceDetailByPlus(NiceDetail niceDetail) {
LambdaQueryWrapper<NiceDetail> wrapper = Wrappers.lambdaQuery();
wrapper.eq(NiceDetail::getConsumerId, niceDetail.getConsumerId());
wrapper.eq(NiceDetail::getVideoId, niceDetail.getVideoId());
return nicedetailDao.selectOne(wrapper);
}
六、频率限制(@Limit 注解)
6.1 自定义注解
import java.lang.annotation.*;
/**
* 限流注解
* 用于控制接口调用频率,防止刷点赞
*/
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Limit {
/** 限流类型(秒/分钟/小时) */
LimitType limitType() default LimitType.SECONDS;
/** 时间周期(单位与 limitType 对应) */
int period() default 3600;
/** 限制参数类型 */
LimitParamType[] limitParamTypes() default {};
/** 周期内允许的最大次数 */
int count() default 30;
/** 参数名称(用于标识不同的限流维度) */
String[] params() default {};
}
enum LimitType {
SECONDS, MINUTES, HOURS
}
enum LimitParamType {
IP, // 按 IP 限流
USER_ID, // 按用户 ID 限流
PARAM // 按参数值限流
}
6.2 限流实现(AOP)
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import java.util.concurrent.TimeUnit;
@Aspect
@Component
public class LimitAspect {
@Autowired
private RedisTemplate<String, String> redisTemplate;
@Around("@annotation(limit)")
public Object around(ProceedingJoinPoint pjp, Limit limit) throws Throwable {
// 构建限流 key
String key = buildLimitKey(limit);
// 获取当前计数
String countStr = redisTemplate.opsForValue().get(key);
int count = countStr == null ? 0 : Integer.parseInt(countStr);
// 判断是否超过限制
if (count >= limit.count()) {
throw new RuntimeException("操作太频繁,请稍后再试");
}
// 计数 +1
long ttl = redisTemplate.getExpire(key, TimeUnit.SECONDS);
if (ttl < 0) {
// 首次计数,设置过期时间
redisTemplate.opsForValue().set(key, "1",
limit.period(), TimeUnit.SECONDS);
} else {
redisTemplate.opsForValue().increment(key);
}
return pjp.proceed();
}
private String buildLimitKey(Limit limit) {
// 根据 IP + 参数构建唯一 key
String ip = HttpContextUtil.getIpAddress();
return "limit:" + ip + ":" + limit.params()[0];
}
}
七、事务控制
7.1 添加事务保证数据一致性
import org.springframework.transaction.annotation.Transactional;
/**
* 带事务的 toggle 实现
*/
@Transactional(rollbackFor = Exception.class)
@Override
public JsonResult niceDetailWithTx(NiceDetail niceDetail) {
NiceDetail existingRecord = nicedetailDao.findNiceDetail(niceDetail);
if (existingRecord != null) {
// 取消点赞
int deleted = nicedetailDao.deleteByPrimaryKey(existingRecord.getId());
if (deleted == 0) {
throw new RuntimeException("取消点赞失败");
}
Video video = videoDao.selectById(existingRecord.getVideoId());
if (video == null) {
throw new RuntimeException("视频不存在");
}
long newCount = video.getGiveUp() - 1;
if (newCount < 0) newCount = 0; // 防止负数
video.setGiveUp(newCount);
int updated = videoDao.updateById(video);
if (updated == 0) {
throw new RuntimeException("更新点赞数失败");
}
} else {
// 添加点赞
int inserted = nicedetailDao.insertSelective(niceDetail);
if (inserted == 0) {
throw new RuntimeException("添加点赞记录失败");
}
NiceDetail newRecord = nicedetailDao.findNiceDetail(niceDetail);
Video video = videoDao.selectById(newRecord.getVideoId());
video.setGiveUp(video.getGiveUp() + 1);
videoDao.updateById(video);
}
return JsonResult.success();
}
7.2 使用 CAS 乐观锁防止并发问题
-- SQL 中使用 CAS 方式更新点赞数
UPDATE t_video
SET give_up = give_up + 1
WHERE id = #{videoId}
AND give_up >= 0;
-- 或者使用版本号乐观锁
ALTER TABLE t_video ADD COLUMN version INT DEFAULT 0;
UPDATE t_video
SET give_up = give_up + 1, version = version + 1
WHERE id = #{videoId} AND version = #{oldVersion};
八、性能优化
8.1 使用 Redis 替代数据库的点赞明细
/**
* Redis 版本的点赞服务
* 点赞明细存在 Redis Set 中,点赞数存在 Redis String 中
* 定时同步到数据库
*/
@Service
public class RedisNiceDetailService {
@Autowired
private StringRedisTemplate redisTemplate;
private static final String LIKE_SET_KEY = "like:set:%s"; // like:set:{videoId}
private static final String LIKE_COUNT_KEY = "like:count:%s"; // like:count:{videoId}
/**
* 点赞/取消点赞(Redis 操作)
*/
public boolean toggleLike(Long videoId, Long userId) {
String setKey = String.format(LIKE_SET_KEY, videoId);
String countKey = String.format(LIKE_COUNT_KEY, videoId);
Boolean isLiked = redisTemplate.opsForSet().isMember(setKey, userId.toString());
if (Boolean.TRUE.equals(isLiked)) {
// 取消点赞
redisTemplate.opsForSet().remove(setKey, userId.toString());
redisTemplate.opsForValue().decrement(countKey);
return false; // 当前未点赞
} else {
// 点赞
redisTemplate.opsForSet().add(setKey, userId.toString());
redisTemplate.opsForValue().increment(countKey);
return true; // 当前已点赞
}
}
/**
* 获取点赞数
*/
public Long getLikeCount(Long videoId) {
String countKey = String.format(LIKE_COUNT_KEY, videoId);
String count = redisTemplate.opsForValue().get(countKey);
return count == null ? 0L : Long.parseLong(count);
}
/**
* 是否已点赞
*/
public boolean isLiked(Long videoId, Long userId) {
if (userId == null) return false;
String setKey = String.format(LIKE_SET_KEY, videoId);
return Boolean.TRUE.equals(
redisTemplate.opsForSet().isMember(setKey, userId.toString()));
}
/**
* 批量查询点赞状态
*/
public Map<Long, Boolean> batchCheckLiked(Long videoId, List<Long> userIds) {
String setKey = String.format(LIKE_SET_KEY, videoId);
List<Object> members = redisTemplate.opsForSet().pop(setKey, 0);
// 使用 pipeline 批量查询
List<Boolean> results = redisTemplate.executePipelined(
(RedisCallback<Object>) connection -> {
for (Long userId : userIds) {
connection.sIsMember(
setKey.getBytes(),
userId.toString().getBytes());
}
return null;
});
Map<Long, Boolean> map = new HashMap<>();
for (int i = 0; i < userIds.size(); i++) {
map.put(userIds.get(i),
Boolean.TRUE.equals(results.get(i)));
}
return map;
}
}
8.2 定时同步 Redis 到数据库
import org.springframework.scheduling.annotation.Scheduled;
/**
* 定时同步 Redis 点赞数据到 MySQL
*/
@Component
public class LikeSyncTask {
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private VideoDao videoDao;
/**
* 每 5 分钟同步一次点赞数到数据库
*/
@Scheduled(fixedRate = 300000)
public void syncLikeCountToDB() {
// 扫描所有 like:count:* 的 key
Set<String> keys = redisTemplate.keys("like:count:*");
if (keys == null || keys.isEmpty()) return;
for (String key : keys) {
String videoIdStr = key.replace("like:count:", "");
String countStr = redisTemplate.opsForValue().get(key);
if (countStr != null && videoIdStr != null) {
Long videoId = Long.parseLong(videoIdStr);
Long count = Long.parseLong(countStr);
// 更新数据库
videoDao.updateGiveUpCount(videoId, count);
}
}
}
}
8.3 使用布隆过滤器防止缓存穿透
/**
* 初始化布隆过滤器(防止恶意请求不存在的视频ID)
*/
@PostConstruct
public void initBloomFilter() {
// 假设有 100 万个视频 ID
int expectedInsertions = 1_000_000;
double fpp = 0.01; // 误判率 1%
BloomFilter<Long> bloomFilter = BloomFilter.create(
Funnels.longFunnel(),
expectedInsertions,
fpp);
// 从数据库加载所有视频 ID
List<Video> allVideos = videoDao.selectList(null);
for (Video video : allVideos) {
bloomFilter.put(video.getId());
}
}
九、完整 API 文档
9.1 接口汇总
| 方法 | 路径 | 说明 | 登录 |
|---|---|---|---|
| POST | /api/nicedetail/niceDetail{userid,consumerId} |
点赞/取消点赞 | 可选 |
| DELETE | /api/nicedetail/unlikeVideoGiveUp/{id} |
游客取消点赞 | 否 |
| GET | /api/nicedetail/likeStatus |
查询点赞状态 | 是 |
| GET | /api/nicedetail/count/{videoId} |
查询点赞总数 | 否 |
9.2 请求示例
# 点赞/取消点赞(登录)
POST /api/nicedetail/niceDetail{userid,consumerId}
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
Content-Type: application/json
{
"videoId": 123
}
# 查询点赞总数
GET /api/nicedetail/count/123
# 查询点赞状态
GET /api/nicedetail/likeStatus?userId=456&videoIds=123,124,125
9.3 响应格式
// 点赞成功
{
"code": 200,
"message": "success",
"data": null
}
// 查询点赞总数
{
"code": 200,
"message": "success",
"data": {
"totalLikes": 128
}
}
// 查询点赞状态
{
"123": true,
"124": false,
"125": true
}
// 超过频率限制
{
"code": 429,
"message": "操作太频繁,请稍后再试",
"data": null
}
十、常见问题排查
10.1 高并发问题
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 点赞数不准确 | 并发更新导致丢失 | 使用 Redis + 定时同步 |
| 重复点赞记录 | 并发插入 | 使用唯一索引 + try-catch |
| 点赞数负数 | 并发扣减 | SQL 加 AND give_up > 0 |
| 超时 | 大量 COUNT 查询 | 点赞数缓存到内容表 |
10.2 数据一致性
/**
* 最终一致性方案
*/
// 1. Redis 实时处理(毫秒级)
// 2. 定时同步到 MySQL(分钟级)
// 3. 兜底:页面展示 Redis 数据,后端用 MySQL 数据做统计
// 如果 Redis 宕机,降级方案:
public JsonResult niceDetailFallback(NiceDetail niceDetail) {
try {
return niceDetailService.niceDetailWithTx(niceDetail);
} catch (Exception e) {
log.error("点赞失败,降级处理");
return JsonResult.error("点赞服务暂时不可用");
}
}
10.3 Token 解析
// JWT Token 解析工具类
public class JWTUtil {
public static Long getAppUID(String authorization) {
if (authorization == null || authorization.isEmpty()) {
return null;
}
try {
// 去掉 "Bearer " 前缀
String token = authorization.startsWith("Bearer ")
? authorization.substring(7)
: authorization;
// 解析 Token
DecodedJWT claims = JWT.require(Algorithm.HMAC256("secret"))
.build()
.verify(token);
return claims.getClaim("userId").asLong();
} catch (Exception e) {
// Token 无效或过期
return null;
}
}
}
10.4 快速排查清单
- 数据库
t_nice_detail是否有uk_consumer_video唯一索引 - 点赞数
give_up字段是否有默认值 0 - 高并发场景是否使用 Redis
- 是否加事务保证明细表和计数一致性
- 游客点赞是否做了限流
- Token 过期后是否优雅降级到游客模式
- 限流注解的
period和count是否符合业务需求 - 接口是否有防重复提交处理(前端按钮置灰 + 后端幂等)
十一、总结
| 功能 | 关键代码 | 说明 |
|---|---|---|
| 点赞记录 | t_nice_detail 表 |
记录用户-内容关系 |
| 点赞计数 | t_video.give_up 字段 |
反范式缓存总数 |
| 切换点赞 | toggle: 有就删 / 无就增 |
避免引入状态字段 |
| 限流 | @Limit(count=30, period=3600) |
每小时 30 次 |
| 身份识别 | JWTUtil.getAppUID(token) |
解析用户 ID |
| 游客处理 | addGiveUp(videoId) |
仅更新计数 |
| Redis 加速 | Set 存明细,String 存计数 |
毫秒级响应 |
| 数据同步 | @Scheduled 定时任务 |
持久化到 MySQL |
Redis vs MySQL 方案对比
| 对比项 | MySQL 直接操作 | Redis + 定时同步 |
|---|---|---|
| 响应速度 | 慢(磁盘IO) | 快(内存操作) |
| 点赞状态查询 | COUNT 查询 | SISMEMBER O(1) |
| 数据持久性 | 实时持久化 | 异步同步(最终一致) |
| 并发能力 | 中等(2000 QPS) | 高(10万+ QPS) |
| 实现复杂度 | 低 | 中 |
| 适用场景 | 小规模(日活 < 1万) | 大规模(日活 > 10万) |
推荐策略: 小型系统直接用 MySQL + 事务;大型系统用 Redis 处理实时操作 + 定时任务同步到 MySQL 做持久化。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐



所有评论(0)