1. 项目背景
1.1 业务场景
运营后台来了两个批量任务。第一个是"618 积分补发"——运营从投诉工单中筛选出 8000 个用户,每人补发 50 积分。开发同学写了最简单的实现:
for (String userId : userIds) {
redis.hincrBy("mall:point:user:" + userId, "total", 50);
redis.zincrBy("mall:rank:point", 50, "user:" + userId);
}
这个循环在网络延迟 0.5ms 的环境下,8000 个用户 × 2 条命令 = 16000 次 RTT × 0.5ms = 8 秒。加上序列化和线程切换,实际耗时 12 秒。运营吐槽"点个按钮要等半分钟"。
第二个任务是账户余额扣减。用户下单后从账户余额中扣款,两个并发请求同时扣同一个账户。如果直接用 GET balance + DECRBY,就会出现第 13 章那样的并发覆盖问题。但这里业务逻辑没那么复杂(不需要限购判断),用完整的 Lua 脚本有点牛刀杀鸡。
这两个问题分别对应 Redis 的两种批量处理方案——Pipeline(解决 RTT 问题)和事务+MULTI/WATCH(解决并发一致性问题)。
1.2 三种方案的区别(必须理清)
| 方案 | 解决的问题 | 是否原子 | 是否减少 RTT | 典型场景 |
|---|---|---|---|---|
| Pipeline | 减少网络往返 | 否 | 是 | 批量导入、批量查询 |
| MULTI/EXEC | 命令排队执行 | 部分(排队连续但无回滚) | 否 | 批量无依赖更新 |
| WATCH+MULTI/EXEC | 乐观锁并发控制 | CAS 语义 | 否 | 余额扣减等低冲突 CAS |
| Lua | 多步逻辑原子化 | 是 | 中等 | 判断后修改、库存扣减 |
1.3 本章目标
- 用 Pipeline 批量写入积分——对比 RTT 和延迟改善。
- 用 WATCH + MULTI/EXEC 实现账户余额的乐观锁扣减。
- 理解 MULTI/EXEC 的错误处理——语法错误 vs 运行时错误的区别。
- 清晰地对比三种方案的适用边界。
2. 项目设计
2.1 第一幕:Pipeline 是批发,不是团购
小胖:"我要批量写 8000 个用户的积分——一个 MULTI 包起来,EXEC 一次性执行,完美。"
小白:"MULTI/EXEC 是排队执行,不是批量传输。你每一条命令还是要从客户端发到 Redis——MULTI 只是告诉 Redis'我开始排队了',EXEC 是'把排队的都执行了'。网络往返没有减少。"
大师画了两张图:
MULTI/EXEC 的网络交互(未减少 RTT):
客户端 Redis
│ ─── MULTI ──────────────────────────▶ │
│ ◀── +OK ───────────────────────────── │
│ ─── HINCRBY user:1 total 50 ────────▶ │ (排队)
│ ◀── +QUEUED ───────────────────────── │
│ ─── ZINCRBY rank:point 50 user:1 ──▶ │ (排队)
│ ◀── +QUEUED ───────────────────────── │
│ ... (8000 次往返!) ... │
│ ─── EXEC ───────────────────────────▶ │
│ ◀── [结果1, 结果2, ...] ──────────── │
│ │
每次命令都要等 QUEUED 响应 → RTT 堆积
Pipeline 的网络交互(大幅减少 RTT):
客户端 Redis
│ ─── HINCRBY user:1 ... ──────────────▶ │
│ ─── ZINCRBY rank:point ... ──────────▶ │ (全部发出,不等待响应)
│ ─── HINCRBY user:2 ... ──────────────▶ │
│ ... (一批发完) ... │
│ ◀── [结果1, 结果2, 结果3, ...] ─────── │ (一批返回)
大师:"Pipeline 就像批发进货——你把一批订单一次性发给供应商,供应商一次把货发回来。不是一个个打电话问'这个有货吗?''那有货吗?'。8000 个用户的写操作,从 16000 次 RTT 减少到 1 次(或按批次分几批)。"
技术映射:Pipeline 优化的是网络层的吞吐,不是 Redis 内部的执行方式。Redis 仍然按顺序执行每一条命令,但客户端的"发-等-收"模式变成了"发发发-收收收"。
2.2 第二幕:MULTI/EXEC 没有回滚!
小胖:"那 MULTI/EXEC 是不是和数据库事务一样?如果中间某条命令失败,自动回滚。"
大师:"这是最大的误解。Redis 事务不支持回滚。MULTI 只是让命令排队,EXEC 把所有命令按顺序执行一遍。如果某条命令执行时出错(比如对 String 执行了 HINCRBY),这条会返回错误——但队列中的其他命令仍然会正常执行。"
示例:
MULTI
HINCRBY mall:point:user:1001 total 10 ← 正常
HINCRBY mall:cache:product:1001 total 10 ← 错误!这是个 String 不是 Hash
HINCRBY mall:point:user:1003 total 10 ← 仍然正常执行!
EXEC 返回:
1) (integer) 10 ← 第1条成功
2) (error) WRONGTYPE ← 第2条失败
3) (integer) 10 ← 第3条成功(没有回滚!)
大师:"Redis 的设计哲学是——保持简单和快速。回滚需要 undo log 或锁,会明显增加复杂度。Redis 假设命令错误是编程 bug,应该在测试阶段发现,而不是靠运行时回滚。"
技术映射:Redis 事务提供的是"一组命令在 EXEC 前不被其他客户端插入"的隔离性,不是传统 ACID 的原子性。某条命令失败不会影响队列中的其他命令。
2.3 第三幕:WATCH——Redis 的乐观锁
小胖:"那余额扣减怎么保证并发安全?两个请求同时扣同一个账户。"
大师:"WATCH 提供了一种 CAS(Compare-And-Swap)机制。把它想象成版本号——WATCH account 后,Redis 记住这个 key 的当前版本。在 EXEC 执行前,如果 key 被其他客户端修改了,EXEC 会返回空,事务不执行。"
WATCH 乐观锁流程:
客户端 A 客户端 B
│ │
├── WATCH account ────────────▶ │
├── GET account → 100 │
│ ├── WATCH account ──▶
│ ├── GET account → 100
│ ├── MULTI
│ ├── DECRBY account 30
│ ├── EXEC → 成功 [70]
│ │
├── MULTI │
├── DECRBY account 50 │
├── EXEC → (nil) ← 失败! │
│ 因为 B 修改了 account │
│ │
└── 重试: WATCH → GET(70) → │
MULTI → DECRBY 30 → EXEC │
→ 成功 [40]
大师:"这个模式适合冲突不高的场景。冲突高的话(比如秒杀同一件商品),重试次数会很多,建议用 Lua。余额扣减的冲突一般不高——大部分请求扣不同的账户。"
技术映射:WATCH 不是锁——它不会阻止其他客户端修改 key,只是在修改发生时让你的事务失败。这是"乐观锁"的经典实现。
2.4 第四幕:Pipeline 不是事务!
小胖:"我把 100 条命令 Pipeline 发过去,中间不会被别人的命令插队吧?"
小白:"会被插队。Pipeline 只是减少网络往返——Redis 仍然一条一条执行命令。你的 100 条 Pipeline 命令发过去后,Redis 按顺序执行,但执行到你第 50 条和第 51 条之间时,另一个客户端的命令可能被插入。"
大师:"所以 Pipeline + 需要原子性 = 错误组合。如果积分补发要求'一个人积分和排名必须同时更新',Pipeline 做不到——因为另一个人可能在你更新积分之后、更新排名之前插进来。这时候要用 Lua。"
3. 项目实战
3.1 环境准备
docker exec -it redis-lab redis-cli -a redis123
3.2 实战一:MULTI/EXEC 基础
# 启动事务
127.0.0.1:6379> MULTI
OK
# 队列中的命令返回 QUEUED
127.0.0.1:6379> HINCRBY mall:point:user:1001 total 50
QUEUED
127.0.0.1:6379> ZINCRBY mall:rank:point 50 user:1001
QUEUED
127.0.0.1:6379> HINCRBY mall:point:user:1002 total 30
QUEUED
127.0.0.1:6379> ZINCRBY mall:rank:point 30 user:1002
QUEUED
# 执行事务
127.0.0.1:6379> EXEC
1) (integer) 50
2) "50"
3) (integer) 30
4) "30"
# DISCARD: 放弃事务
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> SET mall:tmp:bad 1
QUEUED
127.0.0.1:6379> DISCARD
OK
127.0.0.1:6379> GET mall:tmp:bad
(nil)
3.3 实战二:事务中的错误处理
# 准备一个 String 类型的 key
127.0.0.1:6379> SET mall:test:wrongtype "i-am-string"
OK
# 事务中包含错误的命令
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> HINCRBY mall:point:user:1001 total 10
QUEUED
127.0.0.1:6379> HINCRBY mall:test:wrongtype total 10
QUEUED
127.0.0.1:6379> HINCRBY mall:point:user:1003 total 10
QUEUED
127.0.0.1:6379> EXEC
1) (integer) 60 # ← 第1条成功
2) (error) WRONGTYPE # ← 第2条失败(类型错误)
3) (integer) 10 # ← 第3条仍然成功!没有回滚
关键理解:Redis 事务中某条命令失败不会自动回滚。调用方必须逐项检查 EXEC 的返回数组。
3.4 实战三:WATCH 乐观锁——余额扣减
步骤 1:准备账户
127.0.0.1:6379> SET mall:account:user:1001 100
OK
步骤 2:模拟并发扣减(双终端)
# 终端 A: WATCH + MULTI
127.0.0.1:6379> WATCH mall:account:user:1001
OK
127.0.0.1:6379> GET mall:account:user:1001
"100"
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> DECRBY mall:account:user:1001 40
QUEUED
# 此时切换到终端 B,修改同一个 key
# 终端 B:
127.0.0.1:6379> INCRBY mall:account:user:1001 20
(integer) 120
# 回到终端 A,执行 EXEC
127.0.0.1:6379> EXEC
(nil) # ← 事务失败!因为 key 被 B 修改了
# 终端 A 应该重试:
127.0.0.1:6379> WATCH mall:account:user:1001
OK
127.0.0.1:6379> GET mall:account:user:1001
"120" # 重新读取最新值
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> DECRBY mall:account:user:1001 40
QUEUED
127.0.0.1:6379> EXEC
1) (integer) 80 # 成功!
步骤 3:Java 乐观锁余额扣减
public class BalanceService {
private static final int MAX_RETRY = 3;
public DeductResult deduct(String userId, long amount) {
String key = "mall:account:user:" + userId;
for (int retry = 0; retry < MAX_RETRY; retry++) {
// 1. 监视 key
redis.watch(key);
// 2. 读取余额
String balanceStr = redis.get(key);
long balance = balanceStr != null ? Long.parseLong(balanceStr) : 0;
if (balance < amount) {
redis.unwatch(); // 放弃监视
return DeductResult.INSUFFICIENT_BALANCE;
}
// 3. 开启事务
redis.multi();
// 4. 入队扣减命令
redis.decrBy(key, amount);
// 5. 执行事务
List<Object> results = redis.exec();
if (results != null && !results.isEmpty()) {
// EXEC 成功
return DeductResult.SUCCESS;
}
// EXEC 返回 null → 被其他请求修改了 → 重试
try {
Thread.sleep(ThreadLocalRandom.current().nextInt(10, 50));
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return DeductResult.RETRY_FAILED;
}
}
return DeductResult.RETRY_EXHAUSTED;
}
}
3.5 实战四:Pipeline 批量积分发放
# redis-cli 的 --pipe 模式(从文件批量执行)
# 创建一个文件 batch_commands.txt:
# HINCRBY mall:point:user:1001 total 50
# ZINCRBY mall:rank:point 50 user:1001
# HINCRBY mall:point:user:1002 total 50
# ZINCRBY mall:rank:point 50 user:1002
# ... (8000 行)
# 执行:
# cat batch_commands.txt | redis-cli -a redis123 --pipe
Java Pipeline 实现:
public class PointBatchService {
private static final int BATCH_SIZE = 500; // 每批 500 条
public BatchResult batchAddPoints(List<String> userIds, int points) {
List<String> failedUsers = new ArrayList<>();
long startTime = System.currentTimeMillis();
int total = userIds.size();
int processed = 0;
// 分批执行
for (int i = 0; i < total; i += BATCH_SIZE) {
int end = Math.min(i + BATCH_SIZE, total);
List<String> batch = userIds.subList(i, end);
List<Object> results = redis.executePipelined((RedisCallback<Object>) conn -> {
for (String userId : batch) {
byte[] pointKey = ("mall:point:user:" + userId).getBytes();
byte[] rankKey = "mall:rank:point".getBytes();
conn.hashCommands().hIncrBy(pointKey, "total".getBytes(), points);
conn.zSetCommands().zIncrBy(rankKey, points, ("user:" + userId).getBytes());
}
return null;
});
// 处理结果(每条命令对应一个结果)
for (int j = 0; j < results.size(); j++) {
Object result = results.get(j);
if (result instanceof RedisSystemException) {
String userId = batch.get(j / 2); // 每个用户 2 条命令
failedUsers.add(userId);
log.error("积分发放失败: userId={}", userId, (Exception) result);
}
}
processed += batch.size();
}
long elapsed = System.currentTimeMillis() - startTime;
return new BatchResult(total, processed, failedUsers.size(), elapsed);
}
}
Pipeline 性能对比:
| 方案 | 8000 用户耗时 | 网络往返次数 |
|---|---|---|
| 逐条执行 | ~12 秒 | 16000 次 |
| Pipeline(每批 500) | ~0.8 秒 | 16 次 |
| Pipeline(一批全发) | ~0.3 秒 | 1 次 |
3.6 实战五:Pipeline 批量查询
# 不使用 Pipeline: 每个 GET 一次 RTT
127.0.0.1:6379> GET mall:cache:product:1001
127.0.0.1:6379> GET mall:cache:product:1002
127.0.0.1:6379> GET mall:cache:product:1003
# → 3 次 RTT
# 使用 MGET(Redis 原生命令):一次 RTT
127.0.0.1:6379> MGET mall:cache:product:1001 mall:cache:product:1002 mall:cache:product:1003
1) "{\"id\":1001,...}"
2) "{\"id\":1002,...}"
3) "{\"id\":1003,...}"
# 混合类型查询(MGET 只能用于 String)
# 如果同时查 String, Hash, ZSet → 只能用 Pipeline
3.7 三种方案选型速查
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 批量写 8000 用户积分 | Pipeline | 只需减少 RTT,不需要原子性 |
| 余额扣减(低冲突) | WATCH+MULTI | 乐观锁足够,重试少 |
| 余额扣减(高冲突) | Lua | 减少重试,服务端原子执行 |
| 库存扣减+限购检查 | Lua | 需要 if 判断,WATCH 做不到 |
| 批量预热商品缓存 | Pipeline | 纯批量 SET,不需要事务 |
| 批量查询用户状态 | Pipeline | 混合类型查询,MGET 不支持 |
3.8 实战六:Pipeline 批量预热与缓存热加载
大促前的缓存预热是 Pipeline 的经典用例:
public class CacheWarmUpService {
private static final int BATCH_SIZE = 500;
public WarmUpResult warmUpHotProducts(List<Long> productIds) {
long start = System.currentTimeMillis();
int total = productIds.size();
int success = 0;
int failed = 0;
// 1. 先从数据库批量加载
List<Product> products = database.findProductsByIds(productIds);
Map<Long, Product> productMap = products.stream()
.collect(Collectors.toMap(Product::getId, Function.identity()));
// 2. Pipeline 批量写入 Redis
for (int i = 0; i < total; i += BATCH_SIZE) {
int end = Math.min(i + BATCH_SIZE, total);
List<Long> batch = productIds.subList(i, end);
List<Object> results = redis.executePipelined((RedisCallback<Object>) conn -> {
for (Long pid : batch) {
Product product = productMap.get(pid);
if (product != null) {
String key = ("mall:cache:product:" + pid).getBytes();
String value = serialize(product);
int ttl = 600 + ThreadLocalRandom.current().nextInt(120);
conn.setEx(key, ttl, value.getBytes());
}
}
return null;
});
// 统计成功/失败
for (Object result : results) {
if (result instanceof Exception) {
failed++;
} else {
success++;
}
}
}
long elapsed = System.currentTimeMillis() - start;
log.info("缓存预热完成: total={}, success={}, failed={}, elapsed={}ms",
total, success, failed, elapsed);
return new WarmUpResult(total, success, failed, elapsed);
}
}
3.9 实战七:使用 redis-benchmark 对比 Pipeline 效果
# 无 Pipeline:逐条 PING(模拟逐条写)
docker exec redis-lab redis-benchmark -a redis123 -t ping -n 10000 -c 1 -q
# PING: 15678.90 requests per second
# 有 Pipeline:一次发 16 条命令
docker exec redis-lab redis-benchmark -a redis123 -t ping -n 10000 -c 1 -P 16 -q
# PING: 125000.00 requests per second ← 吞吐提升约 8 倍!
# 不同 Pipeline 大小的对比:
# -P 1: 约 15000 QPS (无 Pipeline)
# -P 4: 约 50000 QPS
# -P 8: 约 85000 QPS
# -P 16: 约 125000 QPS
# -P 32: 约 140000 QPS (收益递减)
Pipeline 的最佳实践:
- 批量大小建议控制在 500-2000 条命令之间——太小收益不够,太大增加内存峰值和延迟尖刺。
- 不要在 Pipeline 中混合读写依赖——Pipeline 中命令是顺序执行的,但客户端不会等前一条返回再发下一条。
- 注意 Redis 的输出缓冲区限制——
client-output-buffer-limit如果太小,大量 Pipeline 响应可能被截断。
3.10 实战八:Pipeline 错误处理与重试
Pipeline 中某条命令失败不会影响其他命令,需要逐条检查:
public class RobustPipelineService {
public BatchResult<Integer> batchIncrWithRetry(List<String> keys, int delta, int maxRetries) {
List<String> failedKeys = new ArrayList<>();
int successCount = 0;
for (int batch = 0; batch < keys.size(); batch += BATCH_SIZE) {
int end = Math.min(batch + BATCH_SIZE, keys.size());
List<String> currentBatch = keys.subList(batch, end);
List<Object> results = redis.executePipelined((RedisCallback<Object>) conn -> {
for (String key : currentBatch) {
conn.stringCommands().incrBy(key.getBytes(), delta);
}
return null;
});
// 逐条检查结果
for (int i = 0; i < results.size(); i++) {
Object result = results.get(i);
if (result instanceof Long) {
successCount++;
} else if (result instanceof Exception) {
String key = currentBatch.get(i);
log.error("Pipeline 命令失败: key={}, error={}", key, result);
failedKeys.add(key);
}
}
}
// 失败重试(单独执行,不放在 Pipeline 中)
if (!failedKeys.isEmpty() && maxRetries > 0) {
log.warn("Pipeline 部分失败, 重试 {} 个 key", failedKeys.size());
BatchResult<Integer> retryResult = batchIncrWithRetry(failedKeys, delta, maxRetries - 1);
successCount += retryResult.getSuccessCount();
}
return new BatchResult<>(keys.size(), successCount, failedKeys.size());
}
}
3.11 实战九:WATCH 与 Pipeline 不能混用
一个常见误用:想在 WATCH 的同时用 Pipeline 提升性能——这是不兼容的。
// 错误组合!WATCH 后使用 Pipeline 会导致 WATCH 失效
// 因为 Pipeline 中的命令不会实时返回结果,WATCH 无法正确判断
// 正确做法:
// 方案 1: WATCH + 逐条命令 + MULTI/EXEC
// 方案 2: 直接用 Lua(最推荐,简单可靠)
// Lua 实现余额扣减(替代 WATCH 重试)
private static final String DEDUCT_LUA =
"local balance = tonumber(redis.call('GET', KEYS[1]) or '0'); " +
"local amount = tonumber(ARGV[1]); " +
"if balance < amount then return -1 end; " +
"redis.call('DECRBY', KEYS[1], amount); " +
"return balance - amount";
public long deductWithLua(String userId, long amount) {
String key = "mall:account:user:" + userId;
Long result = redis.eval(DEDUCT_LUA,
Collections.singletonList(key),
Collections.singletonList(String.valueOf(amount)));
return result != null ? result : -1;
}
3.12 实战十:Pipeline 输出缓冲区监控
Pipeline 的一个隐藏风险——如果客户端发送大量命令但 Redis 响应慢,输出缓冲区可能积压:
# 查看输出缓冲区配置
127.0.0.1:6379> CONFIG GET client-output-buffer-limit
# 输出: normal 0 0 60, replica 256mb 64mb 60, pubsub 32mb 8mb 60
# normal: 硬限制0(不限制) 软限制0 超时60秒
# 查看客户端的输出缓冲区状态
127.0.0.1:6379> CLIENT LIST | grep "omem="
# 如果 omem 值很大,说明该客户端的输出缓冲区积压严重
Pipeline 中的客户端如果接收响应慢,响应会堆积在 Redis 的输出缓冲区中,不仅浪费内存,还可能导致 Redis 主动断开连接。最佳实践:Pipeline 批次大小不超过 2000 条;客户端必须及时消费 Pipeline 的响应;为 Pipeline 设置合理的超时。
| 场景 | 推荐批次大小 | 原因 |
|---|---|---|
| 批量写小数据 | 500-2000 | 平衡吞吐与内存 |
| 批量读小 key | 200-500 | 响应包可能较大 |
| 批量写大 value | 50-200 | 避免输出缓冲区溢出 |
| 跨机房操作 | 100-500 | 网络延迟放大 |
4. 项目总结
4.1 三者边界一句话总结
| 方案 | 一句话 |
|---|---|
| Pipeline | "少跑几趟,每条还是各管各的" |
| MULTI/EXEC | "排好队一起执行,但错了不负责" |
| WATCH+MULTI | "乐观锁,谁改了我的数据我就重来" |
| Lua | "全在 Redis 里搞定,谁也别插队" |
4.2 常见踩坑经验
| 案例 | 表象 | 根因 | 解法 |
|---|---|---|---|
| Pipeline 包太大 | 执行到一半报 OOM | 一次发 100 万条命令,客户端和服务端内存都爆 | 分批发送,每批 500-2000 条 |
| WATCH 重试死循环 | 余额扣减接口一直重试不返回 | 热点账户高并发,每次 WATCH 都冲突 | 热点账户改用 Lua;或用队列串行化 |
| 事务中混合 WATCH | WATCH 在 MULTI 之后执行 | WATCH 必须在 MULTI 之前调用 | WATCH → GET → MULTI → 入队 → EXEC |
| 误以为 Pipeline 原子 | 积分和排名不一致 | Pipeline 中两个命令之间被其他客户端插入 | 需要原子性用 Lua |
4.3 思考题
-
场景题:积分系统需要在"增加积分"的同时"更新排名"和"记录积分流水"(三个操作需要原子性)。请设计 Lua 脚本实现这个组合操作。
-
优化题:WATCH 在冲突高的场景下重试次数多、性能差。除了改用 Lua,还有什么方案可以降低 WATCH 的冲突率?
(答案提示将在下一章末尾给出。)
上一章思考题答案提示(第13章):
第1题(热点库存拆分):方案一——水平拆分,将 100 件库存拆成 10 个 key,每个 10 件(
stock:sku1001:0到stock:sku1001:9)。用户请求时随机/哈希选一个 key,用 Lua 扣减。方案二——令牌桶,不存库存数,而是用 List 预存 100 个令牌,用LPOP原子弹出——拿到令牌的算抢到,没拿到的就是抢完。第2题(Lua 无限循环):脚本会持续占用 Redis 主线程,所有其他客户端全部阻塞。如果脚本没有执行写操作(如纯计算死循环),可以用
SCRIPT KILL终止。如果脚本已执行了写操作——SCRIPT KILL无效,只能重启 Redis。这对生产的启示:① 永远不要在 Lua 中写无上限的循环;② 设置lua-time-limit(默认 5000ms),超时后 Redis 会记录日志但并不自动终止。
延伸阅读与资源
Redis 8 实战精讲:从 CRUD 到源码,构建高可用缓存系统
Java 工程师进阶:从 JVM 生产排障到OpenJDK原理
Nacos 3.x 注册配置中心实战修炼:从入门到源码扩展
Elasticsearch从入门到进阶的实战之旅
MySQL Server 9从入门到进阶的实战之旅
RabbitMQ从入门到进阶的实战之旅:从单机到大促高可用架构
Celery 入门到进阶之路:从异步任务到自研调度平台
LangGraph 生产级实战进阶:从零到生产级Agent工作流开发
Dify 从入门到源码:LLM 应用平台实战修炼
从零到生产级:FastAPI 异步高并发、源码与 SRE 实战
实战SQLAlchemy 2.0: 从 CRUD 到生产级架构
从零打造企业级 AI 助手:LangChain RAG、Agent 与生产实战
后端工程师 AI 转型课:Ollama 私有化大模型从入门到生产
MongoDB 实战进阶与内核修炼
NumPy 从入门到生产落地:全链路实战指南(科学计算/向量化/性能调优)
Milvus向量数据库实战修炼:从 0 到 1 精通向量检索与生产落地
Python 3实战精进:从脚本到高并发订单引擎
python入门:Rquests从菜鸟脚本到企业级SDK的网络实战圣经

微信公众号: 架构师日常笔记 欢迎关注!
浙公网安备 33010602011771号