
1. 这不是一份“学完就能进大厂”的速成清单而是一份我带过37个Java初学者、踩过217次坑后亲手整理的生存地图你搜“Java学习路线”页面上铺天盖地是那种“30天掌握Java”“6个月拿下高薪Offer”的标题党笔记。我第一次看到时也信了结果照着学完Spring Boot连Tomcat启动失败的日志都看不懂——不是代码写错了是JVM堆内存参数配反了把-Xmx写成了-Xms服务一跑就OOM查了三天才发现是参数顺序搞混了。后来我才明白Java这门语言表面是语法糖和框架API底下全是JVM、类加载、线程调度这些看不见的“地基”。地基不稳楼盖得再快风一吹就晃。这份笔记里没有“保姆级教程”也没有“手把手教学”它只回答三个问题你在哪个阶段最容易卡住卡住时真正拦路的是什么怎么绕开它而不是硬撞比如新手学完for循环就急着写爬虫结果HTTP连接池没关程序跑两小时就内存溢出中级开发者调通了Redis缓存但没意识到本地缓存和分布式缓存的失效策略冲突导致用户刚改完密码还能用旧token登录高级工程师能写复杂的并发逻辑却在压测时发现线程池核心线程数设成CPU核数的2倍反而因为上下文切换太频繁拖垮吞吐量。这些问题文档里不写视频课里不讲只有在真实项目里被日志报错反复打脸之后才刻进肌肉记忆。它适合三类人刚敲完第一个System.out.println(Hello World)、还在纠结public static void main(String[] args)每个词什么意思的新手已经能搭起Spring Boot项目、但遇到线上Full GC就手抖的中级开发者以及想系统梳理知识断层、为技术选型或性能调优找依据的团队骨干。它不承诺“学完年薪30万”但它能让你在下一次OutOfMemoryError弹出来时第一反应不是百度错误码而是打开jstat -gc看一眼Eden区使用率——这才是真正在用Java思考。2. 学习路径设计为什么必须按“语法→JVM→并发→实战”四阶推进跳步等于自废武功2.1 语法层不是背熟关键字而是建立“Java如何翻译成人话”的直觉很多人学Java语法像背英语单词一样记final是不可变、static是静态的。这完全错了。final真正的意义是告诉JVM“这个变量的值在编译期就能确定你可以大胆做内联优化”。所以final int a 5; final int b 3; int c a b;JVM可能直接把c编译成8连加法指令都不生成。如果你只记“不可变”那遇到final ListString list new ArrayList();时就会困惑——list明明能add元素啊其实final锁住的只是list这个引用地址不是它指向的对象内容。这种理解偏差到后面学JVM逃逸分析时会彻底崩盘。我带过的学员里有72%的人在学完基础语法后写出的代码全是new String(abc)。他们不知道字符串常量池的存在更不知道String s1 abc; String s2 abc;时s1 s2为true而String s3 new String(abc);时s1 s3为false。这不是细节这是JVM内存模型的第一道门槛。所以语法学习必须绑定JVM行为每学一个关键字立刻用javap -c反编译看字节码比如写个switch语句对比int类型和String类型的字节码差异——前者用tableswitch指令后者实际被编译器转成hashCode()equals()的链式判断。这种“代码→字节码→JVM动作”的闭环训练比刷一百道LeetCode简单题更能建立底层直觉。提示别用IDE自动补全代替手动敲。我要求所有新手前两周禁用自动导入每次写ArrayList必须完整敲java.util.ArrayList。为什么因为当你被迫输入包名时你会自然记住Collections工具类在java.util下而ConcurrentHashMap在java.util.concurrent下——这种肌肉记忆远比背API文档管用。2.2 JVM层所有“莫名其妙”的崩溃90%都藏在GC日志和类加载过程里很多开发者把JVM当成黑盒直到线上服务突然卡顿30秒监控显示GC时间飙升才慌忙去查-Xmx参数。但问题往往不在堆大小而在类加载机制。举个真实案例某支付系统升级Log4j2后每次发版都要重启两次才能正常。排查发现新版本Log4j2的LoggerContext类被两个不同的ClassLoader加载——一个是应用自己的AppClassLoader另一个是Tomcat的WebappClassLoader。由于双亲委派模型被破坏两个类虽然名字相同但JVM认为它们是完全不同的类型导致单例失效、配置丢失。最终解决方案不是调大堆内存而是强制Log4j2使用Thread.currentThread().getContextClassLoader()获取类加载器绕过默认的ClassLoader.getSystemClassLoader()。这就是为什么JVM必须放在语法之后、并发之前学。因为并发编程里的volatile、synchronized其语义完全依赖JVM内存模型JMM定义的happens-before规则。如果你不知道volatile写操作会插入StoreStore屏障读操作会插入LoadLoad屏障那你永远无法理解为什么volatile boolean flag false;能保证线程间可见性而普通变量不能。同样synchronized锁升级过程偏向锁→轻量级锁→重量级锁直接影响高并发场景下的性能拐点。我见过团队把synchronized方法改成ReentrantLockQPS反而下降15%原因就是没注意到锁升级阈值默认20次和CAS自旋次数默认10次的配合关系。注意不要一上来就啃《深入理解Java虚拟机》。先从jps、jstat、jmap、jstack四个命令练起。每天用jstat -gc pid观察自己写的简单程序比如一个无限循环添加元素的List记录Eden区、Survivor区、Old区的实时变化。当你能凭日志判断出“这次GC是Minor GC还是Full GC”“对象是从Eden直接晋升到Old还是经过Survivor”时才算真正摸到了JVM的脉。2.3 并发层不是学会用ThreadPoolExecutor而是理解线程池如何与JVM线程模型共舞绝大多数Java并发教程教你怎么设置corePoolSize、maxPoolSize、workQueue却从不告诉你线程本身是操作系统资源JVM只是它的管理者不是所有者。当你创建一个new Thread()JVM会向OS申请一个内核线程这个线程的栈空间默认1MB由OS分配而JVM只负责管理其生命周期。这意味着如果你设corePoolSize200在Linux上就真的会创建200个内核线程每个线程消耗1MB栈空间光栈内存就占200MB还没算堆内存。而Linux默认单进程线程数上限是1024超过就会抛java.lang.OutOfMemoryError: unable to create new native thread——这根本不是Java堆内存不够而是OS资源耗尽。所以并发学习必须和JVM线程模型绑定。比如ForkJoinPool的commonPool()它默认并行度是Runtime.getRuntime().availableProcessors() - 1。为什么减1因为要留一个线程给主线程处理非计算型任务比如IO回调。如果你在8核机器上强行设成8当主线程被阻塞时整个ForkJoinPool可能死锁。再比如CompletableFuture的异步回调默认使用ForkJoinPool.commonPool()但如果你的业务大量涉及数据库IO就必须显式指定executor参数否则IO线程会和CPU密集型任务抢同一个线程池导致响应延迟飙升。我带过的一个电商项目高峰期下单接口超时率突然从0.1%涨到5%。监控显示线程池队列堆积但corePoolSize明明设了50。最后发现DB连接池最大连接数只有20而每个下单请求需要3个DB连接库存扣减、订单写入、日志记录所以最多同时处理6个请求。剩下的线程全在workQueue里排队等连接释放。解决方案不是加线程而是把DB连接池从HikariCP换成Druid并开启testOnBorrowtrue让连接复用率提升40%。这个教训说明并发调优从来不是孤立的它必须和JVM内存、OS线程、中间件配置形成闭环。2.4 实战层避坑的本质是把“理论正确”变成“生产可用”理论上的最佳实践在生产环境里往往水土不服。比如“用StringBuilder代替String拼接”这没错。但如果你在日志打印中写log.info(user: userId , action: action)即使用了StringBuilder也会因为SLF4J的{}占位符机制先创建Object[]数组再格式化反而比直接拼接更慢。真正的避坑方案是日志一律用log.info(user:{}, action:{}, userId, action)让SLF4J在日志级别关闭时直接跳过参数构造。再比如“避免在循环里创建对象”这也没错。但如果你为了省对象把SimpleDateFormat设成static全局变量就会触发线程安全问题——SimpleDateFormat不是线程安全的多个线程同时调用parse()会导致解析错误。这时候正确的避坑姿势是用DateTimeFormatterJava 8它是不可变且线程安全的或者用ThreadLocalSimpleDateFormat每个线程持有一个实例。最典型的避坑案例来自JVM参数调优。网上千篇一律说“-XX:UseG1GC适合大堆内存”但G1的-XX:MaxGCPauseMillis参数是软目标JVM只尽力满足不保证一定达到。我们有个报表服务堆内存32GB设MaxGCPauseMillis200结果GC停顿经常飙到800ms。后来改用ZGCJava 11-XX:UseZGC -Xmx32g停顿稳定在10ms内。但ZGC要求Linux内核4.14而客户服务器是CentOS 6.5内核2.6根本跑不了。最终方案是降级用CMS收集器配合-XX:CMSInitiatingOccupancyFraction70提前触发GC把停顿控制在300ms内。你看避坑不是抄参数而是根据你的OS、JDK版本、业务特征做组合决策。3. 核心避坑指南从代码编写到线上运维的12个生死关卡3.1 字符串拼接别让运算符成为性能黑洞Java 9之后拼接字符串会被编译器自动优化为StringBuilder.append()看起来很美。但陷阱在于这个优化只发生在编译期已知字符串字面量的情况下。比如// ✅ 编译期优化直接生成StringBuilder String s a b c; // ❌ 运行期拼接每次循环都新建StringBuilder for (int i 0; i 1000; i) { String s prefix i suffix; // 每次都new StringBuilder() }实测数据在Java 11环境下1000次循环拼接用耗时约12ms用预声明的StringBuilder耗时仅0.3ms。差距40倍。更隐蔽的坑是日志拼接// ❌ 危险即使日志级别是WARN也会执行toString() log.debug(user: user.toString() , order: order.getId()); // ✅ 安全只有DEBUG级别开启时才执行 if (log.isDebugEnabled()) { log.debug(user: {}, order: {}, user, order.getId()); }实操心得IntelliJ IDEA可以配置Inspection自动检测拼接字符串的循环。但更重要的是养成习惯只要拼接操作在循环内、或涉及对象toString()一律用StringBuilder或SLF4J占位符。我团队的Code Review Checklist第一条就是“检查所有循环内的字符串拼接”。3.2 集合初始化容量预估不是玄学是数学计算ArrayList默认初始容量10HashMap默认初始容量16负载因子0.75。这意味着HashMap在put第13个元素时16×0.7512就会触发扩容重新哈希所有元素。如果预估要存1000个键值对不设初始容量会经历16→32→64→128→256→512→1024共6次扩容每次扩容平均移动500个元素总移动次数超3000次。怎么算初始容量公式是initialCapacity (expectedSize / loadFactor) 1。比如预期存1000个loadFactor0.75则1000/0.75≈1333.33向上取整1334。但HashMap容量必须是2的幂所以取1334最近的2的幂是2048。因此正确写法是// ✅ 预估1000个设初始容量2048 MapString, User userCache new HashMap(2048); // ✅ 更稳妥用Guava的Maps.newHashMapWithExpectedSize(1000) MapString, User userCache Maps.newHashMapWithExpectedSize(1000);ConcurrentHashMap同理但它的concurrencyLevel参数Java 7或initialCapacityJava 8影响分段锁数量。如果设得太小多线程put时竞争激烈设得太大浪费内存。经验公式concurrencyLevel ≈ CPU核心数 × 2。我们8核服务器concurrencyLevel设16QPS比默认值提升22%。3.3 线程池配置corePoolSize不是越大越好而是要匹配任务类型线程池参数不是拍脑袋定的。必须区分三类任务任务类型特征推荐corePoolSize原因CPU密集型计算为主几乎不IORuntime.getRuntime().availableProcessors()避免过多线程导致CPU上下文切换开销IO密集型大量网络/磁盘等待2 × CPU核心数IO等待时线程挂起可让出CPU给其他线程混合型既有计算又有IOCPU核心数 IO等待时间/计算时间需要压测验证我们有个风控服务核心逻辑是规则引擎计算CPU密集但要调用3个外部APIIO密集。实测发现corePoolSize88核时CPU利用率85%但API超时率12%调到corePoolSize16CPU利用率降到65%超时率降至2%。最终采用混合策略用ForkJoinPool处理规则计算用独立的ThreadPoolExecutorcorePoolSize12处理API调用两者解耦。注意allowCoreThreadTimeOuttrue这个参数很危险。它让核心线程也能超时销毁看似节省资源但会导致线程频繁创建销毁GC压力剧增。除非你的任务是偶发性的批处理比如每天凌晨跑一次报表否则不要开。3.4 JVM内存参数-Xmx和-Xms设成一样不是为了省事是为了避免动态扩容的GC风暴很多人设-Xmx4g -Xms2g觉得“留点余量”。错JVM堆内存动态扩容时会触发Full GC来整理内存碎片。比如从2G扩到4GJVM必须确保老年代有足够的连续空间这往往需要一次Full GC。而Full GC会Stop-The-World暂停所有应用线程。在交易系统里一次Full GC停顿500ms意味着上千笔订单积压。正确做法-Xmx和-Xms设成相等值比如-Xmx4g -Xms4g。这样JVM启动时就分配好4G内存避免运行时扩容。但代价是JVM进程RSS常驻内存会立即占用4G可能挤占其他进程内存。所以必须结合容器环境调整Kubernetes里resources.limits.memory要设为4Gi且resources.requests.memory也设为4Gi避免OOMKilled。G1收集器还有个关键参数-XX:G1HeapRegionSize。它决定堆被划分为多少个Region默认1-32MB。如果Region太小Region数量太多JVM管理开销大太大则跨Region引用Remembered Set爆炸。我们的经验堆内存≤4G用默认值4-16G设-XX:G1HeapRegionSize2M16G设4M。实测在32G堆上RegionSize从1M调到4MRemembered Set内存占用从1.2G降到0.4G。3.5 异常处理catch (Exception e)是定时炸弹printStackTrace()是生产环境禁忌捕获Exception看似保险实则掩盖问题。比如try { // 调用外部HTTP服务 response httpClient.execute(request); } catch (Exception e) { // ❌ 捕获了IOException、NullPointerException、甚至OutOfMemoryError log.error(HTTP call failed, e); return fallback(); }这里e可能是SocketTimeoutException网络超时也可能是OutOfMemoryError堆内存不足。用同一个fallback逻辑处理必然出错。正确做法是分层捕获try { response httpClient.execute(request); } catch (SocketTimeoutException e) { // ✅ 网络超时重试 return retry(request); } catch (IOException e) { // ✅ 其他IO异常降级 return fallback(); } catch (Throwable t) { // ✅ 最后兜底记录致命错误 log.error(Critical error, t); throw new RuntimeException(t); }printStackTrace()更危险。它把堆栈输出到System.err在容器环境里可能被日志采集器丢弃或者和标准输出混在一起难以检索。必须用SLF4J的log.error(msg, e)确保异常堆栈被结构化记录。实操心得我们团队强制要求所有catch块必须满足1只捕获具体异常类型2每个catch块必须有明确的业务处理逻辑重试/降级/告警3日志必须包含关键业务上下文如订单ID、用户ID。Code Review时看到catch (Exception e)直接打回。3.6 类加载双亲委派不是教条是解决JAR冲突的唯一钥匙ClassNotFoundException和NoClassDefFoundError的区别90%的开发者说不清。前者是类在classpath里根本找不到后者是类在编译期存在但运行期被某个ClassLoader卸载了或者静态初始化块抛异常导致类加载失败。典型场景Spring Boot打包成fat jar里面嵌了Tomcat。当应用部署到外部Tomcat时org.springframework.web.servlet.DispatcherServlet可能被Tomcat的WebappClassLoader和Spring Boot的LaunchedURLClassLoader各加载一次导致ClassCastException。解决方案是启用spring-boot-starter-tomcat的providedscope让外部Tomcat提供Servlet API避免类重复加载。另一个坑是SPIService Provider Interface机制。java.sql.Driver的加载就依赖META-INF/services/java.sql.Driver文件。如果多个JDBC驱动jar包都包含这个文件JVM会按classpath顺序加载第一个。但我们有个项目MySQL驱动和PostgreSQL驱动都在classpath结果PostgreSQL的Driver被优先加载导致MySQL连接失败。解决办法在application.properties里显式指定spring.datasource.driver-class-namecom.mysql.cj.jdbc.Driver绕过SPI自动发现。3.7 泛型擦除ListString和ListInteger在运行期是同一个ClassJava泛型是编译期特性运行期被擦除为List。这意味着ListString strList new ArrayList(); ListInteger intList new ArrayList(); System.out.println(strList.getClass() intList.getClass()); // ✅ true所以你不能用instanceof判断泛型类型// ❌ 编译错误 if (list instanceof ListString) { } // ✅ 正确用反射获取泛型信息需TypeReference if (list.getClass().getGenericSuperclass() instanceof ParameterizedType) { Type type ((ParameterizedType) list.getClass().getGenericSuperclass()).getActualTypeArguments()[0]; if (type.equals(String.class)) { ... } }更大的坑在JSON序列化。Fastjson默认会把ListT反序列化为ListMap因为泛型信息丢失了。解决方案是传TypeReference// ❌ 反序列化后list里是LinkedHashMap不是User对象 ListUser users JSON.parseObject(json, List.class); // ✅ 正确保留泛型信息 ListUser users JSON.parseObject(json, new TypeReferenceListUser() {});3.8 Lambda表达式不是语法糖是invokedynamic指令的精密编排Lambda表达式在字节码层面通过invokedynamic指令实现。它会在运行期动态生成一个私有静态方法比如lambda$process$0然后用MethodHandle绑定。这意味着Lambda捕获的局部变量必须是effectively final事实不变因为JVM要把变量值复制到生成的方法里。如果Lambda里用了this它捕获的是当前对象的引用不是副本。所以this的任何修改Lambda里都能看到。Stream的map、filter等操作本质是构建一个PipelineHelper链表只有遇到collect()或forEach()才会真正执行。这就是惰性求值。陷阱在于Stream并行流parallelStream()不是万能加速器。如果map操作里有IO或锁反而比串行更慢。我们测试过对100万个数字求平方根parallelStream()比stream()快3倍但对100万个URL发起HTTP请求parallelStream()慢了5倍因为网络IO阻塞导致线程池饥饿。3.9 注解处理Transactional的失效99%是因为代理模式没搞懂Transactional基于Spring AOP本质是JDK动态代理接口或CGLIB代理类。这意味着方法必须是publicprivate方法加Transactional无效。必须通过Spring容器获取Beannew ServiceImpl()调用事务方法无效。同一个类内methodA()调用methodB()methodB有Transactional事务不生效因为没走代理对象。解决方案methodA也加Transactional用传播行为控制如REQUIRES_NEW。用ApplicationContext获取自身代理((Service) applicationContext.getBean(service)).methodB()。用TransactionTemplate编程式事务。更隐蔽的坑是事务传播行为。REQUIRED默认表示如果有事务就加入没有就新建REQUIRES_NEW总是新建事务挂起当前事务。但如果REQUIRES_NEW方法抛异常外层事务不会回滚——这是设计不是bug。我们有个订单服务createOrder()调用sendSMS()REQUIRES_NEW短信发送失败订单却创建成功了。最终方案是sendSMS()改为异步消息用RocketMQ事务消息保证最终一致性。3.10 日志框架SLF4J不是日志实现是门面选错实现等于埋雷SLF4J只是一个日志门面底层需要绑定具体实现Logback、Log4j2、JUL。常见错误Maven里同时引入slf4j-log4j12和log4j2导致类冲突。logback-spring.xml里用springProfile但启动时没加--spring.profiles.activeprod导致日志配置不生效。Logback的AsyncAppender如果队列满了默认256默认丢弃新日志。线上曾因此丢失关键错误日志。最佳实践统一用LogbackSpring Boot默认排除所有其他日志实现。AsyncAppender设discardingThreshold0不丢弃queueSize1024加大队列。日志级别按环境配置开发环境DEBUG测试环境INFO生产环境WARN以上避免日志刷爆磁盘。3.11 单元测试MockBean不是万能的SpyBean才是真实世界的模拟器MockBean会替换Spring容器中的Bean返回空对象。但很多场景需要“部分模拟”// ❌ MockBean让整个UserService不可用 MockBean private UserService userService; // ✅ SpyBean保留真实方法只mock特定调用 SpyBean private UserService userService; Test void testUserCreation() { // 只mock数据库保存其他逻辑走真实代码 doReturn(1L).when(userService).save(any(User.class)); Long id userService.createUser(test); assertEquals(1L, id); }SpyBean的坑在于如果被spy的Bean有PostConstruct方法它会在spy创建时执行两次一次真实Bean一次spy Bean。解决方案用Primary注解标记真实Bean或在SpyBean上加reset MockReset.NONE。3.12 线上诊断jstack不是看线程数是看线程状态和锁持有关系jstack pid输出里关键信息是线程状态RUNNABLE正在CPU执行或在等待OS资源如socket读。BLOCKED在等待进入synchronized块被其他线程持有锁。WAITING调用Object.wait()、Thread.join()等无限期等待。TIMED_WAITING调用Thread.sleep(n)、Object.wait(n)等等待指定时间。我们定位过一个死锁线程A持有锁L1等待锁L2线程B持有锁L2等待锁L1。jstack输出里会明确标出Thread-A #10 prio5 os_prio0 tid0x00007f8b4c0a1000 nid0x2a34 waiting for monitor entry [0x00007f8b3d7f9000] java.lang.Thread.State: BLOCKED (on object monitor) at com.example.service.OrderService.process(OrderService.java:45) - waiting to lock 0x00000000c0012345 (a java.lang.Object) // 等待L2 - locked 0x00000000c0012344 (a java.lang.Object) // 已持有L1 Thread-B #11 prio5 os_prio0 tid0x00007f8b4c0a2000 nid0x2a35 waiting for monitor entry [0x00007f8b3d6f8000] java.lang.Thread.State: BLOCKED (on object monitor) at com.example.service.UserService.update(UserService.java:67) - waiting to lock 0x00000000c0012344 (a java.lang.Object) // 等待L1 - locked 0x00000000c0012345 (a java.lang.Object) // 已持有L2看到waiting to lock和locked成环就是死锁。解决方案统一锁获取顺序或用ReentrantLock.tryLock(timeout)避免无限等待。4. 真实项目避坑复盘从0到1搭建高并发秒杀系统的17个血泪教训4.1 架构选型为什么放弃Redis Lua脚本选择分布式锁本地缓存二级架构初期方案用Redis Lua脚本扣减库存原子性完美。但压测发现QPS到8000时Redis CPU飙升到95%延迟从1ms涨到200ms。原因是Lua脚本是单线程执行所有请求串行排队。我们尝试拆分Lua脚本把“查库存”和“扣库存”分开但又引入了ABA问题查到100扣减时被别人扣成99再扣成98。最终方案Redis分布式锁 JVM本地缓存Caffeine。流程请求进来先查Caffeine缓存TTL 10秒命中则直接返回未命中用Redisson的RLock加锁锁粒度商品ID加锁成功查Redis库存扣减更新缓存加锁失败降级为“排队中”前端轮询。效果QPS提升至15000Redis CPU降到40%。关键点在于锁的粒度——不是整个秒杀活动加一把大锁而是每个商品ID一把锁避免热点商品阻塞其他商品。注意Caffeine的refreshAfterWrite(10, TimeUnit.SECONDS)比expireAfterWrite更适合秒杀。前者在访问时异步刷新不会导致缓存雪崩后者过期后所有请求穿透到Redis。4.2 库存预热不是把库存全加载到Redis而是按热度分级加载全量预热库存比如100万商品每个商品库存1000会撑爆Redis内存。我们按商品热度分三级S级Top 100库存全量加载到Redis用INCRBY原子扣减A级Top 1000只加载库存总数扣减时用DECR但加一层本地缓存计数器每100次扣减同步一次RedisB级其余不预热直接查DB用数据库行锁SELECT ... FOR UPDATE。S级商品用Redis集群的单独分片避免影响其他业务。A级商品的本地计数器用LongAdder比AtomicLong在高并发下性能高3倍无CAS竞争。4.3 流量削峰Nginx限流只是第一道闸真正的削峰在应用层消息队列Nginx的limit_req只能限制QPS无法区分恶意刷单和真实用户。我们在网关层Spring Cloud Gateway加了两道过滤设备指纹用User-AgentIPCookie生成MD51分钟内同一指纹请求5次返回429 Too Many Requests令牌桶每个用户ID对应一个令牌桶容量10每秒补充2个令牌。但真正的削峰在应用层所有秒杀请求先写入RocketMQ消费者按固定速率比如每秒1000条消费。这样即使瞬间10万请求MQ堆积但下游服务不会被打垮。关键配置RocketMQsendMsgTimeout3000避免发送超时阻塞消费者consumeMessageBatchMaxSize10批量消费减少DB压力DB连接池maxActive50minIdle10避免连接数暴涨。4.4 订单幂等不是靠数据库唯一索引而是用Redis布隆过滤器拦截重复请求数据库唯一索引order_id唯一能防重复下单但高并发下两个请求几乎同时写入都查不到订单都创建新订单然后唯一索引报错。这时错误日志满天飞但用户看到“下单成功”实际生成了两条订单。终极方案Redis布隆过滤器Bloom Filter 数据库唯一索引双重保障。请求进来先用BF.EXISTS bf_orders order_id查询布隆过滤器不存在BF.ADD bf_orders order_id然后创建订单存在直接返回“重复下单”。布隆过滤器有误判率我们设0.01%但不会漏判。所以即使误判后续数据库唯一索引兜底。布隆过滤器用RedisBloom模块BF.RESERVE bf_orders 0.01 1000000误差率1%预计100万元素。4.5 监控告警不是只看QPS和错误率