多租户配额怎么控制?用量口径、并发预留与超额处理的实现方法

甲公司的套餐允许每自然月创建 30 个可执行报表任务。管理页面显示"还剩 1 次",两名成员几乎同时点击运行。两个请求都先查询到已用 29 次,于是都放行,月底账本变成 31/30。按钮没有问题,数据库也没有报错;错误在于把读剩余量 和占用名额拆成了两个不受同一事务保护的动作。

这篇用一个虚构租户和两次并发提交,说明配额到底统计什么、为什么要预留、任务失败后如何结算,以及怎样通过测试和 SQL 证明没有超卖。环境为 Java 17、Spring Boot 风格服务层和 MySQL 8.0.16 及以上。数字仅用于演示,不对应实际产品的套餐配置。

目录

  1. 固定输入:两次请求争最后一个名额
  2. 先区分额度、用量、并发和限流
  3. 用计数桶与预留记录构造可核对的数据模型
  4. 提交与启动如何在事务中推进
  5. 失败释放、重试与跨月边界
  6. [自动测试、SQL 验证与上线验收](#自动测试、SQL 验证与上线验收)

一、固定输入:两次请求争最后一个名额

沿用前文的虚构甲公司 t_a。10 月 17 日 12:00 升级到 PRO v4 后,report_jobs 的示例上限由 20 调整为 30 次/自然月。本文约定:**成功提交时预留 1 次,Worker 第一次成功启动时转为已消耗;启动前因业务阻塞或确定不会执行而关闭任务时释放;启动后的失败仍计入已消耗。**这是一个明确的示例政策,其他产品可以选择不同口径,但必须前后一致。

10 月 18 日 10:30 的本月账本如下:

指标 值 含义
当前套餐上限 30 来自 PRO v4 的有效权益
已消耗 consumed 28 已真正启动的报表任务
已预留 reserved 1 已提交、尚未启动的任务 j_old
可新预留 1 30 - 28 - 1

成员 u_07 用请求号 req-A 提交,成员 u_08 用 req-B 几乎同时提交。两人都具备动作权限,目标数据也属于甲公司;如果只看身份和套餐能力,两人都可进入配额检查。预期只能有一个请求拿到最后一个名额 ,另一个得到 QUOTA_EXCEEDED,且失败方不能产生任务或预留记录。成功方重试相同请求号应返回原任务,不再占第二次额度。

图1:两个请求都看到"剩 1 次"并不代表都能拿到名额,预留必须串行确定。

二、先区分额度、用量、并发和限流

"配额"不是一种万能计数器。至少要区分四类问题,否则表面上都叫 limit_value,实现却会互相误用:

类型 典型问题 合适的口径 常见控制方式
周期事件数 每月最多启动多少报表任务 某租户、某指标、某自然月的消耗与预留 事务计数桶 + 幂等预留记录
当前资源量 存储总量是否超过 100 GiB 当前实际字节 + 在途上传预留字节 上传前预留,落盘后按实际大小结算,定期对账
同时运行数 最多并行处理几个任务 当前有效运行租约数 带过期时间的信号量或租约,崩溃后回收
请求速率 每分钟最多调用多少次接口 滑动时间窗或令牌补充速率 网关/服务端令牌桶等限流机制

本文实现第一种,即月度任务事件数。它不等于网关限流:限流可以挡住瞬间流量,但不知道一个请求是否已经生成业务任务,也无法准确处理任务取消与幂等重试。它也不等于存储容量:一个任务计数是离散的 1,文件大小是持续变化的字节数,删除文件后的资源回收规则不同。

计数周期也要定清。这里按业务时区 Asia/Shanghai 的自然月,10 月窗口为北京时间 [10-01 00:00, 11-01 00:00),存入数据库时对应 UTC [2026-09-30 16:00, 2026-10-31 16:00)。任务归属于提交时占用的那个窗口;跨月启动不把它偷偷搬到 11 月。若产品要按启动月扣费,那预留策略与跨月迁移就要另行设计,不能混用本文的口径。

中途从 20 升到 30 时,本月已有消耗和预留不归零,只是后续提交拿新的上限做判断;如果降级后的上限低于已占用量,现有任务不因计数变小而被抹掉,新预留全部拒绝。新建能力本身失效时,即使数学上还有空额,也不得继续预留。

图2:每月次数适合事务账本;容量、同时运行数和请求速率需要各自的统计语义。

三、用计数桶与预留记录构造可核对的数据模型

只在租户表上放一个 used_count 虽然能拦住一部分并发,却解释不了"这 1 次被哪个任务占用""重试是不是重复扣了""为什么失败后释放"。因此本例把快速判定用的计数桶 与逐任务的预留事实 分开。沿用上一篇的 demo_report_job(job_id, tenant_id, request_key, state, ...),新增两张表:

sql 复制代码
-- 上篇已有普通索引 ix_report_tenant_job;这里替换为复合唯一键,
-- 让预留记录可以通过 (tenant_id, job_id) 外键绑定任务归属。
ALTER TABLE demo_report_job
  DROP INDEX ix_report_tenant_job,
  ADD UNIQUE KEY uq_report_tenant_job (tenant_id, job_id);

CREATE TABLE demo_quota_bucket (
  tenant_id VARCHAR(32) NOT NULL,
  metric_key VARCHAR(64) NOT NULL,
  window_start DATETIME(6) NOT NULL,
  window_end DATETIME(6) NOT NULL,
  consumed BIGINT NOT NULL DEFAULT 0,
  reserved BIGINT NOT NULL DEFAULT 0,
  PRIMARY KEY (tenant_id, metric_key, window_start),
  CONSTRAINT ck_bucket_time CHECK (window_start < window_end),
  CONSTRAINT ck_bucket_count CHECK (consumed >= 0 AND reserved >= 0)
) ENGINE=InnoDB;

CREATE TABLE demo_quota_reservation (
  job_id VARCHAR(36) PRIMARY KEY,
  tenant_id VARCHAR(32) NOT NULL,
  metric_key VARCHAR(64) NOT NULL,
  window_start DATETIME(6) NOT NULL,
  request_key VARCHAR(64) NOT NULL,
  reservation_state VARCHAR(16) NOT NULL,
  created_at DATETIME(6) NOT NULL,
  changed_at DATETIME(6) NOT NULL,
  CONSTRAINT uq_quota_request UNIQUE
    (tenant_id, metric_key, request_key),
  CONSTRAINT fk_quota_job FOREIGN KEY (tenant_id, job_id)
    REFERENCES demo_report_job (tenant_id, job_id),
  CONSTRAINT fk_quota_bucket FOREIGN KEY
    (tenant_id, metric_key, window_start)
    REFERENCES demo_quota_bucket (tenant_id, metric_key, window_start),
  CONSTRAINT ck_reservation_state CHECK
    (reservation_state IN ('RESERVED', 'COMMITTED', 'RELEASED'))
) ENGINE=InnoDB;

这两张表的关键不变量是:bucket.reserved = 本窗口 RESERVED 记录数,bucket.consumed = 本窗口 COMMITTED 记录数。因为每个报表任务占 1 次,可以按行数对账;若指标改为字节数,预留事实还需保存 quantity,再按数量求和。RELEASED 记录不能删除,否则重复回调或历史审计失去依据。

demo_report_job 已有按租户和请求号的唯一键;预留表再次保存 request_key 是为了独立验证配额事实。这里把上篇的普通 (tenant_id, job_id) 索引替换为唯一键,再让预留记录用复合外键引用它,防止预留的 tenant_id 与任务行不一致。复合外键仍不能代替业务接口的身份、动作和源数据归属校验。

首次访问新月份时,桶可能还不存在。创建桶也必须以唯一键为准,例如事务中执行一次按 (tenant_id, metric_key, window_start) 的幂等插入,再 SELECT ... FOR UPDATE;不能先 SELECT 发现不存在就各自插入不同的行。窗口开始、结束应从服务端固定的指标配置计算,不能让客户端自己传一个月份来绕过配额。

sql 复制代码
INSERT INTO demo_quota_bucket
  (tenant_id, metric_key, window_start, window_end, consumed, reserved)
VALUES ('t_a', 'report_jobs', '2026-09-30 16:00:00',
        '2026-10-31 16:00:00', 0, 0)
ON DUPLICATE KEY UPDATE tenant_id = tenant_id;

SELECT consumed, reserved FROM demo_quota_bucket
WHERE tenant_id = 't_a' AND metric_key = 'report_jobs'
  AND window_start = '2026-09-30 16:00:00'
FOR UPDATE;
-- 两句必须在同一事务内;实际窗口值由服务端计算,不接受客户端原样输入。

如果桶已存在,服务端还要核对其 window_end 与当前指标配置一致;周期或时区规则变更不能悄悄复用含义不同的旧桶。权益中缺少 report_jobs 上限,也应视为配置不完整并拒绝新预留,不能把缺省值当成无限额。

图3:桶负责快速判断,预留记录负责解释每次变化;两者必须能相互对账。

四、提交与启动如何在事务中推进

上限来自当前租户的有效权益 ,不是桶里的一个永久 limit=30。提交顺序建议是:先确认可信租户、成员动作、目标归属和 batch_report 能力;进入事务后按固定顺序锁住租户行,重新读取足够新的权益,再锁当前月桶、检查重复请求与剩余量,最后在同一事务里创建任务、预留记录并增加 reserved。套餐变更也锁同一租户行,避免在提交事务中途悄悄切换上限。

下面是核心服务代码,仓储方法对应同一数据库事务;QuotaWindow.forMonth 使用服务端配置的时区计算窗口。演示里任务标识由服务端生成,数据库唯一键仍是最终并发保护。

java 复制代码
record QuotaWindow(Instant start, Instant end) {
    static QuotaWindow forMonth(Instant at, ZoneId zone) {
        YearMonth month = YearMonth.from(at.atZone(zone));
        Instant start = month.atDay(1).atStartOfDay(zone).toInstant();
        Instant end = month.plusMonths(1).atDay(1)
                           .atStartOfDay(zone).toInstant();
        return new QuotaWindow(start, end);
    }
}
record QuotaBucket(long consumed, long reserved) {}
record Entitlement(boolean batchReport, long reportJobLimit,
                   String sourceRef) {}
record Actor(String userId, String tenantId) {}
record AcceptedJob(String jobId, String sourceId, String creatorId,
                   String state) {}

interface SubmissionGate {
    void requireMemberActionAndSource(Actor actor, String sourceId);
}
interface EntitlementService {
    Entitlement currentFromDatabase(String tenantId, Instant at);
}

interface QuotaStore {
    void lockTenant(String tenantId);
    Optional<AcceptedJob> byRequestKey(String tenantId, String requestKey);
    void ensureBucket(String tenantId, String metricKey, QuotaWindow window);
    QuotaBucket lockBucket(String tenantId, String metricKey, Instant windowStart);
    void insertPendingJob(String jobId, String tenantId, String sourceId,
                          String userId, String requestKey, String entitlementRef);
    void insertReservation(String jobId, String tenantId, String metricKey,
                           Instant windowStart, String requestKey);
    void addReserved(String tenantId, String metricKey, Instant windowStart, long delta);
}

final class QuotaExceeded extends RuntimeException {}

final class ReportSubmission {
    private static final String METRIC = "report_jobs";
    private static final ZoneId ZONE = ZoneId.of("Asia/Shanghai");
    private final QuotaStore store;
    private final EntitlementService rights;
    private final SubmissionGate gate;

    @Transactional
    AcceptedJob submit(Actor actor, String sourceId, String requestKey, Instant at) {
        store.lockTenant(actor.tenantId());
        gate.requireMemberActionAndSource(actor, sourceId);
        var existing = store.byRequestKey(actor.tenantId(), requestKey);
        if (existing.isPresent()) {
            AcceptedJob prior = existing.get();
            if (!prior.sourceId().equals(sourceId)
                || !prior.creatorId().equals(actor.userId()))
                throw new IllegalArgumentException("IDEMPOTENCY_CONFLICT");
            return prior;
        }

        Entitlement grant = rights.currentFromDatabase(actor.tenantId(), at);
        if (!grant.batchReport()) throw new Denied("CAPABILITY_NOT_ACTIVE");

        QuotaWindow window = QuotaWindow.forMonth(at, ZONE);
        store.ensureBucket(actor.tenantId(), METRIC, window);
        QuotaBucket bucket = store.lockBucket(actor.tenantId(), METRIC, window.start());
        if (bucket.consumed() + bucket.reserved() >= grant.reportJobLimit())
            throw new QuotaExceeded();

        String jobId = UUID.randomUUID().toString();
        store.insertPendingJob(jobId, actor.tenantId(), sourceId,
                               actor.userId(), requestKey, grant.sourceRef());
        store.insertReservation(jobId, actor.tenantId(), METRIC,
                                window.start(), requestKey);
        store.addReserved(actor.tenantId(), METRIC, window.start(), 1);
        return new AcceptedJob(jobId, sourceId, actor.userId(), "PENDING");
    }
}

这里的 SubmissionGate 表示上一篇的有效成员、动作和目标归属校验,不重复展开;它必须使用服务端确认的账号与租户,不能信请求体伪造的 Actor。先验证这些事实再处理相同请求号,避免把幂等查询变成读取旧任务的旁路。rights.currentFromDatabase 应在租户锁取得后读取当前已提交的订阅事实,不能拿一次普通缓存查询代替事务内的权益判断。MySQL 默认隔离级别下还要避免在同一事务中先建立旧快照、后用锁定读取得新状态;实现时可统一采用当前读,或明确选择合适的事务隔离策略。若上限查询失败,不能把它解释为"无限额"。

Worker 第一次成功启动任务时,再用一个事务锁住相同租户与桶、任务和预留记录,把 RESERVED → COMMITTED,同时执行 reserved - 1, consumed + 1 并把任务设为 RUNNING。该动作只能成功一次;重复队列消息看到 COMMITTED 时不得再加 consumed。事务提交后才开始耗时计算。不同事务应遵守一致的锁顺序,例如租户 → 桶 → 任务/预留,减少死锁概率;即便如此仍需对死锁做有限次数的整事务重试。

以下是 Worker 确认当前能力仍有效 、任务仍为 PENDING 后的关键数据库推进。应用先按 job_id 读出可信的租户与窗口,再按统一顺序锁定租户、桶、任务和预留行;每一步受影响行数不为 1 就回滚,而不是继续执行后续语句:

sql 复制代码
START TRANSACTION;
-- 先锁 demo_tenant,再按预留记录的窗口锁 demo_quota_bucket,
-- 最后锁 demo_report_job 与 demo_quota_reservation;随后核对状态。
UPDATE demo_quota_reservation
SET reservation_state = 'COMMITTED', changed_at = UTC_TIMESTAMP(6)
WHERE job_id = :job_id AND reservation_state = 'RESERVED';
-- ROW_COUNT() 必须为 1;否则回滚或识别为已处理的重复消息。

UPDATE demo_quota_bucket
SET reserved = reserved - 1, consumed = consumed + 1
WHERE tenant_id = :tenant_id AND metric_key = 'report_jobs'
  AND window_start = :original_window_start AND reserved >= 1;
-- ROW_COUNT() 必须为 1。

UPDATE demo_report_job
SET state = 'RUNNING', started_at = UTC_TIMESTAMP(6)
WHERE job_id = :job_id AND tenant_id = :tenant_id AND state = 'PENDING';
-- ROW_COUNT() 必须为 1;全部成功后才 COMMIT 并启动实际计算。
COMMIT;

这段是事务内部的状态更新片段,不是可跳过前置锁与授权检查、直接在控制台执行的完整脚本。original_window_start 来自预留记录,不能重新按 Worker 启动时间算月份。释放路径类似,但必须把 RESERVED → RELEASED、桶 reserved - 1 和任务 BLOCKED 放在同一事务里。若权益服务暂时不可用,应保持预留并等待重新判定,不能当作明确撤销来释放。

图4:名额在锁内预留;启动时只在桶内转移状态,不再额外占 1 次。

五、失败释放、重试与跨月边界

"失败退不退"必须跟计量口径一致。本文把第一次启动视为消耗:

情形 预留记录 桶变化 任务处理
重复提交同一请求号 不新增 不变化 返回原任务与状态
提交事务中断 整个事务回滚 不变化 不留下半条任务
Worker 领取前能力被撤销 RESERVED → RELEASED reserved - 1 任务 BLOCKED
Worker 领取前确定不再执行 RESERVED → RELEASED reserved - 1 任务关闭并留原因
第一次成功启动 RESERVED → COMMITTED reserved - 1, consumed + 1 任务 RUNNING
已启动后计算失败 仍为 COMMITTED 不退 consumed 记录失败,可按原任务重试

最后一行尤其重要:如果"已启动失败"退回一次、重试又创建新任务,用户可能无限消耗昂贵计算却只占一个名额。反过来,系统在启动前故障、根本没有执行,就不该长期占住预留。实际产品是否对系统故障补偿额度,应由可审计的特殊补偿事件处理,不要靠人工直接改桶计数。

释放必须与任务变为 BLOCKED 或关闭状态同事务完成,且只能从 RESERVED 转一次。若 Worker 崩溃后任务一直 PENDING,恢复扫描先判定该任务是否仍可执行,再决定继续领取或明确释放;不能仅因"预留超过 5 分钟"就释放,同时让原队列消息稍后继续运行。否则会出现名额已退但工作仍执行。

跨月同样有细节:10 月 31 日 23:59 提交的任务,即使 11 月 1 日才启动,仍转移10 月桶 中的预留,不占 11 月新桶。这样账本归属与提交时承诺一致;若新周期或新套餐已经移除了能力,Worker 领取前的权益校验仍可阻止启动并释放原窗口预留。之前的"商业降级允许已启动任务完成"只适用于已成功转成 COMMITTED 的任务。

升降级时还要注意上限与已有占用的关系。本例在 10 月 17 日从 20 升到 30,既有 consumed + reserved 保留在同一十月桶;如果反向降到 20 而账本已有 25,后续新建一律拒绝,但历史 25 条不会自动减为 20。把额度变化写成"重置计数器",会破坏对账,也会让套餐切换成为绕过限额的办法。

六、自动测试、SQL 验证与上线验收

关键测试要用两个真正并发的请求争同一桶,而不是依次调用两次后检查第二次失败。下面用 JUnit 5 的测试夹具 Fixture 表达场景;夹具在 MySQL 测试库中建立上限 30、消耗 28、预留 1 的初始状态,使用屏障同时放行两个请求,并分别启动独立事务。

java 复制代码
@Test
void onlyOneOfTwoParallelRequestsGetsLastSlot() throws Exception {
    Fixture f = Fixture.withQuota("t_a", 30, 28, 1);
    var barrier = new CyclicBarrier(2);
    ExecutorService pool = Executors.newFixedThreadPool(2);
    try {
        var a = pool.submit(() -> f.submitAfter(barrier, "u_07", "req-A"));
        var b = pool.submit(() -> f.submitAfter(barrier, "u_08", "req-B"));
        var results = List.of(a.get(), b.get());

        assertEquals(1, results.stream().filter(Result::accepted).count());
        assertEquals(1, results.stream().filter(Result::quotaExceeded).count());
        assertEquals(28, f.bucket().consumed());
        assertEquals(2, f.bucket().reserved());
        assertEquals(1, f.jobs.countByKeys("req-A", "req-B"));
    } finally {
        pool.shutdownNow();
    }
}

@Test
void retryAndReleaseNeverDoubleCharge() {
    Fixture f = Fixture.withQuota("t_a", 30, 28, 1);
    var first = f.submit("u_07", "req-A");
    var retry = f.submit("u_07", "req-A");
    assertEquals(first.jobId(), retry.jobId());
    assertEquals(2, f.bucket().reserved());

    f.blockBeforeStart(first.jobId(), "CAPABILITY_NOT_ACTIVE");
    f.blockBeforeStart(first.jobId(), "CAPABILITY_NOT_ACTIVE"); // 重复消息
    assertEquals(1, f.bucket().reserved());
    assertEquals(28, f.bucket().consumed());
    assertEquals("RELEASED", f.reservation(first.jobId()).state());
}

@Test
void crossMonthStartAndDuplicateDeliveryUseOriginalBucketOnce() {
    Fixture f = Fixture.atMonthBoundary();
    var job = f.submitAt("2026-10-31T15:59:59Z", "req-last");
    f.startAt(job.jobId(), "2026-10-31T16:00:01Z");
    f.startAt(job.jobId(), "2026-10-31T16:00:02Z"); // 重复投递
    f.failAfterStart(job.jobId());

    assertEquals("COMMITTED", f.reservation(job.jobId()).state());
    assertEquals(1, f.octoberBucket().consumed());
    assertEquals(0, f.octoberBucket().reserved());
    assertEquals(0, f.novemberBucket().consumed());
}

测试夹具方法用于表达契约,需由项目中的测试数据库和仓储适配实现;仅用内存计数器跑并发测试不能证明 MySQL 行锁、唯一键与事务真的协同工作。还应测试:新月份首次创建桶的竞争、上限变更但用量不清零、跨月提交和启动、死锁重试不重复扣、Worker 启动后失败不退额、租户 A 的并发不影响租户 B。

验收 SQL 同时看计数桶和逐任务事实。以下以十月窗口为例:

sql 复制代码
SELECT tenant_id, metric_key, window_start, window_end,
       consumed, reserved, consumed + reserved AS occupied
FROM demo_quota_bucket
WHERE tenant_id = 't_a' AND metric_key = 'report_jobs'
  AND window_start = '2026-09-30 16:00:00';
-- 两请求竞争后预期:consumed=28,reserved=2,occupied=30。

SELECT reservation_state, COUNT(*) AS n
FROM demo_quota_reservation
WHERE tenant_id = 't_a' AND metric_key = 'report_jobs'
  AND window_start = '2026-09-30 16:00:00'
GROUP BY reservation_state;
-- 预期 COMMITTED=28、RESERVED=2;RELEASED 可有历史行但不占额度。

SELECT b.tenant_id, b.window_start, b.consumed, b.reserved,
       COALESCE(SUM(r.reservation_state = 'COMMITTED'), 0) AS ledger_consumed,
       COALESCE(SUM(r.reservation_state = 'RESERVED'), 0) AS ledger_reserved
FROM demo_quota_bucket b
LEFT JOIN demo_quota_reservation r
  ON r.tenant_id = b.tenant_id AND r.metric_key = b.metric_key
 AND r.window_start = b.window_start
WHERE b.tenant_id = 't_a' AND b.metric_key = 'report_jobs'
  AND b.window_start = '2026-09-30 16:00:00'
GROUP BY b.tenant_id, b.window_start, b.consumed, b.reserved;
-- 预期 consumed=ledger_consumed,reserved=ledger_reserved;不等则停下对账。

SELECT job_id, request_key, reservation_state
FROM demo_quota_reservation
WHERE tenant_id = 't_a' AND request_key IN ('req-A', 'req-B');
-- 预期仅一行;另一请求超额失败,不留下预留。

上面 SUM(条件) 使用 MySQL 布尔表达式的 0/1 求和,仅适用于每个任务恰好占 1 次的示例。实际金额、字节数或多单位资源不能照抄。上线监控至少区分 QUOTA_EXCEEDED、权益未开通、数据库锁等待/死锁、预留长期未结算和账本不平;只统计"接口成功率"看不出是否超卖或漏释放。

图5:正常路径与异常路径都要核对任务、预留记录和桶计数,不能只看接口响应。

本文的核心结论不是"所有配额都要加锁",而是先定义什么行为算消耗,再选与该口径匹配的原子控制点。月度任务数可以用事务桶与逐任务记录;存储容量、并发任务数和请求速率有不同的生命周期与回收机制。只要一个租户的上限、预留、已消耗和业务任务能互相解释,两个人同时点击时,系统才有理由只批准其中一个。

参考资料:

相关推荐
roman_日积跬步-终至千里1 小时前
【数据Agent论文·查询与语义|06】RAT-SQL:怎样把问题、表和字段真正关联起来?
数据库·sql·数据分析
盟接之桥1 小时前
盟接之桥谈线束数字化-按单入按单出:线束企业库存管理的破局之道
大数据·前端·网络·数据库·人工智能·制造
巴勒个啦2 小时前
Prompt Engineering 实战:5 个让 AI 生成生产级业务代码的工程模式
java·前端
倚楼听风语2 小时前
Kotlin 协程与 suspend 原理详解
java·算法·kotlin
Nturmoils2 小时前
数据集成软件 KFS:多套业务库汇聚时,先把来源和口径理清
数据库
麦壳饼2 小时前
SonnetDB:批量写入的大小、背压与刷新策略
数据库·sonnetdb
开开心心就好2 小时前
文件被占用删不掉?解锁工具强制删除
java·运维·服务器·开发语言·前端·pdf·ocr
weixin_460443562 小时前
Java+Linux私有化训考平台:服务、数据库与终端部署结构解析
java·linux·数据库·私有化部署·企业培训考试系统·企业考试系统
云和数据.ChenGuang2 小时前
python大小整数范围解析
java·开发语言·python