凡亿AD22--原理图元件复制、剪切、旋转、镜像
2026/5/13 9:41:25
超市会员管理系统毕设实战:从需求分析到高内聚低耦合架构实现
“会员积分就是多一个字段嘛!”——如果你也这样想过,大概率会踩到以下坑:
结果:功能演示一帆风顺,老师一压并发就“社死”。
毕设要拿优,必须回答:在高并发、弱网、重复提交的情况下,系统仍能不超兑、不丢单、不泄露。
| 维度 | Spring Boot | Flask/Django | 备注 |
|---|---|---|---|
| 依赖注入 | 原生 | 三方扩展 | 声明式事务、AOP 切面积分校验 |
| 并发模型 | 线程池 | 同步/异步混搭 | 超市 POS 峰值 200 QPS,Tomcat 线程模型更直观 |
| 生态成熟 | MyBatis、Seata、RocketMQ | 相对小众 | 毕设时间 8 周,抄作业也要抄得到 |
| Redis 原生数据结构 | 内置 Lua 脚本 | 需要额外封装 | 积分原子扣减用EVAL一行搞定 |
Redis 在积分场景的必要性:
INCRBYFLOAT保证“读-改-写”单指令完成,避免并发脏读。member:主键member_id,无业务含义。point_account:member_id唯一索引,余额字段balance。point_record:幂等键request_id+ 来源source,唯一联合索引。UUID。X-Request-Id放进 header。point_record,存在直接返回,不走业务。point_account,写入point_record。Redis与MySQL差异推送到企业微信,人工复核。/** * PointRedeemService.java * 职责:会员积分兑换,保证幂等、不超兑、事务回滚 */ @Service @Slf4j @RequiredArgsConstructor public class PointRedeemService { private final PointAccountMapper accountMapper; private final PointRecordMapper recordMapper; private final RedisTemplate<String, String> redisTemplate; private static final String KEY_PREFIX = "point:"; /** * 兑换积分 * @param dto memberId、requestId、amount 均为正数 * @return 实际扣减后的余额 retryOn = {LockTimeoutException.class}) @Transactional(rollbackFor = Exception.class) public BigDecimal redeem(PointRedeemDto dto) { // 1. 幂等校验 PointRecord exist = recordMapper.selectOne( Wrappers.<PointRecord>lambdaQuery() .eq(PointRecord::getRequestId, dto.getRequestId())); if (exist != null) { log.warn("重复请求, requestId={}", dto.getRequestId()); return exist.getAfterBalance(); } // 2. 分布式锁, 锁主键防止同会员并发 String lockKey = KEY_PREFIX + dto.getMemberId(); Boolean locked = redisTemplate.opsForValue() .setIfAbsent(lockKey, "1", Duration.ofSeconds(5)); if (!Boolean.TRUE.equals(locked)) { throw new LockTimeoutException("系统繁忙,请稍后再试"); } try { // 3. 查询并校验余额 PointAccount account = accountMapper .selectForUpdate(dto.getMemberId()); // 行锁 if (account.getBalance().compareTo(dto.getAmount()) < 0) { throw new BizException("积分不足"); } // 4. 扣减 & 写流水 BigDecimal after = account.getBalance() .subtract(dto.getAmount()); account.setBalance(after); accountMapper.updateById(account); PointRecord record = PointRecord.builder() .memberId(dto.getMemberId()) .requestId(dto.getRequestId()) .amount(dto.getAmount().negate()) .afterBalance(after) .build(); recordMapper.insert(record); // 5. 删缓存,让下次查询回源 redisTemplate.delete(KEY_PREFIX + dto.getMemberId()); return after; } finally { redisTemplate.delete(lockKey); // 释放分布式锁 } } }代码要点:
selectForUpdate把余额行锁与事务绑定,避免“ABA”问题。@Transactional回滚,积分与流水保持一致。SensitiveConverter。selectForUpdate行锁;Lua脚本先判断再扣减;毕设答辩现场往往把笔记本休眠后唤醒,MySQL 连接池、Redis 连接尚未预热,第一个请求 RT 飙到 2 s,老师皱眉。
对策:
@EventBootStrap预热线程池与连接。performance_schema,减少 30% 内存占用,风扇不吵。| 坑位 | 现象 | 修复方案 |
|---|---|---|
| 自增 ID 暴露 | /member/8直接看到总注册量 | 使用雪花算法,对外hashid |
| 查询未加索引 | SELECT * FROM point_record WHERE member_id=?全表扫描 | 联合索引(member_id, create_time) |
| 日志脱敏 | 控制台打印phone=13800138000 | 使用Logback的SensitiveConverter |
| 缓存穿透 | 查询不存在的会员,请求全打到 DB | 布隆过滤器 + 空值缓存 |
| 大 Key | 把全店日汇总积分存一个String,达 8 MB | 拆分为Hash,按memberId分片 |
本地 JMeter 200 线程、循环 50 次,积分兑换接口平均 RT 38 ms,TPS 4 200,无超兑、无重复流水。
把单店系统跑通只是起点,多门店时会遇到:
member_id还是shop_id?欢迎到 GitHub 仓库提 Issue 或 PR,一起把“超市会员管理系统”做成真正的生产级模板。
如果你也做过类似项目,留言聊聊你踩过的坑,让毕设不再只是“能跑”,而是“能扛”。