做免税商品商城系统和做普通电商系统,给人的感觉完全是两码事。去年我接到一个企业级免税购物商城的改造需求,原本以为只是一次常规的SpringBoot+Vue商城开发,结果在需求梳理阶段就被泼了冷水——免税品的类目管理、旅客身份校验、额度核算、完税价和免税价的双轨展示,每一件事都比普通商城多出一层逻辑。整个系统最终用的是SpringBoot+Vue+MyBatis+MySQL这套经典组合实现的,但真正让系统在企业级场景下站稳脚跟的,是业务建模阶段对免税规则的充分尊重,以及后续在订单状态机、库存并发、权限安全这些环节上的细致处理。
这篇博文围绕这套企业级免税商品优选购物商城管理系统的源码实现展开,适合正在做商城类项目、准备用SpringBoot+Vue技术栈入门企业级开发的读者。我会按业务差异、技术选型、模块拆解、数据库设计、安全与部署的顺序,把关键实现思路和踩过的坑一次性讲透,争取让你看完之后,既能理解这类系统的设计逻辑,也能直接拿去在自己的项目中参考落地。
1. 免税商城和普通电商的差异:业务建模必须先想清楚的三件事
1.1 商品维度:免税品不是普通货架的搬运工
我拿到需求第一件事不是建表,而是把免税业务的专业名词理清楚。普通商城只需要商品、SKU、库存、价格,但免税商城多了一个核心约束——销售渠道。口岸出境免税、离岛免税、市内免税店,每种渠道对应的可销售对象、限额标准、商品目录都不一样。一个商品可能在这个渠道允许销售,在那个渠道就不允许,甚至同一个SKU在不同渠道的定价策略、折扣规则、可购买数量都不同。
所以设计上不能照搬普通商城的商品表结构。我当时落地时采用了商品基础表加渠道映射表的组合方式:商品表只维护品牌、名称、规格、完税价、原产地这些基础信息;渠道映射表单独记录每个SKU在某个免税渠道下的销售价、剩余额度、限购数量、上下架状态。这样运营人员在后台配置渠道策略时,不需要改动代码和基础商品数据,直接维护映射关系就行。这种解耦设计看起来多了一张表,但在后期应对业务规则调整时省了非常多的事。
另外一个容易忽略的点是税费展示。免税商城的核心卖点之一就是价格优势,用户界面需要同时展示市场参考价(完税价)和免税价,甚至要标明节省金额。这个逻辑涉及到税费的计算展示,建议不要在SQL里做计算,而是在后端统一封装一个价格服务,根据商品的税种参数算出各个价格维度,前端只负责渲染。我在实际项目中就是因为一开始把价格计算散落在前端,导致后期调价时要改多个页面,重构后才稳定下来。
1.2 用户维度:不是注册了就能买
免税商城对用户身份的校验比普通电商严格得多。在普通商城,用户注册登录理论上就可以下单,但免税场景下,用户在购买前必须经过身份资格的校验——是不是符合免税购买条件的旅客、有没有超过年度免税额度、本次购买的金额有没有超限。这些校验如果漏掉,系统就不是一个合规的免税商城了。
我在系统中专门设计了一套身份校验与额度管理流程:用户在首次购买前需要提交证件信息以及行程信息,后台调用核验接口确认资格后,系统给用户打上“已验证”的标识,同时记录其免税额度的已用金额和剩余金额。每次下单时,订单服务会先读取额度快照,校验本次订单金额是否在剩余额度内,校验通过后预扣额度,最后在支付成功时才真正占用额度;如果订单取消或超时未支付,额度自动释放回滚。
这个额度预扣逻辑是容易被忽视的细节。最开始我图省事,只在支付成功回调里扣减额度,结果出现了一个问题:用户同时下单两笔,每笔都在额度内,但两笔合起来超了总限额。后来改成下单时预扣、支付失败或取消时释放,才彻底解决了超额购买的漏洞。做免税系统,额度并发控制是无论如何都不能省的一步。
1.3 价格优惠维度:免税价与会员权益叠加的复杂度
免税商城并不是简单的“标一个免税价然后下单”,它同样有会员积分、优惠券、限时活动、满减促销等玩法的叠加需求。难点在于,免税商品的价格基础本身已经经过一轮特殊计算(完税价、免税价、市场参考价的对比),上层再叠加优惠策略时,必须有一套统一的规则引擎来规定叠加顺序。
我当时采用的是“价格计算链”的方式:基础价格 -> 会员等级折扣 -> 优惠券抵扣 -> 满减活动 -> 积分抵扣,每一步都通过独立的策略类实现,按配置的顺序依次调用。这样运营可以在后台配置每种活动的优先级,而不是把逻辑写死在代码里。这里面最需要注意的是:优惠后的最终实付金额不能低于系统允许的最低金额,尤其要防止“优惠券叠加到零元甚至负数”的情况;同时,每个订单的实付金额和占用的免税额度要保持联动逻辑清晰,避免后续对账时两边对不上。
2. 技术选型复盘:SpringBoot+Vue+MyBatis+MySQL这套组合的取舍
2.1 SpringBoot作为后端骨架,省掉的是配置,换来的是规范
很多团队现在做企业级项目依然选择SpringBoot,不是因为它最先进,而是因为它能够把工程结构规范化和快速交付这两件事平衡得最好。在这个免税商城项目里,SpringBoot负责的是整体后端架构:通过Maven多模块方式拆分了common、system、product、order、member等多个模块,模块之间依赖清晰,编译和测试的边界明确。
SpringBoot的自动配置特性在起步阶段尤其省心。比如引入spring-boot-starter-web就搭好了内嵌Tomcat的Web环境,引入spring-boot-starter-validation就能用注解方式统一做参数校验,不需要像传统SSH那样去写一堆XML配置。事务管理方面,直接用@Transactional注解声明在Service层的方法上,配合Spring的声明式事务,就能保证订单创建、库存扣减、额度占用这些跨表操作要么全部成功,要么全部回滚。
企业级项目不能只看开发效率,还要看可维护性。SpringBoot默认的分层架构(Controller-Service-Mapper)经过多年验证,对团队协作很友好:前端同学对接接口文档,后端同学按模块开发,新入职的人也能很快定位到某段业务逻辑。这个项目里我用Spring Security做认证授权,配合JWT实现无状态会话,SpringBoot的starter机制让集成变得非常简单。
2.2 MyBatis在复杂查询和动态SQL上的优势,恰好命中商城业务
商城这类业务十有八九离不开多条件筛选。商品列表页的筛选条件可能包含品牌、类目、价格区间、渠道、上下架状态、优选标签等,而这些条件用户不一定每次都会选,后台管理系统的订单查询更是如此。MyBatis的动态SQL在这种场景下简直是为它量身定做的。
我用一个典型的商品列表查询做例子:如果用户选择了“品牌是A、价格区间100到500、仅看有货”,那么SQL就要动态拼接这些where条件。用MyBatis的 加 标签,可以简洁地实现多条件组合,不会出现字符串拼接导致SQL注入的风险,也不需要在代码里写一堆if else去拼查询语句。这一点在实际开发中的体验差异是很明显的。
除了动态SQL,MyBatis的一对多映射也帮我解决了一个很头疼的问题:订单主表和订单明细表的查询。一次订单查询需要同时查出订单基本信息以及该订单下的全部商品明细,传统JDBC写法要么写多次查询然后在代码里组装,要么用复杂的外连接返回冗余行再手动去重。MyBatis的resultMap配置了collection标签后,一次查询就能自动映射出嵌套的订单明细列表,代码量少,执行效率也可控。
2.3 Vue在前端交互复杂度上的表现,刚好接得住商城页面的花样
免税商城的用户端页面交互并不轻松:商品瀑布流、多条件筛选联动、购物车数量增减、订单状态跟踪、会员中心积分展示,这些操作都需要前端有足够灵活的组件化和状态管理能力。Vue在这方面是相当成熟的选项。
我用Vue 2 + Element UI搭建了管理后台,Vue 3 + Vite + Pinia搭建了用户端商城页面。管理后台需要的是成熟组件库和快速迭代,Element UI的表单、表格、弹窗组件能直接覆盖大部分场景;用户端需要更流畅的交互和更好的性能,Vue 3组合式API配合Vite开发时的热更新体验要明显好于旧版本。提到视频功能,商城某次运营活动需要播放m3u8格式的商品介绍视频,Vue生态里直接集成了video.js插件,配置一下播放源就能搞定,不用前端从零实现流媒体播放逻辑。
Vue Router的路由守卫在商城系统里也扮演了重要角色。用户未登录访问购物车或结算页时,路由守卫会拦截跳转到登录页;登录成功后带redirect参数跳回原页面。这一步体验做好之后,用户可以顺畅地从浏览商品到结算下单,不会因为登录环节中断了购物流程。
2.4 MySQL作为核心存储,稳定性和事务能力是电商业务的底线
电商项目可以引入Redis做缓存、引入Elasticsearch做搜索,但核心的交易数据最终还是要有一个可靠的持久化存储,MySQL在这个位置上依然是最稳妥的选择之一。这套系统里所有不允许丢失的数据——用户信息、商品SKU、订单、支付流水、额度记录,我都放在了MySQL里。
MySQL的InnoDB引擎提供行级锁和事务支持,这对订单创建和库存扣减来说是底线能力。一个订单的创建要同时操作订单表、订单明细表、库存表、额度表,任何一个环节失败都会导致数据不一致,必须依赖数据库事务保证原子性。如果用NoSQL数据库做主存储,这类强一致性场景往往要花很大力气通过分布式事务去弥补,复杂度会高一个量级。
在这个项目里,MySQL还承担了一部分报表统计工作。后台管理需要按天、按渠道、按商品维度统计销售额和订单量,我通过创建合理的索引和定时汇总表来加速这种查询,而不是每次都去扫全表。对于数百万级的订单表,只要索引设计到位,MySQL的查询性能在企业级场景下完全够用。这套系统没有盲目引入太多中间件,技术栈保持精简,反而让维护成本低了很多。
3. 核心流程实现:商品管理、优选排序、购物车与订单状态机
3.1 SPU/SKU设计与商品上下架的管理细节
商品建模是商城系统的地基。常用的做法是SPU(Standard Product Unit,标准产品单元)加SKU(Stock Keeping Unit,库存保有单位)双层结构。SPU对应的是“商品”这个抽象概念,比如一瓶某个品牌的精华水;SKU则对应具体的销售单元,比如“该精华水50ml装”和“该精华水100ml装”,它们的价格、库存、条码都独立管理。
在这套系统里,SPU表保存商品的主图、详情、类目、品牌等公共信息,SKU表保存规格名、规格值、价格、库存、SKU编码等差异化信息。后台管理商品时,运营先建SPU,再在SPU下维护多个SKU,前端商品详情页根据用户选择的规格动态切换价格和库存。前端SKU联动选择是商城体验的细节,需要根据规格组合动态计算哪些组合有效、哪些组合缺货,Vue的computed属性在这个场景下能写出非常简洁的状态判断逻辑。
上下架管理的细节经常被新手忽略:一个SPU下的某个SKU缺货了,应该只下架该SKU而不是整个SPU;如果整个SPU都没有可售SKU,才应该下架整个商品。我在设计接口时专门实现了这个规则,下架操作会检查该SPU下所有SKU的状态,运营操作时系统给出明确提示,避免用户端出现“点击商品后所有SKU全部无货”的尴尬页面。这个细节很小,但对用户体验的影响很直接。
3.2 优选推荐:加权排序比协同过滤更务实
标题里提到的“优选商品”并不是噱头,它需要一个具体可落地的排序逻辑。很多团队一提到推荐就想着上协同过滤、上深度学习模型,但对一个企业级的商城后台来说,最务实的做法往往是用可解释的加权评分公式,把运营想突出的商品推上去。
我当时实现了一套优选商品排序服务:给每个商品计算一个优选分,计算公式综合考虑了销售额、销量、好评率、库存周转率、运营手工置顶权重这几个因子。比如优选分 = 销量得分 * 0.3 + 销售额得分 * 0.25 + 好评率得分 * 0.2 + 库存周转得分 * 0.15 + 运营权重 * 0.1。各项得分在计算前先做归一化处理,避免销售额这种数值大的指标过度主导结果。
这套打分逻辑通过SpringBoot的定时任务每天凌晨计算一次,把商品ID和优选分写入一张优选排序表。用户端首页的“优选推荐”列表直接按这张表的分数倒序读取,性能很好,运营后台还支持单独调整某个商品的运营权重,干预排名。相比黑盒的算法推荐,这种加权方式逻辑透明、可调试、可解释,上线后运营反馈非常好。等到业务数据量真正大了,再在这个基础上引入更复杂的推荐算法也不迟,底层的商品数据和行为日志已经提前埋好了点。
3.3 购物车合并与选中结算的状态处理
购物车模块看起来简单,实际最容易出问题的是用户多端登录、商品下架、价格变动这几个场景。用户可能在手机上加购了几件商品,又跑到电脑网页上登录,购物车必须合并;用户加购之后运营调整了价格,结算时到底按加购时的价格算还是按最新价格算,必须有明确的规则。
我把购物车设计成独立模块,数据存在MySQL的一张购物车表里,字段包含用户ID、SKU号、数量、加购时间、选中状态。用户登录时如果发现本地有勾选的商品记录,会调用合并接口写入数据库,避免重复。在结算页展示购物车商品时,后台会逐条校验商品状态,已下架或已删除的商品自动标记为不可结算并提示用户,价格以实时查询为准,但页面同时展示加购时的价格,如果价格有变动会明确提示,让用户确认后再结算,避免“结算后实际扣款和页面显示不一致”的客诉。
结算接口接收用户选中的SKU以及数量列表,后端在事务里批量锁定库存并生成订单。这里有一个必须处理的边界:如果用户同时在两个终端对同一件商品发起结算,后一个请求需要感知库存已经被锁,不能继续下单。这个场景我在后面数据库设计章节会详细展开,核心思路就是数据库行锁加唯一约束兜底。
3.4 订单状态机与超时未支付自动取消
订单模块是整个系统最核心的部分,我花了不少时间设计订单状态机。订单状态不能只用一个字段随便改,必须由明确的状态流转规则控制,否则很容易出现“已取消的订单还能发货”这种严重事故。
订单状态我设计了六个核心状态:待支付、已支付、待发货、已发货、已完成、已取消。状态流转的触发点很明确:用户发起支付且支付回调成功后,待支付变已支付;后台发货后,已支付变已发货;用户确认收货或系统自动确认收货后,已发货变已完成;用户主动取消或者系统超时取消,则进入已取消状态。每种流转都封装在订单领域服务里,只有该服务能修改订单状态,Controller和其他Service不能直接更新状态字段,从代码层面保证了状态流的严格性。
超时未支付订单的取消是这套系统不能不处理的场景。用户下单后如果一直不支付,库存就会被长期占用,影响其他用户购买。我采用Spring的@Scheduled写了个定时任务,每分钟扫描一次待支付订单,超过30分钟未支付的订单自动取消,同时回滚库存和额度预扣记录。针对线上多实例部署的情况,我在定时任务里加了一个分布式锁注解,保证同一时间只有一个实例在跑这个任务,防止重复取消同一个订单和重复释放库存。
4. 数据库设计实战:核心表结构、索引优化与防超卖方案
4.1 核心表结构总览
数据库设计是整个商城系统的地基,表结构设计得是否合理,直接决定了后期业务扩展的灵活性。这套商城的核心表有这些:用户表、角色表、权限表、用户角色关联表、角色权限关联表、商品SPU表、商品SKU表、渠道映射表、购物车表、订单表、订单明细表、支付流水表、优惠券表、用户额度表、优选排序表、操作日志表。
订单表和订单明细表是典型的主从结构,订单表一条记录对应订单整体信息,包括订单号、用户ID、订单状态、应付金额、实付金额、优惠金额、收货信息、下单时间、支付时间等;订单明细表则记录订单里每一个商品的快照信息,包括SKU号、商品名称、规格、单价、数量、小计金额。这里强调一点,订单明细里的商品名称、单价必须冗余保存商品当时的快照,不能通过SKU号去关联实时商品表,否则商品改名或者调价之后,历史订单的明细就全变了。
用户额度表在这个项目里有特殊价值。字段包括用户ID、年度剩余额度、已用额度、最近占用时间,每次下单预扣额度时更新剩余额度,取消订单时回补。这个表的数据变化频率高,要注意给user_id加上唯一索引,并且配合悲观锁或乐观锁控制并发更新,我在额度回补时采用的是CAS(Compare And Swap)更新方式,更新条件里带上剩余额度旧值,更新后将受影响行数,如果为0说明并发冲突,重试一次。
4.2 防超卖:数据库行锁与乐观扣减
超卖是电商系统开发绕不开的问题,免税商城同样要面对。所谓超卖,就是高并发场景下多个用户同时购买同一件商品,导致卖出的数量超过了实际库存。这个问题如果在代码层用先查询再判断再更新的话,并发请求同时读到剩余库存是100,都认为可以购买,结果一起扣减到80,实际库存只剩下95,超卖了5件。
正确做法是使用数据库原子的条件更新。我在扣减库存时直接执行这样的SQL:
UPDATE product_sku SET stock = stock - #{count} WHERE sku_id = #{skuId} AND stock >= #{count}这个SQL利用InnoDB的行锁机制,同一时刻只有一个事务能更新这一行的库存。执行完成后检查受影响行数,如果等于1说明库存充足且扣减成功,等于0说明库存不足或并发冲突,直接返回“库存不足”给前端。这一步在任何语言里实现都一样的逻辑,关键点是不要先查再改,而是把库存判断和扣减合并成一条原子SQL。
库存扣减之后,紧接着就是创建订单。为了彻底防止同一用户在同一瞬间提交重复订单,我在订单表里对用户ID和订单号的关键业务字段做了唯一约束兜底,即使接口被重复调用,第二次插入也会因为唯一索引而失败,事务回滚后库存也会自动恢复。靠数据库兜底,比单纯依赖业务代码判断要可靠得多。
4.3 分页与查询性能优化
商城后台的订单列表、操作日志、商品列表都是分页查询的重灾区。数据量小的时候感觉不到问题,一旦订单表数据量到几十万甚至上百万,普通的limit深分页会越翻越慢。原因很简单,limit 100000, 20这样的SQL,MySQL会把前100000行全部扫描出来再丢弃,只返回最后20行,这个开销是巨大的。
我当时优化的手段是延迟关联。先让SQL只查询主键ID完成分页,再用主键ID去关联查询完整记录,这样能大幅减少回表的行数。改造后的SQL大概长这样:
SELECT o.* FROM t_order o INNER JOIN ( SELECT id FROM t_order WHERE order_status = 2 ORDER BY create_time DESC LIMIT 100000, 20 ) t ON o.id = t.id这个优化的效果很明显,配合排序字段创建联合索引后,深分页的响应时间从原来的秒级降到了百毫秒级。另外,后台的复杂筛选查询,我根据常用的查询条件建立了几个联合索引,严格遵循最左前缀原则,比如用户ID加下单时间、订单状态加下单时间,确保查询能命中索引而不是全表扫描。
5. 权限安全与支付回调:企业级系统最容易翻车的三个环节
5.1 RBAC权限模型与JWT会话管理
企业级商城系统不像个人项目,后台管理通常有超级管理员、运营、客服、财务、仓库等多个角色,不同角色能访问的菜单和接口完全不同。我采用经典的RBAC(Role-Based Access Control,基于角色的访问控制)模型,把用户、角色、权限三级解耦。用户不直接绑定权限,而是通过角色间接获得权限,新增一个运营岗位只需要创建角色并配置权限,然后把用户加入该角色即可,维护起来非常灵活。
认证授权这块我选择了Spring Security加JWT的组合。用户登录成功之后,后端生成一个包含用户ID和角色信息的JWT令牌返回给前端,前端把令牌存储在本地,每次请求在HTTP头里带上Authorization字段。后端通过Spring Security的过滤器链解析令牌、校验签名和有效期,并把当前用户信息放入SecurityContext,供后续业务逻辑获取当前操作人。
JWT方案相比传统的Session方案,最大的优势是后端服务可以无状态化,多个应用实例之间不需要共享Session存储,利于水平扩展。但JWT也有一个需要重点处理的隐患:令牌在有效期内无法主动失效。我针对这个痛点做了两层弥补,一是JWT的有效期控制在两小时以内,降低令牌泄露后的风险窗口;二是关键操作如修改密码、注销登录后,会将该用户的令牌版本号加一,解析令牌时发现版本号不匹配就要求重新登录。
5.2 支付回调验签与幂等处理
商城系统接支付是不可避免的,这里最容易翻车的是支付回调的安全校验和重复通知处理。支付平台下单成功后会异步通知后端接口,这个通知接口直接面向公网,如果不做签名验证,任何人都可以伪造一个“支付成功”的回调,把订单状态改成已支付而不真正付钱,这是极大的安全漏洞。
我的实现逻辑是:在发起支付时生成一个订单号和签名,支付回调接口收到通知后,先验证回调参数里的签名是否合法,用支付平台配置的回调密钥重新拼接参数、计算签名做比对;签名验证通过后再检查回调里的订单号是否存在、订单金额是否和数据库中的应付金额完全一致,最后才把订单状态更新为已支付。金额必须精确到分进行比对,防止篡改金额攻击。
支付平台为了确保通知送达,通常会重复推送回调,频率可能是15秒、30秒、1分钟、5分钟这样阶梯递增。所以回调接口还必须是幂等的,同一笔订单重复收到回调不能重复处理。我用订单状态做判断,只有当订单处于待支付状态时才更新为已支付,已支付状态直接返回成功响应,拒绝重复处理。为了避免并发回调同时进入业务逻辑,在更新订单状态的SQL上加上“WHERE order_status = 待支付”的条件,利用数据库层面的原子性来保证幂等。
5.3 敏感数据脱敏与审计日志
企业级系统离不开合规要求,用户手机号、证件号、收货地址这些都属于敏感信息。在数据库里,用户手机号和证件号不能明文裸存,至少要做到接口返回时脱敏。我封装了一个脱敏工具类,后端返回给前端的数据中,手机号自动显示为138****1234,证件号只保留前后各一位,这样客服在后台即使有权限查看用户信息,也只能看到必要的信息,降低敏感数据泄露的风险。
另一个容易被忽略的环节是审计日志。后台的关键操作——商品上下架、价格修改、订单强制取消、用户额度调整,每一步都必须记录操作人、操作时间、操作内容和变更前后的数据。我在苍穹系统里用一个自定义注解加上AOP切面统一记录这笔日志,不需要在每个业务方法里手写日志代码。审计日志平时看着不起眼,但真出了问题排查是谁改错了数据、什么时候改的,没有它是寸步难行的。这也是企业级系统和个人项目在认真程度上的核心差别。
6. 部署上线与踩坑笔记:从本地运行到稳定可用
6.1 部署架构与流程
整个系统部署时我用的是经典的前后端分离方案:Vue前端代码通过npm run build打包成静态资源,部署在Nginx的静态目录下;SpringBoot后端打成可执行的Jar包,通过systemd或者Docker容器运行在应用服务器上;MySQL单独部署在一台机器上,通过内网地址让后端应用连接。Nginx同时承担反向代理职责,前端页面发起的/api/开头的请求被Nginx反向代理到后端的端口,这样前端和后端就通过同一个域名提供服务,不用处理跨域问题。
部署流程我尽量自动化。用Git管理代码,在服务器上拉取代码后用Maven打包,前端用Node环境构建,整个过程写成一个部署脚本。虽然这个项目没有上整套CI/CD流水线,但脚本化部署之后,升级版本也就是一条命令的事情。生产环境的MySQL我开启了定时备份,每天凌晨全量备份一次数据库,binlog日志保留七天,这样即使误删了数据也能恢复到最近的时间点。企业级系统要稳,备份策略绝对不能省。
6.2 前后端分离部署的经典坑
前端打包部署后,最经典的问题就是刷新页面404。原因是Vue在history路由模式下,浏览器访问某个子路由比如/member/order,会直接请求Nginx服务器上该路径对应的静态文件,而Nginx静态目录下并没有这个文件,于是返回404。这个问题几乎每个用Vue history路由并前后端分离部署的项目都会遇到。
解决方案是在Nginx配置里加入try_files指令,让所有非静态资源的请求都重新指向index.html,由Vue Router接管路由解析:
location / { root /usr/share/nginx/html; index index.html; try_files $uri $uri/ /index.html; }这个配置的含义是:如果请求的文件不存在,就回退返回index.html,前端框架再根据当前URL的path渲染对应的页面。修改Nginx配置后需要reload生效。这个坑虽然好解,但如果没有提前知道,上线当天现场排查会非常狼狈,当时我们团队第一次部署时就在这个上面卡了快一个小时。
6.3 MyBatis批量操作与连接池的坑
系统开发过程中还遇到过几个技术层面比较典型的坑,第一个是MyBatis批量插入的性能问题。商城初始化商品数据时,运营会一次性导入几百上千个SKU,我最初用的是for循环逐条插入,结果导入几百条数据花了十几秒,体验极差。后来改成了MyBatis的foreach标签拼接批量插入SQL,一次插入几百条,耗时从十几秒降到了几百毫秒。
<insert id="batchInsertSku" parameterType="list"> INSERT INTO product_sku ( spu_id, sku_code, spec_name, spec_value, price, stock, create_time ) VALUES <foreach collection="list" item="item" separator=","> (#{item.spuId}, #{item.skuCode}, #{item.specName}, #{item.specValue}, #{item.price}, #{item.stock}, NOW()) </foreach> </insert>使用批量插入要注意SQL语句的长度限制,单条SQL太长会超过MySQL的max_allowed_packet限制,我做了分批处理,每批500条,既保证了性能又不会超出限制。
第二个坑是数据库连接池耗尽。系统上线初期跑了一段时间后,运维反馈线上偶尔出现“连接池连接获取超时”的报错。排查后发现是某个查询接口在异常情况下没有正确关闭数据库连接,在高并发时把连接池的连接占满了。后来一是确认所有查询都通过MyBatis自动管理连接,二是给关键接口增加了慢查询日志监控,把查询时间超过两秒的SQL抓出来逐一优化,从源头减少连接占用时间。
6.4 定时任务在多实例下的幂等保障
系统上线稳定运行后,团队开始做多实例部署提升可用性。这一做就暴露出一个问题:原本写好的超时订单取消定时任务,在多个应用实例同时启动时会重复执行。两个实例同时在扫描待支付订单,就可能出现同一个订单被两个实例重复执行取消逻辑,虽然业务上不会导致严重的资金问题,但会导致库存回滚两次、额度释放两次的隐患,必须处理。
我当时选了一个轻量级的方案:在数据库里建一张分布式锁表,定时任务执行时先尝试插入一条指定任务名的记录,插入成功表示拿到锁,执行完业务后删除记录;插入失败表示其他实例正在执行该任务,当前实例直接跳过。这个方案实现简单,基于数据库唯一索引天然保证只有一个实例能插入成功,对当前这个体量的系统来说完全够用。如果未来实例数量进一步增加,可以再考虑引入专门的分布式锁组件,但现阶段过度设计反而是负担。
这个经历给我的感受很深:很多问题在开发环境永远发现不了,只有真正部署上线、迎接真实流量之后,才会暴露出来。所以一个企业级项目从开发到稳定可用,中间那段距离,恰恰是踩坑、填坑、总结经验的过程。我把这些过程整理出来,就是希望大家做同类系统时,能少走这些弯路,把精力更多放在真正有业务价值的设计上。