首页 / 资讯中心 / 文章详情

Spring Boot明星周边商城:限量抢购库存与限购设计

Spring Boot明星周边商城:限量抢购库存与限购设计 ★ FEATURED ARTICLE
1. 先把业务搞明白明星周边站和普通商城差在哪这套编号 050 的系统最开始接到的需求描述只有一行字——基于 Spring Boot 的星之语明星周边产品销售网站。放在通用电商模板里这话听着像是个练手项目商品列表、购物车、下单、支付四件套走一遍就完事了。真上手才发现明星周边这门生意跟卖日用品完全是两套逻辑直接把通用商城模板套上去上线第一场新品首发就会被打穿。我先把结论摆出来明星周边商城的技术难点不在商城而在限量抢购 身份限制 长周期履约这三件事叠加出来的复杂度。普通商城的库存是卖完了补货明星周边的库存是卖完了就永远没有普通商城的用户是想买几件买几件周边站的用户是一个人只能买两件多买就是黄牛普通商城下单 48 小时内发货周边站是先收钱、再生产、两三个月后发货中间还要面对工厂延期物流破损粉丝改地址的一堆售后。所以这篇东西不适合直接当代码模板抄它更像是我把这个项目从需求梳理到上线压测整个过程的工作笔记。适合谁看正在做电商类课程设计或毕业设计、想把项目做出真实业务质感的人接了小型独立站外包、需要评估工作量的人以及单纯好奇一个卖明星周边的网站凭什么要写这么多代码的人。下面的内容会从数据建模一路讲到限购校验、库存扣减和上线踩坑代码片段和表结构都是可以直接拿去改的。1.1 三个让通用商城模板直接翻车的业务特性第一个特性是限量与瞬时并发。日用品商城的流量是平缓的周边站的流量是脉冲式的——官宣新品那天晚上八点开售八点零一分流量到顶八点零三分售罄剩下全是刷页面的。这种流量曲线对系统的要求跟日常运营完全不同平时 QPS 可能只有几十开售瞬间能冲到几千而且这几千请求里绝大部分是对同一个 SKU 的库存争抢。通用模板里那种查库存→判断→更新库存的三步走写法在这个场景下必然超卖。第二个特性是身份与限购。明星周边的核心矛盾是真粉丝买不到黄牛囤一堆。所以系统必须能识别同一个人并且这个人可能用多个账号、多个手机号、多个收货地址来绕过限制。这就逼着你在数据模型里加上限购规则表、购买资格表还要在业务层做多维度的身份归并。这块的复杂度远超普通商城的每人限购 1 件这种简单配置。第三个特性是履约周期长且状态复杂。周边商品大多是预售制定金 → 尾款 → 生产 → 入库 → 发货。中间任何一个环节延期都会引发大量咨询和退款申请。订单状态机如果只按待付款/待发货/已完成三态设计运营同学第二天就会来找你改数据库。这块必须提前把状态枚举设计到位并且预留部分发货拆单发货补邮费这类扩展位。把这三条想明白你才知道为什么这套系统不能直接拿网上的开源商城改——改的成本比重写还高。1.2 技术栈与工程分层为什么最终落在 Spring Boot 这一套技术选型上没有太多纠结但每个选择背后都有具体理由不是因为流行所以用。层次选型选它的真实原因应用框架Spring Boot 2.7.x团队熟、生态全、 starters 开箱即用2.7 对 JDK 8 兼容好服务器环境不用升级持久层MyBatis-Plus后台管理大量单表 CRUD代码生成器能省掉一半工作量复杂查询仍可手写 XML数据库MySQL 8.0事务、行锁、JSON 字段都够用InnoDB 在中等并发下表现稳定缓存Redis 6库存预热、限购计数、商品详情缓存、分布式锁一个组件全包消息/延迟RabbitMQ可选或 Redis ZSet 延迟队列订单超时关单、异步发券、发货通知解耦前端Vue 3 Element Plus后台、Thymeleaf 或 Vue前台后台表单多组件库省事前台首屏要快可做服务端渲染或静态化反向代理Nginx静态资源、限流、灰度都在这儿做对象存储自建 MinIO 或云对象存储商品图、艺人图、买家秀量大且不能进数据库这里解释两个容易被问到的点。为什么不上微服务这套系统总共六七个业务模块日订单量在设计峰值下也就几千单团队三个人。上微服务意味着要维护注册中心、配置中心、链路追踪、分布式事务运维成本翻三倍收益接近于零。单体应用 模块化分包product、order、marketing、admin在这个体量下是最优解将来真要拆按包边界拆也不难。为什么库存一定要放 Redis因为 MySQL 的行锁在热点行上撑不住。同一个 SKU 的两千个并发请求打到数据库行锁排队会让连接池瞬间打满接口响应时间从 50ms 飙到 3s然后雪崩。Redis 单线程执行 Lua 脚本天然原子把判断库存 扣减 记录购买资格塞进一个脚本里能扛住这个量级。数据库只做最终的落库确认。1.3 需求清单与角色权限梳理需求梳理阶段我把角色拆得比较细因为后期权限系统如果只做管理员/普通用户两级运营和客服会互相越权。角色核心权限需要特别处理的点游客浏览商品、看艺人页、搜索详情页要能被搜索引擎抓且不能泄露库存精确值注册用户下单、支付、查订单、收藏、评价需要实名/手机号绑定才能参与限购类活动会员用户优先购、专属折扣、抽签资格会员等级要可配置别写死在代码里运营商品上下架、活动配置、优惠券、内容发布不能看用户手机号明文不能改订单金额客服查订单、备注、发起退款、改地址只能操作自己负责的工单范围仓管打单、发货、录入物流单号、库存盘点只能看已付款待发货的订单超级管理员全部 角色分配 系统参数所有敏感操作必须留审计日志这张表定下来之后后面的表设计基本就顺了——比如订单表要加remark客服备注和audit_log独立表用户表要拆出user_role关联表和role_permission权限表而不是在用户表里塞一个is_admin字段。2. 数据库设计IP、商品、库存、限购怎么落地数据库这块是整个项目最容易返工的地方。我前两版表结构都被自己推翻了第一版是按普通商城设计的第二版是按多商户设计的最后才收敛到现在的样子。2.1 艺人/IP 表与商品表的关系设计明星周边站跟普通商城最大的结构差异是多了一层艺人/IP。这个层不是简单的分类它承担了三个职责聚合同一个艺人的所有周边归到一起、授权不同 IP 有不同的授权期限和销售区域、运营艺人页是主要的流量入口需要独立的 SEO 和装修能力。所以我把artist艺人/IP 主体单独建表而不是塞进category。表里除了基础信息关键是这几个字段authorization_start、authorization_end授权有效期过期自动下架该 IP 下所有商品、sort_weight首页排序、status。商品表通过artist_id关联但不是强关联——有些联名款会挂多个艺人所以我又加了一张product_artist_rel关联表。这一点很多人会漏等到运营要上双人联名礼盒的时候才发现改不动。CREATE TABLE artist ( id bigint NOT NULL AUTO_INCREMENT, name varchar(64) NOT NULL COMMENT 艺人或IP名称, avatar varchar(255) DEFAULT NULL, intro text COMMENT 简介用于艺人页装修, authorization_start date DEFAULT NULL, authorization_end date DEFAULT NULL, sort_weight int NOT NULL DEFAULT 0, status tinyint NOT NULL DEFAULT 1 COMMENT 1上架 0下架, created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_status_weight (status, sort_weight) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT艺人/IP主体表; CREATE TABLE product_artist_rel ( id bigint NOT NULL AUTO_INCREMENT, product_id bigint NOT NULL, artist_id bigint NOT NULL, PRIMARY KEY (id), UNIQUE KEY uk_product_artist (product_id, artist_id), KEY idx_artist (artist_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT商品与艺人关联表;这里有个实操心得授权到期不要用定时任务全表扫。我最初写了个每天凌晨两点跑的定时任务把过期 IP 下的商品全查出来改状态结果某天数据量涨上来之后这个任务跑了四十分钟中间还锁了一堆行。后来改成在商品查询的 SQL 里直接带authorization_end CURDATE()条件再配合一个只处理当天到期的小批量任务问题就没了。2.2 SPU/SKU 拆解与周边品类的特殊性周边商品的规格组合跟服装类似但更零碎一件 T 恤有尺码和颜色一个应援棒有版本初回/普通和颜色一套小卡有版本和随机封面。所以 SPU/SKU 两层结构是必须的不能用单表加 JSON 规格糊弄过去——因为库存、价格、条码都是挂在 SKU 上的。不过周边品类有三个特殊字段需要注意限购维度有的商品是每人每 SKU 限 2 件有的是每人每 SPU 限 2 件不同尺码加起来只能买 2 件。这两种规则在表里必须区分我在product表加limit_type字段1按SKU2按SPU。预售标记presale_flag、presale_deliver_date、deposit_amount定金。预售商品的详情页和下单流程跟现货完全不同必须在商品层就标记清楚。随机款盲盒类商品不能选具体款式下单时是随机发货库存扣减针对的是整个 SPU 的公共池。这类商品的 SKU 我做成一个虚拟 SKUspec_json里标记randomtrue避免和正常 SKU 混在一起算库存。CREATE TABLE product_sku ( id bigint NOT NULL AUTO_INCREMENT, product_id bigint NOT NULL, sku_code varchar(64) NOT NULL COMMENT 内部编码唯一, spec_json json DEFAULT NULL COMMENT 规格快照如 {尺码:L,颜色:白色}, price decimal(10,2) NOT NULL, market_price decimal(10,2) DEFAULT NULL COMMENT 划线价, stock int NOT NULL DEFAULT 0 COMMENT 可售库存, locked_stock int NOT NULL DEFAULT 0 COMMENT 锁定库存下单未支付占用, limit_num int DEFAULT NULL COMMENT 单用户限购数空则继承SPU规则, status tinyint NOT NULL DEFAULT 1, PRIMARY KEY (id), UNIQUE KEY uk_sku_code (sku_code), KEY idx_product (product_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT商品SKU表;stock和locked_stock必须分开。这是下单不超卖的基础下单时stock - n, locked_stock n支付成功时locked_stock - n库存真正消耗超时关单时stock n, locked_stock - n库存归还。如果只用一个字段超时关单和支付回调一并发就会互相踩。2.3 库存表与限购表的独立设计库存我最终做成了两张表product_sku里的stock是展示库存stock_record是流水账。每次扣减都写一条流水记录来源下单/取消/退款/盘点调整和操作后的值。这么做的好处是出问题能追坏处是并发下写入量翻倍所以流水表我改成异步写扔消息队列或者用 MyBatis 批量插入延迟落库核心链路只更新主表。限购部分单独建了purchase_limit_recordCREATE TABLE purchase_limit_record ( id bigint NOT NULL AUTO_INCREMENT, activity_id bigint DEFAULT NULL COMMENT 所属活动无活动为0, limit_key varchar(128) NOT NULL COMMENT 限购唯一键如 sku:1001:user:88, user_id bigint NOT NULL, product_id bigint NOT NULL, sku_id bigint DEFAULT NULL, quantity int NOT NULL DEFAULT 0 COMMENT 已购数量, order_no varchar(64) DEFAULT NULL, created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_limit_key (limit_key), KEY idx_user_product (user_id, product_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户限购记录表;注意limit_key上那个唯一索引这是防重复下单的关键之一。它的构造规则是限购维度 商品 ID 用户标识比如按 SKU 限购就是sku:1001:user:88按 SPU 限购就是spu:2001:user:88。有了唯一索引就算应用层判断失误数据库也会拦住重复记录。注意限购表不要做成每次购买插一条而应该每个限购键只有一条记录累加 quantity。前者会导致查询时要做聚合后者用唯一索引就能实现原子占位。我第一版就是每次插一条结果用户连点两次下单插了两条记录限购判断读到的是两个独立记录两边都通过了。2.4 订单状态机与幂等字段的取舍订单设计是整套系统里最容易埋雷的地方。我最终的状态定义是这样的状态码含义可流转到10待支付20、9020已支付待生产30、9025已支付部分发货30、9030已发货40、8040已完成-80退款中81、4081已退款-90已关闭-单独说明三点25 这个中间状态别省多件商品分批发货是常态80/81 要独立退款中的订单不能再发货90 关闭必须区分原因另加close_reason字段超时未付/用户取消/风控关闭客服查单全靠这个。CREATE TABLE orders ( id bigint NOT NULL AUTO_INCREMENT, order_no varchar(32) NOT NULL COMMENT 对外订单号雪花或日期随机, user_id bigint NOT NULL, status tinyint NOT NULL DEFAULT 10, close_reason tinyint DEFAULT NULL, total_amount decimal(10,2) NOT NULL, pay_amount decimal(10,2) NOT NULL, deposit_amount decimal(10,2) DEFAULT 0.00 COMMENT 定金金额预售用, freight decimal(10,2) NOT NULL DEFAULT 0.00, pay_time datetime DEFAULT NULL, expire_time datetime DEFAULT NULL COMMENT 支付截止时间, idempotent_key varchar(64) DEFAULT NULL COMMENT 下单幂等键, version int NOT NULL DEFAULT 0 COMMENT 乐观锁, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), UNIQUE KEY uk_idempotent (idempotent_key), KEY idx_user_status (user_id, status), KEY idx_expire (status, expire_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT订单主表;idempotent_key的来源是个细节前端在进入结算页时生成一个 UUID随下单请求一起提交后端拿它做唯一键。用户连点两次提交订单第二次直接被唯一索引拦掉返回第一次的订单号。这比在应用层加锁优雅得多也不怕多实例部署。3. 核心功能实现从商品浏览到下单成功3.1 商品列表与详情接口三级缓存怎么铺前台首屏的性能直接影响开售那天的成败。这套系统的缓存做了三层第一层 Nginx 静态化商品详情页和艺人页在运营发布后生成静态 HTML直接由 Nginx 吐出。抢购页面除外有实时库存和按钮状态其余页面都可以静态化。第二层 Redis 对象缓存product:detail:{id}存完整详情 JSONTTL 30 分钟商品更新时主动删除。列表页缓存按artistId sort page组合 keyTTL 5 分钟。第三层本地缓存艺人列表、分类树这种极少变动的数据放 CaffeineTTL 10 分钟减少 Redis 网络往返。public ProductDetailVO getDetail(Long productId) { String key product:detail: productId; String cached redisTemplate.opsForValue().get(key); if (StringUtils.hasText(cached)) { ProductDetailVO vo JSON.parseObject(cached, ProductDetailVO.class); fillRealtimeStock(vo); // 库存实时查不走缓存 return vo; } ProductDetailVO vo loadFromDb(productId); if (vo ! null) { redisTemplate.opsForValue().set(key, JSON.toJSONString(vo), 30, TimeUnit.MINUTES); fillRealtimeStock(vo); } return vo; }这里的关键是缓存商品静态信息不缓存库存。我见过不少实现把库存一起缓存了结果详情页显示有货、点进去下单发现没货投诉量直接爆炸。库存必须实时或者秒级读而且展示的时候要做模糊化——库存大于 50 统一显示库存充足小于 20 显示仅剩少量只有小于 5 才显示真实数字。这么做既是体验也是防止黄牛脚本直接读接口判断余量。3.2 抢购场景下的库存扣减三种方案对比与选型这是整个项目最核心的技术点我把三条路都试过。方案做法优点致命问题数据库悲观锁SELECT ... FOR UPDATE锁行再更新实现简单绝对不超卖热点行排队连接池打满实测 300 并发就开始超时数据库乐观锁UPDATE ... WHERE stock n AND version v无锁吞吐比悲观锁高失败重试次数不可控高并发下大量请求空转Redis Lua 预扣 异步落库Lua 脚本原子判断扣减成功后进队列落库吞吐高响应快天然原子需要处理Redis 扣了但落库失败的对账补偿最终选的是第三种配合数据库的乐观锁兜底。Lua 脚本长这样-- KEYS[1] 库存 key如 stock:sku:1001 -- KEYS[2] 限购 key如 limit:sku:1001:user:88 -- ARGV[1] 购买数量 ARGV[2] 单用户限购数 ARGV[3] 库存key过期秒数 local stock tonumber(redis.call(get, KEYS[1])) if stock nil then return -3 -- 库存未预热 end local qty tonumber(ARGV[1]) local limit tonumber(ARGV[2]) local bought tonumber(redis.call(get, KEYS[2]) or 0) if limit 0 and bought qty limit then return -2 -- 超限购 end if stock qty then return -1 -- 库存不足 end redis.call(decrby, KEYS[1], qty) local newBought redis.call(incrby, KEYS[2], qty) if newBought qty then redis.call(expire, KEYS[2], ARGV[3]) end return newBought -- 返回累计购买数0 表示成功返回值设计成正数成功且返回累计数负数具体失败原因前端能根据 -1/-2 给不同提示。这个细节看似小但开售那天用户看到库存不足和超出限购的体验完全不同前者会去别的渠道找后者会直接骂人。库存预热用定时任务在开售前 10 分钟把 SKU 库存灌进 Redis同时打一个stock:preheat:{skuId}标记。如果用户请求到达时 Redis 里没有库存 key脚本返回 -3接口层再回源查一次数据库并补写避免缓存穿透导致误判售罄。3.3 限购策略四层校验的取舍逻辑限购这块我做了四层但不是每层都硬拦得区分强校验和弱识别。第一层账号 ID最基础的Redis 里计数超过直接拒绝。第二层手机号一个手机号只能绑定一个账号所以在注册环节就做了唯一约束。这层能挡住同一人注册多个账号但挡不住用家人手机号注册所以是强校验但不是万能的。第三层收货地址把地址做标准化去掉空格、统一省市区写法、提取手机号做相似度比对。如果同一个地址关联了超过 N 个账号N 配置成 3就在风控表里打标人工复核。这层是弱识别绝不能自动拒绝因为合租、学校宿舍、公司前台这类地址天然有大量用户共用。我一开始做成自动拦截上线两天就误伤了十几个真实粉丝后来改成打标 人工抽检。第四层设备与行为特征记录下单设备的匿名标识、下单时间间隔、页面停留时长。真人至少要浏览几秒脚本是毫秒级的。这层只做风控评分超过阈值触发验证码不直接拒绝。public LimitResult checkLimit(Long userId, Long skuId, Long productId, int qty) { ProductSku sku skuMapper.selectById(skuId); LimitRule rule limitRuleService.resolve(sku, productId); if (rule null || rule.getLimitNum() null) { return LimitResult.pass(); } String limitKey rule.getDimension() 1 ? limit:sku: skuId :user: userId : limit:spu: productId :user: userId; int bought Optional.ofNullable( redisTemplate.opsForValue().get(limitKey)) .map(v - Integer.parseInt(v.toString())).orElse(0); if (bought qty rule.getLimitNum()) { return LimitResult.fail(-2, 该商品每人限购 rule.getLimitNum() 件); } return LimitResult.pass(); }实操提醒限购计数在 Redis 里的过期时间不要设置成永不过期。周边站的活动周期通常是一个月我把限购 key 的 TTL 设成活动结束后 7 天靠定时任务统一清。如果设成永久Redis 内存会被这些 key 慢慢吃满而且下一场活动还要手动清理。3.4 订单状态机与超时关单的实现超时关单这块我最早用的是Scheduled每分钟扫一次数据库SELECT * FROM orders WHERE status10 AND expire_time NOW()。这个方案在订单量小的时候没问题但有两个隐患一是随着历史订单增长扫描越来越慢二是关单和支付回调之间存在竞争——用户在第 59 分 58 秒付款定时任务在第 60 分 00 秒读到这笔单子去关两边同时改状态就出问题了。后来改成延迟消息方案下单成功后往延迟队列推一条消息TTL 30 分钟到期消费时先查订单当前状态只有仍然是待支付才执行关闭。数据库层面加了乐观锁版本号更新语句是UPDATE orders SET status90, versionversion1 WHERE id? AND status10 AND version?影响行数为 0 就说明状态已经被别的流程改过直接放弃。Transactional(rollbackFor Exception.class) public boolean closeOrder(Long orderId) { Orders order orderMapper.selectById(orderId); if (order null || order.getStatus() ! OrderStatus.WAIT_PAY) { return false; // 已支付或已关闭幂等返回 } int rows orderMapper.closeOrder(orderId, OrderStatus.WAIT_PAY, order.getVersion()); if (rows 0) { return false; // 并发情况下被别人改过 } // 归还库存Redis DB 双写 stockService.releaseStock(order); // 释放限购占用 limitService.release(order); // 释放优惠券 couponService.releaseCoupon(order); return true; }归还库存的顺序很重要先改 Redis再改数据库最后异步补流水。反过来的话如果 Redis 改失败数据库已经归还了下次再卖就会超卖。Redis 改成功数据库失败的话最坏情况是少卖几件Redis 里有货、数据库里没有可以通过对账任务修正属于安全方向的失败。3.5 支付回调的幂等与对账支付回调是所有电商项目的经典坑位。三个必做动作验签、幂等、金额校验。验签不用多说第三方支付平台的公钥验证跳过就是裸奔。金额校验容易被忽略——回调里带的金额必须和你自己记录的pay_amount一致不一致直接拒绝并告警这是防篡改的最后一道门。幂等的实现用支付平台的交易流水号做唯一键建一张payment_record表回调进来先INSERT唯一键冲突说明已经处理过直接返回成功给平台。Transactional(rollbackFor Exception.class) public String handleCallback(PayCallbackDTO dto) { // 1. 验签 if (!payService.verifySign(dto)) { return fail; } // 2. 幂等插入唯一键是 platform_trade_no PaymentRecord record new PaymentRecord(); record.setPlatformTradeNo(dto.getTradeNo()); record.setOrderNo(dto.getOrderNo()); record.setAmount(dto.getAmount()); try { paymentRecordMapper.insert(record); } catch (DuplicateKeyException e) { return success; // 已处理过直接应答 } // 3. 金额校验 Orders order orderMapper.selectByOrderNo(dto.getOrderNo()); if (order null || order.getPayAmount().compareTo(dto.getAmount()) ! 0) { log.error(支付金额不匹配 orderNo{}, dto.getOrderNo()); return fail; } // 4. 状态流转 后续业务 orderService.markPaid(order, dto); return success; }对账任务每天跑一次拉取平台的交易流水和我们本地的payment_record做双向比对。有三种差异要处理本地有平台无可能是伪造回调、平台有本地无回调丢了、金额不一致。第三种最危险一律人工介入不要写自动修复。4. 后台与运营让运营同学不找你改数据库后台这块的价值不在于技术难度而在于能不能让运营自己干活。我见过太多项目前台做得花里胡哨后台商品上架还得开发手动执行 SQL这种项目上线即等于把自己焊死在运维岗位上。4.1 商品发布与审核流商品发布我设计了草稿→待审→已上架→已下架四态中间加了审核环节。小团队可能觉得审核多余但周边商品涉及 IP 授权和价格敏感性一个标错价格的 SKU 上架十分钟就够亏的。审核流的具体实现就是在product表加audit_status和audit_by、audit_time配合一张product_audit_log记录每次提交和驳回的原因。发布表单里有个细节值得说规格组合自动生成。运营填颜色白/黑和尺码M/L系统自动生成 4 个 SKU 输入框而不是让运营一条条手填。这个功能不难前端做笛卡尔积就行但省下来的重复劳动非常可观——一套周边动辄十几个 SKU手填必错。4.2 营销模块预售、定金、抽签与满赠周边站最常用的四个营销玩法实现复杂度从低到高满赠最好做订单确认页算一遍赠品加进订单明细价格记 0。要注意赠品也要占库存而且赠品不足时不能阻断主单支付得做成赠品缺货则不发。预售定金稍复杂本质是两笔支付定金支付后订单进入待付尾款尾款有独立的截止时间超时定金不退业务规则代码上就是把订单关到 90 状态且不退定金。这里要额外记deposit_paid和balance_paid两个字段退款时才能算清楚退多少。抽签是最麻烦的。用户报名 → 系统抽签 → 中签用户获得购买资格 → 限时下单。实现上是三张表lottery_activity活动、lottery_apply报名记录、lottery_winner中签结果。抽签算法我用的是打乱索引 取前 N在报名结束后跑一次结果落表。中签用户才能在限购校验之外再通过一层资格校验。public void draw(Long activityId) { LotteryActivity activity activityMapper.selectById(activityId); ListLong applyIds lotteryApplyMapper.listApplyUserIds(activityId); Collections.shuffle(applyIds, new Random(activity.getSeed())); ListLong winners applyIds.stream() .limit(activity.getWinCount()).collect(Collectors.toList()); lotteryWinnerMapper.batchInsert(activityId, winners); activityMapper.updateStatus(activityId, LotteryStatus.DRAWN); }seed那个字段是给运营留的可复现能力——万一有人质疑抽签结果用同一个 seed 重跑能得到相同结果能自证。4.3 数据看板与发货管理看板只做了四个核心指标GMV、支付订单数、客单价、退款率按日/周/月聚合数据来自每日凌晨跑的一张stat_daily汇总表。别在后台实时跑SUM()扫全表订单表上百万行之后一个查询能把数据库拖垮。发货管理是客服和仓管用得最多的页面。这里做了两个提高效率的功能一是批量打单选中订单批量导出成快递面单格式二是扫码发货仓管扫订单号系统自动带出商品明细输入运单号回车即完成。扫码这块用的是普通扫码枪本质是个键盘输入设备前端监听回车键就行不用什么特殊 SDK。5. 上线前后踩过的坑与排查实录5.1 常见问题速查表现象大概率原因排查路径处理方式开售瞬间接口 502Tomcat 线程池打满Nginx 上游超时看server.tomcat.threads.max和 Nginx error log调大线程池 Nginx 加限流非核心接口降级库存显示有货但下单失败Redis 库存与 DB 不一致对比stock:sku:x和product_sku.stock以 Redis 为准先让用户能买事后对账修正 DB同一用户下了两单限购 key 计算用了不同维度打印limit_key看是否一致统一维度逻辑补唯一索引支付成功但订单还是待支付回调处理抛异常导致事务回滚查回调日志和payment_record补一段主动查单的补偿任务详情页图片加载慢图片直传应用服务器未压缩看图片响应大小接对象存储 上传时压缩到 200KB 内订单状态乱跳多处更新订单未带状态前置条件搜所有update orders语句全部改成带status?的条件更新后台列表卡死分页查询缺索引COUNT全表扫EXPLAIN看执行计划补联合索引大表COUNT换成估算这张表是我把上线第一个月所有工单归类之后整理出来的基本上新手会踩的坑都在里面。5.2 压测与容量估算压测这块我做了个简单的估算思路比数字本身更重要。假设活动预告发出后有 5 万人收藏实际开售瞬间按 20% 转化、集中在 60 秒内那就是 1 万个下单请求 / 60 秒 ≈ 170 QPS 的写请求。看起来不高但每个下单请求背后是 1 次库存 Lua 1 次限购校验 1 次订单插入 3 次订单明细插入 1 次 Redis 写实际数据库压力要乘好几倍。所以我的容量规划是这样读请求走缓存写请求走 Redis 预扣数据库只承担最终落库。压测时用 JMeter 模拟 500 并发对下单接口打 1 分钟观察三个指标Redis 的 P99 延迟要低于 10ms、数据库连接池活跃数不能持续打满、接口成功率要高于 99%。第一次压测成功率只有 87%查下来是订单号生成用了synchronized单例改成雪花算法之后直接上到 99.6%。另外提醒一句压测环境的数据量要接近生产。我第一次在空库上压测一切完美上了生产订单表有 40 万行SELECT ... WHERE user_id? AND status?没走对索引直接拖慢整个下单链路。补了idx_user_status联合索引之后才恢复。5.3 几条踩出来的经验第一条别信用户不会连点。开售那天用户的点击频率是平时的十几倍所有提交类接口必须做前端置灰 后端幂等双保险缺一个都会产生重复订单。第二条所有涉及钱和库存的操作都要能在日志里还原。我在下单和支付两个环节都打了带traceId的完整日志出问题时能顺着一条链路把谁在什么时候点了什么、系统返回了什么全串起来。这件事平时看不出价值出事的时候能救命。第三条后台的删除一律做成软删除。周边站的商品下架之后还会有人拿订单来问这个是什么硬删了product表订单详情页就得显示空白。加个deleted字段查询默认过滤成本极低。第四条第三方依赖全部做超时和降级。短信、支付查单、物流查询任何一个挂了都不能拖垮主流程。短信发不出去就记表重试物流查询超时就展示暂无物流信息绝不能因为一个边缘服务让用户下不了单。这套系统从立项到跑顺前前后后改了三版表结构、两次库存方案。回头看我个人最大的体会是电商类项目的技术难度从来不在于会不会写 CRUD而在于你有没有认真想过如果一万个人在同一秒点同一个按钮会发生什么。把这个问题想透剩下的代码反而是最不需要创造力的部分。
阅读完成 · 觉得有帮助?
咨询建站