我扣库存的逻辑是先查出当前库存、减一、再写回,平时好好的,一上量就超卖、库存对不上账:一次数据库并发更新丢失、读改写非原子导致超卖的深度复盘

我扣库存的逻辑写得很直白——先 SELECT 查出当前库存,代码里减一,再 UPDATE 写回。平时流量小一直没问题,活动一放量并发一高就出事了:商品明明只有 100 件却卖出 100 多件超卖,对账时库存扣减数量和卖出订单数对不上。推演两个并发请求才看明白:这是更新丢失——A 查出 stock=100,B 也查出 100(都读到旧值),A 算 99 写回,B 也算 99 写回,B 把 A 的扣减覆盖了,扣了两次库存只减一次。根因是读-改-写三步非原子,而且仅仅包进事务在读已提交隔离级别下也防不住(普通 SELECT 不加锁)。这篇复盘从故障现场讲到更新丢失的本质、为什么包进事务不够,再到原子 UPDATE(stock=stock-1 WHERE stock>0)、乐观锁(version CAS+重试)、悲观锁(FOR UPDATE)的完整正解和选型,以及原子/乐观/悲观三种并发控制范式、单线程直觉在并发下失效、承认并直面代码所处的并发现实的认知。

我扣库存的逻辑是"先查出当前库存、减一、再写回",平时好好的,一上量就超卖、库存对不上账:一次数据库并发更新丢失的深度复盘

那个超卖是搞活动放量后才炸的:我有个扣库存的逻辑,写得很"直白"——SELECT stock FROM product WHERE id=1 查出当前库存,在代码里 stock - 1,再 UPDATE product SET stock=? WHERE id=1 把新值写回去。平时流量小,一直没问题;可活动一放量、并发一高,就出事了:商品明明只有 100 件,却卖出去了 100 多件(超卖);对账时发现库存扣减的数量,和实际卖出的订单数对不上。我对着这个"越并发越不准"的现象,在纸上推演了两个并发请求,才看明白,后背发凉:问题出在我"先查、再算、后写回"(read-modify-write)这个非原子的操作上,它在并发下会"更新丢失(lost update)"。设想两个并发请求 A 和 B 同时来扣库存(当前 stock=100):A 查出 stock=100,B 也查出 stock=100(它俩几乎同时查,都读到了 100);A 算出 100-1=99,写回 99;B 也算出 100-1=99,写回 99;结果:两个请求各扣了一件、本该 stock=98,可数据库里却是 99——B 的写,把 A 已经扣过的那一件"覆盖丢失"了;也就是说,两次扣减,只在库存上体现了一次,凭空多出了一件可卖的库存,并发越高、这种丢失越多,就超卖了。根本原因是:"读-改-写"这三步之间没有任何保护,两个并发事务的读都读到了"旧值",各自基于旧值算完再写回,后写的覆盖了先写的。问题的根,是用"先查再算后写回"扣库存:这三步非原子,并发下两个事务读到同一个旧值、各自算完写回,后者覆盖前者,导致更新丢失、超卖。这篇就把这次"并发更新丢失"的坑,从头到尾复盘一遍。

故障现场:先查、减一、再写回,并发下丢更新

问题在于"读-改-写"非原子,并发下两个事务读到同一旧值、后写覆盖先写:

// ✗ 出问题的扣库存: 先查出库存, 代码里减1, 再写回
public void deductStock(long productId) {
    // 1. 查出当前库存
    int stock = db.queryInt("SELECT stock FROM product WHERE id=?", productId);
    if (stock <= 0) throw new RuntimeException("库存不足");
    // 2. 代码里算新值
    int newStock = stock - 1;
    // 3. 写回
    db.update("UPDATE product SET stock=? WHERE id=?", newStock, productId);
}

// 并发竞态推演(两个并发请求 A、B, 当前 stock=100):
//   A: SELECT → 读到 stock=100
//   B: SELECT → 读到 stock=100   (A还没写回, B也读到旧值100)
//   A: 算 100-1=99, UPDATE stock=99
//   B: 算 100-1=99, UPDATE stock=99  ← B把99又写了一遍, 覆盖了A的扣减!
//   结果: 扣了两次, 库存却只从100变到99(本该98) → 凭空多了1件可卖 → 超卖。

// 为什么? "读-改-写"(read-modify-write)三步非原子:
// - "读"和"写"之间有时间差; 并发的A、B都在这个时间差里读到了【同一个旧值100】;
// - 它们各自基于这个旧值算新值(都算成99), 再写回;
// - → 后写的(B)覆盖了先写的(A)的结果 → A的那次扣减"丢失"了 → 这叫"更新丢失(lost update)"。

// 错误的"修补": 加个 WHERE stock=? 也不够稳, 或以为加事务就行——
//   注意: 仅仅把这三步包进一个事务, 在常见的"读已提交(RC)"隔离级别下, 【并不能】阻止这种更新丢失!
//   因为RC下两个事务的SELECT仍可能都读到旧值(普通SELECT不加锁)。

// 关键: "先查再算后写回"的读-改-写非原子, 并发下多个事务读到同一旧值、各自算完写回, 后者覆盖前者,
//       导致更新丢失、库存扣减不准、超卖 —— 并发修改同一数据, 不能用"查出来改了再写回"。

第一次在纸上推演出"A、B 都读到 100,都写回 99,A 的扣减被覆盖了"时,我又懊恼又警醒:"我以为'查出来、减一、写回去'天经地义,完全没想到在并发下,两个请求会读到同一个旧值、然后互相覆盖,凭空把库存'变多'了。"这个坑最隐蔽的地方在于:只在并发(高流量)下才发作,低流量、单元测试时完全正常(请求都是串行的,读到的总是最新值);只有放量、并发起来,"读和写之间的时间差"被多个请求挤进来,才暴露;而且超卖了数据库也不报错,只是数据悄悄不对,直到对账才发现下面就来拆解,并发扣库存到底该怎么写。

第一件事:搞懂并发下的"更新丢失"

我顺着这次事故,把并发更新丢失的成因和几种解法彻底理清了。

并发"更新丢失(lost update)"是怎么发生的? 怎么避免?

【核心: "读-改-写"非原子, 并发事务读到同一旧值各自算完写回、后者覆盖前者; 解法: 原子UPDATE/乐观锁/悲观锁, 把"读改写"变原子或串行】

1. 更新丢失的本质: 读-改-写 非原子
   - 一个"基于当前值算新值"的更新(库存-1、余额+x、计数++), 要经历"读当前值→算新值→写回"三步;
   - 这三步【不是原子的】, "读"和"写"之间有时间窗口;
   - 并发的多个请求都在这个窗口里读到【同一个旧值】, 各自算完写回 → 后写的覆盖先写的 → 丢失了中间的更新。

2. 为什么"包进事务"不一定够:
   - 在"读已提交(RC, MySQL默认是RR但很多场景表现类似)"等隔离级别下, 普通SELECT不加锁;
   - 两个事务的SELECT仍可能都读到旧值 → 事务隔离≠自动解决更新丢失;
   - (RR可重复读也不直接防这种"读后写"的丢失, 除非用加锁读。)

3. 解法一: 原子UPDATE(最简单, 推荐) —— 把"改"交给数据库一步原子做
   - 不"查出来再算", 而是 UPDATE ... SET stock = stock - 1 WHERE id=? AND stock > 0;
   - 数据库执行这条UPDATE时, 对该行加锁、读取-计算-写入是【原子】的, 并发会被串行化处理;
   - WHERE stock>0 保证不会扣成负数; 看影响行数判断是否扣成功。

4. 解法二: 乐观锁(version/CAS) —— 适合冲突不频繁
   - 加version字段; UPDATE ... SET stock=newStock, version=version+1 WHERE id=? AND version=旧version;
   - 若影响行数为0, 说明version被别人改过(数据变了) → 重试(重新读最新值再试);
   - "乐观"地假设冲突少, 冲突了才重试。

5. 解法三: 悲观锁(SELECT ... FOR UPDATE) —— 适合冲突频繁
   - 在事务里 SELECT stock FROM product WHERE id=? FOR UPDATE → 给该行加排他锁;
   - 其他事务的FOR UPDATE会阻塞等待 → 把并发的"读改写"串行化;
   - 算完写回、提交事务释放锁; "悲观"地认为冲突多, 先锁住。

一句话: 更新丢失源于"读-改-写"非原子、并发读到同一旧值后写覆盖前写; 别"查出来改了再写回",
   改用原子UPDATE(stock=stock-1 WHERE)、乐观锁(version CAS+重试)或悲观锁(FOR UPDATE), 把读改写变原子或串行。

这套认知,是整个坑的根。更新丢失的本质:读-改-写非原子——"基于当前值算新值"的更新要经历"读→算→写回"三步,这三步非原子、读和写之间有时间窗口,并发请求都在窗口里读到同一旧值、各自算完写回,后写覆盖先写为什么包进事务不一定够:RC 等隔离级别下普通 SELECT 不加锁,两个事务的 SELECT 仍可能都读到旧值;事务隔离≠自动解决更新丢失解法一:原子 UPDATE(推荐)——UPDATE ... SET stock = stock - 1 WHERE id=? AND stock > 0,数据库对该行加锁、读取-计算-写入原子完成、并发被串行化,WHERE stock>0 防扣负解法二:乐观锁(version/CAS)——加 version 字段,UPDATE ... WHERE version=旧version,影响行数为 0 说明被改过就重试(适合冲突少)解法三:悲观锁(SELECT ... FOR UPDATE)——给该行加排他锁、把并发读改写串行化(适合冲突多)一句话:更新丢失源于"读-改-写"非原子、并发读到同一旧值后写覆盖前写;别"查出来改了再写回",改用原子 UPDATE、乐观锁或悲观锁,把读改写变原子或串行。

第二件事:正解——原子 UPDATE / 乐观锁 / 悲观锁

搞懂了原理,正解就清晰了:扣库存别"查出来减了再写回",改用原子的 UPDATE stock=stock-1 WHERE stock>0(首选);冲突少用乐观锁(version),冲突多用悲观锁(FOR UPDATE)

// ====== 正解一: 原子 UPDATE(最简单、推荐) ======
public boolean deductStock(long productId) {
    // 把"读-改-写"交给数据库一条原子UPDATE; WHERE stock>0 保证不超卖
    int affected = db.update(
        "UPDATE product SET stock = stock - 1 WHERE id = ? AND stock > 0",
        productId);
    return affected > 0;   // 影响1行=扣成功; 0行=库存不足(stock<=0)没扣到
}
// → 这条UPDATE在数据库内部对该行加锁, "读当前stock-计算-写回"是原子的;
//   并发的多个UPDATE被数据库串行化处理, 不会丢更新; WHERE stock>0 兜住, 绝不扣成负数。

// ====== 正解二: 乐观锁(version字段, 适合冲突不频繁) ======
public boolean deductStockOptimistic(long productId) {
    for (int retry = 0; retry < 3; retry++) {     // 失败重试几次
        Product p = db.queryProduct(productId);    // 读出当前stock和version
        if (p.stock <= 0) return false;
        int affected = db.update(
            "UPDATE product SET stock = ?, version = version + 1 " +
            "WHERE id = ? AND version = ?",         // ★ 带上读到的version做CAS
            p.stock - 1, productId, p.version);
        if (affected > 0) return true;             // 成功
        // affected=0: version被别人改过 → 数据变了 → 循环重试(重新读最新)
    }
    return false;   // 重试多次仍冲突
}
-- ====== 正解三: 悲观锁(FOR UPDATE, 适合冲突频繁/逻辑复杂) ======
-- 在一个事务里:
BEGIN;
SELECT stock FROM product WHERE id = 1 FOR UPDATE;  -- ★ 给该行加排他锁, 其他事务的FOR UPDATE阻塞等待
-- ... 在代码里判断、计算 ...
UPDATE product SET stock = stock - 1 WHERE id = 1;
COMMIT;                                              -- 提交释放锁
-- → FOR UPDATE把并发的"读改写"串行化: 一个事务锁住该行, 别人等它提交才能继续。

-- ====== 选型建议 ======
-- 1. 简单的"基于当前值的增减"(库存/计数/余额): 首选【原子UPDATE】(stock=stock-1 WHERE), 最简单高效;
-- 2. 读改写中间逻辑复杂、但冲突不频繁: 乐观锁(version + 失败重试);
-- 3. 冲突频繁、或必须串行处理: 悲观锁(FOR UPDATE), 注意锁范围别太大(影响并发);
-- 4. 极高并发(秒杀): 往往还要前置(Redis预扣/限流/队列削峰), 别让所有请求都打到DB抢一行锁。

修复的核心,是"别查出来改了再写回,用原子 UPDATE 或加锁"正解一:原子 UPDATE(推荐)——UPDATE product SET stock=stock-1 WHERE id=? AND stock>0,数据库内部对该行原子地读-算-写、并发被串行化,WHERE stock>0 绝不扣负,看影响行数判断成功正解二:乐观锁(version+CAS+重试)——WHERE version=旧version,影响 0 行说明被改过就重试(冲突少时用)正解三:悲观锁(FOR UPDATE)——给该行加排他锁串行化(冲突多时用,注意锁范围)选型:简单增减首选原子 UPDATE、复杂逻辑冲突少用乐观锁、冲突多用悲观锁、极高并发还要前置 Redis 预扣/限流/队列削峰归根结底:别"查出来改了再写回",用原子 UPDATE(stock=stock-1 WHERE)、乐观锁(version CAS+重试)或悲观锁(FOR UPDATE),把读改写变原子或串行。

第三件事:数据库并发与一致性的其他常见坑

排查后我把数据库并发、一致性相关的其他坑也系统梳理了一遍。

数据库并发与一致性的其他常见坑

# 1. 读改写更新丢失(本文): 并发读旧值后写覆盖。→ 原子UPDATE/乐观锁/悲观锁。

# 2. 以为"包进事务"就并发安全: 隔离级别下普通SELECT不加锁仍丢更新。→ 用加锁读或原子写。

# 3. 唯一索引并发插入: "先查不存在再插入"并发下重复插入。→ 唯一索引+冲突处理(ON DUPLICATE/INSERT IGNORE)。

# 4. 死锁: 多事务以不同顺序加锁互相等。→ 统一加锁顺序, 减小事务/锁范围。

# 5. 大事务/长事务: 锁持有久、回滚段大、阻塞别人。→ 事务尽量短小。

# 6. SELECT FOR UPDATE锁范围过大: 锁了过多行甚至间隙锁, 拖垮并发。→ 精确锁需要的行、走索引。

# 7. 扣减不判边界: 没有 WHERE stock>0, 扣成负数。→ 加边界条件。

# 8. 隔离级别理解错: RC/RR/幻读/不可重复读各自能防什么没搞清。→ 理解隔离级别。

# 共同根源: 数据库要在"多个事务并发访问同一数据"时保证正确, 这本质是个并发控制问题;
#   而很多"单线程下天经地义"的写法(查出来改了再写回、查不到再插入), 在并发下都会因为"时间差里别人也动了"而出错。

# 核心: 凡是"并发修改同一数据"的场景, 别用"单线程直觉"(读改写/先查后插); 用数据库提供的并发控制手段——
#   原子写、唯一约束、乐观/悲观锁; 让对同一数据的并发操作, 要么原子、要么串行、要么能检测冲突。

排查让我把数据库并发与一致性的其他坑也梳理清了。一、读改写更新丢失(本文)。二、以为包进事务就安全三、唯一索引并发插入(先查再插重复)。四、死锁五、大事务/长事务六、FOR UPDATE 锁范围过大七、扣减不判边界扣成负数八、隔离级别理解错它们的共同根源是:数据库要在"多个事务并发访问同一数据"时保证正确,这本质是个并发控制问题;而很多"单线程下天经地义"的写法(查出来改了再写回、查不到再插入),在并发下都会因为"时间差里别人也动了"而出错核心是:凡是"并发修改同一数据"的场景,别用"单线程直觉"(读改写/先查后插);用数据库提供的并发控制手段——原子写、唯一约束、乐观/悲观锁;让对同一数据的并发操作,要么原子、要么串行、要么能检测冲突下面这张图,是这次更新丢失坑的成因与解法:

第四件事:三种并发控制方案对比表

这次踩坑后,我把原子 UPDATE、乐观锁、悲观锁三种方案对比成一张表。

方案 原理 适合 代价
原子 UPDATE(stock=stock-1) 数据库一步原子读改写 简单增减(库存/计数) 仅适合能用一条SQL表达的
乐观锁(version CAS) 假设冲突少, 写时校验+重试 冲突不频繁、逻辑复杂 冲突多时重试开销大
悲观锁(FOR UPDATE) 先锁住, 串行处理 冲突频繁、必须串行 锁等待、并发下降

这张表把三种方案钉清了。核心是:三种方案的本质,是应对"并发修改同一数据"的三种不同策略——原子 UPDATE 是"让操作小到能一步原子完成";乐观锁是"先假设没冲突、干完再检查、错了重来";悲观锁是"先假设有冲突、上来就锁住、独占着干";它们对应着对"冲突概率"的不同假设(乐观=冲突少、悲观=冲突多),也是"重试成本"和"等待成本"之间的权衡它给我的最大启发是:处理"共享资源的并发访问"(无论是数据库的行、内存的变量、还是分布式的资源),都绕不开这几种基本范式——"原子操作"(把操作做到不可分割)、"乐观并发控制"(冲突少时,无锁+检测+重试)、"悲观并发控制"(冲突多时,加锁+独占);没有哪个绝对最好,要根据"冲突有多频繁、操作有多复杂、对并发吞吐的要求"去权衡选择这给了我一种处理并发的清醒:面对任何"多方并发访问/修改同一资源"的问题时,先评估"冲突的概率有多高?操作能否原子化?"——能原子化就原子化(最简单)、冲突少用乐观、冲突多用悲观;"用'原子/乐观/悲观'这套并发控制的范式去思考共享资源的访问、按冲突频率选型",是写对一切并发代码的通用方法论认清三种并发控制范式、按冲突频率与操作复杂度选型——是这个坑带给我的认知。

第五件事:这次事故暴露的"单线程直觉"在并发下的失效

这次让我反思更深一层:我写"查出来减一再写回"时,脑子里是个单线程的世界。我把"单线程直觉"和"并发现实"对比成表。

维度 单线程直觉 并发现实
我读到的值 就是最新的、没人会动 读完到写之间, 别人可能已改
读改写 是一个连贯不可分的动作 是三步, 中间可被插入
"先查再操作" 查到的状态一直成立 查完状态可能已变(TOCTOU)
执行顺序 按我写的顺序 多方交错执行
结果 确定 取决于交错时序

这张表道出了问题的思维根源。核心是:我写出那段有 bug 的代码,根上是因为我用"单线程的直觉"去写"会被并发执行的代码"——在单线程的世界里,"我查出来的库存,在我写回之前不会变"是天经地义的;可在并发的世界里,"我查完到我写回之间,别人也读了、也写了"是常态;我默认了一个"我独占这份数据、没人和我抢"的前提,而并发恰恰打破了这个前提它给我的深刻启发是:"单线程顺序执行" 和 "并发交错执行" 是两个规则迥异的世界——大量在单线程下绝对正确的直觉(读到的值是稳定的、操作是连贯的、顺序是确定的),在并发下统统失效;最经典的就是 TOCTOU(检查时和使用时,状态已变):"先检查(查库存够)、再使用(扣减)",在并发下检查通过不代表使用时还成立;"用单线程的脑子写并发的代码", 是并发 bug 最主要的来源这给了我一种写并发代码的根本审慎:但凡我写的代码"会被并发执行"(Web 请求、多线程、多实例),就要主动切换到"并发思维"——时刻假设"在我这两行代码之间, 别人也在动同一份数据",然后问"这样还对吗?我读的值会不会已经过期?我的'检查'到'动作'之间状态会不会变?"——并用并发控制手段(原子/锁/CAS)把关键的"读改写"保护起来;"意识到自己的代码身处并发环境、用并发思维而非单线程直觉去推理",是写出并发安全代码的根本前提认清单线程直觉在并发下失效、用并发思维主动假设别人也在动同一数据——是这个超卖坑带给我的工程态度。

第六件事:并发修改同一数据时,我现在的自检习惯

现在每当我要写"修改数据库里某个共享数据"的逻辑,我都会先按这张图问自己:

这张图的精髓,是"并发改共享数据别读改写,能原子就原子、冲突少乐观、冲突多悲观"不并发普通读改写、简单增减原子 UPDATE、复杂冲突少乐观锁、冲突多悲观锁这套习惯,让我从"查出来改了写回去"变成了"先想这数据会不会被并发改、用对的并发控制"——核心始终是:并发修改同一数据别用"查出来再写回"的单线程写法,用原子 UPDATE、乐观锁或悲观锁,把读改写变原子或串行,避免更新丢失和超卖。

我立下的几条规矩

这场"放量就超卖、库存对不上账"的事故,换来了我写数据库并发操作时,刻进骨子里的几条铁律:

  1. "先查再算后写回"在并发下会更新丢失。两个事务读到同一旧值、后写覆盖先写。
  2. 仅仅包进事务不一定能防更新丢失。普通 SELECT 不加锁仍读到旧值。
  3. 简单增减(库存/计数)用原子 UPDATE:SET x=x-1 WHERE 边界。首选。
  4. 扣减一定加边界条件(WHERE stock>0),绝不扣成负数。
  5. 冲突不频繁用乐观锁(version CAS+重试),频繁用悲观锁(FOR UPDATE)。
  6. 极高并发(秒杀)还要前置 Redis 预扣/限流/队列削峰。
  7. 代码会被并发执行时,用并发思维:假设别人也在动同一份数据。

写在最后

回头看,这场由"查出来减一再写回"引发的、放量即超卖的事故,真正教给我的,远不止"用原子 UPDATE 扣库存"这一个技巧。它让我对"一段代码在'只有我一个人用'时正确, 完全不代表它在'很多人同时用'时正确; 而我们写代码时, 脑子里默认的, 往往是那个'只有我一个人'的、并不真实的世界",有了一次刻骨的体会。我栽跟头,是因为我写那段扣库存代码时,脑子里不自觉地预设了一个"只有我这一个请求在动这个库存"的、安静的世界——在那个世界里,"我查出来的 100,在我写回 99 之前,理所当然还是 100"可真实的生产环境,是一个"成百上千个请求同时涌向同一行库存"的、喧闹的世界;在那里,"我查到的 100"从我读到的那一刻起就可能已经过时了——因为在我""和""的那道缝隙里,挤进了无数个同样在查、在算、在写的请求;我用一个"独处"的心态, 写了一段注定要"在人群中被推搡"的代码这让我领悟到一个关于"独处假设与并发现实"的深刻认知:我们写下的绝大多数"会被并发调用"的代码(每一个 Web 接口、每一个被多线程调用的方法),都生活在一个"众声喧哗、互相争抢"的并发世界里;而"读到的数据是稳定的、我的操作是连贯不被打断的"这类'独处假设', 在这个世界里是不成立的奢望;"把并发环境下的代码, 想象成单人独享的代码", 是一种危险而普遍的思维默认这给了我一种身处并发世界的根本清醒:写任何代码时,都要先清醒地意识到"它将在一个什么样的环境里运行——独占的, 还是并发争抢的?"——若是并发的, 就主动卸下"独处假设", 戴上"众人争抢"的眼镜去重新审视每一处"读了再写、查了再用"的地方, 用并发控制把它们保护好;"承认并直面自己代码所处的并发现实、不被独处的直觉误导", 是写出在真实高并发下依然正确的系统的根本前提认清独处假设在并发现实中不成立、主动戴上众人争抢的眼镜审视读写——这,是我用一次超卖的事故,换来的、关于数据库并发、也关于如何在喧闹的并发世界里正确思考的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次写扣库存、改余额这类并发操作时,本能地避开"查出来再写回"、换上原子 UPDATE 或锁,那我对着那超卖的账目排查的这段时间,就值了。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

我重写了 equals 让两个字段相同的对象相等,把它当 HashMap 的 key 存进去,再用一个一模一样的 key 去取却拿到了 null:一次 Java 只重写 equals 没重写 hashCode 的深度复盘

2026-6-2 21:16:08

技术教程

我那个请求-响应的小包通信,延迟总是莫名其妙地多出 40 毫秒,抓包才发现是 Nagle 算法和延迟确认这两个好心的优化打起来了:一次 TCP 小包延迟的深度复盘

2026-6-2 21:27:40

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索