ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

巨额流量点赞项目实战一:简易版实现

巨额流量点赞项目实战一:简易版实现

前言:

这个项目也是我第一次接手的项目,这个系列,将会把我日常怎么和ai交流的过程以及编码,一并给出,供大家参考。我也会遇到很多问题,网上没有教学视频,刚开始我也不知道最后会长什么样,一些技术文档怎么读,怎么快速上手,我也会提到,等最后实现的时候有了真正的效果,我会把链接重新放到每一篇博客当中,让大家能看到最后的效果。但是现在不行,因为我也不知道最终效果长什么样,这和大家开发的情况也是一样的,最开始只有一个模糊的需求,也不知道自己想要的是什么。

连接数据库:

使用什么连接工具创建数据库都可以,工具而已没必要纠结,我就直接用IDEA就行了

创建基本表:

CREATE DATABASE IF NOT EXISTS like_db; use like_db; -- 用户表 create table if not exists user ( id bigint auto_increment primary key, username varchar(128) not null ); -- 博客表 create table if not exists blog ( id bigint auto_increment primary key, userId bigint not null, title varchar(512) null comment '标题', coverImg varchar(1024) null comment '封面', content text not null comment '内容', thumbCount int default 0 not null comment '点赞数', createTime datetime default CURRENT_TIMESTAMP not null comment '创建时间', updateTime datetime default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '更新时间' ); create index idx_userId on blog (userId); -- 点赞记录表 create table if not exists thumb ( id bigint auto_increment primary key, userId bigint not null, blogId bigint not null, createTime datetime default CURRENT_TIMESTAMP not null comment '创建时间' ); create unique index idx_userId_blogId on thumb (userId, blogId);

用AI引入基本依赖:

现在引入的依赖不是最终的依赖,因为我也不确定最终的依赖有哪一些。我们现在只引入基础的依赖,后续如果欠缺什么我们再加,后续我会讲到

在这个过程当中,他自己处理了版本的处理冲突和一些已经被官方筛查出的错误,并且进行了修改,并且最后成功运行,结果如下:

接口文档优化修改:

这里我想要的是点赞系统接口文档,而不是Demo接口文档。那么我怎么把这个Demo给改了呢?不要自己去找,现在可以借助AI让它告诉我们应该改哪里,或者直接让它帮我们修改:

重新启动之后效果如下:

调试日志配置:

为了让日志更加清晰,我们需要加入一个日志配置文件:

工具依赖引入

现在我们再引入一些其他的依赖,常用的:一个是常用的工具库,一个是我们MyBatis的升级版(像依赖这种事,如果没有特别的规定,一定不要自己手动,如果自己手动引入依赖的话,排错非常的难。AI的话它会自己识别匹配版本,可以节省大量的时间,更专注于业务)

配置数据库

看他给我们的描述,我们应该忘了配置了数据库的这些东西。那么好,现在我们开始配置:

创建代码注释包:

写完之后我们需要验证是否能够连接成功,那么这个时候不要自己去写代测试代码,直接让AI给我们测试代码就可以了,让它创建一个测试包,然后我们在包中让它给出测试代码运行就可以:

现在我们运行测试类,查看一下它的控制台输出:

数据库连接错误如何修改:

很明显他这里是不对的,数据库名不是:like_system,之前我也忘记指定了数据库的名字,所以他这里测试的数据库的名字是他自己新创建的数据库的名字,我们要把它改成我们之前数据库的名字,(这里要改的话其实很简单,去YML文件当中改就可以了。但是我不会这样做,我们整个系统开发流程我都尽量会用AI来弄出来,除了到了很复杂的部分需要微调,我都会尽量不动代码,这样子让大家VIbe Coding也会有体验),如下:

添加通用返回类与异常类:

现在我们来编写一些通用的类,比如说通用的返回类和全局异常处理类(这里AI生成的那些返回状态码和你的那些业务异常是非常有可能不正确的,但是这个时候不要着急也不要去改,因为你的业务异常和你想返回的那些状态码你一开始还没有设计出来,我们先把它保留着,等你后面写业务问题的时候需要返回。你想选定的状态码或者业务异常,再让它进行修改,这里只是一个通用的):

现在我们来实现一些基础的功能,先让项目跑通:

现在我们根据数据库里面刚开始创建了几张表,给它生成基本的CRUD,这里呢我是直接让AI给我生成。如果大家使用的插件比如说是MyBatisX插件,一键生成也可以,那样子的话会更快。这里的话我全程会叫大家用AI来操作,所以我都会用AI来做。

现在我们来开发一些常规的接口:

很明显他理解错了我的意思,他又新建了一个控制层的包,但是我只是想把这个接口放到原来的用户控制层包的里面,所以我们再一次命令AI让他修改

接口文档测试接口

用接口文档测试一下,看看后端是否能够接收。那个登录的具体代码每个人跑出来是不一样的,这里的逻辑需要自己回看一下代码,但是这个都不太影响,只要能够跑通就行了,后续可以继续改

再增加一个接口,

如果单个任务AI回复的比较慢,那么可以再开一个窗口让它多任务进行:

结果如下:

使用AI测试接口:

接下来还可以在文档当中测试,也可以直接让AI进行测试。之前的话在数据库当中我们并没有博客内容,可以自行插入或者直接让AI进行插入

现在在我们简易版的功能已经实现了,不一定合规要求,但是每个接口是不错的,后面根据我们实际需求再来改.

拓展:

点赞接口使用分布式锁:

仔细去看一下点赞和取消点赞那两个接口,会发现它其实是没加锁的。当用户快速的点击取消和增加操作的时候,可能会导致重复插入,而且它还不是原子性的。这里我们使用分布式锁来解决一下,当然如果使用数据库唯一索引也能解决这个问题,但这个项目的目的是为了让大家能更好地运用多种中间件进行组合,开发复杂项目,所以准备开始引入分布式锁

下面是AI第一次接入分布式锁使用的代码,这个代码呢我看了一下它确实加入了过期机制避免了死锁,也使用了弱脚本避免了误删他人的锁,但是它还欠缺一些东西,比如说如果同一个线程重复加锁会失败,它这个是不支持可重入的,当然这个也没关系,目前的业务也没涉及,第二个就是它没有看门狗机制续期,如果业务执行时间超时,锁会自动过期。它的自旋使用的是slow的sleep,高并发下会占用线程资源,既然如此,我们就使用比较成熟的方案,使用Redission,这样子的话比自己维护更加的稳定

package com.example.demo.common; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.script.DefaultRedisScript; import org.springframework.stereotype.Component; import java.util.Collections; import java.util.UUID; import java.util.concurrent.TimeUnit; /** * 基于 Redis 的分布式锁 * <p> * 设计要点: * 1. 加锁:SET key value NX EX(setIfAbsent + TTL),value 为唯一 token,用于解锁时校验归属; * 2. 解锁:使用 Lua 脚本原子地「比对 token + 删除」,避免锁过期后误删他人持有的锁; * 3. 提供带等待超时的 tryLock,在竞争不激烈时短暂自旋重试,减少无谓失败。 */ @Component public class RedisDistributedLock { private final StringRedisTemplate redisTemplate; /** 解锁脚本:仅当 key 的值与传入 token 一致时才删除,保证原子性 */ private static final DefaultRedisScript<Long> UNLOCK_SCRIPT; static { UNLOCK_SCRIPT = new DefaultRedisScript<>(); UNLOCK_SCRIPT.setScriptText( "if redis.call('get', KEYS[1]) == ARGV[1] then " + " return redis.call('del', KEYS[1]) " + "else " + " return 0 " + "end"); UNLOCK_SCRIPT.setResultType(Long.class); } public RedisDistributedLock(StringRedisTemplate redisTemplate) { this.redisTemplate = redisTemplate; } /** * 尝试加锁(仅一次) * * @param lockKey 锁键 * @param leaseSeconds 锁的租约时长(秒),超时后自动释放 * @return 加锁成功返回唯一 token,失败返回 null */ public String tryLock(String lockKey, long leaseSeconds) { String token = UUID.randomUUID().toString(); Boolean acquired = redisTemplate.opsForValue() .setIfAbsent(lockKey, token, leaseSeconds, TimeUnit.SECONDS); return Boolean.TRUE.equals(acquired) ? token : null; } /** * 尝试加锁(带等待超时,失败时短暂自旋重试) * * @param lockKey 锁键 * @param leaseSeconds 锁的租约时长(秒) * @param waitTimeoutMs 最大等待时间(毫秒) * @param retryIntervalMs 重试间隔(毫秒) * @return 加锁成功返回唯一 token,超时仍失败返回 null */ public String tryLock(String lockKey, long leaseSeconds, long waitTimeoutMs, long retryIntervalMs) throws InterruptedException { long deadline = System.currentTimeMillis() + waitTimeoutMs; while (System.currentTimeMillis() < deadline) { String token = tryLock(lockKey, leaseSeconds); if (token != null) { return token; } if (System.currentTimeMillis() + retryIntervalMs >= deadline) { break; } Thread.sleep(retryIntervalMs); } return null; } /** * 释放锁:使用 Lua 脚本比对 token 后再删除,避免误删他人锁 * * @param lockKey 锁键 * @param token 加锁时返回的 token */ public void unlock(String lockKey, String token) { if (token == null) { return; } redisTemplate.execute(UNLOCK_SCRIPT, Collections.singletonList(lockKey), token); } }

修改后:

他修改之后的这个代码其实还是有问题的,它里面一些边界没有考虑到。比如说他取消点赞没有校验是否真的删除成功,然后也没有进行事务处理。点赞成功了,那么点赞记录数有没有增加?假如其中一个断掉了怎么办,这第一节就讲到这里吧,有点长了,后面再继续优化

package com.example.demo.controller; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.example.demo.common.Result; import com.example.demo.common.ResultCode; import com.example.demo.exception.BusinessException; import com.example.demo.model.Blog; import com.example.demo.model.Thumb; import com.example.demo.service.BlogService; import com.example.demo.service.ThumbService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpSession; import org.redisson.api.RLock; import org.redisson.api.RedissonClient; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import java.util.concurrent.TimeUnit; @Tag(name = "点赞管理", description = "博客点赞接口") @RestController @RequestMapping("/api/thumbs") public class ThumbController { /** session 中保存当前登录用户 ID 的 key,与 UserController 登录时写入的一致 */ private static final String SESSION_USER_ID = "userId"; /** 分布式锁 key 前缀,按 userId+blogId 维度加锁 */ private static final String THUMB_LOCK_PREFIX = "thumb:lock:"; /** 抢锁最大等待时长(毫秒) */ private static final long LOCK_WAIT_TIMEOUT_MS = 3000; /** 锁租约时长(秒),到期自动释放,防止死锁 */ private static final long LOCK_LEASE_SECONDS = 10; private final ThumbService thumbService; private final BlogService blogService; private final RedissonClient redissonClient; public ThumbController(ThumbService thumbService, BlogService blogService, RedissonClient redissonClient) { this.thumbService = thumbService; this.blogService = blogService; this.redissonClient = redissonClient; } /** * 点赞: * 1. 按 userId+blogId 维度加 Redisson 分布式锁,避免并发下重复插入 thumb 记录; * 2. 锁内先查询是否已点赞(幂等),未点赞才插入; * 3. 用 SQL 原子递增 blog.thumbCount,避免「读-改-写」丢失更新。 */ @Operation(summary = "点赞博客") @PostMapping("/{blogId}") public Result<Boolean> doThumb(@Parameter(description = "博客 ID", example = "1") @PathVariable Long blogId, HttpSession session) { Long userId = getLoginUserId(session); String lockKey = THUMB_LOCK_PREFIX + userId + ":" + blogId; RLock lock = redissonClient.getLock(lockKey); boolean acquired = false; try { acquired = lock.tryLock(LOCK_WAIT_TIMEOUT_MS, LOCK_LEASE_SECONDS, TimeUnit.MILLISECONDS); if (!acquired) { throw new BusinessException("操作太频繁,请稍后再试"); } long count = thumbService.count(new LambdaQueryWrapper<Thumb>() .eq(Thumb::getUserId, userId) .eq(Thumb::getBlogId, blogId)); if (count > 0) { // 已点赞,幂等返回成功 return Result.success(true); } Thumb thumb = new Thumb(); thumb.setUserId(userId); thumb.setBlogId(blogId); thumbService.save(thumb); // 原子递增点赞数(SET thumbCount = thumbCount + 1,在数据库层完成,避免并发丢失更新) blogService.update(new LambdaUpdateWrapper<Blog>() .eq(Blog::getId, blogId) .setSql("thumbCount = thumbCount + 1")); return Result.success(true); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new BusinessException("操作被中断"); } finally { // 仅当当前线程持有该锁时才释放,避免误删 if (acquired && lock.isHeldByCurrentThread()) { lock.unlock(); } } } /** * 取消点赞: * 1. 按 userId+blogId 维度加 Redisson 分布式锁,避免与点赞操作交叉执行; * 2. 锁内先查询是否已点赞,已点赞才删除; * 3. 用 SQL 原子递减 blog.thumbCount。 */ @Operation(summary = "取消点赞") @DeleteMapping("/{blogId}") public Result<Boolean> undoThumb(@Parameter(description = "博客 ID", example = "1") @PathVariable Long blogId, HttpSession session) { Long userId = getLoginUserId(session); String lockKey = THUMB_LOCK_PREFIX + userId + ":" + blogId; RLock lock = redissonClient.getLock(lockKey); boolean acquired = false; try { acquired = lock.tryLock(LOCK_WAIT_TIMEOUT_MS, LOCK_LEASE_SECONDS, TimeUnit.MILLISECONDS); if (!acquired) { throw new BusinessException("操作太频繁,请稍后再试"); } long count = thumbService.count(new LambdaQueryWrapper<Thumb>() .eq(Thumb::getUserId, userId) .eq(Thumb::getBlogId, blogId)); if (count == 0) { // 未点赞,幂等返回成功 return Result.success(true); } thumbService.remove(new LambdaQueryWrapper<Thumb>() .eq(Thumb::getUserId, userId) .eq(Thumb::getBlogId, blogId)); // 原子递减点赞数 blogService.update(new LambdaUpdateWrapper<Blog>() .eq(Blog::getId, blogId) .setSql("thumbCount = thumbCount - 1")); return Result.success(true); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new BusinessException("操作被中断"); } finally { if (acquired && lock.isHeldByCurrentThread()) { lock.unlock(); } } } private Long getLoginUserId(HttpSession session) { Long userId = (Long) session.getAttribute(SESSION_USER_ID); if (userId == null) { throw new BusinessException(ResultCode.UNAUTHORIZED); } return userId; } }
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进