我在多线程里共用一个 HashMap 做缓存,某天线上 CPU 突然飙到 100% 卡死、线程全堆在 HashMap.get 上,我抓着线程栈复盘了大半天的惨痛经历

我的服务用一个 HashMap 当本地缓存、多线程并发读写,平时好好的,某天线上 CPU 毫无征兆飙到 100% 且降不下来、服务卡死。jstack 一抓头皮发麻:好几个线程死死卡在 HashMap.get 的 for 循环里出不来——死循环。深挖才懂:HashMap 线程不安全,多线程并发 put 同时触发扩容,JDK7 头插法迁移节点时并发把链表指针搞乱、接成 A→B→A→B 的环形链表,此后任何线程 get 一个落在这条链上的不存在 key,就沿链永远找不到 null、无限循环烧满一个核心(JDK8 改尾插不成环但仍会数据丢失/覆盖)。这篇从线程安全到底意味着什么讲起,到 ConcurrentHashMap/加锁的正解、复合操作必须用 computeIfAbsent 等原子方法、top -Hp+jstack 排查并发问题、几种线程安全 Map 取舍与常见并发陷阱,以及那句最戳心的——"碰巧没出事"绝不等于线程安全,非线程安全容器绝不能跨线程共享读写。

我在多线程里共用一个 HashMap 做缓存,某天线上 CPU 突然飙到 100% 卡死、线程全堆在 HashMap.get 上,我抓着线程栈复盘了大半天的惨痛经历

这是一个让我对"线程安全"四个字,从此心存敬畏的故事。我有一个服务,内部用一个 HashMap 当本地缓存,多个线程都会往里读写。它平时跑得好好的,可某一天,线上监控突然告警:某台机器的 CPU,毫无征兆地飙到了 100%,而且降不下来;服务响应全部超时、近乎卡死。我登上去一看,机器没在做什么繁重的计算,可 CPU 就是满载。我赶紧用 jstack 抓了线程栈,结果头皮发麻:有好几个线程,都死死地卡在 HashMap.get() 这个方法里,而且是在一个 for 循环里,出不来——它们陷入了死循环,在疯狂地空转,把 CPU 烧满了。

我当时就懵了:一个最普通不过的 HashMap.get(),怎么会死循环?我顺着这个线索深挖,才终于揭开真相,也补上了我 Java 知识里一个致命的漏洞:HashMap,是线程不安全的。我却在多个线程里,不加任何保护地,并发读写同一个 HashMap。问题就出在它扩容(resize)的时候:当多个线程同时触发扩容,在(JDK 7 的)头插法迁移链表节点的过程中,会因为并发,把链表的指针搞乱、接成一个""——本该是 A→B→null 的链表,被并发操作弄成了 A→B→A→B→……环形链表。而一旦链表成了环,后续任何一个线程,只要 get() 一个恰好落在这条链上、却不存在的 key,它就会沿着链表一个个找下去,可这条链没有尽头(永远遇不到 null),于是它就永远地、疯狂地循环下去,把一个 CPU 核心,彻底烧满这,就是臭名昭著的"HashMap 并发扩容导致死循环"。我这才痛彻地明白:HashMap(以及 ArrayList 等大多数普通集合)从设计上,就只考虑了单线程使用,绝不能跨线程共享读写;在多线程下共享它,轻则数据错乱、丢失,重则(像我这次)直接死循环、打满 CPU、拖垮整个服务。线程安全,从来不是"碰巧没出事"就等于安全;那些非线程安全的容器,在并发下,是真的会以你意想不到的、极其凶险的方式炸给你看的

故障现场:多线程共享 HashMap,扩容成环、get 死循环

我把这个"CPU 烧满"的现场,用代码还原给你看:

// ✗ 灾难: 多线程并发读写同一个非线程安全的 HashMap
public class Cache {
    // ✗ 普通 HashMap, 线程不安全!
    private static final Map<String, Object> cache = new HashMap<>();

    public static Object get(String key) {
        return cache.get(key);          // ✗ 多线程并发调用
    }
    public static void put(String key, Object val) {
        cache.put(key, val);            // ✗ 多线程并发写 → 触发扩容时可能成环
    }
}

// 死循环怎么发生(JDK 7 头插法):
//   1. 多个线程同时 put, 同时触发 resize(扩容)。
//   2. 迁移链表节点时, 头插法 + 并发 → 指针被搞乱, 链表接成环:
//      本该 A -> B -> null, 变成 A -> B -> A -> B -> ...(环形)。
//   3. 此后任何线程 get 一个落在这条链、却不存在的 key:
//      沿链表一个个找 -> 永远遇不到 null -> 无限循环! CPU 烧满。

// 现象:
//   - jstack 显示多个线程卡在 HashMap.get / getEntry 的 for 循环里。
//   - 一个核心 CPU 100%, 降不下来, 服务卡死。
//   - JDK 8 改了尾插法, 不再成环, 但并发下仍会数据丢失/覆盖/出错!

// 根因: HashMap 线程不安全, 多线程并发读写(尤其扩容)→ 链表成环 → get 死循环。
//   线程不安全容器, 绝不能跨线程共享读写。

看着这段代码,我后背直冒冷汗。问题的根源,是我用了一个线程不安全HashMap 当共享缓存,却让多个线程,不加任何保护地,并发读写它死循环,是这么发生的(以 JDK 7 的头插法为例):第一,多个线程同时 put,又同时触发了扩容(resize);第二,在迁移链表节点时,头插法叠加并发,把链表的指针搞乱了,本该是 A→B→null 的链,被接成了 A→B→A→B→……环形链表;第三,此后,任何一个线程,只要 get() 一个恰好落在这条环形链、却不存在的 key,它就会沿着链表一个个找下去,可这条链永远遇不到 null,于是它就无限循环,把一个 CPU 核心彻底烧满现象,正与我看到的吻合:jstack 显示多个线程卡在 HashMap.get / getEntryfor 循环里;一个核心 CPU 100%、降不下来、服务卡死。而且要特别注意:JDK 8 虽然把头插法改成了尾插法、不再"成环"了,但并发下,它依然会数据丢失、互相覆盖、出错——"不死循环",绝不等于"线程安全"!归根结底:HashMap 线程不安全,多线程并发读写(尤其撞上扩容)就会链表成环、get 死循环;线程不安全的容器,绝不能跨线程共享读写。

第一件事:搞懂"线程不安全"到底意味着什么

定位到根源,我必须把"线程安全"这件事,从根上彻底搞清楚:

线程安全: 多个线程同时访问, 行为依然正确, 才叫线程安全

# 为什么 HashMap 不是线程安全的?
#   - 它的 put/get/resize 都不是"原子"的, 由多步操作组成。
#   - 多线程交错执行这些步骤 → 中间状态被另一个线程看到/破坏。
#   - 设计目标: 单线程下高性能; 并发安全要你自己保证。

# 并发读写 HashMap 会出什么事?
#   - JDK 7: 扩容头插法 + 并发 → 链表成环 → get 死循环 → CPU 100%(本文)。
#   - JDK 8: 改尾插法不成环, 但仍会: 数据丢失/覆盖、size 算错、读到脏数据。
#   - 共同点: 行为不可预测, 平时"碰巧没事", 高并发某刻突然爆。

# 哪些常见容器"线程不安全"(不能跨线程共享写)?
#   - HashMap / ArrayList / LinkedList / HashSet / StringBuilder ...
#   - 绝大多数 java.util 下的普通集合, 都是线程不安全的。

# 线程安全的替代品:
#   - Map:  ConcurrentHashMap(首选, 高并发) / Collections.synchronizedMap
#   - List: CopyOnWriteArrayList(读多写少) / Collections.synchronizedList
#   - 计数: AtomicInteger / LongAdder
#   - String 拼接: StringBuffer(或局部变量用 StringBuilder)

# 关键认知: "碰巧没出事" ≠ "线程安全"。
#   并发 bug 隐蔽, 测试难复现, 往往上了量、撞上时序才暴露。

# 核心: 非线程安全容器不能跨线程共享读写;
#   多线程共享就用并发容器(ConcurrentHashMap)或加锁保护。

原理终于清晰了。什么叫"线程安全"?——多个线程同时访问,它的行为依然正确,才叫线程安全。HashMap 为什么不是?因为它的 put/get/resize,都不是"原子"操作,而是由多步组成的;多个线程交错执行这些步骤,就会让中间状态被另一个线程看到、甚至破坏;它的设计目标,是单线程下的高性能,并发安全,需要你自己来保证并发读写它会出什么事?JDK 7,扩容头插法叠加并发,链表成环、get 死循环、CPU 100%(本文);JDK 8,改尾插法不成环了,但依然会数据丢失/覆盖、size 算错、读到脏数据;共同点是——行为不可预测,平时"碰巧没事",高并发某一刻突然爆哪些常见容器是线程不安全的?HashMap/ArrayList/LinkedList/HashSet/StringBuilder……绝大多数 java.util 下的普通集合,都是线程不安全的而它们的线程安全替代品是:Map 用 ConcurrentHashMap(首选)或 synchronizedMap;List 用 CopyOnWriteArrayList(读多写少)或 synchronizedList;计数用 AtomicInteger/LongAdder;字符串拼接用 StringBuffer由此,我刻下一个关键认知:"碰巧没出事"绝不等于"线程安全"——并发 bug 极其隐蔽、测试难复现,往往要上了量、撞上特定时序才暴露。非线程安全容器不能跨线程共享读写;多线程共享,就用并发容器(ConcurrentHashMap),或加锁保护。

第二件事:正解——用 ConcurrentHashMap 或加锁保护

搞懂了原理,正解就清晰了:多线程共享的 Map,要么换成天生线程安全的并发容器,要么自己加锁保护。

// ✓ 正解一: 用 ConcurrentHashMap(首选, 高并发下性能也好)
public class Cache {
    // ✓ 并发安全, 分段/CAS 实现, 多线程读写都没问题
    private static final Map<String, Object> cache = new ConcurrentHashMap<>();

    public static Object get(String key) { return cache.get(key); }      // ✓ 安全
    public static void put(String key, Object val) { cache.put(key, val); } // ✓ 安全

    // ✓ 复合操作要用原子方法, 别"先 get 再 put"(那不原子!)
    public static Object getOrCompute(String key, Function<String,Object> f) {
        return cache.computeIfAbsent(key, f);   // ✓ 原子: 不存在才计算并放入
    }
}

// ✓ 正解二: Collections.synchronizedMap(简单, 但全表锁, 并发度低)
Map<String,Object> m = Collections.synchronizedMap(new HashMap<>());
// 注意: 遍历时仍需手动 synchronized(m){ for(...) } , 否则迭代不安全。

// ✓ 正解三: 自己加锁(适合更复杂的复合逻辑)
private final Object lock = new Object();
public void update(String k, Object v) {
    synchronized (lock) {        // ✓ 临界区互斥, 一次只有一个线程进
        // ... 多步复合操作 ...
    }
}

// 核心: 多线程共享的 Map 用 ConcurrentHashMap(首选);
//   复合操作用 computeIfAbsent 等原子方法, 别用"先查再改"的非原子组合。

修复的方向,清晰而坚定。正解一(首选):把 HashMap 换成 ConcurrentHashMap——它通过分段锁 / CAS 实现,多线程并发读写都安全,而且高并发下性能也好,是共享 Map 的不二之选。但这里有个极易踩的坑:仅仅"换成 ConcurrentHashMap"还不够——如果你的逻辑是"get 判断,再 put"这种复合操作,那两步之间,依然不是原子的,中间可能被别的线程插队!复合操作,必须用它提供的原子方法,比如 computeIfAbsent(不存在才计算并放入,整个过程原子)。正解二:Collections.synchronizedMap,简单,但它是全表锁、并发度低,而且遍历时仍需手动 synchronized 包裹,否则迭代不安全。正解三:对更复杂的复合逻辑,自己用 synchronized 加锁,把临界区保护起来。归根结底:多线程共享的 Map,用 ConcurrentHashMap;复合操作用 computeIfAbsent 等原子方法,别用"先查再改"那种非原子的组合拳。

第三件事:并发问题怎么排查——从 CPU 100% 到线程栈

这次能定位到死循环,靠的是一套排查并发问题的"组合拳",我把它沉淀了下来:

并发问题排查: CPU 飙高 / 卡死 → top 定位线程 → jstack 看栈

# 1. top 找到 CPU 高的进程, 再用 top -Hp  找到 CPU 高的"线程"
top -Hp 12345
#    记下那个 CPU 100% 的线程 id(十进制, 比如 12377)

# 2. 把线程 id 转成 16 进制(jstack 里是 16 进制 nid)
printf "%x\n" 12377     # → 305d

# 3. jstack 抓进程的所有线程栈, 搜那个 16 进制 id
jstack 12345 > stack.txt
#    在 stack.txt 里找 "nid=0x305d" → 看它卡在哪个方法
#    本次就是: 卡在 java.util.HashMap.get / getEntry 的循环里 → 真相大白

# 其他并发排查利器:
#   - jstack 多抓几次对比: 总卡在同一处 = 死循环/死锁; 死锁 jstack 会直接报 "deadlock"。
#   - arthas: thread -n 3 看最忙线程; thread -b 看死锁。
#   - jstack 看到一堆线程 BLOCKED 在同一把锁 → 锁竞争热点。

# 经验:
#   - CPU 100% 且降不下来 → 优先怀疑死循环(常见: HashMap 并发成环、正则回溯)。
#   - 服务卡死但 CPU 不高 → 优先怀疑死锁 / 都在等锁/等 IO。

# 核心: top -Hp 定位高 CPU 线程 → 转 16 进制 → jstack 看它卡在哪,
#   并发问题的真相, 都写在线程栈里。

这套排查法,是这次事故给我最实用的收获。第一步,top -Hp <pid>,从 CPU 高的进程,进一步定位到 CPU 高的那个线程,记下它的线程 id;第二步,printf "%x" 把线程 id 转成 16 进制(因为 jstack 里的 nid 是 16 进制);第三步,jstack <pid> 抓下所有线程栈,在里面搜那个 16 进制 id,就能看到它到底卡在哪个方法——这次,正是卡在 HashMap.get 的循环里,真相大白。还有些利器:多抓几次 jstack 对比,总卡在同一处就是死循环或死锁(死锁 jstack 会直接报 deadlock);arthasthread -n 看最忙线程、thread -b 看死锁;一堆线程 BLOCKED 在同一把锁,就是锁竞争热点。我也总结出经验:CPU 100% 且降不下来,优先怀疑死循环(常见的是 HashMap 并发成环、正则灾难性回溯);服务卡死但 CPU 不高,优先怀疑死锁、或都在等锁/等 IO。归根结底:top -Hp 定位高 CPU 线程 → 转 16 进制 → jstack 看它卡在哪;并发问题的真相,都写在线程栈里。

下面这张图,是这次 HashMap 并发死循环的来龙去脉:

第四件事:几种线程安全 Map 方案的取舍

修复时我没有无脑选,而是把几种线程安全的 Map 方案,横向比了一遍,按场景来挑。

方案 原理 并发性能 适用场景
HashMap 无任何同步 单线程最快 ✗ 绝不能多线程共享读写
ConcurrentHashMap CAS + 桶级锁(JDK8) 高, 读几乎无锁 ✓ 高并发共享 Map 的首选
Collections.synchronizedMap 每个方法全表 synchronized 低, 全表互斥 并发不高 / 临时加固老代码
Hashtable 方法级 synchronized(古老) 遗留代码, 新项目别用
不可变 Map(Map.of/copyOf) 只读不可改 无锁, 读极快 ✓ 配置类只读数据

把它们排在一起,选择就一目了然了。高并发共享、读写都多,无脑选 ConcurrentHashMap——它读几乎无锁、写只锁单个桶,性能和安全兼得;并发不高、或只是想临时给老代码加固,Collections.synchronizedMap 够用,但要知道它是全表互斥、并发上不去;Hashtable古董(方法级 synchronized),新项目别再用;而如果你的数据是只读的配置,那最好的"线程安全"是根本不让它变——用 Map.of / Map.copyOf 做成不可变 Map,无锁、读极快、还杜绝了一切并发写问题。核心原则是:先问"这份数据会不会被多线程改"——不改就用不可变;要改且并发高,就 ConcurrentHashMap;唯独不能,把一个普通 HashMap,丢给多个线程去并发读写

第五件事:那些"看起来安全、其实不安全"的并发陷阱

这次踩坑后,我顺藤摸瓜,把团队代码里那些"自以为安全、其实暗藏并发 bug"的写法,系统排查了一遍。它们比"裸用 HashMap"更隐蔽。

看似安全的写法 其实的问题 正确做法
用了 ConcurrentHashMap, 但"先 get 再 put" 两步之间非原子, 仍会丢更新 用 computeIfAbsent / merge 等原子方法
i++ / count++ 计数 读-改-写三步非原子, 会少计 AtomicInteger / LongAdder
单例里用普通字段做懒加载 多线程可能初始化多次/读到半成品 双重检查 + volatile / 静态内部类
SimpleDateFormat 当静态共享 它本身线程不安全, 会解析错乱 ThreadLocal / 改用 DateTimeFormatter
检查再操作(check-then-act) 检查和操作之间状态会变 整体加锁 / 用原子的复合操作

这张表,让我对并发的"暗礁"有了更清醒的认识。最坑的,是"用了线程安全容器,就以为万事大吉":ConcurrentHashMap单个方法是原子的,可你把"先 get 再 put"两个方法拼起来,中间的缝隙,照样会被并发钻空子、丢更新——得用 computeIfAbsent/merge 这类把复合操作打包成原子的方法。其余的陷阱也都同源:i++读-改-写三步、非原子,要用 AtomicInteger;懒加载的双重检查,少了 volatile 就可能读到半成品对象;SimpleDateFormat 静态共享会解析错乱(用 ThreadLocalDateTimeFormatter);"检查再操作"的 check-then-act,检查和操作之间状态会变,得整体加锁它们的共性是:单个操作安全,不代表"多个操作的组合"安全——并发安全的真正难点,从来不在"用没用对容器",而在"有没有看清,哪些操作必须作为一个不可分割的整体来执行"。

第六件事:面对一个共享变量,我现在会怎么决策

现在,每当我准备让一个变量/容器,被多个线程访问,脑子里都会过一遍这张决策图——核心就一个问题:它会被并发地"改"吗?

这张图的灵魂,是那个必问的问题:这个要被多线程访问的东西,会被并发地"修改"吗?如果它只读、不改,那最好的安全,就是把它做成不可变,天然安全;如果它会被改,再分情况:简单的单值或计数,用 Atomic 系列 / volatile;集合,用并发容器(ConcurrentHashMap/CopyOnWriteArrayList);而集合还要再追问一句——有没有"先查再改、多步"的复合操作?有,就必须用原子方法或整体加锁;没有,并发容器的单个方法就够。最后,无论哪条路,我都会jstack / 压测,去验证它在并发下确实无死锁、无丢更新,才敢上线。

我立下的几条规矩

这场"CPU 烧满、服务卡死"的事故,换来了我写并发代码时,刻进骨子里的几条铁律:

  1. 普通集合(HashMap/ArrayList…)绝不跨线程共享读写。它们只为单线程设计;要共享,就换并发容器或加锁,没有例外。
  2. 多线程共享 Map,首选 ConcurrentHashMap。读几乎无锁、写锁单桶,安全与性能兼得;别再用 Hashtable。
  3. "先查再改"是非原子的,用原子方法。computeIfAbsent / merge / AtomicInteger,把复合操作打包成不可分割的整体。
  4. 能不可变就不可变。只读数据用 Map.of / List.of,从根上消灭并发写问题。
  5. "碰巧没出事"不等于线程安全。并发 bug 隐蔽,要主动用压测、并发测试去逼它现形,别等线上爆。
  6. CPU 100% 降不下,先 top -Hp + jstack。把高 CPU 线程转 16 进制,在线程栈里找它卡在哪,真相都在栈里。
  7. 共享变量前先问一句:它会被并发改吗?这一问,能挡掉绝大多数并发隐患。

附:一个能直观对比"丢更新"的最小例子

为了让团队对"非原子操作丢更新"有切肤之感,我写了个最小复现:多个线程并发往 Map 里累加计数,看普通 HashMap 和正确写法的差距,跑一遍胜过千言。

import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.*;

public class CountDemo {
    public static void main(String[] args) throws Exception {
        int threads = 10, perThread = 100000;

        // ✗ 错误: 普通 HashMap + "先 get 再 put"(还可能直接抛异常/死循环)
        Map<String,Integer> bad = new HashMap<>();
        // ✓ 正确: ConcurrentHashMap + 原子的 merge
        Map<String,Integer> good = new ConcurrentHashMap<>();

        Runnable rBad  = () -> { for (int i=0;i<perThread;i++)
            bad.put("k", bad.getOrDefault("k",0) + 1); };   // ✗ 读-改-写非原子, 丢更新
        Runnable rGood = () -> { for (int i=0;i<perThread;i++)
            good.merge("k", 1, Integer::sum); };            // ✓ merge 原子累加

        runAll(threads, rGood);   // 先跑安全的(普通 HashMap 并发可能死循环, 演示放后面或单独跑)
        System.out.println("期望 = " + (threads * perThread));
        System.out.println("good(ConcurrentHashMap.merge) = " + good.get("k")); // ✓ 正好等于期望

        // 注意: rBad 在并发下可能少计(丢更新), 甚至 CPU 100% 死循环 —— 这正是要避免的。
    }
    static void runAll(int n, Runnable r) throws Exception {
        ExecutorService es = Executors.newFixedThreadPool(n);
        CountDownLatch latch = new CountDownLatch(n);
        for (int i=0;i<n;i++) es.submit(() -> { r.run(); latch.countDown(); });
        latch.await(); es.shutdown();
    }
}

// 核心: good 用 merge 原子累加, 结果永远 = 期望;
//   bad 用"先 get 再 put", 并发下必然少计(丢更新), 甚至死循环。

这段代码,我建议每个写 Java 的同事都亲手跑一遍ConcurrentHashMap.mergegood,结果永远精确等于 线程数 × 每线程次数 这个期望值;而用"先 get 再 put"的 bad,在并发下必然少计(因为两步之间被插队、丢了更新),数字会飘忽不定地小于期望,甚至(若用普通 HashMap)直接 CPU 100% 死循环同样是"往 Map 里加个数",一个滴水不漏,一个悄悄丢数据——而最可怕的是,那个 bad 写法,编译能过、单线程跑也对、低并发时甚至也"看着对",它只在高并发的某些时刻,悄无声息地少算几个数;如果这"几个数"是钱、是库存、是计费,那就是实打实的资损这,正是我想用这个最小例子,刻进每个同事脑子里的东西:并发下的正确,从来不能靠"看着对"来判断;它要靠你真正理解每一步操作是否原子,并用压测和精确的期望值比对,去把它验证出来。

写在最后

回头看,这场由一个最普通的 HashMap 引发的、把整台机器 CPU 烧满的事故,真正教给我的,是一个比"HashMap 线程不安全"本身更深的道理:并发的世界里,"能用"和"安全",是两件完全不同的事那个 HashMap,在单线程下、在压力不大时,跑了很久都"能用",这恰恰是它最危险的地方——它用长时间的"看起来没事",麻痹了我,让我误以为它"没问题";直到流量上来、时序凑巧,它才露出獠牙,以最凶险的方式(死循环、烧 CPU)炸给我看。所以,写并发代码,最忌讳的就是"凭感觉"和"碰运气":你不能因为"测试时没复现"就认为它安全,因为并发 bug,本就是靠"特定的、罕见的时序"才触发的,测试碰不上,不代表线上的滔天流量碰不上。真正的并发安全,源于一种近乎偏执的清醒:对每一个会被多线程碰到的变量,都诚实地问一句"它会被并发修改吗、怎么保证正确",并给出一个经得起推敲的、而非"碰巧没事"的答案把每一个共享变量,都当成一颗需要你亲手拆除引信的炸弹来对待——这,是我用一次"CPU 100%、服务卡死"的崩溃,换来的、关于并发编程最朴素、也最深刻的敬畏。如果这篇复盘,能让你在下一次把一个集合,丢给多个线程之前,多停三秒、多问一句,那我抓栈、熬的这大半天,就值了。

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

我的 Go 服务内存和 goroutine 数量只涨不跌、跑久了必 OOM,最后揪出是一堆 goroutine 永远卡在 channel 上、泄漏了出不来的深度复盘

2026-6-2 1:27:01

技术教程

我的列表分页接口翻到前几页飞快、翻到几十万页却越来越慢直到超时,我盯着那条带巨大 OFFSET 的 SQL 排查了大半天才搞懂深分页的真相

2026-6-2 1:37:43

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