第一章:高并发抽奖系统的设计哲学与Go语言选型
高并发抽奖系统本质上是“确定性逻辑”与“不确定性体验”的精密耦合体——用户感知的是瞬时中奖的惊喜,系统承载的却是毫秒级锁竞争、库存强一致、防刷限流与最终一致性的多重张力。设计哲学的核心,在于主动放弃“全局强一致”的幻觉,转而构建分层一致性模型:抽奖资格校验走本地缓存+布隆过滤器预筛,中奖结果生成依赖无状态原子操作,奖品发放则通过异步可靠消息+幂等落库实现最终一致。
Go语言成为该场景的天然选择,源于其运行时与工程特性的深度契合:
- 轻量级Goroutine支持十万级并发连接,协程调度开销远低于线程;
- 内置Channel与
sync/atomic提供简洁高效的并发原语,避免复杂锁嵌套; - 静态编译产出单二进制文件,完美适配容器化快速扩缩容;
- 丰富的标准库(如
net/http,sync.Map,time/ticker)覆盖高并发基础需求。
以下是一个典型的抽奖资格预检代码片段,融合了本地缓存与布隆过滤器双重防护:
// 初始化布隆过滤器(使用github.com/bits-and-blooms/bloom/v3)
var bloomFilter *bloom.BloomFilter
func init() {
bloomFilter = bloom.NewWithEstimates(100000, 0.01) // 预估10万用户,误判率1%
}
// 检查用户是否已参与过当前活动(本地缓存+布隆过滤器双校验)
func canParticipate(userID string, activityID string) bool {
cacheKey := fmt.Sprintf("participated:%s:%s", activityID, userID)
if _, ok := localCache.Get(cacheKey); ok { // 本地LRU缓存命中
return false
}
if bloomFilter.Test([]byte(userID)) { // 布隆过滤器标记为“可能存在”
return false // 拒绝请求,避免穿透DB
}
// 未命中则放行至后续DB校验环节(此处省略)
return true
}
关键设计取舍包括:
- 放弃Redis分布式锁,改用分片号哈希路由至固定Worker节点,消除跨节点协调开销;
- 奖池采用“预分片+动态权重”机制,将千万级奖品按Hash分128个逻辑桶,每个桶独立计数;
- 所有写操作封装为幂等接口,请求携带唯一traceID,失败可安全重试。
| 维度 | 传统Java方案 | Go语言实践 |
|---|---|---|
| 单机QPS峰值 | ~3k(JVM GC压力显著) | ~15k(协程复用+无GC热点) |
| 部署包体积 | 数百MB(含JRE) | |
| 故障定位耗时 | 线程栈深、GC日志繁杂 | pprof火焰图直击goroutine阻塞点 |
第二章:手机号校验与唯一性保障的工程实践
2.1 手机号正则校验与国际区号兼容性处理
核心挑战:从国内到全球的格式跃迁
传统 ^1[3-9]\d{9}$ 仅覆盖中国大陆,无法识别 +86 138 1234 5678、+44 7700 900123 或 +81-3-1234-5678 等合法国际格式。
兼容性正则设计
^\+[1-9]\d{1,14}\s*(?:[-.\s]?\d+)*$
^\+:强制以+开头,明确国际标识;[1-9]\d{1,14}:匹配 1–15 位国家/地区码(ITU-T E.164 最大长度);\s*(?:[-.\s]?\d+)*:宽松支持空格、短横、点分隔的号码段,兼顾可读性与鲁棒性。
常见国际号码结构对照
| 区号 | 示例格式 | 位数(不含+) |
|---|---|---|
| +86 | +86 13912345678 | 13 |
| +1 | +1-212-555-0199 | 11 |
| +44 | +44 7700 900123 | 11 |
校验流程(简化版)
graph TD
A[输入字符串] --> B{是否以+开头?}
B -->|否| C[拒绝:非E.164格式]
B -->|是| D[提取+后数字序列]
D --> E[验证总长≤15且首非0]
E -->|通过| F[标准化:移除分隔符,保留纯数字]
2.2 基于Redis布隆过滤器的千万级去重预检
在高并发写入场景下,直接查库去重成为性能瓶颈。引入 Redis + 布隆过滤器(Bloom Filter)实现毫秒级存在性预检,可拦截 99%+ 的重复请求。
核心优势对比
| 方案 | 时间复杂度 | 内存占用 | 误判率 | 支持删除 |
|---|---|---|---|---|
| MySQL唯一索引 | O(log n) | 高 | 0% | ✅ |
| Redis Set | O(1) | 极高 | 0% | ✅ |
| Redis Bloom Filter | O(k) | 极低 | 可控(~0.1%) | ❌ |
初始化布隆过滤器(RedisBloom模块)
# 创建容量1000万、误差率0.001的布隆过滤器
BF.RESERVE user_id_bf 0.001 10000000
逻辑说明:
0.001控制误判率上限;10000000是预期元素总量,影响哈希函数数量(k≈7)与位数组长度(≈141MB)。实际内存远低于等量Set(约节省95%)。
预检流程
# Python伪代码(redis-py + redisbloom)
exists = client.bf().exists("user_id_bf", "uid_123456")
if not exists:
client.bf().add("user_id_bf", "uid_123456") # 异步/延迟双写保障一致性
# 继续落库
exists为O(1)无状态查询;add幂等且线程安全。注意:布隆过滤器不可删除,需配合TTL或定期重建应对数据漂移。
graph TD A[请求到达] –> B{BF.EXISTS?} B — Yes –> C[拒绝重复] B — No –> D[BF.ADD → DB写入] D –> E[异步刷新BF缓存]
2.3 并发场景下手机号写入DB前的CAS原子校验
在高并发注册/绑定场景中,多个请求可能同时尝试写入同一手机号,需避免重复插入或覆盖。传统 SELECT + INSERT/UPDATE 存在竞态窗口,必须升级为原子校验。
核心思路:以数据库为协调者,利用唯一索引 + CAS式更新语义
-- 假设 users 表含唯一索引 idx_mobile (mobile)
UPDATE users
SET mobile = ?, updated_at = NOW(), version = version + 1
WHERE mobile = ? AND version = ?;
-- 返回影响行数,=1 表示校验通过且已更新
逻辑分析:该语句将“校验旧值(version)+ 写入新值”合并为单条原子操作。
version字段作为乐观锁版本号,确保仅当当前记录未被其他线程修改时才执行更新。参数说明:?依次为新手机号、待校验的旧手机号(空值或占位符)、期望的旧 version 值。
典型校验流程(mermaid)
graph TD
A[请求携带手机号与期望version] --> B{DB执行UPDATE WHERE mobile=? AND version=?}
B -->|影响行数=1| C[写入成功]
B -->|影响行数=0| D[校验失败:手机号已被占用或version不匹配]
常见校验策略对比
| 策略 | 是否原子 | 数据库依赖 | 适用场景 |
|---|---|---|---|
| 唯一索引 + INSERT IGNORE | 是 | 强 | 纯新增,不支持幂等更新 |
| SELECT FOR UPDATE | 是 | 强(需事务) | 低QPS,长事务风险高 |
| CAS式UPDATE + version | 是 | 中(仅需普通索引) | 高并发、读多写少、需幂等更新 |
2.4 分库分表后手机号全局唯一索引的设计陷阱与绕行方案
分库分表后,phone 字段无法直接建全局唯一索引,主键分散导致跨库约束失效。
常见误用:冗余唯一索引
在各分片独立建 UNIQUE(phone) —— 表面无误,实则允许相同手机号写入不同库。
推荐方案:逻辑唯一校验层
-- 全局唯一校验表(单库单表)
CREATE TABLE phone_unique_registry (
phone_hash BIGINT NOT NULL COMMENT 'xxHash64(phone)',
phone VARCHAR(11) NOT NULL,
shard_id TINYINT NOT NULL COMMENT '归属分片ID',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (phone_hash),
UNIQUE KEY uk_phone (phone)
) ENGINE=InnoDB;
phone_hash作为分布式主键避免热点;uk_phone保障最终一致性。写入前先INSERT IGNORE校验,失败则拒绝。
数据同步机制
| 组件 | 职责 |
|---|---|
| 写入口服务 | 计算 hash、预占 registry |
| Binlog监听器 | 异步落库,补偿校验 |
graph TD
A[应用请求] --> B{查 registry}
B -- 存在 --> C[拒绝注册]
B -- 不存在 --> D[INSERT IGNORE]
D -- 成功 --> E[写入业务分片]
D -- 失败 --> C
2.5 单元测试覆盖手机号边界用例(空格、+86、0086、带短横等)
手机号校验逻辑常因国际前缀与格式干扰而失效。需重点验证常见非法/边缘输入是否被正确归一化或拒绝。
常见边界输入模式
"+86 138-1234-5678"(含空格与短横)"008613812345678"(双零前缀)" 13812345678 "(首尾空格)"+8613812345678"(标准国际格式)
标准化处理代码示例
public static String normalizePhone(String raw) {
if (raw == null) return null;
String cleaned = raw.replaceAll("[^\\d+]", ""); // 移除非数字及+号
if (cleaned.startsWith("+86")) return cleaned.substring(3);
if (cleaned.startsWith("0086")) return cleaned.substring(4);
return cleaned; // 纯11位数字直接返回
}
逻辑说明:先清洗非关键字符,再按前缀规则截断;
+86优先于0086,避免误判;返回值为纯数字字符串,供后续长度校验。
测试用例覆盖对照表
| 输入样例 | 期望输出 | 是否通过 |
|---|---|---|
"+86 138-1234-5678" |
"13812345678" |
✅ |
"008613812345678" |
"13812345678" |
✅ |
" 13812345678 " |
"13812345678" |
✅ |
graph TD A[原始输入] –> B{是否为空?} B –>|是| C[返回null] B –>|否| D[正则清洗] D –> E{以+86开头?} E –>|是| F[截取后11位] E –>|否| G{以0086开头?} G –>|是| F G –>|否| H[直接返回清洗结果]
第三章:抽奖核心逻辑中的状态一致性难题
3.1 抽奖资格判定与库存扣减的非幂等操作拆解
抽奖资格判定与库存扣减天然耦合,但二者语义与失败容忍度截然不同:前者可重试(读多写少),后者必须严格单次执行(写即生效)。
核心矛盾点
- 资格校验依赖用户状态、活动规则、频次限制等可重入数据
- 库存扣减是原子性写操作,重复执行将导致超卖
拆解策略
- 将“查资格 + 扣库存”原子事务拆为两阶段:
- 预占资格(幂等标识生成 + 状态快照)
- 异步扣减(基于唯一业务ID + 版本号强校验)
// 非幂等扣减核心逻辑(需配合唯一 requestId + version)
boolean tryDeduct(String skuId, String requestId, long expectedVersion) {
return jdbcTemplate.update(
"UPDATE lottery_stock SET stock = stock - 1, version = version + 1 " +
"WHERE sku_id = ? AND version = ? AND stock > 0",
skuId, expectedVersion) == 1; // 返回影响行数,确保仅一次成功
}
requestId防重放;expectedVersion保证CAS一致性;返回值==1是幂等性的最终判据。
| 组件 | 幂等性要求 | 典型失败后果 |
|---|---|---|
| 资格判定 | 支持重试 | 误拒(体验降级) |
| 库存扣减 | 严格单次 | 超卖(资损) |
graph TD
A[用户请求] --> B{资格校验}
B -->|通过| C[生成唯一 requestId]
C --> D[异步消息触发扣减]
D --> E[DB CAS 更新 stock & version]
E -->|成功| F[发放奖品]
E -->|失败| G[告警+人工介入]
3.2 使用乐观锁实现“查-判-扣”三步原子化(附Go sync/atomic实战)
数据同步机制
在高并发库存扣减场景中,“查余额→判断是否充足→扣减”三步天然存在竞态。传统互斥锁(sync.Mutex)虽安全但吞吐受限;乐观锁以“无锁重试”换取更高并发性能。
核心实现逻辑
使用 sync/atomic 对整型状态做 CAS(Compare-And-Swap)操作,避免锁开销:
// balance 是 int64 类型的原子变量,代表当前可用库存
func TryDeduct(balance *int64, amount int64) bool {
for {
old := atomic.LoadInt64(balance)
if old < amount {
return false // 余额不足,直接失败
}
// 原子比较并更新:仅当当前值仍为 old 时才设为 old - amount
if atomic.CompareAndSwapInt64(balance, old, old-amount) {
return true
}
// CAS 失败:说明其他 goroutine 已修改,重试
}
}
逻辑分析:
atomic.LoadInt64获取快照值;CompareAndSwapInt64保证“读-判-写”三步在硬件指令级原子执行。参数balance为指针,old是预期旧值,old-amount是新目标值。失败即重试,无阻塞。
乐观锁 vs 悲观锁对比
| 维度 | 乐观锁(CAS) | 悲观锁(Mutex) |
|---|---|---|
| 并发性能 | 高(无锁等待) | 中低(争抢锁开销) |
| 冲突处理成本 | 重试(CPU空转) | 阻塞+上下文切换 |
| 适用场景 | 冲突率低、计算轻量 | 冲突频繁或需临界区复杂逻辑 |
graph TD
A[开始] --> B[原子读取当前余额]
B --> C{余额 ≥ 扣减量?}
C -->|否| D[返回失败]
C -->|是| E[CAS 更新:old → old-amount]
E --> F{CAS 成功?}
F -->|是| G[完成扣减]
F -->|否| B
3.3 Redis Lua脚本兜底:避免网络分区导致的超发漏洞
当分布式系统遭遇网络分区,客户端可能同时向多个 Redis 实例发起扣减请求,导致库存超卖。Lua 脚本在 Redis 单线程中原子执行,是关键兜底手段。
原子扣减 Lua 脚本示例
-- KEYS[1]: 库存 key;ARGV[1]: 扣减数量;ARGV[2]: 当前版本号(用于乐观锁)
if redis.call("GET", KEYS[1]) == ARGV[2] then
local current = tonumber(redis.call("GET", KEYS[1]))
if current >= tonumber(ARGV[1]) then
return redis.call("DECRBY", KEYS[1], ARGV[1])
end
end
return -1 -- 失败标识
该脚本确保“读-判-写”不可分割;KEYS[1] 必须显式传入以满足 Redis 集群路由要求;ARGV[2] 提供版本校验,防御并发覆盖。
兜底策略对比
| 方式 | 原子性 | 网络分区鲁棒性 | 实现复杂度 |
|---|---|---|---|
| SETNX + TTL | ❌ | 弱 | 低 |
| WATCH + MULTI | ❌(事务中断即失败) | 中 | 中 |
| Lua 脚本 | ✅ | 强 | 中高 |
执行流程
graph TD
A[客户端发起扣减] --> B{Lua脚本加载至Redis}
B --> C[Redis单线程串行执行]
C --> D[返回结果码]
D -->|≥0| E[成功]
D -->|-1| F[重试或降级]
第四章:高并发下的数据持久化与事务协同策略
4.1 MySQL SELECT FOR UPDATE在抽奖链路中的死锁规避与索引优化
抽奖系统中高并发扣减库存常因 SELECT FOR UPDATE 争抢行锁引发死锁。核心在于锁粒度控制与执行顺序确定性。
死锁成因示例
-- 用户A执行(按user_id索引扫描)
SELECT * FROM lottery_draw WHERE prize_id = 1001 AND status = 'available' ORDER BY id LIMIT 1 FOR UPDATE;
-- 用户B同时执行(相同prize_id,但可能命中不同行)
SELECT * FROM lottery_draw WHERE prize_id = 1001 AND status = 'available' ORDER BY id DESC LIMIT 1 FOR UPDATE;
⚠️ 分析:
ORDER BY id与ORDER BY id DESC导致加锁顺序相反;若两事务分别持锁再争对方锁,即构成循环等待。status字段无索引时触发全表扫描,扩大锁范围。
索引优化关键项
| 字段 | 是否必须索引 | 原因 |
|---|---|---|
prize_id |
✅ | WHERE 首要过滤条件 |
status |
✅ | 联合索引前导列需覆盖 |
id |
✅(含排序) | ORDER BY id 需避免filesort |
推荐联合索引
ALTER TABLE lottery_draw ADD INDEX idx_prize_status_id (prize_id, status, id);
✅ 该索引使查询走
range扫描,精确锁定目标行,消除排序开销,确保所有事务按id升序加锁,从根源阻断死锁路径。
graph TD
A[请求进入] --> B{按prize_id+status查可用奖品}
B --> C[走idx_prize_status_id索引]
C --> D[仅锁匹配的最小id行]
D --> E[串行化扣减与更新]
4.2 Go语言中database/sql连接池配置与超时传播的最佳实践
连接池核心参数调优
sql.DB 的连接池行为由三个关键字段控制:
| 参数 | 默认值 | 推荐生产值 | 说明 |
|---|---|---|---|
SetMaxOpenConns |
0(无限制) | 50–100 | 最大打开连接数,避免数据库过载 |
SetMaxIdleConns |
2 | 20–50 | 空闲连接上限,提升复用率 |
SetConnMaxLifetime |
0(永不过期) | 30m | 强制连接定期轮换,规避长连接僵死 |
超时的三层传播机制
Go 的 database/sql 将上下文超时自动下推至:
- 驱动层连接建立(
net.DialContext) - 查询执行(如
Stmt.QueryContext) - 连接获取(从池中取连接时阻塞超时)
db, _ := sql.Open("mysql", dsn)
db.SetMaxOpenConns(50)
db.SetMaxIdleConns(25)
db.SetConnMaxLifetime(30 * time.Minute)
// 上下文超时直接控制整个操作生命周期
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
rows, err := db.QueryContext(ctx, "SELECT id FROM users WHERE active = ?")
此代码中,
5s超时会同时约束:连接获取等待、TCP建连、SQL执行。若连接池空闲不足且MaxOpenConns已满,QueryContext将在获取连接阶段即返回context deadline exceeded错误。
连接健康检查流程
graph TD
A[调用 QueryContext] --> B{池中有空闲连接?}
B -- 是 --> C[校验 ConnMaxLifetime]
B -- 否 --> D[尝试新建连接]
C --> E[连接是否过期?]
E -- 是 --> F[关闭旧连接,新建]
E -- 否 --> G[执行查询]
D --> H[应用 DialContext 超时]
4.3 异步落库+本地消息表模式保障抽奖结果最终一致性
核心设计思想
将抽奖核心逻辑(如中奖判定)与持久化解耦,先写入业务库和本地消息表(同一事务),再由独立消费者异步投递至下游系统(如发奖服务)。
数据同步机制
本地消息表结构示例:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | BIGINT PK | 主键 |
| biz_type | VARCHAR(32) | LOTTERY_RESULT |
| biz_id | VARCHAR(64) | 抽奖流水号 |
| payload | TEXT | JSON序列化的中奖详情 |
| status | TINYINT | 0-待发送,1-已发送,2-发送失败 |
| created_at | DATETIME | 插入时间 |
关键代码片段
@Transactional
public void recordWinningResult(LotteryResult result) {
// 1. 写入主业务表(如 lottery_record)
lotteryRecordMapper.insert(result.getRecord());
// 2. 同一事务内写入本地消息表
LocalMessage msg = new LocalMessage()
.setBizType("LOTTERY_RESULT")
.setBizId(result.getTraceId())
.setPayload(JSON.toJSONString(result))
.setStatus(0); // 初始为待发送
localMessageMapper.insert(msg);
}
逻辑分析:利用数据库事务原子性,确保业务状态与消息记录强一致;status=0标识待消费,避免重复投递;biz_id作为幂等键供下游校验。
消费流程
graph TD
A[定时扫描 status=0 消息] --> B{调用发奖接口}
B -->|成功| C[UPDATE status=1]
B -->|失败| D[UPDATE status=2, 重试计数+1]
D --> E[达到阈值→告警人工介入]
4.4 基于gRPC流式响应的实时中奖通知与防刷限流联动
流式通知与限流协同设计
当用户参与抽奖时,服务端通过 ServerStreaming RPC 持续推送中奖状态,同时将请求指纹(userID + timestamp % 60s)实时写入滑动窗口限流器(如 Redis ZSET),实现毫秒级防刷决策。
核心限流策略对照表
| 维度 | 基础限流(QPS) | 行为级限流(抽奖事件) |
|---|---|---|
| 窗口粒度 | 1秒 | 60秒滑动窗口 |
| 触发阈值 | 100 req/s | 5次/分钟 |
| 动态降级 | 拒绝新请求 | 降级为“概率归零”响应 |
gRPC 流式响应片段(带限流钩子)
// lottery.proto
service LotteryService {
rpc StreamDraw(StreamDrawRequest) returns (stream DrawResponse);
}
func (s *LotteryServer) StreamDraw(req *pb.StreamDrawRequest, stream pb.LotteryService_StreamDrawServer) error {
fingerprint := fmt.Sprintf("%s:%d", req.UserId, time.Now().Unix()/60)
// ✅ 原子性校验+自增:Redis EVAL Lua 脚本保障并发安全
count, err := s.rateLimiter.IncrWindow(fingerprint, 60) // 参数:指纹、窗口秒数
if err != nil || count > 5 {
return status.Error(codes.ResourceExhausted, "rate limit exceeded")
}
// 后续触发异步抽奖并流式推送结果
return s.emitStream(stream, req.UserId)
}
逻辑分析:
IncrWindow调用封装了 RedisZREMRANGEBYSCORE + ZCARD + ZADD原子操作;60为滑动窗口长度(秒),确保每分钟最多5次抽奖行为,而非简单固定窗口,有效抵御突发刷量。
第五章:从踩坑到稳定:生产环境全链路压测与观测体系
一次订单超时事故的复盘
2023年双十一大促前夜,某电商核心链路在预压测中未暴露问题,但真实流量涌入后,订单创建接口平均响应时间从120ms飙升至2.8s,错误率突破17%。事后定位发现:压测流量未携带用户地理位置标签,导致CDN缓存未命中,所有请求穿透至下游LBS服务;而该服务依赖的Redis集群因未开启连接池预热,在瞬时3000+连接建立时触发TCP TIME_WAIT堆积,最终引发级联超时。
压测流量染色与链路隔离
我们采用OpenTelemetry SDK在入口网关注入x-trace-env: stress-prod标头,配合Istio Sidecar实现流量路由策略:
- match:
- headers:
x-trace-env:
exact: "stress-prod"
route:
- destination:
host: order-service-stress
subset: v2
同时将压测数据库切换至独立物理实例,并通过MySQL Proxy拦截所有INSERT INTO t_order语句,自动追加/* stress-run-20241105 */注释,便于审计与熔断识别。
全链路黄金指标看板
| 指标类型 | 关键指标 | 阈值告警线 | 数据来源 |
|---|---|---|---|
| 基础设施 | Node CPU Load > 0.95 | 红色 | Prometheus + Node Exporter |
| 中间件 | Kafka Lag > 50000 | 橙色 | Burrow + Kafka Exporter |
| 应用层 | HTTP 5xx Rate > 0.5% | 红色 | Grafana Loki日志聚合 |
| 业务维度 | 支付成功率 | 红色 | Flink实时计算作业 |
观测数据闭环验证机制
当压测期间触发任意阈值告警,系统自动执行以下动作:
- 调用Jaeger API获取最近10分钟所有
/api/v1/order/submit调用的TraceID; - 通过Elasticsearch查询对应TraceID的完整Span日志,提取
db.statement与rpc.service字段; - 将异常Span关联的SQL与RPC服务名输入预置规则引擎(Drools),匹配“慢SQL未走索引”或“第三方API响应>3s”等模式;
- 若匹配成功,自动向企业微信机器人推送含火焰图链接的诊断报告。
生产压测的灰度演进路径
初始阶段仅对非核心链路(如商品浏览)开放1%真实流量压测;第二阶段引入ShadowDB技术,将写操作同步至影子库并比对主从数据一致性;第三阶段上线“熔断快照”能力——当CPU使用率连续5分钟>85%,自动保存JVM堆转储、线程栈及Netstat连接状态至S3归档桶,供离线分析使用。
flowchart LR
A[压测流量注入] --> B{是否携带stress-prod标头?}
B -->|是| C[路由至stress-service集群]
B -->|否| D[走正常生产流量]
C --> E[数据库写入ShadowDB]
C --> F[日志打标stress-run-*]
E --> G[Binlog对比工具校验数据一致性]
F --> H[Loki按标头聚合延迟P99]
根因定位效率提升实测数据
在2024年618大促保障中,全链路压测覆盖订单、库存、优惠券三大核心域,共模拟峰值QPS 12800。相比去年,P1级故障平均定位时间从47分钟缩短至8.3分钟,其中76%的根因直接由观测看板中的“依赖服务RT突增TOP5”模块准确定位,无需人工翻查日志。压测期间发现并修复了3个隐藏缺陷:优惠券核销服务未设置Hystrix fallback、库存扣减Redis Lua脚本缺少原子性校验、ES搜索服务分片数配置低于节点数导致查询倾斜。
