超市会员管理系统毕设实战:从需求分析到高内聚低耦合架构实现
1. 背景痛点:CRUD 之外,毕设还缺什么?
“会员积分就是多一个字段嘛!”——如果你也这样想过,大概率会踩到以下坑:
- 并发兑换:同一会员在两台 POS 机同时扫码,积分被扣两次,商品却只发一份。
- 幂等缺失:前端重复提交,后台不校验,积分流水多出 N 条。
- 事务过大:Service 方法一口气包住“查库存→扣积分→减库存→写流水”,回滚时库存对不上。
- 无异常策略:网络超时直接抛 500,用户看到白屏,只能重启 APP。
- 日志裸奔:手机号、余额明文打印,测试同学一抓一大把。
结果:功能演示一帆风顺,老师一压并发就“社死”。
毕设要拿优,必须回答:在高并发、弱网、重复提交的情况下,系统仍能不超兑、不丢单、不泄露。
2. 技术选型:为什么不是 Django,而是 Spring Boot + Redis?
| 维度 | Spring Boot | Flask/Django | 备注 |
|---|---|---|---|
| 依赖注入 | 原生 | 三方扩展 | 声明式事务、AOP 切面积分校验 |
| 并发模型 | 线程池 | 同步/异步混搭 | 超市 POS 峰值 200 QPS,Tomcat 线程模型更直观 |
| 生态成熟 | MyBatis、Seata、RocketMQ | 相对小众 | 毕设时间 8 周,抄作业也要抄得到 |
| Redis 原生数据结构 | 内置 Lua 脚本 | 需要额外封装 | 积分原子扣减用EVAL一行搞定 |
Redis 在积分场景的必要性:
- 高频读、低频写:会员信息 80% 查询 20% 更新,缓存后 MySQL QPS 降 5 倍。
- 原子操作:
INCRBYFLOAT保证“读-改-写”单指令完成,避免并发脏读。 - 过期策略:设置 7 天滑动过期,防止“僵尸会员”占内存。
3. 核心实现细节
3.1 业务建模:把“积分”当账户
- 会员表
member:主键member_id,无业务含义。 - 积分账户表
point_account:member_id唯一索引,余额字段balance。 - 积分流水表
point_record:幂等键request_id+ 来源source,唯一联合索引。
3.2 幂等性设计:请求 ID 贯穿三层
- 前端提交时生成
UUID。 - Gateway 网关把
X-Request-Id放进 header。 - 服务层用 Spring Aceed 拦截器先查
point_record,存在直接返回,不走业务。
3.3 事务边界:写操作拆成两段
- 本地事务:扣减
point_account,写入point_record。 - 异步消息:发 RocketMQ 事件,库存系统监听后扣库存。
好处:事务半径缩小,积分侧不受库存回滚影响。
3.4 缓存与 DB 一致性策略
- 更新后删除:积分变动后先删缓存,再写数据库,下次查询自动回源。
- 延迟双删:定时任务 5 秒后二次删除,防止并发读脏。
- 对账补偿:每日凌晨跑批,把
Redis与MySQL差异推送到企业微信,人工复核。
4. 代码实战:积分扣减服务(Clean Code 版)
/** * 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。
5. 性能与安全考量
5.1 高并发下积分超兑风险
- 场景:会员 1000 积分,同时发起 10 次 100 积分兑换。
- 根因:无行锁或缓存自减,读到的余额都是 1000。
- 解决:
- 数据库层
selectForUpdate行锁; - Redis 层
Lua脚本先判断再扣减; - 网关层限流:会员维度 10 次/秒,超出直接降级。
- 数据库层
5.2 冷启动对演示效果的影响
毕设答辩现场往往把笔记本休眠后唤醒,MySQL 连接池、Redis 连接尚未预热,第一个请求 RT 飙到 2 s,老师皱眉。
对策:
- 启动时执行
@EventBootStrap预热线程池与连接。 - 写一段“假”请求把热点数据刷进缓存,演示时秒开。
- 本地装 MySQL 8.0,关闭
performance_schema,减少 30% 内存占用,风扇不吵。
6. 生产环境避坑指南
| 坑位 | 现象 | 修复方案 |
|---|---|---|
| 自增 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分片 |
7. 效果展示
本地 JMeter 200 线程、循环 50 次,积分兑换接口平均 RT 38 ms,TPS 4 200,无超兑、无重复流水。
8. 结语与延伸
把单店系统跑通只是起点,多门店时会遇到:
- 积分通兑还是门店隔离?
- 总部结算 vs 门店垫资?
- 数据分片用
member_id还是shop_id?
欢迎到 GitHub 仓库提 Issue 或 PR,一起把“超市会员管理系统”做成真正的生产级模板。
如果你也做过类似项目,留言聊聊你踩过的坑,让毕设不再只是“能跑”,而是“能扛”。