资源不会消失,只会泄露

2 min read
Zekari
系统设计并发控制资源管理原子操作分布式系统

钱去哪了

用户点击生成。系统检查积分。扣除积分。创建任务。

看起来完美。

但如果用户同时点击100次会发生什么?

第一个时间窗口:并发请求之间

💡 Click the maximize icon to view in fullscreen

每个请求都看到同样的积分余额。

每个请求都认为积分充足。

每个请求都成功扣除积分。

结果:用户用10积分,创建了100个任务。

问题不在用户的手速。问题在时间窗口。

从"查询余额"到"扣除积分"之间,存在一个时间窗口。在这个窗口里,其他请求可以插队。

让操作不可分割

解决方案不是让系统更快。是让操作不可分割。

CREATE FUNCTION create_job_and_charge_credits(
  p_user_id UUID,
  p_credit_cost INTEGER
) RETURNS TABLE(job_id UUID, success BOOLEAN) AS $
DECLARE
  v_current_credits INTEGER;
BEGIN
  -- 🔒 行级锁定:FOR UPDATE
  SELECT video_credits INTO v_current_credits
  FROM profiles
  WHERE id = p_user_id
  FOR UPDATE;

  -- 检查积分
  IF v_current_credits < p_credit_cost THEN
    RETURN QUERY SELECT NULL::UUID, FALSE;
    RETURN;
  END IF;

  -- 扣除积分 + 创建任务(原子操作)
  UPDATE profiles
  SET video_credits = video_credits - p_credit_cost
  WHERE id = p_user_id;

  INSERT INTO ai_generations (user_id, credits_charged, status)
  VALUES (p_user_id, p_credit_cost, 'pending')
  RETURNING id INTO v_job_id;

  RETURN QUERY SELECT v_job_id, TRUE;
END;
$ LANGUAGE plpgsql;

一个函数。一次调用。没有时间窗口。

FOR UPDATE 的作用:当第一个请求锁定用户行时,其他99个请求必须等待。等待第一个请求完成。等待积分被正确扣除。

不是预防并发。是让并发变得安全。

第二个时间窗口:回调永不到达

任务提交给第三方厂商。等待回调通知结果。

看起来合理。

但如果回调永远不来会发生什么?

💡 Click the maximize icon to view in fullscreen

积分已扣除。任务状态"处理中"。永远处理中。

这不是小概率事件:

  • 网络分区
  • 厂商Bug
  • 配置错误

回调可能永远不来。

主动验证而非被动等待

解决方案不是更可靠的回调。是主动验证。

// Cron: 每30分钟执行
async function cleanZombieTasks() {
  const THRESHOLD = 30; // 分钟
  const cutoffTime = new Date(Date.now() - THRESHOLD * 60 * 1000);

  // 查询僵尸任务
  const zombieTasks = await database
    .select('*')
    .from('ai_generations')
    .whereIn('status', ['submitted_to_vendor', 'processing'])
    .where('updated_at', '<', cutoffTime);

  for (const task of zombieTasks) {
    // 主动查询厂商状态
    const vendorStatus = await queryVendorStatus(task.vendor_task_id);

    if (vendorStatus.found) {
      // 厂商有记录,更新状态
      await updateTaskStatus(task.id, vendorStatus.status);
    } else {
      // 厂商也找不到,退款
      await markTaskAsFailed(task.id, 'vendor_lost_task');
      await refundCredits(task.user_id, task.credits_charged);
    }
  }
}

不依赖对方的通知。

每30分钟,系统主动查询超时任务。找到任务就更新状态。找不到任务就退款。

不是相信承诺。是主动验证。

两个时间窗口的共同点

都是对"理想情况"的过度信任。

第一个窗口:相信"检查"和"扣除"之间没有人插队。

第二个窗口:相信第三方一定会通知。

时间窗口不是Bug。是系统设计的必然结果。任何需要"多步操作"的流程,都存在时间窗口。任何依赖"外部通知"的系统,都可能等不到通知。

资源管理的本质

资源不会消失。只会泄露。

泄露有两个来源:

  1. 外部:用户的并发请求
  2. 内部:系统自身的缺陷

解决方案不是预防所有问题。是确保问题发生时,资源能被正确回收。

原子操作:把多步变一步,消除外部时间窗口。把"查询"和"扣除"合并成一个不可分割的操作。

主动清理:不等通知,主动验证,消除内部时间窗口。不依赖回调,定期扫描僵尸任务。

关于原子操作的更深入讨论,可以参考 rpc-atomic-operations

关于幂等性检查如何防止重复扣费,参考 idempotency-check

最后

系统设计不是写出"正常情况下工作"的代码。

是写出"异常情况下也能恢复"的代码。

不是预防所有失败。是确保失败可恢复。

资源泄露是必然的。让资源可回收,才是关键。

Related Posts

Articles you might also find interesting

实现幂等性处理,忽略已处理的任务

3 min read

在代码层面识别和忽略已处理的任务,不是简单的布尔检查,而是对时序、并发和状态的深刻理解

系统设计并发控制
Read More

监听 Redis 连接事件 - 让不可见的脆弱变得可见

4 min read

连接看起来应该是透明的。但当它断开时,你才意识到透明不等于可靠。监听不是多余,而是对脆弱性的承认。

系统设计Redis
Read More

RPC函数的原子化处理

1 min read

当一个远程函数做太多事情,失败就变得难以理解

分布式系统RPC
Read More

RPC函数

2 min read

关于远程过程调用的本质思考:当你试图让远方看起来像眼前

分布式系统RPC
Read More

幂等性检查

1 min read

在不确定的系统中,幂等性检查是对重复的容忍,是对稳定性的追求,也是对失败的预期与接纳

系统设计分布式系统
Read More

管理后台需要两次设计

3 min read

第一次设计回答"发生了什么",第二次设计回答"我能做什么"。在第一次就试图解决所有问题,结果是功能很多但都不够深入。

系统设计API 设计
Read More

告警分级与响应时间

2 min read

不是所有问题都需要立即响应。RPC失败会在凌晨3点叫醒人。安全事件每15分钟检查一次。支付成功只记录,不告警。系统的响应时间应该匹配问题的紧急程度。

系统设计监控
Read More

文档标准是成本计算的前提

3 min read

API文档不只是写给开发者看的。它定义了系统的边界、成本结构和可维护性。统一的文档标准让隐性成本变得可见。

API文档
Read More

BullMQ 队列

3 min read

队列不是技术选型,而是对时间的承认,对顺序的尊重,对不确定性的应对

系统设计异步处理
Read More

BullMQ Worker

2 min read

Worker 的本质是对时间的重新分配,是对主线的解放,也是对专注的追求

系统设计异步处理
Read More

配置不会自动同步

2 min read

视频生成任务永远pending,代码完美部署,队列正确配置。问题不在代码,在于配置的独立性被低估。静默失败比错误更危险。

部署配置管理
Read More

CRUD 操作

2 min read

四个字母背后,是数据的生命周期,是权限的边界,也是系统设计的基础逻辑

系统设计软件工程
Read More

数据库参数国际化:从 13 个迁移学到的设计原则

3 min read

数据不该懂语言。当数据库参数嵌入中文标签时,系统的边界就被语言限制了。这篇文章从 13 个参数对齐迁移中提炼出设计原则——国际化不是功能,是系统设计的底层约束。

数据库国际化
Read More

Stripe Webhook中的防御性编程

2 min read

三个Bug揭示的真相:假设是代码中最危险的东西。API返回类型、环境配置、变量作用域——每个看似合理的假设都可能导致客户损失。

Web开发系统设计
Read More

双重验证:Stripe生产模式的防御性切换

7 min read

从测试到生产不是更换API keys,而是建立一套双重验证系统。每一步都在两个环境中验证,确保真实支付不会因假设而失败。

系统设计Stripe
Read More

端到端 Postback 模拟测试

2 min read

真实的测试不是模拟完美的流程,而是重现真实世界的混乱。Postback 测试的价值在于发现系统在不确定性中的表现。

测试API
Read More

错误隔离

3 min read

失败是必然的。真正的问题不是失败本身,而是失败如何蔓延。错误隔离不是为了消除失败,而是为了控制失败的范围。

系统设计可靠性工程
Read More

在运行的系统上生长新功能

3 min read

扩展不是推倒重来,而是理解边界,找到生长点。管理层作为观察者和调节器,附着在核心系统上,监测它,影响它,但不改变它的运行逻辑。

系统设计架构
Read More

单例模式管理 Redis 连接

5 min read

连接不是技术细节,而是系统与外部世界的第一次握手,是可靠性的起点

系统设计后端架构
Read More

缺失值的级联效应

3 min read

一个NULL值如何在调用链中传播,最终导致错误的错误消息。理解防御层的设计,在失败传播前拦截。

系统设计防御性编程
Read More

监控观察期法

3 min read

部署不是结束,而是验证的开始。修复代码只是假设,监控数据才是证明。48小时观察期:让错误主动暴露,让数据证明修复。

系统设计监控
Read More

Props Drilling

3 min read

数据在组件树中层层传递,每一层都不需要它,却必须经手。这不是技术债,是架构对真实需求的误解。

React组件设计
Read More

队列生产者实例的工厂函数

4 min read

工厂函数不是设计模式的炫技,而是对重复的拒绝,对集中管理的追求,对变化的准备

系统设计设计模式
Read More

队列、可靠性与系统边界

1 min read

探讨消息队列系统如何通过时间换空间,用异步换解耦,以及可靠性背后的权衡

架构可靠性
Read More

使用Secret Token验证回调请求的合法性

2 min read

在开放的网络中,信任不能被假设。Secret Token 是对身份的确认,对伪装的识别,也是对安全边界的坚守

Web 安全系统设计
Read More