鲜花销售系统毕业设计:基于领域驱动与缓存策略的效率提升实战
一、背景痛点:为什么“能跑”≠“能演”
毕设答辩现场最怕的不是功能缺失,而是点击下单后页面转圈 5 秒仍无响应。鲜花销售场景天然具备“短时高频、库存敏感”特征,常见症状如下:
- 商品列表接口一次性把 2000 张高清图读到内存,带宽打满,F12 一看 TTFB 3.8 s。
- 秒杀活动开始瞬间,MySQL 行锁排队,连接池瞬间打满,出现“库存为 0 仍可下单”超卖。
- 订单重复提交:前端没做防抖,后端没做幂等,同一个人 1 分钟生成 17 张待支付订单。
- 后台报表查询把订单表和支付表 JOIN 后全表扫描,CPU 飙到 90%,演示电脑风扇狂转。
这些“小毛病”在本地 1 并发调试时隐形,一旦老师掏出手机扫码登录,系统直接社死。
二、技术选型对比:毕业设计也要算“性价比”
硬件资源有限(4C8G 云服务器),必须让每一兆内存、每一次 I/O 花在刀刃上。下面给出三组常见纠结点的量化对比,方便直接抄作业。
| 维度 | 方案 A(省时间) | 方案 B(省资源) | 场景建议 |
|---|---|---|---|
| ORM | MyBatis-Plus | Spring Data JPA | 复杂查询多、字段动态 → MyBatis;关联固定、对象聚合 → JPA |
| 缓存 | Caffeine 本地 | Redis 分布式 | 单机演示 + 读多写少可用 Caffeine;秒杀场景必须 Redis |
| 下单流程 | 同步双写 DB+Redis | 异步消息队列 | 资源受限毕设优先同步,引入 Redis 预扣即可;若校赛并发 > 500 再考虑 RocketMQ |
结论:本系统采用 MyBatis-Plus + Redis + 同步预扣,兼顾“老师能看懂”与“现场不翻车”。
三、核心实现:DDD 解耦 + 缓存预扣 + 分布式锁
3.1 领域模型划分(DDD 轻量级落地)
- 商品域:负责只读聚合
FlowerBO,缓存热点字段。 - 库存域:聚合根
StockAR,提供preOccupy()、confirm()、cancel()三个原子行为。 - 订单域:聚合根
OrderAR,依赖库存域接口,而非直接操作数据库。
3.2 下单流程时序(10 步浓缩)
- 用户层
OrderController接收CreateOrderCmd。 - 应用层
OrderAppService先通过StockFacade.preOccupy(skuId, num)尝试预扣。 StockFacade使用 Redis Lua 脚本保证原子性:- 缓存命中且库存 ≥ 购买数 → 扣减并写入预扣记录。
- 缓存未命中 → 布隆过滤器拦截,回源 DB 加载并回填 Redis。
- 预扣成功拿到
preOccupyId,应用层再创建订单聚合。 - 订单聚合发布领域事件
OrderCreatedEvent。 - 监听事件写本地订单表,事务单表保证轻量。
- 返回前端订单号,进入 15 分钟支付窗口。
- 支付回调收到后,库存域执行
confirm(preOccupyId)真正落 DB。 - 超时未支付则定时任务
cancel(preOccupyId)回滚缓存。 - 所有对外接口均通过
@Idempotent(key = "#cmd.requestId")实现幂等。
3.3 关键代码片段(Clean Code 示范)
@Service @RequiredArgsConstructor public class StockService implements StockFacade { private final StringRedisTemplate redis; private final StockMapper stockMapper; // 预扣 Lua 脚本,保证原子性 private static final String LUA_PRE_OCCUPY = "if redis.call('exists',KEYS[1])==1 then " + " local left=tonumber(redis.call('get',KEYS[1])); " + " if left>=tonumber(ARGV[1]) then " + " redis.call('decrby',KEYS[1],ARGV[1]); " + " redis.call('hset',KEYS[2],ARGV[2],ARGV[1]); " + " return 1; " + " end; " + "end; " + "return 0;"; @Override public Optional<Long> preOccupy(Long skuId, Integer num, String requestId) { String stockKey = "stock:" + skuId; String occupyKey = "occupy:" + skuId; Long result = redis.execute( new DefaultRedisScript<>(LUA_PRE_OCCUPY, Long.class), List.of(stockKey, occupyKey), String.valueOf(num), requestId ); return result == 1L ? Optional.of(IdWorker.getId()) : Optional.empty(); } @Transactional(rollbackFor = Exception.class, timeout = 3) public void confirm(Long skuId, String requestId) { // 真正落库,行锁只在这步出现,耗时 < 30 ms stockMapper.decreaseByRequestId(skuId, requestId); redis.opsForHash().delete("occupy:" + skuId, requestId); } }要点注释:
@Transactional边界只在 confirm/cancel 阶段,预扣不走 DB,避免大事务。- 缓存穿透:布隆过滤器
bloom:stock在系统启动时异步加载,未命中直接返回失败。 - 分布式锁:若 Lua 脚本返回 0,可再尝试
Redisson.tryLock(3,1,TimeUnit.SECONDS)回源 DB,防止缓存雪崩时所有线程打 DB。
3.4 数据库索引优化
- 订单表
order以user_id + create_time组合索引,解决“我的订单”分页慢查询。 - 库存表
stock在(sku_id, version)上建联合索引,配合乐观锁version字段,防止 confirm 阶段并发更新冲突。
四、性能与安全:从 60 QPS 到 600 QPS 的量化记录
测试环境:Docker 限制 2C4G,MySQL 8.0 + Redis 7.0,JMeter 20 线程循环 5 min。
| 场景 | 优化前 | 优化后 | 提升比 |
|---|---|---|---|
| 商品列表 | 平均 1800 ms | 120 ms | 15× |
| 秒杀下单 | 平均 3200 ms,失败率 18% | 平均 180 ms,失败率 0.3% | 18× |
| 峰值 QPS | 60 | 620 | 10× |
缓存一致性策略:
- 库存写操作遵循“先 DB 再缓存”的Write-Through+Lua模式,confirm/cancel 阶段同步更新。
- 采用请求 ID + 业务幂等表实现接口幂等,重复提交直接返回上次结果,老师狂点鼠标也不怕。
- 防刷:基于 Redis 令牌桶
rate:{userId},限制单用户 30 秒最多 10 次下单,超出直接返回 429。
五、生产环境避坑指南(演示现场版)
- 冷启动延迟:Spring Boot 3.x + MyBatis 在 1C2G 机器首次扫描 Mapper 耗时 12 s,可在
application.yml加mybatis.mapper-locations=classpath*:mapper/*.xml并关闭devtools。 - 数据库连接池:Hikari 默认 10 连接,演示前务必压测调整
maximum-pool-size=ceil(cpu*4),否则并发 20 时即出现Connection is not available。 - 演示环境资源限制:若学校只给 1M 外网带宽,把商品图全部放
src/main/resources/static/img/thumb并开启 Spring Boot 内置压缩,流量降 70%。 - 热 Key 问题:秒杀 SKU 被频繁读取导致 Redis 单节点 CPU 高,可在 Lua 脚本里把
stockKey拆成stock:{skuId}:{bucket},分 10 段降低热点。 - 日志异步:logback 的
<appender name="ASYNC" class="ch.qos.logback.classic.AsyncAppender">队列长度 2048,防止同步写盘阻塞下单线程。
六、如何在有限硬件下继续“压榨”并发
毕业设计拿不到 32C64G 的机器,但可以把 4C8G 用到极致:
- 用
tc(Linux Traffic Control)在 Docker 容器网卡加 100 ms 延迟,模拟弱网环境,观察缓存穿透率。 - 写脚本循环
redis-cli --hotkeys找出新热 Key,再调整分桶数。 - 把 MySQL 的
innodb_flush_log_at_trx_commit调成 2,牺牲 1 秒宕机数据容忍,换 30% 写吞吐提升,演示时老师不会拔电源。 - 用 JMeter 的Ultimate Thread Group阶梯加压,先 50 线程 2 min,再 200 线程 30 s,观察系统拐点,写入论文“性能曲线”一节,图表更专业。
动手改造自己的系统,把以上代码直接复制到src/test/java跑一遍单元测试,再对着日志里的Slow SQL > 100 ms提示逐条优化,你会发现:毕设不仅能跑,还能在答辩现场跑出“丝滑”的 600 QPS。祝你一次过审,顺利毕业。