ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

SpringBoot+Redis实战:网球馆预订管理系统从设计到上线

SpringBoot+Redis实战:网球馆预订管理系统从设计到上线

1. 项目缘起:从一张排班表和电话簿开始的网球馆

我最早接触到这个项目,是在朋友的网球馆里聊出来的需求。那家馆规模不大,但也不算小——6片室内场、4片室外场,工作日晚上和周末基本满场。前台有三个人轮班,墙上挂着一块巨大的纸质排班表,用不同颜色的便利贴标记已预订场地。生意好的时候,电话一个接一个,前台一边接电话一边翻本子查空场,遇到老会员打电话来约场地,还得顺口报出对方的剩余次数和卡内余额。结果就是:要么两拨人撞了同一个场地,要么会员来了说"我明明约了晚上7点",前台翻半天记录找不出凭证,最后只能和稀泥。

这套系统就是为解决这些具体问题做的。用SpringBoot搭建,覆盖场地预订、会员储值、计费结算、经营统计这几条核心链路。如果你是刚学完SpringBoot基础、想找一个"完整业务系统"练手的人,或者正在做毕业设计/项目实战,需要一套可复现的网球馆管理系统设计思路,这篇文章值得从头看到尾。我会把从需求分析、数据库建模到核心代码、上线部署的完整设计过程拆开讲,包括那些只在真实项目里才会踩到的坑。

2. 需求边界与业务流程:先画清楚谁在用、怎么用,再谈写代码

2.1 三类用户角色和一整套运营场景

做管理系统最忌讳一上来就建表、写接口。我花了一天半时间待在前台,观察他们一整天的工作流程,最后把用户角色收敛成三类:管理员、前台收银员、会员(通过小程序端自助操作)。

管理员关心的是经营数据——每天收入多少、哪个时段上座率低、哪些场地利用率不够、会员储值余额还剩多少。前台收银员是系统的日常操作者,他们要处理的是会员办卡、充值、现场订场、取消预订、入场核销、结算退款。会员端实际使用最多的功能就两个:查看可预订时段、在线预订场地。一个完整的操作闭环大致是这样的:会员打开小程序看到本周空场列表,选定场地和时间段后提交预订;系统校验该时段是否被占、会员余额是否充足,随后扣减储值并生成订单;到场后前台在管理端扫码或按手机号核销;若会员提前取消预订,系统按规则退还费用。

2.2 我整理出的核心业务规则清单

在画ER图之前,先把业务规则穷举出来,这条建议适用于所有场馆类系统。我和馆方反复确认后,定下了这几条关键规则:

  • 场地按小时段预订,最小粒度30分钟,每片场地同一时间段只能被一个订单占用。
  • 会员卡分两种:次卡(如30次卡,不限时段)和储值卡(按小时单价扣费)。次卡不退款、过期作废;储值卡余额可退,但退卡时赠送金额按比例扣除。
  • 取消预订规则:提前2小时以上取消全额退;2小时内取消扣50%;开场后不可取消。
  • 场地价格分普通时段和高峰时段,工作日18:00前是普通价,18:00后及周末全天是高峰价。

这几条规则看起来简单,落到系统设计上却直接决定了数据库表结构、计费模块的算法复杂度,以及退款功能的状态流转。把规则跟馆方一条条签字确认,是我在这个项目里做得最值的一件事——后面所有的开发工作几乎没有返工过。

3. 技术选型:基于当前团队能力与业务规模的最优解

3.1 为什么选SpringBoot,而不是其他方案

选型那会儿其实面临过几个选择:纯Servlet的SSH老框架、Node.js的Express、Python的Django/Flask,以及SpringBoot。我个人的判断标准很简单:这个系统要长期维护,馆方日后大概率会请别的开发人员接手,那么选一个生态成熟、招人容易、资料丰富的技术栈是最稳的。SpringBoot恰好全部命中。

SpringBoot带来的最大价值不是"快",而是约定大于配置——内嵌Tomcat、自动装配、Starter机制让项目从零搭建到能跑通只要几分钟。另外它天然适合做前后端分离:后端只暴露JSON接口,前端小程序、管理后台各自独立部署,后续迭代互不干扰。这与传统JSP/Thymeleaf服务端渲染方案相比,更符合"一个后端服务多端复用"的长期需求。

3.2 具体技术栈清单与选择理由
技术选型理由
核心框架SpringBoot 2.7.x稳定成熟,社区资料最全
ORMMyBatis-Plus单表CRUD零SQL,复杂统计用XML
数据库MySQL 8.0免费、普及度高、事务支持可靠
缓存/分布式锁Redis 5.0+处理预订并发冲突的关键组件
前端管理端Vue3 + Element Plus上手快,组件全,适合后台管理系统
客户端微信小程序原生馆方会员普遍用微信,免安装
接口文档Knife4j前后端联调效率提升明显
权限认证Sa-Token比Shiro轻量,比Spring Security易用

有一个容易忽略的细节是,SpringBoot版本不要盲目追新——2.7.x在2023年进入OSS维护期,安全性没问题,兼容性经过了大量项目验证。我见过不少项目因为用太新的SpringBoot 3.x,遇到第三方starter不兼容,反而耽误进度。

3.3 前后端分离下的项目分层设计

包结构上我采用的是经典的四层架构,但没有过度设计:

com.tennis.court ├── controller // 接口层,只做参数接收与结果封装 ├── service // 业务层,事务边界都在这一层 ├── mapper // 数据访问层,MyBatis-Plus的BaseMapper ├── entity // 数据库实体 ├── dto // 入参/出参对象,不直接暴露Entity ├── vo // 视图对象,按前端需要聚合字段 ├── config // 配置类:Redis、Sa-Token、跨域等 ├── common // 统一返回体、异常处理、常量 └── enums // 业务枚举:订单状态、卡类型等

一个设计原则:Entity和VO严格分离。很多初学的人图省事,直接把Entity序列化返回给前端,这会在后期造成两个问题——一是数据库字段变更会影响接口结构,二是联表查询产生的冗余字段(如场地名称)不知道该放哪。我从一开始就强制要求写VO,虽然前期多花了一点时间,但上线后接口基本没改过。

4. 数据库建模:五张核心表如何支撑整个业务闭环

4.1 场地与价格策略拆分设计

场地信息独立成表,价格策略不直接挂在场地表上,而是单独一张价格规则表。这样设计的理由是:价格会变(节假日调整、淡季促销),但场地本身的信息(位置、类型、是否开灯)极少变化。若把价格字段直接写在场地表里,每次调价都要UPDATE多条记录,历史订单的价格还原也会出问题。

CREATE TABLE court ( id BIGINT PRIMARY KEY AUTO_INCREMENT, court_no VARCHAR(16) NOT NULL COMMENT '场地编号,如A01', court_name VARCHAR(32) NOT NULL COMMENT '场地名称', court_type TINYINT NOT NULL COMMENT '1-室内 2-室外', is_night_light TINYINT DEFAULT 0 COMMENT '是否支持夜场照明', status TINYINT DEFAULT 1 COMMENT '1-可用 0-停用', created_time DATETIME DEFAULT CURRENT_TIMESTAMP, updated_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_court_no (court_no) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='场地信息表';

价格策略表的关键在于用时间段配置而不是简单存两个价格字段。我给表设计了start_time和end_time,配合price_type字段(1-普通 2-高峰),这样一天内可以有多个价格段,甚至可以精确到周六日单独配置:

CREATE TABLE court_price ( id BIGINT PRIMARY KEY AUTO_INCREMENT, court_type TINYINT NOT NULL COMMENT '适用场地类型 1-室内 2-室外', price_type TINYINT NOT NULL COMMENT '1-普通时段 2-高峰时段', start_time VARCHAR(8) NOT NULL COMMENT '生效开始时间 HH:mm:ss', end_time VARCHAR(8) NOT NULL COMMENT '生效结束时间 HH:mm:ss', weekdays VARCHAR(32) NOT NULL COMMENT '适用星期,如 1,2,3,4,5', price DECIMAL(10,2) NOT NULL COMMENT '每小时单价(元)', created_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='场地价格规则表';
4.2 会员、订单与结算流水:三张表撑起资金安全

会员表(member)要存的关键字段是:手机号、姓名、卡类型(次卡/储值卡)、剩余次数、卡内余额、赠款余额、开卡时间、到期时间。这里有一个在设计时很容易忽略的点:储值卡充值时经常会产生赠送金额,而赠送金额在退款时的计算规则可能与本金额不同,所以必须分开两个字段存。

CREATE TABLE member ( id BIGINT PRIMARY KEY AUTO_INCREMENT, phone VARCHAR(20) NOT NULL COMMENT '手机号,登录凭证', real_name VARCHAR(32) COMMENT '姓名', card_type TINYINT NOT NULL COMMENT '1-次卡 2-储值卡', total_count INT DEFAULT 0 COMMENT '次卡总次数', remain_count INT DEFAULT 0 COMMENT '次卡剩余次数', balance_amount DECIMAL(10,2) DEFAULT 0 COMMENT '储值本金余额', bonus_amount DECIMAL(10,2) DEFAULT 0 COMMENT '赠送金额余额', expired_date DATE COMMENT '有效期截止日', status TINYINT DEFAULT 1 COMMENT '1-正常 0-冻结', created_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_phone (phone) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='会员信息表';

订单表(booking_order)是系统的核心,它同时承担了预订记录和消费凭证的角色。字段设计要覆盖预订的完整生命周期:预订人、场地、时段、预订金额、实付金额、订单状态。状态我用枚举管理,全局统一约定:0-待支付、1-已支付/已预订、2-已核销/已完成、3-已取消、4-已退款。

订单号的设计也有讲究,我采用"日期 + 随机数"的形式,不使用自增ID做对外订单号——自增ID会暴露平台的单量,而且海量数据下并发插入自增ID也容易成为瓶颈。最终订单号格式为yyyyMMddHHmmss + 4位随机数,例如202503211930002871。

4.3 预订冲突的数据库层防线:唯一索引

场地预订最核心的并发问题是同一片场地同一时段被两个订单同时抢到。单靠应用层判断"该时段是否已被占",在并发下必然有漏洞——两个请求同时查出"空",然后同时插入。

数据库层面的第一道防线是唯一索引。我建了一张预订时段明细表booking_slot,每个订单占用的每个30分钟时段都单独一条记录:

CREATE TABLE booking_slot ( id BIGINT PRIMARY KEY AUTO_INCREMENT, booking_id BIGINT NOT NULL COMMENT '订单ID', court_id BIGINT NOT NULL COMMENT '场地ID', slot_date DATE NOT NULL COMMENT '预订日期', start_time VARCHAR(8) NOT NULL COMMENT '时段开始 HH:mm:ss', end_time VARCHAR(8) NOT NULL COMMENT '时段结束 HH:mm:ss', UNIQUE KEY uk_court_slot (court_id, slot_date, start_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='预订时段明细表';

uk_court_slot这个唯一索引是防并发冲突最硬的一层保障。即使应用层的分布式锁因为网络抖动失效了,数据库也会拒绝两条相同(court_id, slot_date, start_time)的记录插入。当时设计时我在旁边加了一行注释:"此索引是预订功能的最后一道防线,任何情况下不能删除。"后续开发中它真的救了系统一次,这个在第6节会详细说。

5. 核心模块实现:从预订到结算的完整链路

5.1 场地预订:分布式锁 + 唯一索引的双保险

预订接口的完整流程是:参数校验 → 判断场地时段是否可订 → 判断会员余额/次数 → 生成订单 → 扣减余额 → 写入时段明细。这里的每一步都可能出问题,我们逐一处理。

首先是防重入。用户在小程序端可能因为网络抖动连续点击两次"提交预订",产生两个相同订单。我在接口入口加了一个幂等校验:以memberId + slotDate + startTime为key,从Redis中查询是否已有预订记录,有则直接拒绝。

String lockKey = "booking:prevent:" + memberId + ":" + slotDate + ":" + startTime; Boolean firstTry = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", Duration.ofSeconds(5)); if (!Boolean.TRUE.equals(firstTry)) { throw new BizException("请勿重复提交预订请求"); }

接下来是并发冲突的核心处理。我在预订场地时用Redis分布式锁锁住场地维度的粒度,而不是锁整个系统:

String courtLockKey = "booking:court:" + courtId + ":" + slotDate; RLock lock = redissonClient.getLock(courtLockKey); boolean locked = lock.tryLock(3, 10, TimeUnit.SECONDS); if (!locked) { throw new BizException("当前操作人数较多,请稍后重试"); } try { // 校验时段是否已被占用 // 生成订单、扣费、写入booking_slot } finally { lock.unlock(); }

注意这里锁的粒度是courtId + slotDate,也就是"某片场地某一天"。为什么不是细到courtId + slotDate + startTime?因为一次预订可能跨多个时段(比如从19:00到21:00),如果用单时段粒度锁定,两个请求分别锁住不同时段但实际订单时段有重叠,就会产生漏网之鱼。锁粗到"一天"这个粒度,虽然会稍微降低并发度,但对场馆预订这种量级(一天最多几十单)来说完全够用,且逻辑更安全。

扣费逻辑上,次卡用户扣remain_count,储值卡用户按"每小时单价 × 小时数"从余额扣减。这里要特别强调的一点:余额扣减必须使用原子SQL,不能在应用层先查出余额再计算后UPDATE。否则两个并发请求同时读到余额100元,各自扣除60元后再写回,最终余额只剩40元,造成超扣。

正确的扣减方式:

UPDATE member SET bonus_amount = bonus_amount - #{deductBonus}, balance_amount = balance_amount - #{deductBalance} WHERE id = #{memberId} AND balance_amount + bonus_amount >= #{totalAmount}

WHERE条件中带上余额校验,若影响行数为0则说明余额不足,事务回滚。这样既防止了超扣,又省掉了锁或CAS的额外开销。

5.2 计费规则引擎:策略模式替代一长串if-else

计费是这个系统里最容易写成一坨if-else的地方。场地类型分室内室外,卡类型分次卡储值卡,时段分普通高峰,取消预订分三种情况退款……如果全部堆在Service里,三个月后你自己都看不懂。

我采用策略模式做了一套计费规则引擎。先定义统一接口:

public interface BillingStrategy { /** * 计算订单应付金额 * @return 应付金额(元) */ BigDecimal calculate(BookingRequest request); /** * 执行扣费(次卡扣次数、储值卡扣余额) */ void deduct(BookingRequest request, MemberAccount account); /** * 执行退款 */ void refund(BookingRecord record, CancelReason reason); }

然后分别实现TimeCardStrategy(次卡计费)和StorageCardStrategy(储值卡计费)。次卡计费里有个业务细节:次卡用户订2小时,扣2次;但高峰期订场可能要额外补差价——有的场馆会要求次卡用户在黄金时段每场加收10元。这个规则在推算时没有覆盖,后来试运营才补上,好在策略模式让新增规则变得很轻,只需在TimeCardStrategy里加一个差价计算方法,不动其他地方。

取消预订的退款也走策略模式。核心是退款状态机:订单未核销 + 当前时间距离预订开始时间 >= 2小时,全额退;距离开始 < 2小时,退50%;开场后不可退。这个判断逻辑我放在refund方法里统一处理,而不是散落在Controller层。

5.3 结算与对账:事务边界必须定义清楚

预订、扣费、写时段明细这三个操作必须处于同一事务中。我直接在Service方法上加@Transactional(rollbackFor = Exception.class)。这里有个细节容易踩坑:rollbackFor必须显式指定为Exception.class,因为Spring默认只在遇到运行时异常时才回滚,而自定义的BizException如果继承的是Exception(受检异常),不指定的话事务不会回滚。

整体事务流程:

@Transactional(rollbackFor = Exception.class) public BookingOrder createBooking(BookingRequest request) { // 1. 查询会员并加行级锁(SELECT ... FOR UPDATE) // 2. 校验场地时段占用 // 3. 按策略计算金额 // 4. 生成订单(状态:已支付/已预订) // 5. 写入 booking_slot 时段明细 // 6. 原子扣减余额/次数 return order; }

第1步我特意用了SELECT ... FOR UPDATE对会员行加锁。为什么已经有分布式锁还要加行锁?因为分布式锁只能拦截"走了我们系统接口"的并发请求,如果有其他入口(比如运营后台的补偿脚本、线下补单功能)直接改数据库,行锁就是最后的防线。两层锁互相兜底,这是我在系统里比较得意的一个设计。

6. 踩过的坑与排查过程:五个最有价值的教训

6.1 时区问题导致预订时间差8小时

第一期联调时就出现了诡异现象:小程序端看到可预订的时段是晚上19:00-21:00,但前端传参到后端,数据库里存的却是凌晨11:00-13:00。排查一圈,发现问题出在JSON序列化时区配置上。

SpringBoot默认的Jackson序列化LocalDateTime时,时区取的是系统默认时区。但服务器部署在Etc/UTC时区,而业务上要求北京时间。时间在API层就被"平移"了8小时。

解决办法有两步,缺一不可。第一步在SpringBoot配置文件中全局指定:

spring: jackson: time-zone: GMT+8

第二步是数据库连接串显式指定时区:

jdbc:mysql://localhost:3306/tennis_court?serverTimezone=Asia/Shanghai&useSSL=false

只改其中任何一处,问题都可能依然存在。实际上,推荐在系统里统一用LocalDateTime来处理业务时间,不要混用Date、LocalDateTime、String多种类型,时间字段多了之后类型不统一是非常痛苦的。

6.2 事务失效:内部方法调用导致 @Transactional 不生效

当时在 储值卡充值 功能里写了这样一个方法:

public void recharge(Long memberId, BigDecimal amount) { // 生成充值订单 // 调用内部方法 doRecharge(memberId, amount); } @Transactional(rollbackFor = Exception.class) public void doRecharge(Long memberId, BigDecimal amount) { // 更新余额 // 写入充值流水 }

测试时发现:在doRecharge里故意抛异常后,数据库里的余额更新竟然没有回滚。查了一圈才想到Spring事务是基于AOP代理实现的,只有通过代理对象调用的方法事务才生效。doRecharge是this.doRecharge()内部调用,绕过了代理,注解自然失效。

修正方案有两种:一是把充值逻辑拆到另一个Service类中,由外层Service通过注入的Bean调用;二是用@Resource注入自身代理:

@Resource private AccountService self; public void recharge(...) { ... self.doRecharge(memberId, amount); }

后来我琢磨了一下,真正稳妥的做法还是第一种——把"账户资金变更"独立成AccountService,专门负责余额变动、流水记录,业务上职责更清晰,事务边界也更明确。

6.3 分布式锁过期导致重复下单

这个问题是在压力测试阶段发现的。当时用JMeter模拟100个并发用户同时抢订同一片场地同一时段,发现少量请求出现了"重复下单"——订单表里出现了两条相同时段、相同场地的记录。

排查后发现,问题出在Redisson锁的默认租约时间(leaseTime)上。当时我只调了tryLock(3, 10, TimeUnit.SECONDS),意思是锁最多持有10秒。但预订流程中有一步调用了一个慢速接口,耗时超过10秒,锁自动释放了。后续的并发请求趁虚而入,拿到锁后查数据库发现时段仍是"未占用"(因为第一个订单还没提交),于是插入了重复记录。

针对这个问题做了三重加固:

第一重是看门狗机制——设置tryLock时不传leaseTime,让Redisson启用默认的看门狗自动续期(默认30秒续期一次)。只要业务没执行完,锁就不会释放。

第二重是在事务内二次校验。在写booking_slot之前,执行一次查重SQL:

SELECT COUNT(*) FROM booking_slot WHERE court_id = #{courtId} AND slot_date = #{slotDate} AND start_time BETWEEN #{startTime} AND #{endTime}

第三重就是数据库唯一索引的终极兜底(第4.3节那张表)。三重防线之下,最终连压力测试的异常单也全部挡住了。唯一索引约束冲突的报错会通过DuplicateKeyException被捕获,转换成语义明确的提示返回前端。

6.4 统计报表的慢查询:全表扫描拖垮了MySQL

运营端有一个"每日收入统计"接口,查询某天的订单汇总。上线第一周运营反馈"报表页面转圈圈",查了MySQL慢查询日志,发现这条SQL执行了4.8秒:

SELECT DATE_FORMAT(create_time, '%Y-%m-%d') AS day, SUM(pay_amount) FROM booking_order WHERE create_time BETWEEN '2025-03-01 00:00:00' AND '2025-03-31 23:59:59' GROUP BY DATE_FORMAT(create_time, '%Y-%m-%d');

问题很明显:booking_order表没有对create_time建索引,日期范围查询无法走索引,只能全表扫描。加了索引后:

ALTER TABLE booking_order ADD INDEX idx_create_time (create_time);

执行时间从4.8秒降到0.2秒以内。同时把日期范围判断改成半开区间>= '2025-03-01' AND < '2025-04-01',避免23:59:59这种边界值漏数据的问题。

这类问题的教训是:凡是会作为WHERE条件、JOIN条件、排序字段的列,都要在设计初期考虑索引。不要等上线后靠慢查询日志来救火。

6.5 会员扣次后余额变负数:丢失更新场景

这个坑发生在"次卡用户取消预订后恢复次数"的场景。用户取消订单时,系统先退还次数,再修改订单状态。但测试时发现,如果用户连着取消两笔订单,可能出现次数恢复后又立刻被其他订单消耗,导致最终剩余次数对不上。

根因是恢复次数和扣减次数用了两条SQL,没有包在同一事务里。当取消操作进行到一半时,另一个并发的预订请求读到了旧的剩余次数,扣减后在取消操作的回滚/提交顺序下形成了丢失更新。

解决方式是把"取消预订"整体置于事务中,并且对会员行加锁:

@Transactional(rollbackFor = Exception.class) public void cancelBooking(Long bookingId, String reason) { MemberAccount account = memberMapper.selectByIdForUpdate(memberId); // 恢复次数/退回金额 // 更新订单状态 }

这里selectByIdForUpdate会对会员行加悲观锁,直到事务结束才释放。这样并发请求必须等待取消事务完成,才能读到最新余额,彻底消除了丢失更新。

7. 部署上线的最后一公里:那些没人提醒你的细节

7.1 环境准备与发布流程

项目用的是经典的前后端分离部署:后端SpringBoot打包成Jar,跑在单台2核4G的云服务器上;前端小程序走微信官方托管,管理端Vue项目打包后部署在Nginx上做静态资源服务。

后端部署的核心配置文件要区分三套环境:

spring: profiles: active: prod

然后在application-prod.yml中配置生产库连接、Redis连接、日志文件路径。生产环境的配置有几个必须注意的坑:MySQL账号绝不能使用root;Redis必须设置密码且不监听公网端口;生产环境建议开启/actuator/health非敏感端点用于探活。

我习惯用Shell脚本一键发布:

#!/bin/bash APP_NAME=tennis-court-admin JAR_NAME=target/tennis-court-admin.jar PORT=8080 # 停旧服务 kill -9 $(pgrep -f $APP_NAME) || true # 备份旧包 mv $JAR_NAME $JAR_NAME.bak.$(date +%Y%m%d%H%M%S) # 启动 nohup java -jar $JAR_NAME \ --spring.profiles.active=prod \ --server.port=$PORT \ > /data/logs/$APP_NAME.log 2>&1 & # 探活 for i in $(seq 1 30); do curl -s http://127.0.0.1:$PORT/actuator/health && break sleep 2 done
7.2 系统上线后我坚持要做的三件加固

第一件是接口限流。预订接口对单用户做了每分钟最多5次的限流,防止脚本恶意刷接口。实现上我用Redis自增计数加过期时间,整段逻辑不到30行,但对系统的保护是实打实的。

第二件是定时任务兜底。虽然正常流程中Redis分布式锁保证了并发安全,但为了应对极端情况(比如Redis宕机),我加了一个每分钟执行一次的兜底任务:扫描所有start_time在30分钟内且状态仍为"待支付"的订单,自动关闭并释放时段。这就避免了用户提交订单后不支付,把时段一直占着导致其他会员无法订场的情况。在商业运营中,这类"僵尸订单"如果不清理,用户投诉会很快积累起来。

第三件是数据备份。每天凌晨3点用mysqldump全量备份数据库,备份文件保留最近14天。这个任务写了个cron表达式定时执行,值得强调的是,备份脚本一定要定期手动验证能否恢复,否则等到真要恢复数据时才发现备份文件损坏,那种无力感经历过一次就不想有第二次。

8. 复盘与扩展:这个系统还能走向哪里

系统上线稳定运行三个月后,我总结了几条经验,也想清楚了后续的演进方向。

技术层面的收获是:并发控制的本质不是某一个技术的功劳,而是多层防御的协同。分布式锁负责拦流量、事务锁负责保一致性、唯一索引负责任何情况下都绝不产生脏数据。三层各自解决不同层面的问题,互为兜底。做业务系统时,不要迷信单点方案,多想想"如果这层挂了,下面还有什么能挡住"。

业务层面也发现了一些当初没覆盖到的需求:场地灯光耗电统计、教练约课管理、会员次卡转赠、临时散客入场计费。其中"散客计费"是运营提得最多的一项——网球馆经常有临时到场不打球的客人,只用场地不打会员卡,需要一个快速登记和按小时计费的功能。这是在设计初期没有覆盖的场景,代码上新增一个WalkInOrder流程也不算复杂,但数据库设计时如果没预留好扩展位,现在就比较头疼。

功能层面我最想做的改进是智能推荐:根据会员的历史预订偏好(常订的时段、常订的场地)和当前空场情况,在小程序端推推荐时段。技术上完全可以用一张简单的偏好统计表实现,不需要上机器学习。另一个方向是经营可视化——按月/按周对比各时段、各场地的利用率柱状图,这能直接帮馆方做定价决策。

如果此刻你也正准备开发一个类似的场馆管理系统,或者正为自己的毕业设计/个人项目选型,我最后想认真叮嘱的是:先花时间把业务流程弄清楚,比急着敲代码重要十倍。我在这个项目里最满意的地方不是代码本身,而是前期跟馆方反复确认的那一页业务规则清单。它让后面每一项开发都有的放矢,也让"设计与实现"这件事真正落了地。做到这一步,你的项目就已经成功了一半。

RELATED READING

延伸阅读

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