ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

直播活动页高并发实战:Redis+Lua防重与状态机设计

直播活动页高并发实战:Redis+Lua防重与状态机设计 一次直播活动页上线真正麻烦的往往不是直播本身而是活动时间窗、高并发访问、用户重复点击和状态一致性这些问题。“8月8日 20:00-21:00 品牌直播间活动”这类场景技术侧要承接的是活动开始前用户不断刷新页面、准点瞬间大量请求进入、优惠券或互动资格被重复领取、活动结束后页面状态没有及时切换。这篇文章围绕一个可复现的“品牌直播活动页”完整案例从数据库设计、后端接口、Redis 缓存、分布式锁、前端倒计时到 JMeter 压测验证讲清楚每段代码为什么这样写以及上线前要看哪些指标。这个案例不绑定任何具体品牌或经营数据只讨论通用技术方案。业务需求抽出来就是三条活动分时段开始和结束用户进入直播间后能查看倒计时满足条件时可领取一张限定优惠券且每个用户最多领取一次。如果用一句话概括技术主线用 Redis 缓存活动状态和发放结果用 Lua 脚本保证在并发下扣减和发放不重复用轮询接口让前端倒计时在多个端保持一致最后通过压测验证削峰效果。1. 先把直播活动页面的技术问题拆开1.1 直播活动页面的真实技术诉求从业务视角看直播活动页面很简单展示主播信息、显示倒计时、有按钮可点。但从技术视角看这里至少有四层问题。第一层是活动时间状态。活动不是一个固定页面而是分成未开始、进行中、已结束三个状态。服务端必须根据当前时间动态计算状态前端倒计时不能只依赖本地时间否则用户改了系统时间或者网络延迟页面展示就会和活动真实状态不一致。第二层是集中流量。直播活动通常会有确定的开播时间比如 20:00 到 21:00。用户会在开播前几分钟集中进入页面准点瞬间再集中点互动按钮。这种流量不是均匀的而是有一个明显尖峰。第三层是防重。优惠券或互动资格属于有限资源。用户连续快速点击按钮浏览器可能发出多个请求用户换设备、换账号也可能重复参加。服务端必须保证“一个人一次活动只能领一次”这个约束在并发下依然成立。第四层是前端和服务端的状态同步。用户在 20:59 停留页面21:00 活动结束页面要自动变成结束状态按钮要置灰。如果前端不主动请求服务端这个切换就做不到。所以直播活动页不是普通展示页而是一个带状态机、有写操作、要抗住短时高并发的业务系统。1.2 活动状态机设计活动状态必须用状态机管理不能散落在代码里到处判断。这个案例只有三种状态NOT_STARTED - RUNNING - ENDED状态转换规则当前时间小于活动开始时间状态为 NOT_STARTED未开始。当前时间大于等于开始时间且小于结束时间状态为 RUNNING进行中。当前时间大于等于结束时间状态为 ENDED已结束。注意这里没有“暂停”和“中止”状态。实际活动如果要做人工紧急下线可以加一个 DISABLED 状态并允许从任意状态强制进入。生产环境建议把状态机扩展成五态未开始、进行中、暂停、已结束、已下线但最小闭环先跑三态逻辑更清晰。状态机的核心作用是让后端接口和前端页面共享同一套状态依据。后端接口会返回给前端一个status字段前端根据它控制按钮可点击状态而不是前端自己用本地时间判断。1.3 为什么不能只有前端倒计时很多初写活动页的同学会在前端直接写new Date()再用活动开始时间减去本地时间计算倒计时。这种方式在个人电脑上没问题但一旦放到生产环境会出现三类问题用户本地时间不准倒计时和服务端不一致。CDN 缓存了旧的页面数据用户看到的活动时间是缓存值。用户通过前端就能猜到接口规则进而绕过 UI 直接调接口。服务端必须成为时间权威来源。前端可以从服务端获取serverTime、startTime、endTime然后基于服务端时间计算倒计时并通过轮询定期校准。这样才能保证页面状态和真实活动时间一致。2. 环境准备与项目基础结构2.1 技术选型和环境版本下面案例基于常见技术栈学习环境和轻量生产环境都可以跑通。落地时版本要按自己团队实际情况对齐不要直接照抄。组件版本建议作用JDK17运行后端服务Spring Boot3.2.xWeb 应用框架MyBatis-Plus3.5.x操作数据库Redis7.x缓存活动状态、发放计数、分布式锁MySQL8.x持久化活动配置和领取记录Vue3.4.x前端活动页JMeter5.6.x并发压测MyBatis-Plus 不是必须的如果团队更习惯 JPA可以替换。为了减少样板代码这里用 MyBatis-Plus。2.2 后端项目目录结构使用 Maven 管理后端工程关键目录如下live-activity/ ├── pom.xml ├── src/main/java/com/example/liveactivity/ │ ├── LiveActivityApplication.java │ ├── controller/ │ │ ├── ActivityController.java │ │ └── CouponController.java │ ├── service/ │ │ ├── ActivityService.java │ │ ├── ActivityStateService.java │ │ └── CouponGrantService.java │ ├── mapper/ │ │ ├── ActivityMapper.java │ │ └── CouponRecordMapper.java │ ├── entity/ │ │ ├── Activity.java │ │ └── CouponRecord.java │ ├── common/ │ │ ├── Result.java │ │ └── BizException.java │ └── config/ │ └── RedisConfig.java └── src/main/resources/ ├── application.yml ├── db/schema.sql └── lua/grant_coupon.lua工程结构按“controller - service - mapper - entity”分层。直播活动业务规模不大不需要引入复杂的 DDD 分层但至少要把接口入口、状态计算、发放逻辑分开否则后面积累到几十个接口时会很乱。2.3 初始化数据库表活动表保存活动基础配置领券记录表保存用户领取流水。两张表设计如下。CREATE TABLE t_activity ( id bigint NOT NULL AUTO_INCREMENT COMMENT 活动ID, activity_name varchar(128) NOT NULL COMMENT 活动名称, start_time datetime NOT NULL COMMENT 开始时间, end_time datetime NOT NULL COMMENT 结束时间, total_limit int NOT NULL DEFAULT 0 COMMENT 总发放上限, per_user_limit int NOT NULL DEFAULT 1 COMMENT 单人可领数量, status tinyint NOT NULL DEFAULT 0 COMMENT 0未开始 1进行中 2已结束 3已下线, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT直播活动配置表; CREATE TABLE t_coupon_record ( id bigint NOT NULL AUTO_INCREMENT, activity_id bigint NOT NULL COMMENT 活动ID, user_id varchar(64) NOT NULL COMMENT 用户ID, coupon_code varchar(64) NOT NULL COMMENT 券码或批次号, grant_status tinyint NOT NULL DEFAULT 0 COMMENT 0领取中 1成功 2失败, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_activity_user (activity_id, user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT活动领券记录表;t_coupon_record上的唯一键uk_activity_user是数据库层的最后一道防线。即使应用层并发控制出了问题数据库唯一索引也会挡住重复插入。这个设计在生产环境不能省。2.4 application.yml 配置server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/live_activity?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: root driver-class-name: com.mysql.cj.jdbc.Driver data: redis: host: localhost port: 6379 database: 0 password: timeout: 3s mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0配置中需要确认两点。第一serverTimezoneAsia/Shanghai要设置否则 MySQL 时间和 Java 时间可能出现偏差。第二Redis 超时时间不要太长直播活动页接口如果 Redis 响应慢前端倒计时轮询会跟着卡顿。注意学习环境可以不做多副本部署但生产环境至少要有主从 MySQL 和 Redis 哨兵或集群。文章末尾的生产清单会再展开。3. 后端核心实现状态计算、缓存和防重3.1 活动信息查询接口用户打开直播活动页第一个请求通常是获取活动信息和当前服务端时间。这里不能直接返回数据库里的原始时间字段而是要返回计算后的状态。RestController RequestMapping(/api/activity) public class ActivityController { private final ActivityService activityService; public ActivityController(ActivityService activityService) { this.activityService activityService; } GetMapping(/{activityId}) public ResultActivityVO getActivity(PathVariable Long activityId) { return Result.ok(activityService.getActivityVO(activityId)); } }对应的ActivityVO包含页面所需的全部信息public class ActivityVO { private Long activityId; private String activityName; private LocalDateTime startTime; private LocalDateTime endTime; private LocalDateTime serverTime; private Integer status; private Long remainingLimit; }这里的status不能直接读库里的字段持久化状态。因为活动开始和结束是时间自动触发的数据库里的status可以作为管理端人工修改的状态但接口展示状态要由服务端动态计算。3.2 动态状态计算逻辑状态计算放在独立的ActivityStateService中方便复用和单测。Service public class ActivityStateService { public Integer calcStatus(Activity activity, LocalDateTime now) { if (activity.getStatus() ! null activity.getStatus() 3) { return 3; } if (now.isBefore(activity.getStartTime())) { return 0; } if (now.isBefore(activity.getEndTime())) { return 1; } return 2; } public long calcRemainingLimit(Activity activity, long grantedCount) { long total activity.getTotalLimit() ! null ? activity.getTotalLimit() : 0; long remaining total - grantedCount; return Math.max(remaining, 0); } }这里有一个容易忽略的点活动已经结束但数据库状态没更新怎么办接口层用现在时间动态计算状态就不会出现“时间到了前端还显示进行中”的问题。数据库里的status字段更多是管理端人工下线用的开关而不是运行时的唯一依据。3.3 读多写少场景用缓存保护数据库直播活动页的查询接口会被轮询频繁调用如果每次都查 MySQL数据库压力会非常大。Redis 缓存策略如下keylive:activity:info:{activityId}value活动基本信息 JSON包含活动 ID、名称、开始时间、结束时间、状态过期时间10 到 30 秒缓存更新后台定时任务每分钟刷新一次管理端修改活动配置后主动删除缓存封装一个查询方法Service public class ActivityService { private static final String ACTIVITY_CACHE_KEY live:activity:info:; private static final long ACTIVITY_CACHE_TTL 30L; private final ActivityMapper activityMapper; private final StringRedisTemplate stringRedisTemplate; private final ActivityStateService stateService; public ActivityVO getActivityVO(Long activityId) { String cacheKey ACTIVITY_CACHE_KEY activityId; String cached stringRedisTemplate.opsForValue().get(cacheKey); if (cached ! null) { Activity activity JSON.parseObject(cached, Activity.class); return toVO(activity, System.currentTimeMillis()); } Activity activity activityMapper.selectById(activityId); if (activity null) { throw new BizException(活动不存在); } stringRedisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(activity), ACTIVITY_CACHE_TTL, TimeUnit.SECONDS); return toVO(activity, System.currentTimeMillis()); } private ActivityVO toVO(Activity activity, long now) { LocalDateTime currentTime LocalDateTime.now(); Integer status stateService.calcStatus(activity, currentTime); ActivityVO vo new ActivityVO(); vo.setActivityId(activity.getId()); vo.setActivityName(activity.getActivityName()); vo.setStartTime(activity.getStartTime()); vo.setEndTime(activity.getEndTime()); vo.setServerTime(currentTime); vo.setStatus(status); return vo; } }这段代码有一个缓存击穿风险如果活动信息过期同时几千个请求发现缓存为空会同时打到数据库。可以在查询数据库前加一个互斥锁也可以依赖本地进程内的缓存。对于学习项目先用“互斥锁”方式最直观。public Activity getActivityWithLock(Long activityId) { String lockKey live:lock:activity: activityId; Boolean locked stringRedisTemplate.opsForValue().setIfAbsent(lockKey, 1, 5, TimeUnit.SECONDS); if (Boolean.TRUE.equals(locked)) { try { Activity activity activityMapper.selectById(activityId); stringRedisTemplate.opsForValue().set(ACTIVITY_CACHE_KEY activityId, JSON.toJSONString(activity), ACTIVITY_CACHE_TTL, TimeUnit.SECONDS); return activity; } finally { stringRedisTemplate.delete(lockKey); } } // 等待后重试 try { Thread.sleep(50); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } return getActivityWithLock(activityId); }注意分布式锁用 Redis 的setIfAbsent只是入门方案。生产环境要引入 Redisson使用看门狗续期逻辑避免业务执行时间超过锁过期时间导致锁提前释放。3.4 领券防重Lua 脚本保证原子性用户点击“领取”按钮时后端写入t_coupon_record同时扣减 Redis 中的剩余数量。并发场景下最怕两类问题重复领取、超发。先定义 Redis 的两个 keylive:coupon:granted:{activityId}:{userId} live:coupon:remaining:{activityId}领券时执行 Lua 脚本-- KEYS[1] 用户是否已领取的 key -- KEYS[2] 剩余数量的 key -- ARGV[1] 活动总发放上限 -- ARGV[2] 用户中心标记例如 1 表示已领取 local granted redis.call(EXISTS, KEYS[1]) if granted 1 then return 0 end local remaining redis.call(GET, KEYS[2]) if not remaining then remaining ARGV[1] end if tonumber(remaining) 0 then return -1 end redis.call(DECR, KEYS[2]) redis.call(SETEX, KEYS[1], 86400, ARGV[2]) return 1脚本执行结果有三类返回 1领取成功。返回 0该用户已经领过不能重复领取。返回 -1总库存已经发完。Lua 脚本之所以能防并发是因为 Redis 会原子执行整个脚本脚本执行过程中不会有其他命令插入因此不会出现两个请求同时读到剩余数量为 1然后都减成 0 的情况。Java 侧调用脚本Service public class CouponGrantService { private final StringRedisTemplate stringRedisTemplate; private final CouponRecordMapper couponRecordMapper; private static final DefaultRedisScriptLong GRANT_SCRIPT new DefaultRedisScript(); static { GRANT_SCRIPT.setLocation(new ClassPathResource(lua/grant_coupon.lua)); GRANT_SCRIPT.setResultType(Long.class); } Transactional(rollbackFor Exception.class) public GrantResult grant(Long activityId, String userId, int totalLimit, String couponCode) { String grantedKey live:coupon:granted: activityId : userId; String remainingKey live:coupon:remaining: activityId; Long result stringRedisTemplate.execute( GRANT_SCRIPT, Arrays.asList(grantedKey, remainingKey), String.valueOf(totalLimit), 1 ); if (result null || result 0L) { return GrantResult.repeat(请勿重复领取); } if (result -1L) { return GrantResult.fail(优惠券已领完); } // 落库 CouponRecord record new CouponRecord(); record.setActivityId(activityId); record.setUserId(userId); record.setCouponCode(couponCode); record.setGrantStatus(1); couponRecordMapper.insert(record); return GrantResult.ok(couponCode); } }这里要理解一个取舍Redis 扣减成功后才落 MySQL。如果 MySQL 插入失败会出现 Redis 已记录领取但数据库没有流水。处理方式是先落 MySQL确认成功后再扣 Redis或者记录一个补偿日志定时任务对比 Redis 和数据库差异。更稳妥的姿势是Redis 扣减只是前端入口预占资格后台异步队列负责真正发券和落库。但最小闭环项目里先保持一致性问题说明清楚即可生产环境建议走消息队列。3.5 用户维度限流除了防重直播活动页还需要做限流。按用户维度限流可以防止单个用户在短时间内高频轮询或点击。Component public class UserRateLimiter { private final StringRedisTemplate stringRedisTemplate; public boolean tryAcquire(String userId, String action, int maxTimes, int windowSeconds) { String key live:rate: action : userId; Long count stringRedisTemplate.opsForValue().increment(key); if (count ! null count 1L) { stringRedisTemplate.expire(key, windowSeconds, TimeUnit.SECONDS); } return count ! null count maxTimes; } }在领券接口上应用if (!userRateLimiter.tryAcquire(userId, coupon:grant, 5, 10)) { return GrantResult.fail(操作太频繁请稍后再试); }注意限流窗口的粒度。10 秒最多 5 次适用于“点击领券”这种低频操作但如果限流用在页面轮询上窗口和次数就都要放大否则用户会频繁被拦截。4. 前端活动页面倒计时、轮询和按钮状态4.1 Vue 3 页面结构与状态前端页面保持轻量用 Vue 3 axios 即可。核心状态有三个活动信息、倒计时数值、按钮交互状态。interface ActivityVO { activityId: number activityName: string startTime: string endTime: string serverTime: string status: number remainingLimit: number } interface PageState { activity: ActivityVO | null countdown: string loading: boolean grantLoading: boolean }页面加载后调用活动查询接口返回结果后启动倒计时。倒计时计算依赖服务端时间而不是本地时间。4.2 倒计时组件倒计时要计算三个时间差的的滑动窗口距离开始、距离结束、已结束。function calcCountdown(activity: ActivityVO): string { const serverTime new Date(activity.serverTime).getTime() const startTime new Date(activity.startTime).getTime() const endTime new Date(activity.endTime).getTime() if (serverTime startTime) { return formatDuration(startTime - serverTime) } if (serverTime endTime) { return formatDuration(endTime - serverTime) } return 已结束 }这里的serverTime是接口返回的服务端当前时间。每次轮询都会拿到新的serverTime用它对倒计时进行校准避免本地时间偏移越积越大。下面是一个可用的倒计时格式化方法function formatDuration(ms: number): string { const totalSeconds Math.floor(ms / 1000) const hours Math.floor(totalSeconds / 3600) const minutes Math.floor((totalSeconds % 3600) / 60) const seconds totalSeconds % 60 return ${pad(hours)}:${pad(minutes)}:${pad(seconds)} } function pad(n: number): string { return n 10 ? 0${n} : ${n} }4.3 页面轮询与异常分支轮询间隔通常 15 到 30 秒。太短会放大服务端压力太长会导致活动开始或结束时页面状态切换不及时。直播活动对实时性要求比较高可以设 10 秒。let timer: number | undefined function startPolling() { stopPolling() timer window.setInterval(async () { try { const { data } await getActivity(activityId) state.activity data state.countdown calcCountdown(data) updateButtonState(data.status) } catch (e) { // 轮询失败不阻塞页面保留上一次状态延后重试 console.error(poll activity error, e) } }, 10_000) } function stopPolling() { if (timer) { window.clearInterval(timer) timer undefined } }轮询接口失败时的处理要明确不能直接把页面状态写成已结束也不能让按钮变成可点击。最稳妥的做法是保留上一次成功获取的状态并在下一次轮询时恢复。如果连续失败超过 3 次可以提示用户检查网络。4.4 按钮状态与业务规则对应按钮状态由服务端返回的status和用户的领取结果共同决定。不能只由前端决定也不能只由后端决定。前端展示状态触发条件按钮行为未开始status 0按钮禁用显示开播倒计时进行中且未领取status 1按钮可点击显示“立即领取”进行中且已领取status 1 且本地已标记按钮禁用显示“已领取”已结束status 2按钮禁用显示“活动已结束”服务端返回失败网络错误或接口异常按钮禁用或保留状态提示稍后重试点击按钮后即使前端已经收到“已领取”后端仍要按用户 ID 再次校验这是防重的基本要求。async function handleGrant() { if (!state.activity || state.activity.status ! 1) { return } state.grantLoading true try { const result await grantCoupon(activityId) if (result.code 0) { state.granted true message.success(领取成功) } else { message.error(result.message) } } catch (e) { message.error(网络异常请稍后再试) } finally { state.grantLoading false } }需要注意用户点击成功后前端置灰按钮但刷新页面后不能只靠内存状态判断应该让服务端提供一个“是否已领取”的查询接口或者活动详情接口里直接返回granted布尔字段。更完整的做法是活动详情接口接受userId参数并返回该用户领取状态这样刷新页面后按钮状态仍然正确。5. 运行验证与并发压测5.1 本地运行验证流程先后台启动 Redis 和 MySQL然后初始化表结构和测试数据。INSERT INTO t_activity ( activity_name, start_time, end_time, total_limit, per_user_limit, status ) VALUES ( 品牌直播专场, 2025-08-08 20:00:00, 2025-08-08 21:00:00, 10000, 1, 0 );启动 Spring Boot 应用mvn spring-boot:run启动前端开发服务器npm install npm run dev验证功能时建议直接看接口返回而不是只看页面curl http://localhost:8080/api/activity/1预期返回类似{ code: 0, message: ok, data: { activityId: 1, activityName: 品牌直播专场, startTime: 2025-08-08 20:00:00, endTime: 2025-08-08 21:00:00, serverTime: 2025-08-08 19:40:00, status: 0, remainingLimit: 10000 } }这里serverTime会随当前时间变化status应按活动时间段对应切换。5.2 JMeter 压测脚本要点压测目标不是把服务跑挂而是验证以下三件事高并发下领券接口不会重复发券。Redis 缓存能扛住集中查询流量。数据库不会出现连接耗尽。JMeter 新建线程组线程数500Ramp-up 时间5 秒循环次数5 次HTTP 请求配置Method: POST Path: /api/coupon/grant Parameters: activityId 1 userId ${__UUID}压测命名时要固定userId否则每个用户只领一次根本测不到防重场景。正确做法是准备 1000 个固定用户每个用户并发发起多次请求。压测结束后重点看聚合报告里的Error%和p95响应时间同时检查数据库t_coupon_record中activity_id1的总条数应该等于 Redis 成功发放数量且不能超过活动总上限。5.3 预期结果和监控指标指标预期总发放数量不超过total_limit同一用户领取次数数据库唯一索引和 Redis 双重保证为 1读接口 p95不超过 300ms写接口 p95不超过 500ms数据库连接池占用稳定不超过最大连接数 70%如果压测中发现Error%偏高先看服务端日志区分是限流拦截、库存不足还是数据库连接异常。三种问题处理方式完全不同。6. 常见问题排查链路6.1 活动状态一直显示未开始现象时间已经过了开始时间页面还显示“未开始”。检查顺序确认数据库时间字段是否正确尤其是日期格式是否带时区。确认服务端时区和数据库时区一致使用SELECT NOW();对照两次时间。确认 Redis 缓存是否残留旧的 activity JSON。如果活动配置修改过但缓存没有删除接口会一直返回旧数据。确认是否请求了其他环境比如商品详情页里写死了测试环境域名而用户访问的是生产域名。处理方式redis-cli DEL live:activity:info:1同时检查管理端修改活动时间后是否主动调用缓存删除接口。生产环境建议在配置变更接口里统一执行deleteCache。6.2 用户重复领取优惠券成功现象同一个用户短时间内多次点击领券成功多次。逐层排查先看 Redis 脚本是否返回 0。如果每次都返回 1说明 Lua 脚本的 granted key 拼错了可能漏掉了activityId。再看数据库t_coupon_record是否有重复记录。如果有重复说明唯一索引没有生效检查表结构是否遗漏uk_activity_user。如果 Redis 和数据库都正常但页面显示重复成功可能是前端没有根据返回结果更新按钮状态用户多次点击时先发起了多个请求。生产环境不能依赖前端按钮置灰必须把 Redis 和数据库唯一索引当成两层防线。6.3 Redis 缓存大面积失效导致抖动现象整点前后接口响应变慢数据库 CPU 飙升。原因活动信息缓存 TTL 设置成 30 秒且在同一秒过期数据库瞬间收到大量查询。尤其是 20:00 整点用户集中进入直播间如果这时缓存刚好过期压力会被放大。处理方式给缓存 TTL 增加随机偏移比如 20 到 40 秒。热点 key 使用本地缓存兜底。数据库查询后做更长时间的缓存配置修改时主动删除缓存而不是依赖短 TTL。long ttl 20L ThreadLocalRandom.current().nextLong(20L); stringRedisTemplate.opsForValue().set(cacheKey, json, ttl, TimeUnit.SECONDS);6.4 多个服务器实例倒计时不一致现象不同用户看到倒计时差几秒甚至活动开始时间不同。原因应用部署多台服务器时各服务器系统时间可能不一致。接口返回serverTime用的是当前实例的LocalDateTime.now()不同实例之间存在秒级偏差。处理方式统一用 Redis 或数据库时间作为基准但 RedisTIME命令只有秒级精度页面倒计时自身就有误差。更推荐的做法是让 NTP 同步所有服务器时间并在监控里增加时间偏移指标。接口返回的serverTime必须由同一时间源生成尽量避免每台服务器各自取本地时间。如果对时间精度要求很高可以把开始时间戳和结束时间戳也返回给前端由前端自己计算偏移再用服务端轮询结果做校准。6.5 数据库连接池被慢 SQL 拖垮现象压测开始后出现大量超时日志里有Connection is not available, request timed out。检查步骤SHOW PROCESSLIST;看是否有长时间未释放的查询。确认 SQL 是否命中索引activity_id列是否有索引。检查事务范围不允许在事务内执行 Redis 网络请求。常见反例是Transactional方法里先调 Redis 再调数据库事务还没提交连接一直占用连接池迅速耗尽。优化思路是缩小事务范围先做 Redis 预扣落库时再开启短事务。7. 生产环境落地清单和扩展方向7.1 发布前检查清单上线这类直播活动页不要只看功能能跑通还要按下面清单逐项确认。检查项说明确认方式活动时间配置开始和结束时间使用数据库时间服务端动态计算状态查询接口返回 status 随当前时间变化缓存预热活动开始前 10 分钟预热 Redis 中的活动信息和剩余数量手动访问一次详情接口和领券预检接口防重验证同一个用户重复请求不会重复发放压测或接口单测限流策略对领券和轮询都配置了用户维度限流压测中观察拦截日志数据库唯一索引t_coupon_record存在(activity_id, user_id)唯一键SHOW INDEX FROM t_coupon_record;日志和监控领券结果、缓存命中率、Redis 耗时都有日志配置 Prometheus 或至少 ELK回滚方案活动出现问题时能快速下线管理端支持状态切换到 3 已下线降级方案Redis 故障时页面不能完全不可用详情接口加本地缓存兜底领券接口返回活动暂停7.2 从最小闭环到生产架构的扩展方向这个案例跑通后可以按以下方向继续深入。第一把领券链路改造为异步。Redis 预扣成功后发送 MQ 消息后台消费者写数据库并真正发放优惠券。这样领券接口只操作 Redis响应更快数据库压力也更小。代价是引入消息队列和补偿任务。第二把状态机扩展为可管理模型。增加“暂停”“已下线”状态支持管理端人工干预。状态变化时记录操作日志方便审计。第三引入分布式锁中间件。不要自己写setIfAbsent锁使用 Redisson 等成熟方案处理看门狗续期和重试问题。第四做压测和容量评估。直播活动页的容量规划可以按“在线人数 / 轮询间隔”粗算读 QPS再按“可领取人数 × 点击次数”粗算写 QPS再乘以峰值系数。压测不只在开发环境做建议在预发环境用生产流量比例压一轮观察 Redis CPU、数据库连接池、应用 GC 三项核心指标。7.3 对新手最有价值的练习建议如果只做一个小练习来验证理解建议按这个顺序先不写前端用 curl 和 JMeter 调通活动详情和领券接口。手动把数据库活动时间改成过去、当前、未来观察接口状态变化。不用 Redis只靠数据库唯一索引做防重压测看超时和死锁。引入 Redis 和 Lua 脚本后再次压测对比响应时间和错误率。最后再写前端轮询和倒计时重点验证活动开始和结束两个边界瞬间的页面切换。这组练习的价值在于先理解状态和时间的关系再理解防重为什么必须在服务端完成最后理解缓存和并发控制如何配合。多数直播活动页的问题本质都在这些环节里。
返回列表