大流量下库存扣减的数据库瓶颈:Redis分片缓存解决方案

大流量下库存扣减的数据库瓶颈:Redis分片缓存解决方案

引言

为什么库存扣减如此重要?

在电商、秒杀、抢购等互联网业务场景中,库存扣减是整个交易链路中最核心、最关键的环节之一。一个看似简单的"减库存"操作,背后却隐藏着巨大的技术挑战。

案例启示:

2018年某电商平台双11大促期间,一款爆款商品在开售30秒内,库存从10万件变成负数,导致超卖3万多笔订单。最终平台不得不赔偿违约金,品牌方声誉受损,用户信任度大幅下降。这个惨痛的教训告诉我们:在高并发场景下,如果库存扣减处理不当,后果不堪设想。

库存扣减的三大核心挑战:

  1. 高并发处理能力 - 秒杀场景可能在几秒内涌入数十万甚至上百万的请求,系统需要具备足够的吞吐能力
  2. 数据准确性保证 - 绝对不能出现超卖现象(卖出数量超过实际库存),这是业务红线
  3. 极致的用户体验 - 响应时间要足够短,避免用户等待焦虑,提升转化率

传统方案的困境

在最开始的电商系统中,我们通常会这样实现库存扣减:

sql 复制代码
-- 第一步:查询库存
SELECT stock FROM product WHERE id = 1;

-- 第二步:判断库存是否充足
-- 第三步:如果充足,更新库存
UPDATE product SET stock = stock - 1 WHERE id = 1;

这种方案在低并发场景下看似没问题,但一旦流量上去,各种问题就会暴露无遗:

问题一:并发冲突导致超卖

当多个请求同时执行时,由于没有加锁,可能会出现"读取-修改-写入"的竞态条件。例如两个请求同时读取到库存为10,都认为自己可以扣减1件,最终库存只减少了1,但卖出了2件。

问题二:数据库成为性能瓶颈

MySQL单表的QPS通常在500-1000左右,即使使用索引优化,面对万级以上的并发也会力不从心。数据库连接池会被耗尽,大量请求超时失败,用户看到"系统繁忙,请稍后重试"的提示,体验极差。

问题三:锁机制降低并发能力

为了解决超卖问题,我们可能会使用悲观锁(SELECT FOR UPDATE)或乐观锁(CAS)。这些方案虽然解决了超卖问题,但大大降低了并发能力,因为串行化了请求处理。

一、传统方案的深度分析

1.1 数据库库存扣减的本质

在深入讨论Redis方案之前,我们先深入理解传统数据库方案的本质问题和局限。

最原始的实现方式:

java 复制代码
// JDBC方式实现库存扣减
public boolean deductStock(Long productId, Integer quantity) {
    Connection conn = null;
    try {
        conn = dataSource.getConnection();
        conn.setAutoCommit(false);

        // 1. 查询库存
        PreparedStatement queryStmt = conn.prepareStatement(
            "SELECT stock FROM product WHERE id = ?"
        );
        queryStmt.setLong(1, productId);
        ResultSet rs = queryStmt.executeQuery();

        if (!rs.next()) {
            return false; // 商品不存在
        }

        int currentStock = rs.getInt("stock");

        // 2. 判断库存是否充足
        if (currentStock < quantity) {
            conn.rollback();
            return false; // 库存不足
        }

        // 3. 更新库存
        PreparedStatement updateStmt = conn.prepareStatement(
            "UPDATE product SET stock = ? WHERE id = ?"
        );
        updateStmt.setInt(1, currentStock - quantity);
        updateStmt.setLong(2, productId);
        updateStmt.executeUpdate();

        conn.commit();
        return true;

    } catch (Exception e) {
        if (conn != null) {
            conn.rollback();
        }
        throw new RuntimeException("扣减库存失败", e);
    } finally {
        if (conn != null) {
            conn.close();
        }
    }
}

这种方式的问题:

整个操作包含三个独立的数据库操作:SELECT、应用层判断、UPDATE。在高并发场景下,多个线程可能同时执行到SELECT阶段,读取到相同的库存值,然后都认为自己可以扣减,导致超卖。

1.2 并发冲突的详细分析

让我们通过一个详细的时序图来理解并发冲突是如何发生的:

ini 复制代码
时间线    请求A                        请求B                        数据库存量
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
T1       读取库存=10                  ────────────────────────    10
T2       ────────────────────────     读取库存=10                 10
T3       判断: 10 >= 1 ✓              ────────────────────────    10
T4       ────────────────────────     判断: 10 >= 1 ✓            10
T5       执行: UPDATE stock=9         ────────────────────────    9
T6       ────────────────────────     执行: UPDATE stock=9        9
T7       返回成功                     ────────────────────────    9
T8       ────────────────────────     返回成功                    9

结果:两个请求都成功,但库存只减少了1!这就是超卖问题的本质。

实际生产环境的影响:

假设一款iPhone有1000件库存,同时有10000个用户抢购:

  • 预期结果:前1000个用户抢购成功,库存归零,后9000个用户看到"库存不足"
  • 实际结果:由于并发冲突,可能出现1200个用户抢购成功,超卖200件

超卖200件意味着:

  • 需要向200个用户退款并赔偿违约金
  • 品牌方信誉受损
  • 可能面临法律风险
  • 用户流失率上升

1.3 悲观锁方案:SELECT FOR UPDATE

为了解决并发冲突,最直观的方案是使用数据库的行锁:

sql 复制代码
-- 悲观锁方案
BEGIN;
SELECT stock FROM product WHERE id = 1 FOR UPDATE;
-- 应用层判断库存是否充足
UPDATE product SET stock = stock - 1 WHERE id = 1;
COMMIT;

SELECT FOR UPDATE 的工作原理:

  1. 当事务A执行 SELECT FOR UPDATE 时,MySQL会对该行加排他锁(X锁)
  2. 其他事务试图对同一行执行 SELECT FOR UPDATE 时,会被阻塞
  3. 只有当事务A提交或回滚后,锁才会释放,其他事务才能继续执行

Java实现:

java 复制代码
public boolean deductStockWithPessimisticLock(Long productId, Integer quantity) {
    Connection conn = null;
    try {
        conn = dataSource.getConnection();
        conn.setAutoCommit(false);

        // 使用FOR UPDATE加行锁
        PreparedStatement queryStmt = conn.prepareStatement(
            "SELECT stock FROM product WHERE id = ? FOR UPDATE"
        );
        queryStmt.setLong(1, productId);
        ResultSet rs = queryStmt.executeQuery();

        if (!rs.next()) {
            conn.rollback();
            return false;
        }

        int currentStock = rs.getInt("stock");

        if (currentStock < quantity) {
            conn.rollback();
            return false;
        }

        // 更新库存
        PreparedStatement updateStmt = conn.prepareStatement(
            "UPDATE product SET stock = stock - ? WHERE id = ?"
        );
        updateStmt.setInt(1, quantity);
        updateStmt.setLong(2, productId);
        updateStmt.executeUpdate();

        conn.commit();
        return true;

    } catch (Exception e) {
        if (conn != null) {
            conn.rollback();
        }
        throw new RuntimeException("扣减库存失败", e);
    } finally {
        if (conn != null) {
            conn.close();
        }
    }
}

悲观锁方案的优缺点:

优点 缺点
实现简单,容易理解 并发能力低,请求串行化
可以有效防止超卖 数据库连接占用时间长
数据一致性有保障 容易产生死锁
适用于低并发场景 高并发时响应时间长

1.4 乐观锁方案:CAS(Compare And Swap)

另一种思路是乐观锁,不直接加锁,而是通过版本号机制来检测冲突:

sql 复制代码
-- 建表时增加version字段
CREATE TABLE product (
    id BIGINT PRIMARY KEY,
    name VARCHAR(100),
    stock INT,
    version INT DEFAULT 0,
    update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);

-- 乐观锁扣减
UPDATE product
SET stock = stock - 1,
    version = version + 1
WHERE id = 1
  AND version = 5;  -- 假设读取时version是5

工作原理:

  1. 先查询商品信息和当前版本号:SELECT id, stock, version FROM product WHERE id = 1
  2. 应用层判断库存是否充足
  3. 执行UPDATE时,在WHERE条件中带上版本号
  4. 如果UPDATE影响行数为0,说明版本号已变化,需要重试

Java实现:

java 复制代码
public boolean deductStockWithOptimisticLock(Long productId, Integer quantity) {
    int maxRetries = 3; // 最大重试次数

    for (int i = 0; i < maxRetries; i++) {
        try {
            // 1. 查询当前库存和版本号
            Product product = productMapper.selectById(productId);

            if (product == null) {
                return false; // 商品不存在
            }

            if (product.getStock() < quantity) {
                return false; // 库存不足
            }

            // 2. 使用版本号更新
            int rows = productMapper.deductStockWithVersion(
                productId,
                product.getVersion(),
                quantity
            );

            if (rows > 0) {
                return true; // 更新成功
            }

            // rows == 0 说明版本号已变化,重试
            Thread.sleep(10); // 短暂等待后重试

        } catch (Exception e) {
            // 记录日志,继续重试
        }
    }

    return false; // 重试次数用尽,失败
}

MyBatis Mapper:

xml 复制代码
<update id="deductStockWithVersion">
    UPDATE product
    SET stock = stock - #{quantity},
        version = version + 1
    WHERE id = #{productId}
      AND version = #{version}
</update>

乐观锁方案的优缺点:

优点 缺点
无锁竞争,吞吐量较高 冲突率高时需要重试
死锁风险低 高并发下CPU消耗高
适用于读多写少场景 无法解决ABA问题

1.5 数据库方案的根本瓶颈

通过前面的分析,我们可以看到无论是悲观锁还是乐观锁,都存在一个根本性的限制:

数据库的本质限制:

  1. 磁盘I/O限制 - 即使使用SSD,磁盘I/O仍然是瓶颈
  2. 锁竞争 - 行锁、表锁、间隙锁等都会限制并发
  3. 连接数限制 - 数据库连接池大小有限制
  4. 事务开销 - ACID保证带来的额外开销

MySQL的性能极限:

根据官方测试数据和业界实践:

  • 单表QPS上限: 500-1000(简单查询)
  • 单表写入QPS: 200-500(带索引)
  • 并发连接数: 默认151,最大可调至10000
  • 行锁等待时间: 高并发下可达数秒

结论:

数据库方案无论怎么优化,其QPS上限也就是几百到一千左右。对于秒杀场景动辄数万甚至数十万的QPS需求,数据库方案是根本无法满足的。

1.6 性能对比数据

让我们通过一个直观的对比来看传统方案与Redis方案的差距:

详细对比数据:

对比维度 传统方案(悲观锁) 传统方案(乐观锁) Redis方案 提升倍数
QPS性能 200 600 100,000+ 100-500倍
响应时间 500ms 150ms <1ms 100-500倍
并发能力 受DB连接限制 中等 无上限 水平扩展
超卖控制 无超卖 无超卖 0%超卖 完美解决
扩展方式 垂直扩展 垂直扩展 水平扩展 成本低
运维成本 降低70%
可靠性 单点故障风险 单点故障风险 高可用集群 大幅提升

实战案例对比:

某电商秒杀活动,1000件iPhone 15 Pro,10万用户同时抢购:

方案 成功处理QPS 平均响应时间 超卖数量 用户体验
直接操作DB 300 2秒 超卖50件 大量超时
悲观锁 200 3秒 0件 严重拥堵
乐观锁 500 500ms 0件 部分重试
Redis方案 80000 2ms 0件 秒级响应

二、Redis缓存解决方案

2.1 为什么选择Redis?

Redis是解决库存扣减问题的理想选择,这得益于其独特的架构和特性:

Redis的核心优势:

  1. 纯内存操作 - 所有数据存储在内存中,读写速度极快
  2. 单线程模型 - 避免了多线程竞争和上下文切换开销
  3. I/O多路复用 - 高效处理大量并发连接
  4. 丰富的数据结构 - String、Hash、Set、List等满足不同需求
  5. 原子操作 - 单个命令是原子的,无需额外加锁
  6. Lua脚本支持 - 多个操作可以原子执行
  7. 持久化机制 - RDB和AOF两种方式保证数据安全
  8. 分布式能力 - 主从复制、哨兵、集群等完善方案

Redis与MySQL的性能对比:

操作 MySQL Redis 性能差异
简单查询 10-50ms 0.1-1ms 10-50倍
带条件更新 20-100ms 0.1-1ms 20-100倍
事务操作 50-200ms 1-5ms 10-50倍
单机QPS 500-1000 100,000+ 100倍+

2.2 整体架构设计

采用Redis缓存方案后,整个系统的架构需要重新设计:

系统分层详解:

第一层:客户端层

  • Web前端:Vue/React/Angular单页应用
  • 移动端:iOS/Android原生应用
  • 小程序:微信/支付宝小程序

第二层:负载均衡层

  • Nginx反向代理:实现负载均衡和静态资源缓存
  • CDN加速:静态资源分发到边缘节点
  • API网关:统一入口、限流、鉴权、熔断
nginx 复制代码
upstream backend {
    server 192.168.1.101:8080;
    server 192.168.1.102:8080;
    server 192.168.1.103:8080;
}

server {
    listen 80;
    server_name api.example.com;

    location /api/ {
        proxy_pass http://backend;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}

第三层:应用层

  • Spring Boot应用集群:水平扩展,无状态设计
  • 限流组件:Guava RateLimiter或Sentinel
  • 熔断降级:Hystrix或Resilience4j

第四层:缓存层(核心)

  • Redis分片集群:按商品ID分布库存
  • 本地缓存:Caffeine作为二级缓存
  • 缓存预热:系统启动时加载热点数据

第五层:消息层

  • RabbitMQ/Kafka:异步同步Redis与MySQL
  • 消息持久化:保证消息不丢失
  • 死信队列:处理异常情况

第六层:持久层

  • MySQL集群:主从复制,读写分离
  • 分库分表:按业务维度拆分
  • 数据归档:历史数据迁移

2.3 Redis分片策略

为什么需要分片?单个Redis节点虽然性能强大,但在极端场景下仍有瓶颈:

单Redis节点的限制:

  1. 内存限制 - 单机内存有限(如64GB)
  2. CPU限制 - 单核CPU处理能力有上限
  3. 网络带宽限制 - 单网卡带宽有限
  4. 单点故障风险 - 一台机器故障影响全部商品

分片策略设计:

ini 复制代码
分片公式:shardIndex = productId % shardCount

示例(5个分片):
商品ID=1  → 分片0 → Redis节点0
商品ID=2  → 分片1 → Redis节点1
商品ID=3  → 分片2 → Redis节点2
商品ID=4  → 分片3 → Redis节点3
商品ID=5  → 分片4 → Redis节点4
商品ID=6  → 分片0 → Redis节点0
...

分片的优势:

优势 说明 效果
负载均衡 商品均匀分布到各节点 单节点压力降低N倍
高并发 N个节点并行处理 QPS提升N倍
易扩展 增加节点即可扩容 无需停机
高可用 单节点故障只影响部分商品 整体可用性提高

Key命名规范:

ruby 复制代码
库存Key:inventory:shard:{shardIndex}:product:{productId}:stock
用户Set:inventory:shard:{shardIndex}:product:{productId}:users
订单Hash:inventory:shard:{shardIndex}:product:{productId}:orders

示例:
inventory:shard:0:product:1:stock = "1000"
inventory:shard:0:product:1:users = {"user1", "user2", ...}
inventory:shard:0:product:1:orders = {"order1": "user1:1:1234567890", ...}

Java实现分片计算:

java 复制代码
@Component
@ConfigurationProperties(prefix = "redis.sharding")
public class RedisShardingConfig {

    private int nodeCount = 5; // 分片数量

    public int calculateShard(Long productId) {
        return (int) (productId % nodeCount);
    }

    public String buildStockKey(Long productId) {
        int shard = calculateShard(productId);
        return String.format("inventory:shard:%d:product:%d:stock", shard, productId);
    }

    public String buildUserSetKey(Long productId) {
        int shard = calculateShard(productId);
        return String.format("inventory:shard:%d:product:%d:users", shard, productId);
    }

    public String buildOrderHashKey(Long productId) {
        int shard = calculateShard(productId);
        return String.format("inventory:shard:%d:product:%d:orders", shard, productId);
    }

    // getter/setter省略
}

配置文件:

yaml 复制代码
redis:
  sharding:
    node-count: 5  # 分片数量

2.4 分片扩容方案

当业务增长时,可能需要增加分片数量。以下是几种扩容方案:

方案一:停机扩容(简单)

  1. 停止写入操作
  2. 将所有Redis数据导出
  3. 按新的分片规则重新分配数据
  4. 更新应用配置
  5. 启动服务

优点:实现简单 缺点:需要停机

方案二:在线扩容(推荐)

markdown 复制代码
步骤:
1. 新增Redis节点
2. 双写机制:同时写入旧分片和新分片
3. 数据迁移:后台逐步迁移数据
4. 读取切换:优先读新分片,未命中则读旧分片
5. 下线旧节点

双写实现:

java 复制代码
public void setStock(Long productId, Integer stock) {
    // 旧分片(3个)
    int oldShard = productId % 3;
    String oldKey = "inventory:shard:" + oldShard + ":product:" + productId;

    // 新分片(5个)
    int newShard = productId % 5;
    String newKey = "inventory:shard:" + newShard + ":product:" + productId;

    // 同时写入两个分片
    redisTemplate.opsForValue().set(oldKey, stock);
    redisTemplate.opsForValue().set(newKey, stock);
}

三、核心实现:Lua脚本原子扣减

3.1 为什么必须使用Lua脚本?

Redis的单个命令(如DECR、INCR)是原子的,但我们的库存扣减逻辑通常涉及多个步骤:

库存扣减的完整流程:

  1. 检查用户是否已购买(防止重复购买)
  2. 检查库存是否充足
  3. 扣减库存
  4. 记录购买用户(防止重复购买)
  5. 记录订单信息

如果使用多个Redis命令:

java 复制代码
// ❌ 错误示例:不是原子操作
public boolean deduct(String productId, String userId, int qty) {
    // 步骤1:检查用户是否已购买
    Boolean isMember = redisTemplate.opsForSet().isMember(userSetKey, userId);
    if (isMember) {
        return false;
    }

    // 步骤2:检查库存
    String stockStr = redisTemplate.opsForValue().get(stockKey);
    if (Integer.parseInt(stockStr) < qty) {
        return false;
    }

    // 步骤3:扣减库存
    redisTemplate.opsForValue().increment(stockKey, -qty);

    // 步骤4:记录用户
    redisTemplate.opsForSet().add(userSetKey, userId);

    // 步骤5:记录订单
    redisTemplate.opsForHash().put(orderHashKey, orderNo, orderInfo);

    return true;
}

问题分析:

上述代码虽然逻辑正确,但存在严重的并发问题:

css 复制代码
时间线    线程A                        线程B
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
T1       检查用户: 未购买             ───────────────────
T2       ───────────────────          检查用户: 未购买
T3       检查库存: 库存=10            ───────────────────
T4       ───────────────────          检查库存: 库存=10
T5       判断: 10 >= 1 ✓              ───────────────────
T6       ───────────────────          判断: 10 >= 1 ✓
T7       扣减库存: 库存=9             ───────────────────
T8       ───────────────────          扣减库存: 库存=8
T9       记录用户                     ───────────────────
T10      ───────────────────          记录用户(同一用户!)

结果:同一用户成功购买两次!

Lua脚本的原子性保证:

Redis保证Lua脚本的执行是原子的:

  • 脚本执行期间,不会插入其他命令
  • 脚本要么全部执行成功,要么完全不执行
  • 不存在竞态条件

3.2 库存扣减Lua脚本详解

完整Lua脚本:

lua 复制代码
-- ===================== 参数说明 =====================
-- KEYS[1]: 库存Key(如:inventory:shard:0:product:1:stock)
-- ARGV[1]: 扣减数量
-- ARGV[2]: 用户ID
-- ARGV[3]: 订单号
-- ARGV[4]: 时间戳

-- ===================== 变量定义 =====================
local stockKey = KEYS[1]                -- 库存Key
local userSetKey = stockKey .. ':users' -- 用户集合Key
local orderHashKey = stockKey .. ':orders' -- 订单Hash Key
local qty = tonumber(ARGV[1])           -- 扣减数量
local userId = ARGV[2]                  -- 用户ID
local orderNo = ARGV[3]                 -- 订单号
local timestamp = ARGV[4]               -- 时间戳

-- ===================== 第一步:检查用户是否已购买 =====================
-- 使用SISMEMBER命令检查用户是否在购买集合中
if redis.call('SISMEMBER', userSetKey, userId) == 1 then
  return -1  -- 返回-1表示用户已购买
end

-- ===================== 第二步:检查库存是否存在 =====================
local stock = tonumber(redis.call('GET', stockKey))
if stock == nil then
  return -2  -- 返回-2表示商品不存在
end

-- ===================== 第三步:检查库存是否充足 =====================
if stock < qty then
  return 0  -- 返回0表示库存不足
end

-- ===================== 第四步:扣减库存 =====================
-- 使用DECRBY命令原子性地扣减库存
redis.call('DECRBY', stockKey, qty)

-- ===================== 第五步:记录购买用户 =====================
-- 将用户ID添加到Set中,防止重复购买
redis.call('SADD', userSetKey, userId)

-- ===================== 第六步:记录订单信息 =====================
-- 将订单信息存储到Hash中
-- 格式:orderNo -> userId:quantity:timestamp
local orderInfo = userId .. ':' .. qty .. ':' .. timestamp
redis.call('HSET', orderHashKey, orderNo, orderInfo)

-- ===================== 返回成功 =====================
return 1  -- 返回1表示扣减成功

脚本执行流程图:

sql 复制代码
输入参数
   │
   ├─ KEYS[1] = "inventory:shard:0:product:1:stock"
   ├─ ARGV[1] = "1" (购买数量)
   ├─ ARGV[2] = "user123" (用户ID)
   ├─ ARGV[3] = "order456" (订单号)
   └─ ARGV[4] = "1699900000" (时间戳)
   │
   ▼
┌─────────────────────────────────┐
│  检查用户是否已购买              │
│  SISMEMBER userSetKey userId    │
└──────────────┬──────────────────┘
               │
        ┌──────┴──────┐
        │             │
       是            否
        │             │
        ▼             ▼
   返回 -1      ┌─────────────────────────┐
   (已购买)     │  检查库存是否存在        │
               │  GET stockKey           │
               └────────────┬────────────┘
                            │
                     ┌──────┴──────┐
                     │             │
                   不存在         存在
                     │             │
                     ▼             ▼
                返回 -2      ┌─────────────────────────┐
                (商品不存在)   │  检查库存是否充足        │
                              │  stock >= qty ?         │
                              └────────────┬────────────┘
                                           │
                                    ┌──────┴──────┐
                                    │             │
                                  不足           充足
                                    │             │
                                    ▼             ▼
                               返回 0      ┌───────────────────┐
                              (库存不足)    │  扣减库存          │
                                            │  DECRBY stockKey  │
                                            └─────────┬─────────┘
                                                      │
                                                      ▼
                                            ┌───────────────────┐
                                            │  记录购买用户      │
                                            │  SADD userSetKey  │
                                            └─────────┬─────────┘
                                                      │
                                                      ▼
                                            ┌───────────────────┐
                                            │  记录订单信息      │
                                            │  HSET ordersHash  │
                                            └─────────┬─────────┘
                                                      │
                                                      ▼
                                               返回 1 (成功)

3.3 库存回滚Lua脚本

当订单取消或超时未支付时,需要回滚库存:

lua 复制代码
-- ===================== 参数说明 =====================
-- KEYS[1]: 库存Key
-- ARGV[1]: 回滚数量
-- ARGV[2]: 用户ID
-- ARGV[3]: 订单号

local stockKey = KEYS[1]
local userSetKey = stockKey .. ':users'
local orderHashKey = stockKey .. ':orders'
local qty = tonumber(ARGV[1])
local userId = ARGV[2]
local orderNo = ARGV[3]

-- ===================== 第一步:删除订单记录 =====================
redis.call('HDEL', orderHashKey, orderNo)

-- ===================== 第二步:移除用户购买记录 =====================
redis.call('SREM', userSetKey, userId)

-- ===================== 第三步:恢复库存 =====================
redis.call('INCRBY', stockKey, qty)

-- ===================== 返回成功 =====================
return 1

3.4 Java实现详解

完整的Service实现:

java 复制代码
package com.redis.demo.service;

import com.alibaba.fastjson2.JSON;
import com.redis.demo.config.RedisShardingConfig;
import com.redis.demo.dto.DeductRequest;
import com.redis.demo.dto.DeductResponse;
import com.redis.demo.entity.Inventory;
import com.redis.demo.mapper.InventoryMapper;
import com.redis.demo.mq.message.InventoryDeductMessage;
import com.redis.demo.mq.producer.RabbitMQProducer;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Service;

import javax.annotation.PostConstruct;
import javax.annotation.Resource;
import java.util.Collections;
import java.util.UUID;
import java.util.concurrent.TimeUnit;

/**
 * 库存服务 - Redis分片缓存实现
 */
@Slf4j
@Service
public class InventoryService {

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @Resource
    private InventoryMapper inventoryMapper;

    @Autowired(required = false)
    private RabbitMQProducer rabbitMQProducer;

    @Resource
    private RedisShardingConfig redisShardingConfig;

    /**
     * Lua脚本: 原子性库存扣减
     */
    private static final String DEDUCT_LUA_SCRIPT =
            "local stockKey = KEYS[1]\n" +
            "local userSetKey = stockKey .. ':users'\n" +
            "local qty = tonumber(ARGV[1])\n" +
            "local userId = ARGV[2]\n" +
            "local orderNo = ARGV[3]\n" +
            "\n" +
            "-- 检查用户是否已购买\n" +
            "if redis.call('SISMEMBER', userSetKey, userId) == 1 then\n" +
            "  return -1\n" +
            "end\n" +
            "\n" +
            "-- 检查库存\n" +
            "local stock = tonumber(redis.call('GET', stockKey))\n" +
            "if stock == nil then\n" +
            "  return -2\n" +
            "end\n" +
            "\n" +
            "if stock < qty then\n" +
            "  return 0\n" +
            "end\n" +
            "\n" +
            "-- 扣减库存\n" +
            "redis.call('DECRBY', stockKey, qty)\n" +
            "-- 记录用户\n" +
            "redis.call('SADD', userSetKey, userId)\n" +
            "-- 记录订单\n" +
            "redis.call('HSET', stockKey .. ':orders', orderNo, userId .. ':' .. qty .. ':' .. ARGV[4])\n" +
            "\n" +
            "return 1";

    /**
     * Lua脚本: 回滚库存
     */
    private static final String ROLLBACK_LUA_SCRIPT =
            "local stockKey = KEYS[1]\n" +
            "local userSetKey = stockKey .. ':users'\n" +
            "local qty = tonumber(ARGV[1])\n" +
            "local userId = ARGV[2]\n" +
            "local orderNo = ARGV[3]\n" +
            "\n" +
            "redis.call('HDEL', stockKey .. ':orders', orderNo)\n" +
            "redis.call('SREM', userSetKey, userId)\n" +
            "redis.call('INCRBY', stockKey, qty)\n" +
            "\n" +
            "return 1";

    private DefaultRedisScript<Long> deductScript;
    private DefaultRedisScript<Long> rollbackScript;

    @PostConstruct
    public void init() {
        // 初始化扣减脚本
        deductScript = new DefaultRedisScript<>();
        deductScript.setScriptText(DEDUCT_LUA_SCRIPT);
        deductScript.setResultType(Long.class);

        // 初始化回滚脚本
        rollbackScript = new DefaultRedisScript<>();
        rollbackScript.setScriptText(ROLLBACK_LUA_SCRIPT);
        rollbackScript.setResultType(Long.class);

        log.info("Lua脚本初始化完成");
    }

    /**
     * 扣减库存
     */
    public DeductResponse deduct(DeductRequest request) {
        long startTime = System.currentTimeMillis();
        DeductResponse response = new DeductResponse();
        response.setSuccess(false);

        try {
            // 1. 计算分片
            int shard = calculateShard(request.getProductId());
            response.setShardIndex(shard);

            // 2. 构建Key
            String stockKey = buildStockKey(request.getProductId());

            // 3. 检查缓存是否存在
            String cachedStock = stringRedisTemplate.opsForValue().get(stockKey);
            if (StringUtils.isBlank(cachedStock)) {
                // 缓存不存在,从数据库加载
                Inventory inventory = inventoryMapper.selectByProductId(request.getProductId());
                if (inventory == null) {
                    response.setErrorMessage("商品不存在");
                    return response;
                }
                // 初始化缓存
                stringRedisTemplate.opsForValue().set(stockKey,
                    String.valueOf(inventory.getAvailableStock()),
                    24, TimeUnit.HOURS);
                cachedStock = String.valueOf(inventory.getAvailableStock());
            }

            // 4. 执行Lua脚本扣减库存
            Long result = stringRedisTemplate.execute(
                deductScript,
                Collections.singletonList(stockKey),
                String.valueOf(request.getQuantity()),
                String.valueOf(request.getUserId()),
                request.getOrderNo(),
                String.valueOf(System.currentTimeMillis())
            );

            // 5. 处理结果
            if (result == null || result == -2L) {
                response.setErrorMessage("商品库存数据异常");
                return response;
            }

            if (result == -1L) {
                response.setErrorMessage("用户已购买,不能重复购买");
                return response;
            }

            if (result == 0L) {
                String remaining = stringRedisTemplate.opsForValue().get(stockKey);
                response.setRemainingStock(remaining != null ? Integer.parseInt(remaining) : 0);
                response.setErrorMessage("库存不足");
                return response;
            }

            // 6. 扣减成功
            String remaining = stringRedisTemplate.opsForValue().get(stockKey);
            response.setRemainingStock(Integer.parseInt(remaining));
            response.setSuccess(true);

            // 7. 发送MQ消息异步同步数据库
            if (rabbitMQProducer != null) {
                InventoryDeductMessage message = InventoryDeductMessage.builder()
                    .productId(request.getProductId())
                    .userId(request.getUserId())
                    .orderNo(request.getOrderNo())
                    .quantity(request.getQuantity())
                    .shardIndex(shard)
                    .timestamp(System.currentTimeMillis())
                    .build();

                rabbitMQProducer.sendInventoryDeductMessage(message);
            }

            log.info("库存扣减成功 - 商品ID:{}, 用户ID:{}, 数量:{}, 剩余库存:{}, 分片:{}, 耗时:{}ms",
                request.getProductId(), request.getUserId(), request.getQuantity(),
                response.getRemainingStock(), shard,
                System.currentTimeMillis() - startTime);

        } catch (Exception e) {
            log.error("库存扣减异常", e);
            response.setErrorMessage("系统异常: " + e.getMessage());
        } finally {
            response.setCostTime(System.currentTimeMillis() - startTime);
        }

        return response;
    }

    /**
     * 回滚库存
     */
    public boolean rollback(Long productId, Long userId, String orderNo, Integer quantity) {
        try {
            String stockKey = buildStockKey(productId);

            stringRedisTemplate.execute(
                rollbackScript,
                Collections.singletonList(stockKey),
                String.valueOf(quantity),
                String.valueOf(userId),
                orderNo
            );

            log.info("库存回滚成功 - 商品ID:{}, 用户ID:{}, 订单号:{}, 数量:{}",
                productId, userId, orderNo, quantity);
            return true;
        } catch (Exception e) {
            log.error("库存回滚失败", e);
            return false;
        }
    }

    /**
     * 计算Redis分片
     */
    private int calculateShard(Long productId) {
        return (int) (productId % redisShardingConfig.getNodeCount());
    }

    /**
     * 构建库存Key
     */
    private String buildStockKey(Long productId) {
        int shard = calculateShard(productId);
        return String.format("inventory:shard:%d:product:%d:stock", shard, productId);
    }

    /**
     * 查询库存(优先从缓存)
     */
    public Integer getStock(Long productId) {
        String stockKey = buildStockKey(productId);
        String cached = stringRedisTemplate.opsForValue().get(stockKey);

        if (StringUtils.isNotBlank(cached)) {
            return Integer.parseInt(cached);
        }

        // 缓存不存在,查询数据库
        Inventory inventory = inventoryMapper.selectByProductId(productId);
        if (inventory != null) {
            // 回写缓存
            stringRedisTemplate.opsForValue().set(stockKey,
                String.valueOf(inventory.getAvailableStock()),
                24, TimeUnit.HOURS);
            return inventory.getAvailableStock();
        }

        return 0;
    }

    /**
     * 预热库存缓存
     */
    public void warmUpCache() {
        log.info("开始预热库存缓存...");
        try {
            List<Inventory> inventories = inventoryMapper.selectAll();
            if (inventories == null || inventories.isEmpty()) {
                log.warn("没有库存数据需要预热");
                return;
            }

            int successCount = 0;
            int failCount = 0;

            for (Inventory inventory : inventories) {
                try {
                    String stockKey = buildStockKey(inventory.getProductId());
                    stringRedisTemplate.opsForValue().set(stockKey,
                        String.valueOf(inventory.getAvailableStock()),
                        24, TimeUnit.HOURS);
                    successCount++;
                } catch (Exception e) {
                    log.error("预热商品{}库存失败", inventory.getProductId(), e);
                    failCount++;
                }
            }

            log.info("库存缓存预热完成 - 成功:{}, 失败:{}", successCount, failCount);
        } catch (Exception e) {
            log.error("预热库存缓存异常", e);
        }
    }
}

四、缓存与数据库双写一致性

4.1 一致性挑战分析

使用Redis缓存后,我们面临一个核心问题:如何保证Redis缓存与MySQL数据库的数据一致性?

双写一致性困境:

复制代码
场景:用户购买商品,需要同时更新Redis和MySQL

方案一:先更新Redis,再更新MySQL
问题:如果MySQL更新失败,Redis已经扣减,数据不一致

方案二:先更新MySQL,再更新Redis
问题:如果Redis更新失败,MySQL已经扣减,数据不一致

方案三:同时更新(使用分布式事务)
问题:性能极差,无法满足高并发需求

4.2 异步双写方案

我们采用异步双写方案,核心思想是:

  1. 写流程:Redis扣减 → 立即返回用户 → MQ异步同步MySQL
  2. 读流程:先读Redis → 缓存命中返回 → 缓存未命中读MySQL并回写Redis

写流程详解:

java 复制代码
// 完整的写流程
public DeductResponse deduct(DeductRequest request) {
    // 第1步:Redis扣减(核心路径,<1ms)
    Long result = executeLuaScript(request);

    if (result != 1) {
        // 扣减失败,直接返回
        return buildFailResponse(result);
    }

    // 第2步:立即返回用户(不等待MySQL)
    DeductResponse response = buildSuccessResponse();
    response.setCostTime(System.currentTimeMillis() - startTime);

    // 第3步:异步发送MQ消息(非阻塞)
    CompletableFuture.runAsync(() -> {
        try {
            rabbitMQProducer.sendInventoryDeductMessage(message);
        } catch (Exception e) {
            // 发送失败,记录日志,后续补偿
            log.error("发送MQ消息失败", e);
        }
    });

    return response;
}

读流程详解:

java 复制代码
// 完整的读流程
public Integer getStock(Long productId) {
    String stockKey = buildStockKey(productId);

    // 第1步:先读Redis
    String cached = redisTemplate.opsForValue().get(stockKey);
    if (StringUtils.isNotBlank(cached)) {
        return Integer.parseInt(cached); // 缓存命中,直接返回
    }

    // 第2步:缓存未命中,读MySQL
    Inventory inventory = inventoryMapper.selectByProductId(productId);
    if (inventory != null) {
        // 第3步:回写Redis(Cache Aside模式)
        redisTemplate.opsForValue().set(stockKey,
            String.valueOf(inventory.getAvailableStock()),
            24, TimeUnit.HOURS);
        return inventory.getAvailableStock();
    }

    return 0;
}

4.3 消息队列实现

RabbitMQ配置:

java 复制代码
@Configuration
public class RabbitMQConfig {

    // 交换机
    @Bean
    public DirectExchange inventoryExchange() {
        return new DirectExchange("inventory.deduct.exchange", true, false);
    }

    // 队列
    @Bean
    public Queue inventoryDeductQueue() {
        return QueueBuilder.durable("inventory.deduct.queue")
                .withArgument("x-dead-letter-exchange", "inventory.dlt.exchange")
                .build();
    }

    // 绑定
    @Bean
    public Binding inventoryBinding() {
        return BindingBuilder.bind(inventoryDeductQueue())
                .to(inventoryExchange())
                .with("inventory.deduct");
    }

    // 死信交换机
    @Bean
    public DirectExchange inventoryDltExchange() {
        return new DirectExchange("inventory.dlt.exchange", true, false);
    }

    // 死信队列
    @Bean
    public Queue inventoryDltQueue() {
        return QueueBuilder.durable("inventory.dlt.queue").build();
    }

    // 死信绑定
    @Bean
    public Binding inventoryDltBinding() {
        return BindingBuilder.bind(inventoryDltQueue())
                .to(inventoryDltExchange())
                .with("inventory.deduct");
    }
}

生产者实现:

java 复制代码
@Component
@Slf4j
public class RabbitMQProducer {

    @Autowired
    private RabbitTemplate rabbitTemplate;

    public void sendInventoryDeductMessage(InventoryDeductMessage message) {
        try {
            rabbitTemplate.convertAndSend(
                "inventory.deduct.exchange",
                "inventory.deduct",
                JSON.toJSONString(message)
            );
            log.info("MQ消息发送成功 - 订单号:{}", message.getOrderNo());
        } catch (Exception e) {
            log.error("MQ消息发送失败 - 订单号:{}", message.getOrderNo(), e);
            throw e;
        }
    }
}

消费者实现:

java 复制代码
@Component
@Slf4j
public class InventoryDeductConsumer {

    @Autowired
    private InventoryMapper inventoryMapper;

    @RabbitListener(queues = "inventory.deduct.queue")
    public void onMessage(String message, Channel channel, long deliveryTag) {
        try {
            InventoryDeductMessage msg = JSON.parseObject(message, InventoryDeductMessage.class);

            // 使用乐观锁更新数据库(带重试)
            int maxRetries = 3;
            for (int i = 0; i < maxRetries; i++) {
                try {
                    int rows = inventoryMapper.deductStock(
                        msg.getProductId(),
                        msg.getQuantity()
                    );

                    if (rows > 0) {
                        // 更新成功,确认消息
                        channel.basicAck(deliveryTag, false);
                        log.info("数据库更新成功 - 订单号:{}", msg.getOrderNo());
                        return;
                    }
                } catch (Exception e) {
                    if (i == maxRetries - 1) {
                        throw e;
                    }
                    Thread.sleep(100);
                }
            }

        } catch (Exception e) {
            log.error("消息处理失败", e);
            try {
                // 拒绝消息,重新入队
                channel.basicNack(deliveryTag, false, true);
            } catch (Exception ex) {
                log.error("NACK失败", ex);
            }
        }
    }
}

4.4 一致性保障机制

为了确保最终一致性,我们实现了以下机制:

1. Redis事务保障

使用Lua脚本保证Redis操作的原子性,无需额外的事务控制。

2. MQ可靠投递

  • 消息持久化:队列和消息都设置为持久化
  • ACK机制:消费成功后手动确认
  • 重试策略:消费失败自动重试
yaml 复制代码
spring:
  rabbitmq:
    publisher-confirm-type: correlated  # 发布确认
    publisher-returns: true              # 发布返回
    listener:
      simple:
        acknowledge-mode: manual          # 手动确认
        retry:
          enabled: true
          max-attempts: 3
          initial-interval: 1000ms

3. 幂等设计

使用唯一订单号防止重复处理:

sql 复制代码
-- 数据库表增加唯一约束
CREATE TABLE inventory_log (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    order_no VARCHAR(64) UNIQUE NOT NULL,
    product_id BIGINT NOT NULL,
    quantity INT NOT NULL,
    create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

4. 定时对账

java 复制代码
@Component
@Slf4j
public class ReconciliationTask {

    @Scheduled(cron = "0 0 2 * * ?") // 每天凌晨2点执行
    public void reconcile() {
        log.info("开始执行库存对账任务");

        // 1. 获取Redis中的所有订单
        // 2. 获取MySQL中的所有订单
        // 3. 比对差异
        // 4. 生成差异报告
        // 5. 自动修复或人工介入
    }
}

5. 补偿机制

发现不一致时自动触发修复:

java 复制代码
public void compensateInventory(Long productId) {
    // 1. 获取Redis库存
    String redisStock = getStockFromRedis(productId);

    // 2. 获取MySQL库存
    int dbStock = getStockFromDB(productId);

    // 3. 判断差异
    int diff = dbStock - Integer.parseInt(redisStock);

    if (diff != 0) {
        // 4. 以MySQL为准,修复Redis
        log.warn("发现库存差异 - 商品ID:{}, Redis:{}, MySQL:{}, 差异:{}",
            productId, redisStock, dbStock, diff);

        // 修复Redis
        setStockToRedis(productId, dbStock);
    }
}

五、完整库存扣减流程

详细步骤说明:

  1. 用户发起请求 - 用户点击"立即购买"按钮,前端发送POST请求到后端API

  2. 请求参数校验 - 校验商品ID、用户ID、购买数量等参数的合法性

  3. 计算Redis分片 - 根据商品ID使用哈希算法计算该商品在哪个Redis分片

  4. 检查缓存 - 检查Redis中是否有该商品的库存缓存

  5. 缓存不存在 - 从MySQL加载库存并初始化到Redis,设置24小时过期

  6. 执行Lua脚本 - 原子性地检查用户、库存并扣减,整个过程在Redis内部完成

  7. 返回用户 - 立即返回结果给用户,总耗时<1ms

  8. 发送MQ消息 - 异步发送库存变更消息到RabbitMQ

  9. 消费者处理 - MQ消费者监听队列,异步更新MySQL数据库

  10. 重试机制 - 更新失败自动重试,最多3次


六、实战案例:电商秒杀场景

6.1 业务背景

某电商平台准备举办一场iPhone 15 Pro秒杀活动:

活动参数:

  • 商品数量:1000件iPhone 15 Pro
  • 参与用户:预计10万用户同时抢购
  • 性能要求:QPS要求50000+,响应时间<10ms
  • 业务要求:不允许超卖,同一用户限购1件

6.2 技术架构设计

Redis分片架构:

css 复制代码
分片数量:5个主节点,每个配置1个从节点
分片算法:productId % 5
Key命名:inventory:shard:{0-4}:product:{productId}:stock

Redis节点分布:
├── 分片0: 192.168.1.101:6379 (主) + 192.168.1.106:6379 (从)
├── 分片1: 192.168.1.102:6379 (主) + 192.168.1.107:6379 (从)
├── 分片2: 192.168.1.103:6379 (主) + 192.168.1.108:6379 (从)
├── 分片3: 192.168.1.104:6379 (主) + 192.168.1.109:6379 (从)
└── 分片4: 192.168.1.105:6379 (主) + 192.168.1.110:6379 (从)

应用服务器集群:

复制代码
服务器数量:10台
配置:8核16G
JVM堆:8GB
线程池:500+
部署方式:Docker容器
负载均衡:Nginx

6.3 核心代码实现

限流组件:

java 复制代码
@Component
public class RateLimiter {

    // 每秒发放10000个令牌
    private final RateLimiter limiter = GuavaRateLimiter.create(10000.0);

    public boolean tryAcquire() {
        return limiter.tryAcquire();
    }
}

// Controller中使用
@PostMapping("/seckill/{productId}")
public Result seckill(@PathVariable Long productId, @RequestParam Long userId) {
    // 第1步:限流
    if (!rateLimiter.tryAcquire()) {
        return Result.error("请求过于频繁,请稍后重试");
    }

    // 第2步:扣减库存
    DeductRequest request = DeductRequest.builder()
        .productId(productId)
        .userId(userId)
        .quantity(1)
        .orderNo(generateOrderNo())
        .build();

    DeductResponse response = inventoryService.deduct(request);

    if (response.isSuccess()) {
        return Result.success("抢购成功");
    } else {
        return Result.error(response.getErrorMessage());
    }
}

降级方案:

java 复制代码
public DeductResponse deduct(DeductRequest request) {
    try {
        // 优先使用Redis扣减
        return deductByRedis(request);
    } catch (RedisConnectionException e) {
        log.warn("Redis故障,降级到MySQL - {}", e.getMessage());
        // Redis故障,降级到MySQL(串行扣减)
        return deductByDB(request);
    }
}

private DeductResponse deductByDB(DeductRequest request) {
    // 使用分布式锁保证串行执行
    String lockKey = "lock:product:" + request.getProductId();
    try {
        // 获取分布式锁
        Boolean locked = redisTemplate.opsForValue()
            .setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);

        if (Boolean.TRUE.equals(locked)) {
            // 执行数据库扣减
            return inventoryMapper.deductStock(request);
        } else {
            return DeductResponse.fail("系统繁忙,请稍后重试");
        }
    } finally {
        // 释放锁
        redisTemplate.delete(lockKey);
    }
}

6.4 实施效果

性能对比:

指标 优化前(直接DB) 优化后(Redis方案) 提升
QPS 500 80000+ 160倍
响应时间 200ms 3ms 98%
超卖率 2% 0% 完美解决
成功率 85% 99.9% 17%
CPU使用率 95% 40% 降低58%
数据库连接数 800/800 50/800 降低94%

用户满意度提升:

  • 页面加载时间从2秒降至<100ms
  • 抢购成功率从85%提升至99.9%
  • 客服投诉量下降90%

七、生产环境部署

7.1 Redis集群部署

Docker Compose部署:

yaml 复制代码
version: '3.8'

services:
  redis-node-0:
    image: redis:7-alpine
    container_name: redis-node-0
    command: redis-server --appendonly yes --maxmemory 2gb --maxmemory-policy allkeys-lru
    ports:
      - "7000:6379"
    volumes:
      - redis-node-0-data:/data

  redis-node-1:
    image: redis:7-alpine
    container_name: redis-node-1
    command: redis-server --appendonly yes --maxmemory 2gb --maxmemory-policy allkeys-lru
    ports:
      - "7001:6379"
    volumes:
      - redis-node-1-data:/data

  redis-node-2:
    image: redis:7-alpine
    container_name: redis-node-2
    command: redis-server --appendonly yes --maxmemory 2gb --maxmemory-policy allkeys-lru
    ports:
      - "7002:6379"
    volumes:
      - redis-node-2-data:/data

  redis-node-3:
    image: redis:7-alpine
    container_name: redis-node-3
    command: redis-server --appendonly yes --maxmemory 2gb --maxmemory-policy allkeys-lru
    ports:
      - "7003:6379"
    volumes:
      - redis-node-3-data:/data

  redis-node-4:
    image: redis:7-alpine
    container_name: redis-node-4
    command: redis-server --appendonly yes --maxmemory 2gb --maxmemory-policy allkeys-lru
    ports:
      - "7004:6379"
    volumes:
      - redis-node-4-data:/data

volumes:
  redis-node-0-data:
  redis-node-1-data:
  redis-node-2-data:
  redis-node-3-data:
  redis-node-4-data:

7.2 应用配置

application.yml:

yaml 复制代码
server:
  port: 8080
  tomcat:
    threads:
      max: 500
      min-spare: 50

spring:
  redis:
    lettuce:
      pool:
        max-active: 50
        max-idle: 20
        min-idle: 10
        max-wait: 1000ms
    cluster:
      nodes:
        - 192.168.1.101:7000
        - 192.168.1.102:7001
        - 192.168.1.103:7002
        - 192.168.1.104:7003
        - 192.168.1.105:7004
      max-redirects: 3

  rabbitmq:
    host: 192.168.1.201
    port: 5672
    username: admin
    password: admin123
    publisher-confirm-type: correlated
    publisher-returns: true
    listener:
      simple:
        acknowledge-mode: manual
        retry:
          enabled: true
          max-attempts: 3

  datasource:
    url: jdbc:mysql://192.168.1.301:3306/inventory?useUnicode=true&characterEncoding=utf8
    username: root
    password: root123
    hikari:
      maximum-pool-size: 50
      minimum-idle: 10
      connection-timeout: 3000
      idle-timeout: 600000

# 自定义配置
redis:
  sharding:
    node-count: 5

八、总结

本文详细介绍了在大流量场景下使用Redis分片缓存解决库存扣减数据库瓶颈的完整方案。

相关推荐
無限進步D3 小时前
Java 运行原理
java·开发语言·入门
難釋懷3 小时前
安装Canal
java
是苏浙3 小时前
JDK17新增特性
java·开发语言
不光头强3 小时前
spring cloud知识总结
后端·spring·spring cloud
SPC的存折5 小时前
1、Redis数据库基础
linux·运维·服务器·数据库·redis·缓存
GetcharZp6 小时前
告别 Python 依赖!用 LangChainGo 打造高性能大模型应用,Go 程序员必看!
后端
阿里加多6 小时前
第 4 章:Go 线程模型——GMP 深度解析
java·开发语言·后端·golang
likerhood7 小时前
java中`==`和`.equals()`区别
java·开发语言·python
小小李程序员7 小时前
Langchain4j工具调用获取不到ThreadLocal
java·后端·ai