从单体扛百万并发:我在社交打赏系统中的微观优化实战

作者:旷野说
定位:Spring Boot 开发者速查手册 + 高并发系统设计认知指南
关键澄清:单体应用并非高并发的“原罪”,真正的瓶颈常在于“无状态化缺失 + 数据一致性粗放 + 异常处理失控”。优化应始于微观,而非盲目拆微服务。


很多人一提“高并发”,就立刻想到“上微服务”“上 Kubernetes”“上 Service Mesh”。
但现实是:我们“C 端”的核心打赏系统,在日活千万级时,仍是单体架构

为什么?因为过早拆分,只会把简单问题复杂化
真正的高并发优化,应始于微观层面的精准控制

  • 如何用 Redis + Lua 保证“扣库存 + 防重复”原子性?
  • 如何让落库刷盘不成为瓶颈?
  • 如何通过 Java 异常体系与 Servlet 容器选型,守住系统稳定性?

今天,我就以“用户打赏心动钻石”这个高频交易场景为例,带你看单体应用如何通过微观优化,扛住百万级并发


一、问题起点:单体应用的“高并发幻觉”

初期,我们的打赏逻辑很简单:

@Transactional
public void sendGift(Long senderId, Long receiverId, int diamond) {
    User sender = userMapper.selectById(senderId);
    if (sender.getBalance() < diamond) throw new InsufficientBalanceException();
    
    sender.setBalance(sender.getBalance() - diamond);
    userMapper.updateById(sender);
    
    // 记录打赏 + 发通知
    giftRecordMapper.insert(...);
    notificationService.send(...);
}

表面看没问题,实则埋雷

  • 每次打赏都查 DB 用户余额 → 高 QPS 下 DB 打满
  • 无防重机制 → 网络重试导致重复扣款
  • 通知失败 → 整个事务回滚,用户以为没打赏成功

结果:QPS 超 500 就开始超时,资损投诉不断


二、微观第一招:用 Redis + Lua 实现“业务完整性原子化”

我们意识到:高频交易的核心状态(如余额、库存)必须脱离 DB,前置到缓存

但普通 Redis 操作不行——“查余额 → 判断 → 扣减”非原子,高并发下必然超扣。

解法:Lua 脚本 + Redis 原子执行

-- KEYS[1] = user_balance_key, ARGV[1] = user_id, ARGV[2] = amount
local balance = tonumber(redis.call('HGET', KEYS[1], ARGV[1]))
if not balance or balance < tonumber(ARGV[2]) then
    return 0  -- 余额不足
end
redis.call('HINCRBY', KEYS[1], ARGV[1], -tonumber(ARGV[2]))
redis.call('SADD', 'gift_claimed:' .. ARGV[3], ARGV[1]) -- 防重 Set
return 1

在 Java 中调用:

public boolean tryDeductBalance(Long userId, int amount, String giftId) {
    String luaScript = loadLua("deduct_balance.lua");
    Long result = redisTemplate.execute(
        new DefaultRedisScript<>(luaScript, Long.class),
        Collections.singletonList("user:balance"),
        userId.toString(), String.valueOf(amount), giftId
    );
    return result == 1;
}

效果

  • 原子性:扣余额 + 记录防重,一步完成
  • 高性能:Redis 单线程执行,10 万 QPS 毫秒响应
  • 防重:基于 giftId + userId 的 Set,天然去重

💡 注意:Lua 脚本必须无状态、无外部依赖,否则会阻塞 Redis 单线程。


三、微观第二招:异步落库刷盘,解耦核心路径

用户通过 Lua 预扣后,我们立即返回“打赏成功”,但此时 DB 还未更新。

关键设计落库不是同步操作,而是异步事件

public ResponseEntity<?> sendGiftAsync(GiftRequest req) {
    if (!giftService.tryDeductBalance(req.getSenderId(), req.getAmount(), req.getGiftId())) {
        return ResponseEntity.badRequest().body("余额不足或重复请求");
    }
    // 发送异步事件,100ms 内落库
    mqProducer.send(new GiftEvent(req));
    return ResponseEntity.ok("打赏成功");
}

消费者端

@KafkaListener(topics = "gift-event")
public void handleGiftEvent(ConsumerRecord<String, String> record) {
    try {
        GiftEvent event = parse(record.value());
        // 直接插入,靠唯一索引兜底
        giftRecordMapper.insertIgnoreDuplicate(event);
        userBalanceLogMapper.insert(event); // 用于对账
    } catch (Exception e) {
        // 记录失败消息,供对账补偿
        failedEventStore.save(record);
    }
}

优势

  • 核心路径无 DB I/O,响应 < 50ms
  • DB 压力降低 90%,连接池不再打满
  • 失败可追溯,不丢失任何一笔交易

四、微观第三招:对账兜底,构建最终一致性

即使 Redis + MQ 再可靠,分布式系统永远有极端故障(如 Redis 主从切换丢数据)。

解法:每日对账 + 实时补偿

// 对账任务:比对 Redis 扣减日志 vs DB 落库记录
@Scheduled(cron = "0 0 2 * * ?")
public void reconcileGiftClaims() {
    List<RedisDeductLog> redisLogs = redisLogService.getRecentLogs();
    List<GiftRecord> dbRecords = giftRecordMapper.getRecordsSince(...);
    
    Set<String> redisKeys = redisLogs.stream().map(l -> l.getTxId()).collect(toSet());
    Set<String> dbKeys = dbRecords.stream().map(r -> r.getTxId()).collect(toSet());
    
    // Redis 有,DB 无 → 补发
    redisKeys.removeAll(dbKeys);
    if (!redisKeys.isEmpty()) {
        compensationService.replayEvents(redisKeys);
    }
}

经验:对账不是“可有可无”,而是高并发交易系统的最后一道保险


五、微观第四招:Java 异常与 Servlet 容器选型——稳住最后一道防线

很多人忽略:高并发下,异常处理不当会直接拖垮线程池

1. 异常分层设计
  • 业务异常(如余额不足)→ 返回 400,不打印 error 日志
  • 系统异常(如 DB 连接超时)→ 返回 500,触发告警
  • 可恢复异常(如 MQ 重试)→ 捕获后入死信队列,不抛出
@ControllerAdvice
public class GlobalExceptionHandler {
    @ExceptionHandler(InsufficientBalanceException.class)
    public ResponseEntity<?> handleBizEx(InsufficientBalanceException e) {
        return ResponseEntity.status(400).body(e.getMessage());
    }
    
    @ExceptionHandler(Exception.class)
    public ResponseEntity<?> handleSysEx(Exception e) {
        log.error("System error", e); // 告警
        return ResponseEntity.status(500).body("系统繁忙");
    }
}
2. Servlet 容器选型:Undertow vs Tomcat
  • Tomcat:兼容性好,但 NIO 模型在高并发下线程开销大
  • Undertow:基于 XNIO,非阻塞 I/O,内存占用低 30%,吞吐高 20%

我们在 Spring Boot 中切换:

# application.properties
server.servlet.container=undertow
server.undertow.io-threads=16
server.undertow.worker-threads=200

效果:单机 QPS 从 1200 提升到 1800,GC 频率下降 40%。


单体高并发优化口诀(我的实战总结)

Lua 原子保正确,
异步落库解耦合;
对账兜底守底线,
异常分层稳如山;
Undertow 轻上阵,
单体也能扛百万。


结语:优化始于微观,成于体系

我们没有一上来就拆微服务,而是在单体内部构建了一套“高内聚、低耦合、强可观测”的交易子系统
结果:单机扛住 2000+ QPS,资损率连续 18 个月为 0

高并发不是架构的胜利,而是微观细节的胜利
当你能把 Redis Lua、异步落库、对账补偿、异常治理、容器调优做到极致,
你会发现:单体,也可以很强大


技术是手段,稳定是目的。
在你急着“拆”之前,先问问自己:“我是否已经榨干了单体的每一滴性能?”

欢迎在评论区交流你的高并发优化经验!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值