news 2026/9/2 5:57:03

从零构建高并发点赞系统:Spring Boot + Vue 3 全栈实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
从零构建高并发点赞系统:Spring Boot + Vue 3 全栈实战

最近在开发一个社交类应用时,遇到了一个看似简单却影响用户体验的“小”需求:如何优雅地实现一个“喜欢/点赞”功能,并让用户感受到即时、友好的互动反馈?这个功能几乎是所有内容型产品的标配,从微博、知乎到抖音,无处不在。然而,实现它不仅仅是前端加个爱心图标、后端加个计数那么简单。它涉及到前后端状态同步、高并发下的数据一致性、防刷机制、以及最重要的——如何通过技术手段传递出“喜欢吗?我也喜欢~”这种温暖、即时的情感共鸣。

本文将从一个全栈开发者的视角,手把手带你从零构建一个健壮、可扩展的“喜欢”功能。我们将覆盖从数据库设计、后端API开发、到前端交互与状态管理的完整闭环。无论你是刚入门全栈的新手,还是希望优化现有点赞逻辑的开发者,都能从中获得一套可直接复用于生产环境的解决方案。

1. 功能核心概念与设计思路

在动手写代码之前,我们必须明确“喜欢”功能的业务边界和技术挑战。

1.1 什么是“喜欢”功能?

“喜欢”(Like),在不同产品中也被称为“点赞”、“爱心”、“收藏”(弱化版本)。其核心业务逻辑是:一个用户(主体)对一条内容(客体)表达一次正向的情感反馈。这次反馈通常会被记录,并可能带来以下影响:

  1. 计数显示:内容上会展示收到“喜欢”的总数。
  2. 状态回显:当前用户如果已经点过喜欢,界面会高亮显示(如红色爱心),表明“已喜欢”。
  3. 社交互动:可能触发通知,告诉内容创作者“有人喜欢了你的内容”。
  4. 排序权重:喜欢数可能作为内容热度因子,影响其在列表中的排序。

1.2 关键设计考量与挑战

  1. 幂等性:用户连续点击两次“喜欢”按钮,结果应该是“取消喜欢”,而不是重复计数。这是一个典型的“切换”(Toggle)操作,必须保证幂等。
  2. 实时性:点击后,前端状态(爱心颜色、计数)需要立刻更新,给用户即时反馈,即使后端请求可能稍有延迟(乐观更新)。
  3. 高并发与数据一致性:热门内容可能在瞬间收到成千上万个“喜欢”,如何保证计数准确,不发生少计或超计?
  4. 防刷与安全:如何防止恶意脚本无限刷喜欢数?需要引入用户认证和频率限制。
  5. 扩展性:未来是否要支持“踩”(Dislike)?是否要区分“喜欢”的类型?数据库和API设计需要预留扩展空间。

基于以上分析,我们将采用以下技术方案:

  • 后端:使用 Spring Boot 构建 RESTful API,配合 MySQL 数据库。
  • 数据库:使用两张表,一张存储内容,一张专门记录用户-喜欢的关联关系,计数通过查询或缓存获取,避免直接更新计数字段。
  • 并发控制:利用数据库唯一索引防止重复插入,使用 Redis 缓存热门内容的计数并配合异步落库,应对高并发。
  • 前端:使用 Vue 3 组合式 API 实现一个具有乐观更新功能的“喜欢”组件。

2. 环境准备与版本说明

在开始编码前,请确保你的开发环境已就绪。以下是本文示例所使用的环境,你可以根据实际情况调整。

后端环境:

  • JDK: 17 或以上版本(本文使用 Amazon Corretto 17)
  • 构建工具: Maven 3.8+
  • IDE: IntelliJ IDEA 或 VS Code
  • 数据库: MySQL 8.0
  • 缓存: Redis 6.2

前端环境:

  • Node.js: 18.x 或以上版本
  • 包管理器: npm 或 yarn
  • 框架: Vue 3.3+,配合 Vite 构建工具

关键依赖版本(在pom.xml中定义):

<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.1.5</version> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies>

3. 数据库设计与核心模型

良好的数据结构是功能的基石。我们设计两张核心表。

3.1 内容表 (post)

存储被点赞的对象,可以是文章、视频、评论等。

CREATE TABLE `post` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID', `title` varchar(255) DEFAULT NULL COMMENT '标题', `content` text COMMENT '内容', `author_id` bigint NOT NULL COMMENT '作者ID', `created_at` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (`id`), KEY `idx_author` (`author_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='内容表';

3.2 喜欢关系表 (user_like)

这是核心表,记录“谁”喜欢了“什么”。唯一索引是保证幂等性的关键。

CREATE TABLE `user_like` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', `user_id` bigint NOT NULL COMMENT '用户ID', `target_id` bigint NOT NULL COMMENT '点赞目标ID (对应post.id)', `target_type` varchar(50) NOT NULL DEFAULT 'POST' COMMENT '点赞目标类型 (POST, COMMENT...)', `status` tinyint NOT NULL DEFAULT '1' COMMENT '状态: 1-有效, 0-取消', `created_at` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (`id`), UNIQUE KEY `uk_user_target` (`user_id`,`target_id`,`target_type`), -- 唯一索引,防止重复点赞 KEY `idx_target` (`target_id`,`target_type`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='用户点赞关系表';

设计解释

  • uk_user_target唯一索引:确保同一个用户对同一个目标只能有一条有效记录。当用户重复点击时,我们使用ON DUPLICATE KEY UPDATE或先查后改的逻辑来切换status字段,从而实现“点赞/取消”的切换。
  • target_type字段:为未来扩展预留,可以支持对评论、视频等其他实体点赞。
  • status字段:采用软删除逻辑,便于数据统计和恢复。

4. 后端API实现:Spring Boot 实战

我们创建一个简单的 Spring Boot 应用,实现点赞的核心 API。

4.1 项目结构与实体类

首先,创建对应的 JPA 实体。

1. Post 实体

// 文件路径:src/main/java/com/example/like/demo/entity/Post.java package com.example.like.demo.entity; import jakarta.persistence.*; import lombok.Data; import org.hibernate.annotations.CreationTimestamp; import org.hibernate.annotations.UpdateTimestamp; import java.time.LocalDateTime; @Entity @Table(name = "post") @Data public class Post { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String title; @Lob private String content; @Column(name = "author_id", nullable = false) private Long authorId; @CreationTimestamp @Column(name = "created_at", updatable = false) private LocalDateTime createdAt; @UpdateTimestamp @Column(name = "updated_at") private LocalDateTime updatedAt; }

2. UserLike 实体

// 文件路径:src/main/java/com/example/like/demo/entity/UserLike.java package com.example.like.demo.entity; import jakarta.persistence.*; import lombok.Data; import org.hibernate.annotations.CreationTimestamp; import org.hibernate.annotations.UpdateTimestamp; import java.time.LocalDateTime; @Entity @Table(name = "user_like", uniqueConstraints = { @UniqueConstraint(columnNames = {"userId", "targetId", "targetType"}) }) // JPA方式声明唯一约束 @Data public class UserLike { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(name = "user_id", nullable = false) private Long userId; @Column(name = "target_id", nullable = false) private Long targetId; @Column(name = "target_type", nullable = false) private String targetType = "POST"; // 默认类型为POST @Column(nullable = false) private Integer status = 1; // 1-有效,0-取消 @CreationTimestamp @Column(name = "created_at", updatable = false) private LocalDateTime createdAt; @UpdateTimestamp @Column(name = "updated_at") private LocalDateTime updatedAt; }

4.2 数据访问层:Repository

创建 Spring Data JPA 仓库接口。

// 文件路径:src/main/java/com/example/like/demo/repository/UserLikeRepository.java package com.example.like.demo.repository; import com.example.like.demo.entity.UserLike; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.transaction.annotation.Transactional; import java.util.Optional; public interface UserLikeRepository extends JpaRepository<UserLike, Long> { // 根据用户、目标查找点赞记录 Optional<UserLike> findByUserIdAndTargetIdAndTargetType(Long userId, Long targetId, String targetType); // 统计某个目标的有效点赞数 @Query("SELECT COUNT(ul) FROM UserLike ul WHERE ul.targetId = :targetId AND ul.targetType = :targetType AND ul.status = 1") Long countByTargetIdAndTargetType(@Param("targetId") Long targetId, @Param("targetType") String targetType); // 更新点赞状态(取消或重新点赞) @Modifying @Transactional @Query("UPDATE UserLike ul SET ul.status = :status, ul.updatedAt = CURRENT_TIMESTAMP WHERE ul.userId = :userId AND ul.targetId = :targetId AND ul.targetType = :targetType") int updateStatus(@Param("userId") Long userId, @Param("targetId") Long targetId, @Param("targetType") String targetType, @Param("status") Integer status); }

4.3 业务逻辑层:Service

Service 层封装核心的点赞/取消逻辑,并处理并发和数据一致性问题。

// 文件路径:src/main/java/com/example/like/demo/service/LikeService.java package com.example.like.demo.service; import com.example.like.demo.entity.UserLike; import com.example.like.demo.repository.UserLikeRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.Optional; import java.util.concurrent.TimeUnit; @Service @RequiredArgsConstructor @Slf4j public class LikeService { private final UserLikeRepository userLikeRepository; private final StringRedisTemplate redisTemplate; // Redis Key 模板 private static final String LIKE_COUNT_KEY = "like:count:%s:%s"; // like:count:POST:123 /** * 点赞或取消点赞 * @param userId 用户ID * @param targetId 目标ID * @param targetType 目标类型 * @return 操作后的状态:true-已点赞,false-未点赞 */ @Transactional public boolean toggleLike(Long userId, Long targetId, String targetType) { // 1. 先尝试查询现有记录 Optional<UserLike> existingLikeOpt = userLikeRepository.findByUserIdAndTargetIdAndTargetType(userId, targetId, targetType); if (existingLikeOpt.isPresent()) { // 2. 记录存在,切换状态 UserLike existingLike = existingLikeOpt.get(); Integer newStatus = existingLike.getStatus() == 1 ? 0 : 1; int updatedRows = userLikeRepository.updateStatus(userId, targetId, targetType, newStatus); if (updatedRows > 0) { // 3. 更新Redis缓存中的计数 updateLikeCountInRedis(targetId, targetType, newStatus == 1 ? 1 : -1); return newStatus == 1; } } else { // 4. 记录不存在,创建新的点赞记录 try { UserLike newLike = new UserLike(); newLike.setUserId(userId); newLike.setTargetId(targetId); newLike.setTargetType(targetType); newLike.setStatus(1); userLikeRepository.save(newLike); // 5. 更新Redis缓存中的计数 updateLikeCountInRedis(targetId, targetType, 1); return true; // 点赞成功 } catch (DataIntegrityViolationException e) { // 6. 捕获唯一约束冲突(极短时间内重复请求) log.warn("Duplicate like request detected for user {} on target {}.", userId, targetId); // 冲突意味着记录已存在,重新查询状态 return userLikeRepository.findByUserIdAndTargetIdAndTargetType(userId, targetId, targetType) .map(ul -> ul.getStatus() == 1) .orElse(false); } } return false; } /** * 获取点赞总数(优先从Redis读取) */ public Long getLikeCount(Long targetId, String targetType) { String key = String.format(LIKE_COUNT_KEY, targetType, targetId); String countStr = redisTemplate.opsForValue().get(key); if (countStr != null) { // 缓存命中 return Long.parseLong(countStr); } else { // 缓存未命中,从数据库查询并写入缓存 Long countFromDB = userLikeRepository.countByTargetIdAndTargetType(targetId, targetType); redisTemplate.opsForValue().set(key, String.valueOf(countFromDB), 1, TimeUnit.HOURS); // 缓存1小时 return countFromDB; } } /** * 获取用户对某个目标的点赞状态 */ public boolean getLikeStatus(Long userId, Long targetId, String targetType) { return userLikeRepository.findByUserIdAndTargetIdAndTargetType(userId, targetId, targetType) .map(ul -> ul.getStatus() == 1) .orElse(false); } /** * 原子化地更新Redis中的点赞计数 */ private void updateLikeCountInRedis(Long targetId, String targetType, long delta) { String key = String.format(LIKE_COUNT_KEY, targetType, targetId); redisTemplate.opsForValue().increment(key, delta); // 可以设置一个较短的过期时间,或者不设置,由定时任务同步到DB redisTemplate.expire(key, 1, TimeUnit.HOURS); } }

4.4 控制层:REST API

提供简洁的 API 接口供前端调用。

// 文件路径:src/main/java/com/example/like/demo/controller/LikeController.java package com.example.like.demo.controller; import com.example.like.demo.service.LikeService; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; import java.util.HashMap; import java.util.Map; @RestController @RequestMapping("/api/like") @RequiredArgsConstructor public class LikeController { private final LikeService likeService; /** * 点赞/取消点赞 * POST /api/like/toggle?targetId=123&targetType=POST * 请求头需携带用户认证信息(如JWT Token),这里简化处理,从请求参数获取userId */ @PostMapping("/toggle") public Map<String, Object> toggleLike(@RequestParam Long targetId, @RequestParam(defaultValue = "POST") String targetType, @RequestHeader("X-User-Id") Long userId) { // 实际应从Token解析 boolean liked = likeService.toggleLike(userId, targetId, targetType); Long currentCount = likeService.getLikeCount(targetId, targetType); Map<String, Object> result = new HashMap<>(); result.put("liked", liked); result.put("count", currentCount); result.put("targetId", targetId); return result; } /** * 获取点赞状态和总数 * GET /api/like/status?targetId=123&targetType=POST */ @GetMapping("/status") public Map<String, Object> getLikeStatus(@RequestParam Long targetId, @RequestParam(defaultValue = "POST") String targetType, @RequestHeader("X-User-Id") Long userId) { boolean liked = likeService.getLikeStatus(userId, targetId, targetType); Long count = likeService.getLikeCount(targetId, targetType); Map<String, Object> result = new HashMap<>(); result.put("liked", liked); result.put("count", count); return result; } }

注意:在实际项目中,用户ID应从认证信息(如JWT Token)中安全获取,而不是通过请求头传递。此处为简化演示。

4.5 应用配置

配置数据库和Redis连接。

# 文件路径:src/main/resources/application.yml spring: datasource: url: jdbc:mysql://localhost:3306/like_demo?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: yourpassword driver-class-name: com.mysql.cj.jdbc.Driver jpa: hibernate: ddl-auto: update # 开发环境,生产环境应使用none或validate,并通过SQL脚本管理表结构 show-sql: true properties: hibernate: format_sql: true redis: host: localhost port: 6379 database: 0 timeout: 2000ms server: port: 8080

5. 前端实现:Vue 3 交互组件

后端API准备好后,我们创建一个具有良好用户体验的前端“喜欢”按钮组件。

5.1 项目初始化与依赖

使用 Vite 快速创建一个 Vue 3 项目。

npm create vue@latest like-frontend # 按照提示选择项目配置,本文使用 TypeScript + Pinia cd like-frontend npm install npm install axios # 用于HTTP请求

5.2 喜欢按钮组件 (LikeButton.vue)

这是一个独立的、可复用的组件。

<!-- 文件路径:src/components/LikeButton.vue --> <template> <button @click="handleLikeClick" :disabled="loading" :class="['like-button', { liked, loading }]" aria-label="点赞" > <span class="icon-wrapper"> <!-- 使用SVG图标,更具灵活性 --> <svg v-if="liked" class="icon liked-icon" viewBox="0 0 24 24" fill="currentColor" > <path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/> </svg> <svg v-else class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" > <path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/> </svg> </span> <span class="count" :class="{ 'count-updated': countUpdated }"> {{ formattedCount }} </span> </button> </template> <script setup lang="ts"> import { ref, computed, onMounted } from 'vue'; import axios from 'axios'; const props = defineProps<{ targetId: number | string; targetType?: string; initialLiked?: boolean; initialCount?: number; }>(); const emit = defineEmits<{ (e: 'likeToggled', payload: { liked: boolean; count: number }): void; }>(); const liked = ref(props.initialLiked || false); const count = ref(props.initialCount || 0); const loading = ref(false); const countUpdated = ref(false); // 格式化计数显示,超过1000显示为1k const formattedCount = computed(() => { if (count.value >= 1000) { return (count.value / 1000).toFixed(1).replace(/\.0$/, '') + 'k'; } return count.value.toString(); }); // 处理点击事件:乐观更新 + 请求 const handleLikeClick = async () => { if (loading.value) return; loading.value = true; const oldLiked = liked.value; const oldCount = count.value; // 1. 乐观更新:立即更新UI,给用户即时反馈 liked.value = !oldLiked; count.value = oldLiked ? oldCount - 1 : oldCount + 1; countUpdated.value = true; setTimeout(() => (countUpdated.value = false), 300); // 动画效果 try { // 2. 发送请求到后端 const response = await axios.post( 'http://localhost:8080/api/like/toggle', null, // POST body为空 { params: { targetId: props.targetId, targetType: props.targetType || 'POST', }, headers: { 'X-User-Id': 1, // 模拟用户ID,实际应从登录状态获取 }, } ); const data = response.data; // 3. 请求成功,用后端返回的真实数据同步一次(防止乐观更新与后端不一致) liked.value = data.liked; count.value = data.count; // 4. 触发自定义事件,通知父组件 emit('likeToggled', { liked: liked.value, count: count.value }); } catch (error) { console.error('点赞操作失败:', error); // 5. 请求失败,回滚乐观更新 liked.value = oldLiked; count.value = oldCount; // 可以在这里添加用户提示,例如使用Toast alert('操作失败,请稍后重试'); } finally { loading.value = false; } }; // 组件挂载时,可以主动获取一次状态 onMounted(async () => { try { const response = await axios.get('http://localhost:8080/api/like/status', { params: { targetId: props.targetId, targetType: props.targetType || 'POST', }, headers: { 'X-User-Id': 1, }, }); const data = response.data; liked.value = data.liked; count.value = data.count; } catch (error) { console.error('获取点赞状态失败:', error); } }); </script> <style scoped> .like-button { display: inline-flex; align-items: center; gap: 6px; padding: 8px 16px; border: 1px solid #ddd; border-radius: 20px; background-color: #fff; color: #555; font-size: 14px; cursor: pointer; transition: all 0.2s ease; user-select: none; } .like-button:hover:not(.loading) { border-color: #f08; background-color: #fef5f9; } .like-button.liked { border-color: #f08; color: #f08; background-color: #fef5f9; } .like-button.loading { opacity: 0.7; cursor: not-allowed; } .icon-wrapper { display: flex; align-items: center; } .icon { width: 18px; height: 18px; } .liked-icon { fill: #f08; /* 点赞后的填充色 */ } .count { font-weight: 600; transition: transform 0.3s ease; } .count-updated { transform: scale(1.2); color: #f08; } </style>

5.3 在页面中使用组件

App.vue或任何页面组件中引入并使用。

<!-- 文件路径:src/App.vue --> <template> <div class="app"> <h1>“喜欢吗?我也喜欢~” - 功能演示</h1> <div class="post-card"> <h2>一篇很棒的技术文章</h2> <p>这里是文章的内容摘要...</p> <div class="actions"> <!-- 使用LikeButton组件 --> <LikeButton :target-id="postId" target-type="POST" :initial-liked="false" :initial-count="42" @like-toggled="handleLikeToggled" /> <span class="hint">(试试点击它,感受即时反馈)</span> </div> <div v-if="lastAction" class="feedback"> 你刚刚 {{ lastAction.liked ? '点赞了' : '取消了点赞' }},当前总数:{{ lastAction.count }} </div> </div> </div> </template> <script setup lang="ts"> import { ref } from 'vue'; import LikeButton from './components/LikeButton.vue'; const postId = ref(123); // 模拟的文章ID const lastAction = ref<{ liked: boolean; count: number } | null>(null); const handleLikeToggled = (payload: { liked: boolean; count: number }) => { lastAction.value = payload; // 在实际应用中,这里可以触发更多逻辑,如显示通知、更新列表等 console.log('点赞状态变化:', payload); }; </script> <style> .app { font-family: Arial, sans-serif; max-width: 600px; margin: 40px auto; padding: 20px; } .post-card { border: 1px solid #eee; border-radius: 12px; padding: 24px; margin-top: 20px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); } .actions { display: flex; align-items: center; gap: 15px; margin-top: 20px; } .hint { font-size: 12px; color: #888; } .feedback { margin-top: 15px; padding: 10px; background-color: #f0f9ff; border-radius: 6px; color: #0066cc; } </style>

6. 运行与验证

6.1 启动后端服务

  1. 确保 MySQL 和 Redis 服务已启动并配置正确。
  2. 在 IDE 中运行 Spring Boot 主类LikeDemoApplication,或使用命令mvn spring-boot:run
  3. 服务启动后,访问http://localhost:8080应能看到 Whitelabel Error Page(正常,因为我们没写首页),说明服务已运行。

6.2 启动前端应用

  1. 进入前端项目目录like-frontend
  2. 运行npm run dev
  3. 根据终端输出,访问开发服务器地址(通常是http://localhost:5173)。

6.3 功能测试

  1. 打开前端页面,你会看到一个带有爱心图标和计数的按钮。
  2. 点击按钮,观察以下变化:
    • 爱心图标应立即变为红色(填充状态)。
    • 计数应立即增加1,并有一个轻微的放大动画。
    • 控制台网络标签中应能看到一个发送到http://localhost:8080/api/like/toggle的 POST 请求。
    • 按钮状态变为“已点赞”。
  3. 再次点击按钮,观察变化:
    • 爱心图标变回轮廓状态。
    • 计数减少1。
    • 状态恢复为“未点赞”。
  4. 刷新页面,点赞状态和计数应被正确保持(从后端API重新获取)。

7. 常见问题与排查思路

在实际开发中,你可能会遇到以下问题:

问题现象可能原因排查思路与解决方案
点击按钮无任何反应1. 前端事件未绑定。
2. 按钮被disabled
检查浏览器控制台是否有JS错误。检查@click事件绑定和loading状态。
点击后UI更新,但很快回滚后端API请求失败,乐观更新被回滚。1. 检查网络请求是否成功(状态码200)。
2. 查看后端日志,确认业务逻辑是否出错(如数据库连接失败)。
3. 检查API路径、参数、请求头是否正确。
重复点击导致计数错误1. 前端未防止重复提交。
2. 后端并发处理有漏洞。
1. 前端确保loading状态生效。
2. 后端依赖数据库唯一索引 (uk_user_target) 是最终防线,确保其生效。
3. 检查后端toggleLike方法的事务和异常处理逻辑。
计数显示为负数Redis 缓存中的计数与数据库不一致。1. 检查updateLikeCountInRedis方法逻辑,确保delta值正确(+1或-1)。
2. 实现一个定时任务,定期将 Redis 计数同步回数据库,并修复差异。
其他用户看不到点赞数更新1. 前端未轮询或使用WebSocket。
2. 计数缓存未及时失效。
1. 对于实时性要求高的场景,考虑使用 WebSocket (如 Socket.IO) 广播点赞事件。
2. 缩短 Redis 计数缓存的过期时间,或在使用计数时强制回源查询。
数据库出现重复的user_like记录唯一索引未生效或代码逻辑绕过。1. 确认数据库表UNIQUE KEY uk_user_target已创建成功。
2. 在代码中,插入前先查询 (findByUserIdAndTargetIdAndTargetType) 是常见做法,但在极高并发下仍可能插入重复,最终需靠唯一索引约束和异常处理 (DataIntegrityViolationException) 兜底。

8. 最佳实践与进阶优化

上面的实现是一个基础且可用的版本。对于生产环境,还需要考虑更多。

8.1 性能优化

  1. 计数缓存策略:我们使用了 Redis 缓存计数。对于超级热点的内容(如顶级网红视频),可以考虑:
    • 使用更高效的数据结构,如 Redis 的Hash来存储多个目标的计数。
    • 引入本地缓存(如 Caffeine),配合 Redis 形成二级缓存。
  2. 异步落库:点赞操作可以拆解为“写关系表”和“更新计数”。可以将“更新计数”这个操作放入消息队列(如 RabbitMQ、Kafka)异步处理,大幅提高写接口的响应速度和解耦。
  3. 数据库优化user_like表会随着时间急剧增长。需考虑:
    • 按时间进行分表(如按月分表)。
    • 建立合适的索引:(target_id, target_type)用于查计数,(user_id)用于查询用户喜欢列表。

8.2 可扩展性设计

  1. 支持多种实体:我们设计了target_type字段,未来可以轻松支持对评论、用户、视频等点赞。
  2. 抽象服务层:可以定义一个Likeable接口,让不同的实体类实现它,服务层通过策略模式来处理不同实体的点赞逻辑。
  3. 开放API:当前的API是内部格式。对外提供时,应遵循 RESTful 规范,使用标准的 HTTP 状态码,并考虑 API 版本管理。

8.3 安全与风控

  1. 用户认证必须使用安全的认证方式(如 JWT),在服务器端从 Token 中解析用户ID,绝不能信任前端传来的用户ID。
  2. 频率限制:防止刷赞。可以在网关层或使用@RateLimiter注解对/api/like/toggle接口进行限流,例如每个用户每分钟最多操作60次。
  3. 业务风控:识别异常行为,例如同一个IP在短时间内对大量不同目标点赞,可能触发风控规则,进行验证码挑战或临时限制。

8.4 前端体验增强

  1. 防抖与节流:虽然组件有loading状态,但在网络极慢时,用户可能连续点击。可以加入防抖函数,确保一定时间内只发送一次请求。
  2. 动画效果:使用 CSS 或动画库(如 GSAP)为爱心图标添加更丰富的点击动画(如脉动、飘散效果),提升情感化设计。
  3. 离线支持:在 PWA 或需要离线能力的场景中,可以将点赞操作暂存到 IndexedDB,待网络恢复后同步到服务器。

通过以上步骤,我们不仅实现了一个功能完整的“喜欢”系统,更深入探讨了其背后的技术细节、潜在问题和优化方向。从数据库的唯一索引保证数据唯一性,到后端的幂等设计,再到前端的乐观更新提供即时反馈,每一个环节都影响着最终的用户体验。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/2 5:56:55

基于Python深度学习的人体动作识别:从ST-GCN原理到工程实践

简介&#xff1a;这是一套面向Python开发者与计算机视觉学习者的先进人体动作识别系统源码&#xff0c;聚焦于安全监控、体育分析、虚拟现实交互等场景下的动作智能识别需求。资源共44个文件&#xff0c;压缩包大小1.91MB&#xff0c;包含25个Python核心脚本&#xff08;如yolo…

作者头像 李华
网站建设 2026/9/2 5:51:43

HexView实战:HEX/S19/BIN文件处理与刷写镜像制作技巧

简介&#xff1a;HexView&#xff08;Vector&#xff09;V1.09.01是一款面向软件开发者、调试工程师与安全分析人员的十六进制查看与编辑工具。该工具包共19个文件&#xff0c;压缩包仅1.93MB&#xff0c;内容紧凑实用&#xff1a;包含hexview.exe主程序、多个dll运行时组件、参…

作者头像 李华
网站建设 2026/9/2 5:51:27

光敏二极管原理与跨阻放大器电路设计实战

光敏二极管&#xff0c;这个在硬件工程师面试中几乎必考的基础元件&#xff0c;你真的理解透了吗&#xff1f;很多工程师能背出“光照产生电流”的定义&#xff0c;但在实际电路设计、选型、故障排查时却频频踩坑。面试官问“光敏二极管如何工作”&#xff0c;期待的绝不是一个…

作者头像 李华
网站建设 2026/9/2 5:51:26

Git入门:从版本管理到本地仓库基本操作

个人主页&#xff1a;小则又沐风 个人专栏&#xff1a; • [数据结构] • [竞赛专栏] • [C语言] • [C] • [Linux] • [OJ项目] • [MySQL] •[GIT] 前言 什么是GIT 现在我们假想一个场景&#xff1a; 实验课的老师布置了一个作业——写一份实验报告。 我们很快写完了第一版…

作者头像 李华
网站建设 2026/9/2 5:49:41

游戏模型替换技术实践:从原理到避坑的完整指南

你打开游戏&#xff0c;准备继续推进主线&#xff0c;却发现某个关键角色的立绘、语音甚至剧情表现&#xff0c;都变成了另一个毫不相干的人物。这不是你眼花&#xff0c;也不是游戏更新了隐藏彩蛋&#xff0c;而是一种在特定玩家圈子里流传的“魔改”操作。今天要聊的&#xf…

作者头像 李华