Administrator
Administrator
发布于 2026-03-16 / 1 阅读
0
0

拼团库存扣减QPS测试

package org.example.test.qps;
​
import lombok.extern.slf4j.Slf4j;
import org.example.infrastructure.redis.RedissonService;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.redisson.api.RLock;
import org.redisson.api.RScript;
import org.redisson.api.RedissonClient;
import org.redisson.client.codec.StringCodec;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;
​
import javax.annotation.Resource;
import java.util.Collections;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
​
/**
 * occupyTeamStock 三种方案 QPS 对比测试
 *
 * 方案一:无锁化 incr —— 3次 Redis RTT,含恢复量兜底(与生产代码一致)
 * 方案二:Lua 脚本   —— 1次 Redis RTT,脚本内部原子执行,基础库存扣减
 * 方案三:分布式锁   —— 5次 Redis RTT,全程串行化,无锁释放 Bug
 */
@Slf4j
@RunWith(SpringRunner.class)
@SpringBootTest
public class OccupyTeamStockQpsTest {
​
    @Resource
    private RedissonService redissonService;
​
    // Lua 脚本需要直接操作 RScript,RedissonService 没有封装该接口,直接注入 Client
    @Resource
    private RedissonClient redissonClient;
​
    // -------- 公共参数 --------
    private static final String STOCK_KEY      = "qps_test_team_stock";
    private static final String RECOVERY_KEY   = "qps_test_team_stock_recovery"; // 仅方案一使用
    private static final String LUA_STOCK_KEY  = "qps_test_team_stock_lua";      // 仅方案二使用(扣减模式)
​
    private static final int    TARGET         = 100_000; // 足够大,避免全部超卖挡住性能测量
    private static final int    VALID_TIME     = 3600;    // 秒
    private static final int    THREAD_COUNT   = 200;     // 并发线程数
    private static final int    TOTAL_REQUESTS = 10_000;  // 总请求次数
​
    @Before
    public void resetKeys() {
        // 每个 @Test 独立清空,保证数据互不干扰
        redissonService.remove(STOCK_KEY);
        redissonService.remove(RECOVERY_KEY);
        redissonService.remove("occupy_lock_" + STOCK_KEY);
​
        // 方案二 Lua 采用“扣减逻辑”,需预先放入总库存
        // 使用 StringCodec 确保 Lua 脚本中 tonumber 可以正常解析
        redissonClient.getBucket(LUA_STOCK_KEY, StringCodec.INSTANCE).set(String.valueOf(TARGET));
    }
​
    // ============================================================
    // 方案一:无锁化 incr(与生产代码完全一致,含恢复量)
    // ============================================================
    @Test
    public void test_qps_noLock() throws InterruptedException {
        runQpsTest("【方案一 无锁化 incr(含恢复量)】", this::occupyTeamStockNoLock);
    }
​
    private boolean occupyTeamStockNoLock() {
        // step1: 读取恢复量(非原子读,高并发下可能读到旧值)
        Long recoveryCount = redissonService.getAtomicLong(RECOVERY_KEY);
        recoveryCount = (recoveryCount == null) ? 0L : recoveryCount;
​
        // step2: 原子自增占位;+1 是因为 team 创建时已有一人占用
        long occupy = redissonService.incr(STOCK_KEY) + 1;
        if (occupy > TARGET + recoveryCount) {
            // 超卖:此 incr 槽位作废,恢复量 +1 让后续请求可以复用槽位范围
            redissonService.incr(RECOVERY_KEY);
            return false;
        }
​
        return true;
    }
​
    // ============================================================
    // 方案二:Lua 脚本(基础库存扣减)
    // ============================================================
    private static final String LUA_DEDUCT_STOCK =
            "local stockKey = KEYS[1]\n" +
            "local deductCount = tonumber(ARGV[1])\n" +
            "local currentStock = redis.call('GET', stockKey)\n" +
            "if not currentStock then\n" +
            "    return -1\n" +  // 缓存不存在
            "end\n" +
            "if tonumber(currentStock) < deductCount then\n" +
            "    return 0\n" +   // 库存不足
            "end\n" +
            "redis.call('DECRBY', stockKey, deductCount)\n" +
            "return 1\n";      // 扣减成功
​
    @Test
    public void test_qps_lua() throws InterruptedException {
        runQpsTest("【方案二 Lua 脚本(基础库存扣减)】", this::occupyTeamStockLua);
    }
​
    private boolean occupyTeamStockLua() {
        // StringCodec 保证 key/argv 以字符串形式传给 Redis
        Long result = redissonClient.getScript(StringCodec.INSTANCE)
                .eval(
                        RScript.Mode.READ_WRITE,
                        LUA_DEDUCT_STOCK,
                        RScript.ReturnType.INTEGER,
                        Collections.singletonList(LUA_STOCK_KEY), // KEYS[1]
                        "1"                                       // ARGV[1]: 每次扣减 1
                );
        return result != null && result == 1L;
    }
​
    // ============================================================
    // 方案三:分布式锁(Redisson RLock)
    // 已修复 unlock 逻辑 Bug
    // ============================================================
    @Test
    public void test_qps_distributedLock() throws InterruptedException {
        runQpsTest("【方案三 分布式锁(Redisson RLock)】", this::occupyTeamStockDistributedLock);
    }
​
    private boolean occupyTeamStockDistributedLock() {
        RLock lock = redissonService.getLock("occupy_lock_" + STOCK_KEY);
        try {
            // waitTime=3s(最多等待获锁时间),leaseTime=5s(持锁超时自动释放,防止宕机死锁)
            if (lock.tryLock(3, 5, TimeUnit.SECONDS)) {
                try {
                    Long current = redissonService.getAtomicLong(STOCK_KEY);
                    current = (current == null) ? 0L : current;
                    if (current >= TARGET) {
                        return false;
                    }
                    redissonService.incr(STOCK_KEY);
                    return true;
                } finally {
                    // 【Bug修复】只在成功获取到锁后,才执行 unlock
                    lock.unlock();
                }
            } else {
                // 等待超时未获取到锁,直接返回失败
                return false;
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return false;
        }
        // 【Bug修复】去除了外层错误的 finally { lock.unlock(); }
    }
​
    // ============================================================
    // 通用 QPS 测试框架
    // ============================================================
    private void runQpsTest(String label, Callable<Boolean> task) throws InterruptedException {
        ExecutorService pool    = Executors.newFixedThreadPool(THREAD_COUNT);
        CyclicBarrier   barrier = new CyclicBarrier(THREAD_COUNT);
        CountDownLatch  latch   = new CountDownLatch(TOTAL_REQUESTS);
​
        AtomicInteger successCount = new AtomicInteger(0);
        AtomicInteger failCount    = new AtomicInteger(0);
        AtomicInteger errorCount   = new AtomicInteger(0);
​
        long startTime = System.currentTimeMillis();
​
        for (int i = 0; i < TOTAL_REQUESTS; i++) {
            pool.submit(() -> {
                try {
                    barrier.await();
                    boolean result = task.call();
                    if (result) successCount.incrementAndGet();
                    else        failCount.incrementAndGet();
                } catch (Exception e) {
                    errorCount.incrementAndGet();
                    log.error("{} 执行异常", label, e);
                } finally {
                    latch.countDown();
                }
            });
        }
​
        latch.await(60, TimeUnit.SECONDS);
        long elapsed = System.currentTimeMillis() - startTime;
        pool.shutdown();
​
        double qps   = TOTAL_REQUESTS * 1000.0 / elapsed;
        double avgRt = (double) elapsed / TOTAL_REQUESTS;
​
        log.info("=========================================");
        log.info("测试方案:     {}", label);
        log.info("并发线程数:   {}", THREAD_COUNT);
        log.info("总请求数:     {}", TOTAL_REQUESTS);
        log.info("成功数:       {}", successCount.get());
        log.info("失败数(超限): {}", failCount.get());
        log.info("异常数:       {}", errorCount.get());
        log.info("总耗时:       {} ms", elapsed);
        log.info("QPS:          {}", String.format("%.2f", qps));
        log.info("平均 RT:      {} ms/req", String.format("%.3f", avgRt));
        log.info("=========================================");
    }
}

先测试无锁化库存扣减

一个请求消耗是0.26ms,QPS达到六千左右

image-20260315224808805

image-20260315223610773

Lua脚本

Lua脚本的QPS是一万多了

image-20260315223042653

分布式锁

分布式锁的QPS就只有399了

image-20260315224610242

分析

主要因素还是网络延迟吧,Lua脚本解析的代价是次要的。

  • Lua 脚本:1次RTT + Redis端绝对原子性 = 性能最高。
  • 无锁化 incr:2~3次RTT = 性能减半。
  • 分布式锁:加锁/解锁本身需要复杂的Lua脚本(约消耗2次RTT),加上业务的GET和INCR(2次RTT),以及强烈的线程排队竞争,导致性能断崖式下跌。

Redis 执行核心命令始终是单线程的。无论你使用“无锁化”还是“Lua脚本”,在 Redis 引擎内部,所有的命令全部都是排队串行执行的。完全不存在“多个请求在 Redis 内部并行扣减”的情况。

直接去测接口的QPS好低,只有44.5QPS

image-20260316000511459

image-20260316000043744

image-20260316000533978

image-20260316000730096

这个是对同一个组队名额在数据库中有扣减,就会导致MySQL上锁等待

但是实际上应当测试多个队伍名额扣减,而不是单个队伍

应当对每个队伍扣3次就行,pdd拼团也是3次,然后队伍数量越多,QPS就应该越多

最大连接25,QPS100

image-20260316002311749

最大连接500,QPS 325

image-20260316002415366

最大连接1500,QPS 329

image-20260316002625969

目前看来瓶颈就是写库了,要进一步提高并发,还是得扣减成功后直接投递到MQ中就返回,后续再异步落库


评论