嘿,兄弟们好,我是飞哥,临近过年没事,再来唠唠我做过的票务系统。
在票务这行,库存就是命脉。 "超卖" (Over-selling)让你赔钱丢名声; "少卖"(Under-selling)让老板觉得你技术不行,票明明有却卖不出去。
今天飞哥就结合这几年在票务系统摸爬滚打的经验,跟大家好好唠唠这里面的深水区。
1. 为什么"超卖"和"少卖"是系统的生死劫?
很多兄弟初学并发,觉得写个 synchronized 或是 ReentrantLock 就能高枕无忧了。但在分布式架构下,这就像是用塑料袋去兜洪水。
- 超卖: 就像 10 个人同时挤进一个窄门,大家看到货架上还有最后一张票,结果 10 个人都下单成功了。
- 少卖: 又叫"库存空转"。用户抢了票占了座,结果不付钱。你把票锁死了,别人买不到,最后演出开始了,座位还空着,白白浪费钱。
2. 三个段位的防御战:从行锁到 Lua 脚本
咱们票务系统处理库存,通常会经历三个阶段。我做了个对比表,大家对号入座:
| 方案 | 技术手段 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 青铜 | DB 行锁 (UPDATE...WHERE stock > 0) |
绝对一致,简单粗暴 | 并发一高数据库直接宕机 | 内部员工购票、小场次 |
| 白银 | 分布式锁 (Redisson) | 逻辑清晰,保护 DB | 锁竞争剧烈,响应时间长 | 中等流量促销 |
| 黄金 | Redis + Lua 脚本 | 原子操作,极高性能 | 逻辑略复杂,需考虑一致性 | 大促、万人抢票(首选) |
3. 看家本领:Redis + Lua 丝滑扣减
在抢票这种瞬时爆发场景,我们通常把库存预热到 Redis 里。
为什么一定要用 Lua?因为 Redis 执行 Lua 脚本是原子性的。它能保证"查询库存 -> 判断余量 -> 扣减库存"这三步,像德芙一样丝滑,中间不会被任何请求插队。
Java 核心逻辑参考:
java
// Lua 脚本:原子扣减
String luaScript =
"local stock = tonumber(redis.call('get', KEYS[1])) " +
"if (stock > 0) then " +
"redis.call('decr', KEYS[1]) " +
"return 1 " + // 扣减成功
"else " +
"return 0 " + // 库存不足
"end";
// 执行扣减
Long result = redisTemplate.execute(
new DefaultRedisScript<>(luaScript, Long.class),
Collections.singletonList("show_101_stock")
);
if (result == 1) {
// 抢到预扣名额,赶紧去异步创建订单
sendOrderMessage(userId, showId);
} else {
throw new BusinessException("票已售罄,下次早点来!");
}
4. 别让"占座不买票"拖垮你:延时回滚策略
超卖防住了,那"少卖"怎么办?票务系统最怕用户抢了票不付钱。
我们的标准打法是:"预扣库存 + 延迟检查"。请看这张流程图:
敲黑板: 回滚库存时一定要注意幂等性。别因为网络抖动回滚了两次,那库存就凭空变多了,成了"灵异事件"。
5. 飞哥的血泪复盘:缓存和 DB 的"信任游戏"
记得刚入行那会儿,我有次只做了 Redis 扣减,没做后台对账。结果 Redis 意外宕机,重启后虽然有持久化,但还是丢了几个计数。
那天晚上,DB 里的订单票数和 Redis 里的库存数对不上,差了十几张。别小看这十几张票,那是几十通投诉电话和客服小姐姐的眼泪。
反思: 缓存只是冲锋队的盾牌,数据库才是最后的防线。 现在我们的系统都会跑一个异步对账程序,每隔几分钟对一次账。如果发现 Redis 里的数和 DB 差异过大,立马报警并人工介入。
最后
很多人觉得搞定高并发就是堆机器、用牛逼的中间件。
其实干了这么多年票务,飞哥最深的感触是:技术方案没有完美的,只有最合适的。 你能防住 99.99% 的异常,剩下的 0.01% 靠的是完善的监控和快速响应的" plan B "。
写代码时多想一步"要是挂了怎么办",你的系统就能比别人稳一倍。
更新好文,可关注公众号《码上实战》