ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

Spring Boot+Redis+MySQL构建高并发电子功德箱系统实战

Spring Boot+Redis+MySQL构建高并发电子功德箱系统实战 在实际项目开发中我们经常需要实现一些带有趣味性、社交传播属性的功能比如“点赞”、“祈福”、“打卡”等。这些功能的核心逻辑并不复杂但如何将其设计成一个完整、可维护、且能应对一定并发量的后端服务则需要考虑更多工程细节。本文将以一个模拟的“电子功德箱”系统为例从零开始构建一个支持扫码“捐献功德”的Web应用后端。我们将使用Spring Boot作为基础框架整合MySQL、Redis并详细讲解二维码生成、异步任务、接口防刷、数据统计等关键技术的实现。通过本文你将掌握如何将一个简单的创意落地为一个结构清晰、可供学习与复现的Java后端项目。1. 理解“电子功德箱”系统的核心需求与技术栈“电子功德箱”是一个带有趣味隐喻的系统其本质是一个接受用户“捐赠”通常是虚拟积分或记录并记录、展示的Web服务。用户通过扫描二维码访问特定页面完成一次“功德1”的操作。这背后涉及几个明确的技术需求。1.1 业务需求拆解首先我们需要将模糊的“电子功德”概念转化为具体的功能点功德项目管理后台可以创建不同的“功德箱”例如“祈福考试顺利”、“祝愿家人安康”等。每个项目有独立的标题、描述、目标功德值、当前功德值和专属二维码。功德捐献用户通过扫描二维码或访问链接进入项目详情页点击按钮完成一次“功德1”。每次捐献应记录用户标识如IP、临时ID、捐献时间、所属项目。数据展示实时展示每个功德箱的当前功德值、捐献总次数、捐献榜单等。防刷机制防止恶意用户通过脚本频繁刷功德需要基本的频率限制。异步与性能捐献操作可能较频繁需要异步处理以提升接口响应速度并使用缓存应对高并发读取。1.2 技术栈选型与理由基于以上需求我们选择以下技术栈并解释其选型原因Spring Boot 2.7快速构建RESTful API提供自动配置、内嵌Web服务器等便利是Java领域微服务开发的事实标准。MySQL 8.0作为主数据库持久化存储功德项目、捐献记录等核心关系型数据。选择8.0版本是为了使用JSON字段、窗口函数等现代特性。Redis 7.0作为缓存和计数器。功德箱的当前功德值这类高频读写且允许短暂不一致的数据非常适合用Redis的INCR命令原子性递增极大减轻数据库压力。MyBatis-Plus 3.5简化单表CRUD操作提高开发效率同时保留编写复杂SQL的灵活性。Hutool 5.8国产工具类库用于生成二维码、处理HTTP请求等避免重复造轮子。Spring Boot Starter AOP用于实现统一的接口限流和日志切面。Lombok通过注解简化POJO类的Getter/Setter、构造方法等代码。注意本文旨在演示核心流程和最佳实践生产环境还需考虑分布式锁、消息队列保证最终一致性、更精细的权限控制、HTTPS、监控告警等。2. 项目初始化与核心依赖配置我们首先创建一个标准的Spring Boot项目并配置好所有必要的依赖。2.1 使用Spring Initializr创建项目可以通过 https://start.spring.io 或IDE的创建向导生成项目。关键依赖选择Spring Web用于构建Web接口。Spring Data Redis集成Redis客户端。MySQL DriverMySQL数据库驱动。MyBatis Framework或直接引入mybatis-plus-boot-starter。Lombok简化代码。生成的pom.xml核心依赖部分如下dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3.1/version /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency dependency groupIdcn.hutool/groupId artifactIdhutool-all/artifactId version5.8.20/version /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-aop/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-test/artifactId scopetest/scope /dependency /dependencies2.2 配置文件详解在application.yml中配置数据库、Redis连接以及MyBatis-Plus。这里区分开发环境配置。server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/merit_box_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: yourpassword redis: host: localhost port: 6379 password: # 如果没有密码则留空 database: 0 lettuce: pool: max-active: 8 max-wait: -1ms max-idle: 8 min-idle: 0 mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # 开发环境开启SQL日志 global-config: db-config: logic-delete-field: deleted # 全局逻辑删除字段名 logic-delete-value: 1 # 逻辑已删除值 logic-not-delete-value: 0 # 逻辑未删除值 # 自定义配置项 merit: qrcode: base-url: http://localhost:8080/project/ # 二维码指向的前端页面基础URL limit: ip-period: 10 # 同一IP限制周期秒 ip-max-count: 3 # 同一IP在周期内最大请求次数关键配置解释spring.datasource.url中的serverTimezone必须设置避免时区错误。spring.redis.lettuce.pool配置了连接池对于并发请求是必要的。mybatis-plus.global-config.db-config配置了逻辑删除这是数据软删除的推荐做法。merit.qrcode.base-url是生成二维码链接的基础生产环境需替换为真实域名。merit.limit.*定义了接口防刷的规则。3. 数据库设计与实体类映射良好的数据结构是系统的基石。我们设计两张核心表。3.1 创建数据库表在MySQL中执行以下SQLCREATE DATABASE IF NOT EXISTS merit_box_db DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci; USE merit_box_db; -- 功德项目表 CREATE TABLE merit_project ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 主键ID, title VARCHAR(100) NOT NULL COMMENT 项目标题如“祈福考试顺利”, description TEXT COMMENT 项目描述, target_amount BIGINT DEFAULT 0 COMMENT 目标功德值0表示无目标, current_amount BIGINT DEFAULT 0 COMMENT 当前功德值, qr_code_url VARCHAR(500) COMMENT 二维码图片存储URL, status TINYINT DEFAULT 1 COMMENT 状态0-禁用1-启用, creator VARCHAR(50) COMMENT 创建者, create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 更新时间, deleted TINYINT DEFAULT 0 COMMENT 逻辑删除标记, INDEX idx_status (status), INDEX idx_create_time (create_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT功德项目表; -- 功德捐献记录表 CREATE TABLE merit_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 主键ID, project_id BIGINT NOT NULL COMMENT 关联的项目ID, donor_ip VARCHAR(50) COMMENT 捐献者IP地址, donor_session_id VARCHAR(100) COMMENT 捐献者会话ID前端生成, remark VARCHAR(200) COMMENT 捐献寄语, donate_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 捐献时间, INDEX idx_project_id (project_id), INDEX idx_donate_time (donate_time), INDEX idx_ip_project (donor_ip, project_id), -- 用于IP维度去重或查询 FOREIGN KEY (project_id) REFERENCES merit_project(id) ON DELETE CASCADE ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT功德捐献记录表;表结构设计要点merit_project.current_amount是核心字段它将与Redis中的计数器保持同步。使用逻辑删除而非物理删除避免误操作丢失数据。merit_record表记录了每一次捐献的明细用于溯源和数据分析。donor_session_id可用于识别同一浏览器会话实现更精准的防刷。为常用查询字段如project_id,donate_time,status建立了索引。3.2 创建对应的实体类使用MyBatis-Plus和Lombok简化实体类编写。// MeritProject.java package com.example.meritbox.entity; import com.baomidou.mybatisplus.annotation.*; import lombok.Data; import java.time.LocalDateTime; Data TableName(merit_project) public class MeritProject { TableId(type IdType.AUTO) private Long id; private String title; private String description; private Long targetAmount; private Long currentAmount; private String qrCodeUrl; private Integer status; private String creator; TableField(fill FieldFill.INSERT) private LocalDateTime createTime; TableField(fill FieldFill.INSERT_UPDATE) private LocalDateTime updateTime; TableLogic private Integer deleted; }// MeritRecord.java package com.example.meritbox.entity; import com.baomidou.mybatisplus.annotation.*; import lombok.Data; import java.time.LocalDateTime; Data TableName(merit_record) public class MeritRecord { TableId(type IdType.AUTO) private Long id; private Long projectId; private String donorIp; private String donorSessionId; private String remark; TableField(fill FieldFill.INSERT) private LocalDateTime donateTime; }实体类关键注解TableName指定对应数据库表名。TableId标记主键IdType.AUTO对应数据库自增。TableField(fill ...)配合元对象处理器实现创建/更新时间自动填充。TableLogic实现逻辑删除。4. 核心业务逻辑实现从二维码生成到功德捐献这是系统的核心部分我们将分步骤实现项目管理、二维码生成、捐献接口和异步处理。4.1 功德项目管理Service首先创建MeritProjectService包含基本的CRUD和核心业务方法。// MeritProjectService.java (接口) package com.example.meritbox.service; import com.baomidou.mybatisplus.extension.service.IService; import com.example.meritbox.entity.MeritProject; import com.example.meritbox.vo.ProjectVO; public interface MeritProjectService extends IServiceMeritProject { /** * 创建项目并生成二维码 */ ProjectVO createProject(MeritProject project); /** * 根据ID获取项目详情包含缓存逻辑 */ ProjectVO getProjectDetail(Long id); }// MeritProjectServiceImpl.java (实现类) package com.example.meritbox.service.impl; import cn.hutool.extra.qrcode.QrCodeUtil; import cn.hutool.extra.qrcode.QrConfig; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.example.meritbox.entity.MeritProject; import com.example.meritbox.mapper.MeritProjectMapper; import com.example.meritbox.service.MeritProjectService; import com.example.meritbox.vo.ProjectVO; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.BeanUtils; import org.springframework.beans.factory.annotation.Value; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Service; import javax.annotation.Resource; import java.io.File; import java.nio.file.Paths; Service Slf4j public class MeritProjectServiceImpl extends ServiceImplMeritProjectMapper, MeritProject implements MeritProjectService { Value(${merit.qrcode.base-url}) private String qrCodeBaseUrl; Value(${server.servlet.context-path:}) private String contextPath; Resource private RedisTemplateString, Object redisTemplate; private static final String PROJECT_CACHE_KEY_PREFIX merit:project:; private static final String PROJECT_AMOUNT_KEY_PREFIX merit:amount:; Override public ProjectVO createProject(MeritProject project) { // 1. 保存项目到数据库 project.setCurrentAmount(0L); this.save(project); Long projectId project.getId(); // 2. 生成访问链接和二维码 String accessUrl qrCodeBaseUrl projectId; String qrCodeFilePath generateAndSaveQrCode(accessUrl, projectId); // 3. 更新项目的二维码URL字段 project.setQrCodeUrl(qrCodeFilePath); this.updateById(project); // 4. 初始化Redis中的功德值计数器 String amountKey PROJECT_AMOUNT_KEY_PREFIX projectId; redisTemplate.opsForValue().set(amountKey, 0L); // 5. 返回VO对象 ProjectVO vo new ProjectVO(); BeanUtils.copyProperties(project, vo); vo.setAccessUrl(accessUrl); return vo; } private String generateAndSaveQrCode(String content, Long projectId) { try { QrConfig config new QrConfig(300, 300); config.setMargin(1); // 生成二维码图片到本地目录 (生产环境应上传至OSS或文件服务器) String fileName project_ projectId .png; File destFile Paths.get(uploads, qrcode, fileName).toFile(); destFile.getParentFile().mkdirs(); QrCodeUtil.generate(content, config, destFile); // 返回相对路径或完整URL这里返回相对路径供示例 return /uploads/qrcode/ fileName; } catch (Exception e) { log.error(生成二维码失败 projectId: {}, projectId, e); throw new RuntimeException(二维码生成失败); } } Override public ProjectVO getProjectDetail(Long id) { // 先尝试从缓存获取 String cacheKey PROJECT_CACHE_KEY_PREFIX id; ProjectVO cachedVo (ProjectVO) redisTemplate.opsForValue().get(cacheKey); if (cachedVo ! null) { return cachedVo; } // 缓存未命中查询数据库 MeritProject project this.getById(id); if (project null) { return null; } ProjectVO vo new ProjectVO(); BeanUtils.copyProperties(project, vo); // 从Redis获取实时功德值覆盖数据库的可能旧值 String amountKey PROJECT_AMOUNT_KEY_PREFIX id; Long currentAmount (Long) redisTemplate.opsForValue().get(amountKey); vo.setCurrentAmount(currentAmount ! null ? currentAmount : project.getCurrentAmount()); // 设置访问URL vo.setAccessUrl(qrCodeBaseUrl id); // 放入缓存设置过期时间如5分钟 redisTemplate.opsForValue().set(cacheKey, vo, 5, java.util.concurrent.TimeUnit.MINUTES); return vo; } }关键实现解析二维码生成使用Hutool的QrCodeUtil将拼接好的项目访问URL如http://localhost:8080/project/1生成为PNG图片。生产环境需要将图片上传到对象存储如OSS并返回可访问的CDN URL。缓存策略项目详情使用Redis缓存merit:project:{id}过期时间较短5分钟保证数据的相对实时性。功德值则使用独立的计数器merit:amount:{id}永不主动过期与数据库异步同步。数据一致性currentAmount以Redis中的值为准。这引入了最终一致性问题我们将在后续的异步任务中解决。4.2 功德捐献接口与防刷逻辑捐献接口是系统的核心写入接口必须处理好并发和防刷。首先创建一个捐献请求的DTO数据传输对象// DonateRequest.java package com.example.meritbox.dto; import lombok.Data; import javax.validation.constraints.NotNull; Data public class DonateRequest { NotNull(message 项目ID不能为空) private Long projectId; private String remark; // 用户填写的寄语 private String sessionId; // 前端生成的会话ID }然后实现捐献的Service层逻辑这里我们引入Async进行异步处理// MeritDonateService.java package com.example.meritbox.service; public interface MeritDonateService { /** * 捐献功德异步 * param projectId 项目ID * param request 请求对象包含IP和sessionId * return 捐献后的最新功德值 */ Long donateAsync(Long projectId, DonateRequest request); }// MeritDonateServiceImpl.java package com.example.meritbox.service.impl; import com.example.meritbox.dto.DonateRequest; import com.example.meritbox.entity.MeritRecord; import com.example.meritbox.mapper.MeritRecordMapper; import com.example.meritbox.service.MeritDonateService; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.script.DefaultRedisScript; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import javax.annotation.Resource; import javax.servlet.http.HttpServletRequest; import java.time.LocalDateTime; import java.util.Arrays; import java.util.List; Service Slf4j public class MeritDonateServiceImpl implements MeritDonateService { Resource private RedisTemplateString, Object redisTemplate; Resource private MeritRecordMapper meritRecordMapper; Value(${merit.limit.ip-period}) private Integer ipPeriod; Value(${merit.limit.ip-max-count}) private Integer ipMaxCount; private static final String AMOUNT_KEY_PREFIX merit:amount:; private static final String LIMIT_KEY_PREFIX merit:limit:ip:; Override Async(taskExecutor) // 指定异步线程池执行 public Long donateAsync(Long projectId, DonateRequest request) { // 1. 防刷检查基于IP String ip request.getDonorIp(); String limitKey LIMIT_KEY_PREFIX ip : projectId; boolean canDonate checkRateLimit(limitKey); if (!canDonate) { log.warn(IP: {} 在项目 {} 上触发频率限制, ip, projectId); throw new RuntimeException(操作过于频繁请稍后再试); } // 2. 原子性增加Redis中的功德值 String amountKey AMOUNT_KEY_PREFIX projectId; Long newAmount redisTemplate.opsForValue().increment(amountKey, 1L); log.info(项目 {} 功德值增加至: {}, projectId, newAmount); // 3. 异步落库捐献记录 saveDonateRecord(projectId, request, newAmount); return newAmount; } /** * 使用Redis Lua脚本实现原子性的滑动窗口限流 */ private boolean checkRateLimit(String key) { String luaScript local current redis.call(get, KEYS[1])\n if current false then\n redis.call(setex, KEYS[1], ARGV[1], 1)\n return 1\n else\n if tonumber(current) tonumber(ARGV[2]) then\n redis.call(incr, KEYS[1])\n return 1\n else\n return 0\n end\n end; DefaultRedisScriptLong script new DefaultRedisScript(luaScript, Long.class); ListString keys Arrays.asList(key); Long result redisTemplate.execute(script, keys, ipPeriod, ipMaxCount); return result ! null result 1L; } Transactional(rollbackFor Exception.class) public void saveDonateRecord(Long projectId, DonateRequest request, Long currentAmount) { MeritRecord record new MeritRecord(); record.setProjectId(projectId); record.setDonorIp(request.getDonorIp()); record.setDonorSessionId(request.getSessionId()); record.setRemark(request.getRemark()); record.setDonateTime(LocalDateTime.now()); meritRecordMapper.insert(record); log.debug(捐献记录已保存记录ID: {}, record.getId()); // 注意这里只保存了记录功德值的最终一致性由定时任务同步 } }关键实现解析异步处理Async注解使得捐献操作主要是Redis递增和记录落库在独立线程中执行用户点击后立即得到响应提升体验。原子性递增使用Redis的INCR命令保证在高并发下功德值计数的准确性。防刷限流使用Redis Lua脚本实现滑动窗口限流。脚本逻辑是如果key不存在则设置过期时间并计数为1如果存在且未超限则递增如果超限则返回0。这保证了检查和递增的原子性防止并发绕过。数据最终一致性捐献记录立即入库但功德值current_amount只在Redis中更新。数据库中的current_amount字段是滞后更新的这通过后续的定时任务来同步。4.3 定时任务同步数据我们需要一个定时任务定期将Redis中的功德值同步回MySQL保证数据的持久化和最终一致性。// MeritSyncTask.java package com.example.meritbox.task; import com.example.meritbox.entity.MeritProject; import com.example.meritbox.mapper.MeritProjectMapper; import lombok.extern.slf4j.Slf4j; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; import javax.annotation.Resource; import java.util.Set; Component Slf4j public class MeritSyncTask { Resource private RedisTemplateString, Object redisTemplate; Resource private MeritProjectMapper meritProjectMapper; private static final String AMOUNT_KEY_PATTERN merit:amount:*; /** * 每30秒同步一次功德值 */ Scheduled(fixedDelay 30000) // 单位毫秒 Transactional(rollbackFor Exception.class) public void syncAmountToDatabase() { log.info(开始同步功德值到数据库...); SetString keys redisTemplate.keys(AMOUNT_KEY_PATTERN); if (keys null || keys.isEmpty()) { return; } for (String key : keys) { try { Long projectId Long.parseLong(key.substring(key.lastIndexOf(:) 1)); Long redisAmount (Long) redisTemplate.opsForValue().get(key); if (redisAmount ! null) { MeritProject updateEntity new MeritProject(); updateEntity.setId(projectId); updateEntity.setCurrentAmount(redisAmount); // 使用MyBatis-Plus的updateById只更新current_amount字段 meritProjectMapper.updateById(updateEntity); log.debug(已同步项目 {} 功德值: {}, projectId, redisAmount); } } catch (Exception e) { log.error(同步功德值失败 key: {}, key, e); // 单个项目同步失败不应影响其他项目继续循环 } } log.info(功德值同步完成。); } }定时任务要点Scheduled(fixedDelay 30000)表示上次任务结束后间隔30秒再执行下一次。使用redisTemplate.keys()获取所有功德值计数器key生产环境数据量大时需谨慎可考虑用SCAN命令迭代。同步时只更新current_amount字段避免覆盖其他信息。单个项目同步失败被捕获不影响整体任务保证任务健壮性。5. 控制器层与接口暴露将Service的能力通过RESTful API暴露出来。5.1 项目查询与捐献接口// MeritController.java package com.example.meritbox.controller; import com.example.meritbox.dto.DonateRequest; import com.example.meritbox.service.MeritDonateService; import com.example.meritbox.service.MeritProjectService; import com.example.meritbox.vo.ProjectVO; import lombok.extern.slf4j.Slf4j; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; import javax.annotation.Resource; import javax.servlet.http.HttpServletRequest; RestController RequestMapping(/api/merit) Slf4j public class MeritController { Resource private MeritProjectService projectService; Resource private MeritDonateService donateService; GetMapping(/project/{id}) public ProjectVO getProject(PathVariable Long id) { return projectService.getProjectDetail(id); } PostMapping(/donate) public ApiResponseLong donate(Validated RequestBody DonateRequest request, HttpServletRequest httpRequest) { // 从HttpServletRequest中获取客户端IP需考虑代理情况 String clientIp getClientIp(httpRequest); request.setDonorIp(clientIp); // 如果前端未传sessionId可以在这里生成一个简单的 if (request.getSessionId() null) { request.setSessionId(httpRequest.getSession().getId()); } Long newAmount donateService.donateAsync(request.getProjectId(), request); return ApiResponse.success(newAmount); } private String getClientIp(HttpServletRequest request) { String ip request.getHeader(X-Forwarded-For); if (ip null || ip.isEmpty() || unknown.equalsIgnoreCase(ip)) { ip request.getHeader(Proxy-Client-IP); } if (ip null || ip.isEmpty() || unknown.equalsIgnoreCase(ip)) { ip request.getHeader(WL-Proxy-Client-IP); } if (ip null || ip.isEmpty() || unknown.equalsIgnoreCase(ip)) { ip request.getRemoteAddr(); } // 对于多级代理取第一个非unknown的IP if (ip ! null ip.contains(,)) { ip ip.split(,)[0].trim(); } return ip; } } // 统一的API响应封装类 Data class ApiResponseT { private Integer code; private String message; private T data; private long timestamp; public static T ApiResponseT success(T data) { ApiResponseT response new ApiResponse(); response.setCode(200); response.setMessage(success); response.setData(data); response.setTimestamp(System.currentTimeMillis()); return response; } // 可以补充error等方法 }5.2 启用异步与定时任务支持在Spring Boot主应用类或配置类上添加注解SpringBootApplication EnableAsync // 启用异步支持 EnableScheduling // 启用定时任务支持 public class MeritBoxApplication { public static void main(String[] args) { SpringApplication.run(MeritBoxApplication.class, args); } // 配置异步任务线程池 Bean(taskExecutor) public Executor taskExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); executor.setCorePoolSize(5); executor.setMaxPoolSize(10); executor.setQueueCapacity(100); executor.setThreadNamePrefix(merit-async-); executor.initialize(); return executor; } }6. 运行验证与常见问题排查完成编码后我们需要验证系统是否按预期工作。6.1 启动与基础验证启动服务确保MySQL和Redis服务已启动运行MeritBoxApplication。创建项目使用Postman或curl调用创建项目接口需先实现对应的POST /api/merit/project。curl -X POST -H Content-Type: application/json -d {title:祈福考试顺利,description:祝愿所有考生金榜题名,creator:admin} http://localhost:8080/api/merit/project检查数据库merit_project表是否插入成功uploads/qrcode目录下是否生成二维码图片。查询项目访问GET /api/merit/project/1确认返回信息包含功德值和访问URL。捐献功德多次调用捐献接口。curl -X POST -H Content-Type: application/json -d {projectId:1,remark:功德1} http://localhost:8080/api/merit/donate观察响应中的功德值是否递增检查merit_record表是否有记录观察Redis中merit:amount:1的值。验证防刷在10秒内根据配置连续调用捐献接口超过3次第4次应返回“操作过于频繁”的错误。6.2 常见问题排查表在开发和测试过程中你可能会遇到以下问题问题现象可能原因检查方式处理建议服务启动失败报数据库连接错误1. MySQL服务未启动。2.application.yml中数据库配置错误用户名、密码、URL。3. 数据库merit_box_db不存在。1. 检查MySQL服务状态。2. 核对配置文件。3. 登录MySQL执行SHOW DATABASES;。1. 启动服务。2. 修正配置。3. 执行创建数据库的SQL。调用捐献接口后功德值未增加1. Redis服务未启动或连接失败。2. 异步方法Async未生效。3. 防刷逻辑拦截了请求。1. 检查Redis服务状态和连接配置。2. 查看应用日志确认donateAsync方法是否被调用。3. 查看日志中是否有“触发频率限制”的警告。1. 启动Redis检查spring.redis配置。2. 确保主类有EnableAsync且调用方与被调用方不在同一个类。3. 调整防刷阈值或检查IP获取逻辑。二维码生成失败或路径错误1.uploads/qrcode目录没有写权限。2.merit.qrcode.base-url配置错误。1. 检查应用运行用户对目录的权限。2. 查看生成的qr_code_url字段和accessUrl。1. 创建目录并赋予写权限或改为绝对路径。2. 修正配置确保是完整可访问的URL。定时任务未同步数据1. 未添加EnableScheduling注解。2. 任务方法被异常中断。3. Redis key模式不匹配。1. 检查主类注解。2. 查看任务执行日志是否有异常堆栈。3. 使用Redis客户端查看merit:amount:*的key是否存在。1. 添加注解。2. 修复任务方法中的异常。3. 确认AMOUNT_KEY_PREFIX常量与捐献时设置的一致。防刷逻辑对同一IP不生效1. IP获取逻辑有误获取到的总是同一个IP如127.0.0.1。2. Redis限流key的过期时间设置有问题。1. 打印getClientIp方法获取的IP。2. 用Redis客户端查看限流key的TTL。1. 优化IP获取逻辑确保能获取到真实客户端IP尤其在Nginx后。2. 检查Lua脚本中setex的过期时间参数。7. 生产环境部署与最佳实践建议学习环境跑通后要将系统部署到生产环境还需要考虑更多因素。7.1 部署与配置 checklist配置文件分离使用application-prod.yml并将敏感信息数据库密码、Redis密码移至环境变量或配置中心。连接池优化根据预估的并发量调整数据库如Druid和RedisLettuce连接池参数。二维码存储切勿存储在本地磁盘。应集成阿里云OSS、腾讯云COS等对象存储服务上传后返回CDN加速的URL。前端页面需要开发一个简单的前端页面如Vue/React单页应用用于展示功德箱信息和处理用户点击“功德1”的请求。本文后端接口已为前端提供了所需的数据和操作接口。API安全添加API签名验证或JWT Token认证防止接口被恶意调用。对于捐献接口可考虑增加图形验证码。监控告警集成Spring Boot Actuator、Prometheus和Grafana监控应用健康状态、接口QPS、响应时间、Redis和数据库连接池状态。为关键错误如同步任务失败设置告警。7.2 性能与一致性优化建议缓存策略升级项目详情缓存可以考虑使用更高效的序列化方式如Jackson并针对热点项目设置更长的过期时间或永不过期通过消息监听数据库变更来主动更新缓存。防刷策略增强目前的IP限流是基础方案。可增加基于sessionId projectId的维度或引入更复杂的令牌桶算法。对于恶意攻击可接入WAFWeb应用防火墙。数据同步优化定时任务同步存在延迟。对于一致性要求极高的场景可以考虑使用“双写”同时写Redis和MQ由消费者写DB或“订阅Binlog”通过Canal等工具监听MySQL变更更新Redis等方案。本场景对短暂延迟不敏感定时任务足矣。数据库优化merit_record表会持续增长需考虑历史数据归档或分表策略。可以为donate_time字段创建分区表。7.3 扩展方向功德榜单使用Redis的ZSET有序集合实现实时捐献榜单键为merit:rank:{projectId}分数为捐献时间戳成员为用户标识。捐献时使用ZADD查询时使用ZREVRANGE。消息通知集成WebSocket或消息推送服务当功德值达到目标时向创建者发送通知。数据可视化提供后台管理界面使用ECharts等图表库展示各项目的功德增长趋势、捐献时间分布等。多端适配确保API兼容小程序、H5、APP等多种前端形式。通过以上步骤我们完成了一个从需求分析、技术选型、数据库设计、核心代码实现到部署优化的完整“电子功德箱”后端系统。这个项目麻雀虽小五脏俱全涵盖了Web开发中缓存、异步、限流、定时任务、数据一致性等常见问题的解决方案。你可以在此基础上根据实际需求进行扩展和深化。
RELATED READING

延伸阅读

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