工作时,大部分人只会抱怨项目很无趣、没有挑战,但遇到问题也只会安于现状。只有少数人会发现工作中的痛点,并且真正动手解决它,给工作带来价值。这是提高自己,让自己和别人区分开来的重要方法。
脱离业务的设计都是耍流氓,技术的意义在于服务业务,满足需求是基础。
老规矩,先思考,后看解答。
问答题
(1)详细描述用户在浏览器输入 www.baidu.com 到用户看见网页,中间的技术处理细节。
(2)在一台 1 核 1G 的服务器中运行 Java 程序,可以使用哪些 JVM 参数调优,为什么?
(3)NIO 解决了什么问题?什么原理?并简述一个包含 NIO 技术的通信框架的调优技巧。
(4)线程池的配置项有哪些?各有什么作用?
(5)请给出 3 种多服务器共享 Session 的方案。
(6)简述线程间如何通信?
(7)如何优化超时处理机制?
(8)Redis 适合存储哪些类型的数据?支持事务吗?如何处理击穿、雪崩和穿透?
(9)简述对 Linux 的熟悉程度。
(10)如何实现一个 RPC 框架?基于什么原理?
(11)缓存有哪些作用?它的原理是什么?
(12)从请求一个 Spring Boot 的 API 接口到收到响应,中间经历了哪些过程?
(13)SQL 语句执行很慢,如何处理?
(14)文件上传需要时间,如何防止文件在上传还未完成时被提前读取?
参考答案
(1)技术处理细节如下:
(A)浏览器处理输入:自动在网址上添加默认的协议和端口号,并在本地查找是否有这个网址的缓存;
(B)DNS 解析:查询域名对应的 IP 地址,依次在本地 Host 文件、本地域名服务器、根域名服务器中查找;
(C)建立连接:通过 TCP 三次握手与服务器建立连接;
(D)发送 HTTP 请求:若使用 HTTPS 协议,还会进行 TLS 握手,建立加密信道,然后设置请求头发出请求;
(E)服务器处理请求:根据请求解析出受访的主机、应用、资源,封装并返回 HTTP 响应报文;
(F)浏览器解析响应和渲染页面:如果响应头中的 Content-Type 是 text/html,则进行 HTML 解析,否则做其他处理;在渲染页面时,如果遇到外部资源链接(如图片、JS 文件等),则会进一步请求这些资源(在 HTTP/2 中,服务端可主动推送 CSS/JS 等关联资源)。
(2)可使用以下 JVM 参数进行调优:
(A)堆内存设置:
- -Xms:设置初始堆空间。默认为物理内存的 1/64,由于服务器内存只有 1G,建议设置为较小值,建议将初始堆空间(-Xms)和最大堆空间(-Xmx)设置为相同的值,以避免堆内存的动态扩展和收缩带来的性能开销。
- -Xmx:设置最大堆空间。默认为物理内存的 1/4,同样考虑到内存受限和性能开销,且最好与 -Xms 设置的值相同,如 -Xmx256m 或 -Xmx512m,留出一定空间供操作系统和其他程序使用,确保服务器的稳定。
(B)垃圾回收器选择:
- -XX:+UseG1GC:G1 垃圾回收器能够更灵活地管理内存,减少垃圾回收带来的停顿,提高程序的响应速度。
- -XX:+UseSerialGC:使用串行垃圾回收器。在单核 CPU 和有限内存的环境中,串行垃圾回收器是一个合理的选择,因为它不需要额外的线程来处理垃圾回收,从而减少了 CPU 和内存的消耗。
(C)线程栈大小设置:
- -Xss:设置每个线程的栈大小。默认的线程栈大小通常是 1MB,在内存有限的情况下,将其设置为较小值,如 -Xss512k,以节省内存并允许创建更多的线程。但也要注意不能设置得过小,如果方法调用的深度过大,可能会导致栈溢出。
(D)性能监控参数:
- -XX:+PrintGCDetails:输出详细的 GC 日志。有助于了解垃圾回收行为,从而进行针对性的优化。
- -XX:+PrintGCDateStamps:在 GC 日志中添加时间戳。可以更准确地分析 GC 事件的时间分布。
- -Xloggc::将 GC 日志输出到指定文件。便于收集和分析 GC 数据,如 -Xloggc:log/gc.log。
(3)NIO 主要解决了在高并发、高性能需求下,使用阻塞 I/O 模型时带来的资源消耗大、效率低下等问题。传统的阻塞 I/O 一个线程管理一个 Socket,而 NIO 只用一个或少数几个线程就能管理多个网络连接,特别适用于需要同时处理大量并发连接或大量数据传输的场景,比如网络服务器、文件传输等。它的原理是基于事件驱动的 I/O 模型,使用多路复用器(Selector)和事件通知机制来实现非阻塞 I/O 操作。核心概念包括:
- Selector:多路复用器,可以注册多个 Channel,并监听这些通道上的特定事件,比如读取数据、写入数据等;
- Channel:通道,数据传输的载体,比如文件通道、网络连接通道等,它是双向的,既可以读取也可以写入;
- Buffer:缓冲区,一个固定大小的内存块,用于存储要读取或写入的数据,所有数据都通过它来处理。
具体工作流程如下:
- 创建一个 Selector,并将其绑定到某个线程上;
- 创建通道,并将其注册到 Selector 上,指定监听的事件类型;
- 调用 Selector 的 select() 方法阻塞等待事件发生或者超时;
- 若某通道上的事件发生,则 select() 方法返回;
- 应用程序可通过调用 selectedKeys() 方法获取已就绪的事件列表;
- 根据不同的事件类型,应用程序进行相应的读取、写入或其他操作;
- 所有事情做完后释放资源,关闭通道和多路复用器。
以 Netty 为例,它的调优技巧有:
(A)线程池:根据应用负载和硬件配置,设置合适的线程池大小,如核心线程数、最大线程数;BossGroup 通常设置为单线程,负责处理连接请求;WorkerGroup 负责处理 I/O 操作,可以设置为 CPU 核心数 × 2;使用独立的线程池 EventExecutorGroup 异步执行耗时操作;
(B)内存:启用 Netty 的内存池功能,复用内存中的对象,减少内存分配和回收的开销;
(C)协议:根据应用场景选择合适的通信协议(如 UDP、WebSocket、TCP),对于 TCP 协议,将 TCP_NODELAY 设置为 true,降低延迟;并选择更加高效的序列化框架,比如用 Protobuf 替代 JSON;
(D)I/O 模型:根据应用场景和操作系统选择合适的 I/O 模型,比如在 Linux 上用 epoll 会更加高效;
(E)编解码器:选择合适的编解码器,比如使用 Netty 自带的工具处理粘包和拆包: LengthFieldBasedFrameDecoder(默认,基于消息头中的长度字段,灵活高效)、DelimiterBasedFrameDecoder(基于特定分隔符,兼容性强,适用于文本协议);
(F)日志:调整日志级别来减少不必要的日志输出,提升性能;
(G)使用压缩:大数据量使用 Gzip 压缩,减少数据传输量,提高 I/O 性能。
(4)线程池通过复用线程,避免频繁创建和销毁线程,减少系统资源的浪费。它的配置项及作用有:
(A)核心线程数:始终保持存活的线程数量,即使线程池处于空闲状态,核心线程也不会被回收;
(B)最大线程数:允许创建的最大线程数量,任务队列满了之后会创建新的非核心线程,直至达到该值;
(C)非核心线程存活时间:非核心线程在空闲时能存活的最长时间,超时会被回收,与单位(unit)配合使用;
(D)任务队列:当任务数量超出核心线程数时,选择合适的队列来存放待执行的任务,比如有界队列(ArrayBlockingQueue)、无界队列(LinkedBlockingQueue)或同步队列(SynchronousQueue,是一个不存储任何元素的阻塞队列,更像一种信号机制,比如添加元素时,若没有线程在等待从这个队列中移除元素,那么添加操作会阻塞,直到有线程从队列中移除元素,反之亦然);
(E)线程工厂:用于自定义线程创建逻辑,可设置线程的名称、优先级、是否是守护线程等(守护线程在后台运行,通过调用 setDaemon(true) 设置,其作用是为其他线程提供服务,如垃圾回收、日志记录、监控等,当所有的非守护线程结束时,守护线程会自动结束);
(F)拒绝策略:当任务队列已满且线程池达到最大线程数时,选择合适的策略来处理新提交的任务,比如抛出异常(AbortPolicy,默认策略)、直接丢弃任务(DiscardPolicy,静默丢弃,不抛异常)、由提交任务的线程处理任务(CallerRunsPolicy)或通过实现 RejectedExecutionHandler 接口来自定义策略。
应当避免过度配置,线程数过多会导致频繁上下文切换,降低性能。对于 CPU 密集型任务而言(如计算、压缩、加密),任务主要消耗 CPU 资源,建议使用有界队列,它的核心线程数可设置为:CPU 核心数 + 1,加 1 是用于应对线程因阻塞导致的 CPU 空闲。对于 IO 密集型任务而言(如网络请求、数据库查询),任务大部分时间都在等待 IO 操作,CPU 利用率低,增加最大线程数可让更多线程在 IO 等待期间处理事情,它的最大线程数可设置为:CPU 核心数 × ((线程等待时间 + 线程 CPU 计算时间) / 线程 CPU 计算时间),通常为 2N ~ 10N(N 为 CPU 核心数),任务执行时间和等待时间可通过 APM 工具统计(如 Micrometer)。最大线程数包含核心线程数。
创建线程池主要有两种方式:ThreadPoolExecutor 构造函数(推荐)和 Executors 工具类(快速但需谨慎使用)。优先使用 ThreadPoolExecutor 自定义配置参数,以实现粒度更细的控制,用 Executors 工具类创建的线程池可能因默认参数导致资源耗尽(比如使用无界队列导致 OOM)。
1 | ExecutorService executor = new ThreadPoolExecutor( |
- 固定大小线程池(newFixedThreadPool):核心线程数与最大线程数相等,任务队列无界,适合任务量已知的场景。
- 可缓存线程池(newCachedThreadPool):核心线程数为 0,最大线程数无界,使用同步队列,适用短时高并发场景。
- 单线程池(newSingleThreadExecutor):仅一个线程,任务队列无界,适用需顺序执行任务的场景。
- 定时任务线程池(newScheduledThreadPool):支持执行定时或周期性任务(如心跳检测、周期性数据同步)。
(5)方案如下:
(A)使用外部存储(如 Redis)集中存储 Session 数据,所有服务器统一对外部存储读取或写入;
(B)使用文件来存储 Session 数据,通过共享文件使得其他服务器能够访问 Session 数据;
(C)搭建一个 Session 服务器专门管理 Session,其他服务统一访问 Session 服务器。
Session 是一种在服务端用于跟踪用户会话信息的机制,它的出现主要是为了解决 HTTP 协议无状态的问题,以及为用户提供更好的体验。无状态即每个请求都是独立的,服务器不会记住与客户端之前的交互信息,但在实际应用中,用户有时会希望一些信息能在多个请求之间保持一段时间,如登录状态、购物车内容等。它的用途常有:
(A)用户认证:通过 Session 可以判断用户是否已经登录,并获取用户的身份信息;
(B)状态保持:可用于在多个请求之间保持用户的状态信息,如购物车内容、表单填写进度等;
(C)个性化服务:服务器根据 Session 中的用户信息提供个性化服务,如内容推荐、偏好设置等。
在 Java 开发中,Session、Cookie 和 Token 都是用于管理用户会话和身份验证的重要机制,以下是三者的区别:
- Session:存储在服务端,相对安全,适合需要长时间保持会话状态的应用(当用户首次登录后,服务器会创建并保存一个唯一的 Session,并将 Session ID 通过 Set-Cookie 或其他方式发送给客户端,客户端在后续的请求中携带这个 Session ID,服务器通过验证 Session ID 识别用户身份和登录状态)。
- Cookie:存储在客户端 / 浏览器中,安全性较低,可设置 HttpOnly 增加安全性,适合需要频繁访问的应用(服务器在 HTTP 响应中设置 Cookie,比如可将 Session ID 或 Token 设置到 Cookie 中,客户端在后续的请求中自动携带这些 Cookie。自动登录功能就是先判断 Cookie 是否存在,然后再判断 Cookie 里面保存的用户信息是否正确,正确即可登录)。
- Token:通常存储在客户端,更加安全,因为可以设置过期时间,适合需要跨域访问或增强安全性的应用。它拥有无状态的特性,完美适配 HTTP,当用户首次登录成功后,服务端会生成一个 Token 返回给客户端,Token 通常包含唯一用户标识(如 userID)、签名和过期时间戳。客户端在后续的请求中都会携带这个 Token,放在请求头中,服务端通过验证 Token 完成校验,从而避免频繁查询数据库,具体包含验证签名、检查时效、核验权限(验证签名为使用相同的哈希算法和预存密钥重新计算签名,将计算结果与 Token 中的签名作对比,若一致则说明信息未被篡改)。
针对不同的应用场景,这三者可以相互配合同时使用,比如在一个应用里,一些功能需要保持会话状态,另一些功能又需要请求是无状态的,那就可以同时用 Session 和 Token,利用各自的优势,满足多样化的需求。
单点登录(SSO):只需登录一次,就能访问一个企业内部所有的系统,从而消除不同系统有不同账号的问题,与之对应的为单点退出。由于浏览器的同源策略,通常 cookie 是不能跨域共享的(在同一主域下可以),这里介绍一个常用的解决方案 CAS(Central Authentication Service),即建立一个独立的认证中心,负责所有的用户注册和认证,大致流程为:
(I)用户通过浏览器访问子系统 A 的资源,发现需要登录,于是重定向到认证中心去登录;
(II)登录成功后,认证中心会创建一个全局的 session 和一个 ticket,然后再重定向回子系统 A,ticket 附在重定向 URL 中给子系统 A,全局的 sessionId 则通过 cookie 发送给浏览器,浏览器将其保存下来;
(III)子系统 A 将收到的 ticket 再次发送给认证中心验证有效性(防止有人截取伪造),若验证通过,则说明用户确实已经登录且这个 ticket 的确是由认证中心颁发,接着在认证中心注册子系统 A;
(IV)子系统 A 为用户创建属于自己的 session,返回被访问的资源,同时也将自己的 sessionId 通过 cookie 发送给浏览器,此时浏览器有两个 cookie,一个是认证中心的,一个是子系统 A 的,后续访问子系统 A 其他资源的时候,浏览器会带上子系统 A 的 cookie,子系统 A 就知道用户已经登录;
(V)然后用户访问另一个子系统 B 的资源,由于此时还没有子系统 B 的 cookie,所以仍会提示未登录,但浏览器已经有了认证中心的 cookie,因此重定向时会将其携带给认证中心,认证中心收到后得知用户已经全局登录了,所以直接生成一个新的 ticket 返回给子系统 B,无需再次登录;
(VI)接下来子系统 B 的流程与子系统 A 的一样;
(VII)最终,浏览器保存了一个认证中心的全局 cookie 和多个子系统的 cookie;
(VIII)注册子系统是为单点退出做准备,当用户在某个子系统退出时,认证中心需要把自己的全局会话和 cookie 删除,然后还要通知各个子系统,让它们也把自己的会话和 cookie 统统删除。还有一个解决方案是通过标准化协议 SAML / OIDC 实现更安全的跨域 SSO,工作流程与 CAS 类似:用户访问 SP(服务提供者,各子系统) -> 重定向至 IdP(身份提供者,负责集中认证,如 Keycloak) -> 完成认证(可集成 MFA 提升安全性,如生物识别) -> IdP 返回加密令牌(如 token,替代 sessionId,减少服务端状态存储) -> SP 验证令牌并建立会话。
授权码:为了读取必要数据,很多第三方应用支持用其他平台的账号登录,比如用 QQ、微信的账号登,此时为保障原始密码安全,在使用这些第三方应用时不会直接输账号密码,而是重定向到目标平台的官方认证中心去登录和授权,成功后返回一个授权码给第三方(在前端/浏览器得到,由于授权码会跟第三方自己在官方认证中心注册的 app_id、app_secret 关联,所以泄露也没事),第三方再用这个授权码去申请 token(在后端调用接口得到,防止泄漏),有了 token 后每次请求都带上,就能正常访问了。
(6)线程间通信指的是在多线程编程中,不同线程之间如何交换信息或协调执行。常见的实现方式有:
(A)共享内存:多个线程可访问同一块内存,通过读写这块内存实现数据交换,如全局变量,但要注意同步问题;
(B)消息传递:线程之间通过发送和接收消息来进行通信,比如使用消息队列;
(C)条件变量:通过条件变量实现线程间的等待和唤醒,适用于线程需要等待某个条件成立才能继续执行的场景;
(D)信号量:这是一种用于线程同步和互斥的机制,它维护一个计数器(就是一个整数),表示可用资源的数量,线程通过等待(P 操作)和释放(V 操作)信号量来实现同步,用于控制同时访问共享资源的线程数量;
(E)事件:线程需要等待某个事件(如 I/O 操作完成)才能继续执行。
在 Java 中的实现:
(A)volatile:保证共享变量的可见性和有序性(配合 synchronized 或 ReentrantLock 使用,保证原子性);
(B)BlockingQueue:提供了线程安全的队列实现,可用于在生产者和消费者线程之间传递数据;
(C)wait 和 notify/notifyAll:由 Object 类提供,用于线程间的等待和通知,必须与 synchronized 配合使用;
(D)Condition:提供了比 wait / notify 更为灵活的通信机制,通常与 ReentrantLock 配合使用;
(E)Semaphore:在 java.util.concurrent 包中,通过 acquire 方法获取许可,通过 release 方法释放许可;
(F)CountDownLatch:允许一个或多个线程等待其他线程完成操作(老师必须要等到所有学生交完试卷才能走);
(G)CyclicBarrier:允许一组线程互相等待,直到所有线程都到达某个屏障点(多人游戏必须都要加载到 100% 才能开始);
(H)Exchanger:允许两个线程在预定的交换点上交换数据(比如在遗传算法中交换父代的染色体组合)。
其中 ReentrantLock、Semaphore、CountDownLatch、CyclicBarrier、还有 FutureTask 等都是基于 AQS 实现的,AQS(AbstractQueuedSynchronizer,抽象队列同步器)是 JUC 的基石,它的核心原理包括:
(A)通过一个 volatile int state 变量表示资源占用状态,并提供了 getState()、setState()、compareAndSetState() 等方法。它的具体语义由子类定义,比如在 ReentrantLock 中表示锁的重入次数,而在 Semaphore 中表示剩余许可证数量,在 CountDownLatch 中表示未完成的计数等。
(B)通过一个 FIFO 的双向链表(CLH 队列的变体,原本是单向链表)存放等待获取资源的线程,队列中的每个节点是一个 Node 对象,包含节点状态、前驱和后继节点指针、关联的线程引用等。线程在占用资源成功时继续执行,失败时则被封装为 Node 放入队列尾部,并在适当时机唤醒(类似银行业务办理窗口都满了,后面来的只能去等候区排队等着叫号)。
AQS 是一个抽象类,采用了模板方法模式,将具体的资源获取 / 释放逻辑留给子类实现,主要的模板方法有 tryAcquire / tryRelease(独占模式)和 tryAcquireShared / tryReleaseShared(共享模式)。独占模式和共享模式是 AQS 的两种工作流程,独占模式每次仅允许单个线程获取资源(如 ReentrantLock),共享模式则允许多个线程同时获取资源(如 Semaphore)。
并发编程的最佳实践:
(A)减少共享变量的使用:通过设计降低对共享变量的访问,尽量用不可变对象、局部变量或 ThreadLocal 代替;
(B)使用线程安全的集合类:比如 ConcurrentHashMap、CopyOnWriteArrayList 等,这些类是线程安全的;
(C)选择合适的同步工具:比如 synchronized、ReentrantLock 或 java.util.concurrent 包中的工具类;
(D)确保所有线程都以相同的加锁顺序获取锁,避免死锁;
(E)给线程起个有意义的名字;
(F)使用线程池。
synchronized 和 Lock 的区别:
(A)synchronized:是一个内置的关键字,可用于方法和代码块,不需要显式地获取和释放锁,当线程进入一个 synchronized 方法或代码块时,会自动获得锁,退出时自动释放锁,只支持抢占式的非公平锁,适用于简单的同步场景。
(B)Lock:是一个接口,常用的实现类有 ReentrantLock,需要显式地调用 lock() 方法获取锁,通常在 finally 块中调用 unlock() 方法确保锁的释放,还提供了 tryLock()、isLocked() 等方法实现更灵活的控制,支持公平锁和非公平锁,可在创建对象时指定,适用于复杂的同步场景。
为什么 wait、notify 和 notifyAll 这些方法不在 Thread 类中?
这与设计有关,wait、notify 和 notifyAll 都是锁的操作,如果 wait 方法定义在 Thread 类中,那么线程等待的是哪个锁就不明显了,将它们定义在对象中会更好。所以 Java 提供的锁是对象级的而不是线程级的,它们都被定义在 Object 类中。不过,Thread 类中有一个方法叫 holdsLock(),可用来检测线程是否拥有某个具体对象的锁。多提一点,处于等待状态的线程可能会收到错误警报和伪唤醒,可在循环中检查等待条件。
(7)超时处理的优化策略如下:
(A)提高任务执行效率:优化算法、减少不必要的 I/O 操作,缩短执行时间;
(B)合理分配资源:确保系统有足够的资源来处理任务(如内存、网络带宽等);
(C)使用缓存:对于频繁访问但不经常变化的数据,可使用缓存来减少时间;
(D)异步处理:使用异步编程来处理任务(如 CompletableFuture),减少等待时间;
(E)使用更高效的超时检测机制:如使用 ScheduledThreadPoolExecutor 定期检查状态,取代阻塞式的 get;
(F)友好的错误提示:发生超时时,向用户显示清晰的错误提示和可能的解决方案;
(G)重试机制:提供重试按钮或自动重试功能,让用户可以方便地重新尝试操作;
(H)进度条或加载指示器:在操作进行时显示进度条或加载指示器,让用户知道并非无响应。
(8)适合存储的数据类型包括:
(A)String:适合存储任何形式的字符串(如 JSON),存软件版本号、接口执行结果、用户语种(SET / GET);
1 | 格式:SET key value |
(B)Hash:适合存储具有多个属性的对象信息,比如数据字典、用户信息、商品信息(HSET / HGETALL);
1 | 格式:HSET key field1 value1 field2 value2 ... |
(C)List:适合存储需要有一定顺序的数据集合,比如任务列表、操作日志、消息通知(LPUSH / RPOP);
1 | 格式:LPUSH key value1 value2 value3 ... |
(D)Set:适合存储无序不重复的数据集合,如在线用户、标签、类别、抽奖参与人员(SADD / SMEMBERS);
1 | 格式:SADD key member1 member2 member3 ... |
(E)Sorted Set:适合存储有序不重复的数据集合,根据赋予的分数排列顺序,比如排行榜、时间线(ZADD / ZRANGE,OTA 时,用收到终端心跳时的当前时间作为分数,每收到一次心跳就更新分数,这样就能根据分数删除心跳超时的终端 ID,以此获取当前在线的终端列表)。
1 | score 代表集合中每个元素的权重或排名值,member 代表有序集合中的实际元素。 |
若想用 Redis 存储关系型表数据,可用 Hash + Sorted Set 组合实现。以下面这张 ACT 命令表为例:
| 字段 | 数据类型 |
|---|---|
| cycle | int(Primary Key) |
| ba | int |
| bg | int |
| cid | int |
| row | int |
用 Hash 结构存储每行记录:
| Key | Value |
|---|---|
| cmd:act:row:{cycle} | cycle 1 ba 1 bg 1 cid 1 row 100 |
用 Sorted Set 结构为每行记录建立索引并存储:
| Key | Value |
|---|---|
| cmd:act:idx | 123 cmd:act:row:123(score 为 cycle,member 为 rowKey) |
查看 Redis 内存占用情况用命令 INFO memory,清空 Redis 用命令 FLUSHALL。
Redis 事务与传统的数据库事务不同,它是一种将多个命令打包执行的机制,通过命令 MULTI、EXEC、DISCARD、WATCH 实现。Redis 事务最大的区别在于,若某条命令执行失败,后续命令仍会继续执行,且不会自动回滚已执行成功的命令,设计上认为运行时错误需在业务层处理。
- MULTI:开启事务,后续命令进入队列而非立即执行(语法正确的命令被缓存,语法错误会导致事务终止)。
- EXEC:串行执行队列中的所有命令,按顺序返回结果。
- DISCARD:放弃事务并清空队列。
- WATCH(乐观锁):在 MULTI 命令执行之前,可以用 WATCH 指定待监控的 Keys,然后在执行 EXEC 之前,如果被监控的 Keys 发生了修改,则 EXEC 将放弃执行该事务队列中的所有命令。因为 Redis 事务无隔离级别概念,在执行期间其他客户端也可修改键值,因此 WATCH 乐观锁十分重要,可通过它监控账户余额,确保并发安全。
(I)击穿指的是缓存中某个热点数据突然失效(因过期或被淘汰),导致大量请求直接访问数据库,造成数据库压力骤增。比如秒杀商品、热点新闻的缓存突然失效,解决方法:
(A)使用互斥锁(比如 Redis 的 SETNX 或 Redisson 分布式锁),保证失效时仅允许一个线程访问数据库并重建缓存,其他线程等待锁释放后读取新缓存;
(B)不设置 key 的过期时间,在 value 中存一个逻辑过期时间,当后台任务发现数据过期时,再异步更新缓存。
Redis 的 SETNX(SET if Not eXists):Redis 提供的原子命令,意思是在指定键(Key)不存在时设置,若键已存在,则不执行任何操作。当多个客户端并发调用时,它确保仅有一个客户端能成功设置,是分布式锁的基础。为避免死锁(如客户端崩溃未释放锁),需设置键的过期时间。
1
2
3
4
5
6
7
8
9基本用法
返回 1:键不存在,设置成功
返回 0:键已存在,未执行操作
SETNX key value
分布式锁用法:为保证原子性,NX(不存在才设置)和 PX(过期时间,单位:毫秒)必须同时设置
返回 1:获取锁成功
返回 0:锁已被其他客户端持有
SET key value NX PX 30000Redisson 分布式锁:若直接用 Redis 手动处理原子性、锁续期等操作,则代码复杂且易错,加上传统的并发工具(如 Lock、Semaphore)仅限单机,在分布式场景缺乏开箱即用的等效实现,于是 Redisson 应运而生,它解决了 Java 在分布式系统中的共享资源协调问题(如锁、线程协同等),让开发者能以操作本地对象的方式处理分布式问题,其价值类似于 JDBC 对数据库操作的封装。
Redisson 通过 Lua 脚本确保加锁、解锁、重入等操作的原子性,并提供了开箱即用的 RLock(可重入锁)、RSemaphore、RCountDownLatch 等接口,还有线程安全的数据结构 RMap、RList 和分布式线程池 RExecutorService 等等。Redisson 分布式锁使用不当可能引发以下问题:
(a)锁提前释放:比如业务执行时间超过锁过期时间,导致锁释放时业务还未执行完,此时其他线程可提前获取到锁,若操作同一数据则会引发数据不一致。解决方案为启用看门狗机制自动续期,它通过后台线程定时检查并延长锁的有效期:获取锁时(如调用 lock() 或 tryLock() 并传递 leaseTime = -1),看门狗线程启动,线程默认每 10 秒检查一次锁是否仍被当前线程持有(使用 HEXISTS 命令判断锁的归属),若锁仍被持有,则重置锁过期时间为 30 秒,业务执行完成后释放锁,看门狗线程自动终止。
注意,若显式指定了锁的超时时间(如 lock(10, TimeUnit.SECONDS)),则看门狗不会启动!因此,对执行时间不确定的任务(如 RPC 调用、批量处理),推荐用看门狗;对短时高频任务建议指定固定超时时间,避免续期开销。
(b)死锁:如持有锁的线程崩溃或网络中断,导致锁无法释放。解决办法为设置一个合理的锁过期时间,并且添加守护线程监控业务线程,若锁超时未自己释放,则由守护线程强制中断业务并释放锁(在 finally 块中显式释放)。
(c)误释放锁:比如线程 A 释放了线程 B 的锁(锁超时自动失效时线程 A 未感知,在线程 B 获取到锁后,线程 A 又手动释放),解决办法是在获取锁时为其生成一个唯一标识作为锁的值(如机器 IP + 进程 ID + 线程 ID + 随机数),释放锁时通过对比锁的值与线程自己保存的标识是否一致来验证锁的持有者。
(d)锁不可重入:比如递归调用时,在同一线程内重复获取同一把锁导致自我阻塞(在没抢到锁的情况下一直循环抢锁,称为自旋锁,在递归函数中使用自旋锁可能引发死锁,比如递归调用先于锁释放时)。解决办法为使用可重入锁 RLock 记录线程 ID 和重入次数(可重入锁:成功获取到锁后记录锁的持有者,并用一个计数器记录获取的次数,在持有锁的情况下再次申请锁只是给计数器加 1,释放锁时把计数器减 1,只有当计数器为 0 且持有者是当前线程时才真正释放锁)。
(e)锁竞争激烈性能下降:高并发下大量线程循环抢锁,导致 CPU 飙升。解决方案为采用指数退避算法降低请求频率(请求重试时,通过指数级增长的等待时间来避免系统过载或冲突加剧),或者将抢锁请求路由到消息队列(如 Kafka)顺序消费。
(II)雪崩指的是缓存中大量数据同时失效或 Redis 宕机,导致所有请求直接访问数据库,可能引发数据库崩溃。比如大促期间缓存集中过期,解决方法:
(A)对缓存数据的过期时间添加随机值,避免批量数据同时失效;
(B)增加二级缓存(如 Memcached),当 Redis 缓存失效时,可从二级缓存读取;
(C)部署 Redis Cluster,支持数据备份和故障转移,防止单点故障导致服务不可用;
(D)对关键数据开启持久化功能,配合 Hystrix 等熔断工具,在数据库压力过大时暂时拒绝部分请求。
RDB 持久化:生成某一时刻内存数据的二进制快照文件(默认名为 dump.rdb),可通过 SAVE(阻塞主线程) 或 BGSAVE(后台异步子线程) 命令手动生成,也可通过配置让其自动生成。
AOF 持久化:以日志的形式记录 Redis 服务器处理过的每一个写操作命令(默认名为 appendonly.aof,且默认的 appendfsync 同步频率为每秒同步一次),然后在 Redis 服务器重启时通过执行该文件中的命令来恢复数据。可与 RDB 混合使用,结合 RDB 的快速恢复和 AOF 的数据丢失低风险,兼顾性能与安全。
Redis Cluster:由多组服务器组成,每组服务器包含一个主节点(Master,处理读写)和至少一个从节点(Slave,只读,手动绑定到主节点的命令为 CLUSTER REPLICATE <主节点 ID>),每个主节点负责管理分配给它的一个哈希槽的子集;从节点负责备份对应主节点的数据,提供故障转移能力,即主节点挂掉时,自动从它的从节点中选取一个当作新的主节点(官方要求最小的集群为 3 主 3 从)。
数据同步由从节点发起(从节点向主节点发送 PSYNC 命令,并携带自身的复制 ID 和复制偏移量),分为全量同步和增量同步:全量同步(首次连接或复制偏移量不在主节点的复制缓冲区范围内)为主节点生成 RDB 快照文件发给从节点,从节点清空旧数据后加载;增量同步(断线重连且复制偏移量在主节点的复制缓冲区范围内)为主节点把复制缓冲区中位于偏移量之后的增量写命令发给从节点执行。
故障转移通过节点间的 Gossip 协议和分布式共识实现:每个节点每秒发送 PING/PONG 消息检测其他节点的状态,若某节点在超时时间内无响应,则被标记为「疑似故障」,但只有主节点拥有投票权,只有多数主节点认为该节点失效时(> 50%),该节点状态才升级为「确认故障」。若故障节点为主节点,则它的从节点发起竞选,向其他主节点广播,收到请求的主节点基于复制偏移量投票(最新数据优先),获得多数票的从节点晋升为新主节点。
哈希槽(Hash Slot):共有固定的 16384 个槽,每个主节点分管其中一部分(2^14,槽位过少负载不均、过多开销过大,每个槽内部结构为数组加链表),保存 (key, value) 的时候,对 key 使用 CRC16 算法得到一个整数值,然后再对 16384 求余数(CRC16(key) % 16384),看余数落在哪个槽里,就放到对应的主节点中(即数据分片)。当要添加新的主节点时,就从现有主节点负责的槽中各自分出一些给新的主节点,对应数据也搬过去;查询缓存数据时,客户端可以向任意一个节点发出请求,如果查不到,该节点会告诉我们该去哪个节点查,因为每个节点都保存了槽位与节点的关系映射表 slots_to_nodes(位图 + 节点 ID 列表,仅需 2KB 内存空间,带宽友好),心跳包携带着映射表在节点间同步。
为什么不选择一致性哈希算法?一致性哈希在服务器较少时可能出现严重的数据倾斜问题,常通过将一台真实的服务器看成多台虚拟的服务器来解决(比如用「真实服务器的 IP 加编号」的哈希值),以此增加它们在圆圈上分布的均匀性,而 Hash Slot 强制每个主节点管理近乎相等的槽数量,天然规避了数据倾斜。另外,当需要添加或删除服务器时,虽然一致性哈希只会让相邻节点的缓存失效,但它需要重新计算大量 key 的落点(从 Hash(key) 的位置开始,放入顺时针找到的第一台服务器中),这与 Hash Slot 直接迁移特定槽位的方式相比成本更高。
(III)穿透指的是请求的数据在缓存和数据库中均不存在,导致请求持续绕过缓存直接访问数据库。比如攻击者发送恶意或异常请求,故意查询不存在的数据,解决方法:
(A)对请求参数进行合法性校验,拦截明显无效的请求;
(B)使用布隆过滤器判断数据是否存在,若不存在,则直接返回,需预加载合法数据到过滤器(如有效用户 ID);
(C)当查询结果为「数据不存在」时,将空值(如 NULL 或特定标记字符串)写入缓存并设置较短过期时间(如 5 - 30 分钟),后续相同请求直接返回缓存中的空值,需注意监控空值缓存的内存占用,防止恶意攻击导致内存溢出。
- 布隆过滤器:是一个由二进制位组成的固定长度数组(初始值全为 0,数组大小取决于创建时指定的两个参数:预期插入的元素数量(如 100 万)和误判率(如 0.03 / 3%)),它能快速判断一个元素是否「一定不存在」或「可能存在」于集合中,其优势在于空间效率高、查询速度快,但存在误判,且不支持删除操作。其原理为:先预加载所有的合法元素,将每个元素输入到多个独立的哈希函数中,然后将多个哈希值对应的位数组位置设置为 1(例如:元素 a 经过 3 个哈希函数映射到位置 2、5、10,则这三个位置设为 1),查询时,对于待查元素作相同处理,然后检查位数组中的对应位置是否全为 1,若存在 0,则代表元素一定不在集合中,若全为 1,则代表元素可能存在(可能因哈希冲突误判),误判率与位数组长度、哈希函数数量、已插入元素数量相关,可通过增大位数组长度、调整哈希函数数量来优化。布隆过滤器是以空间换时间,建议选择可扩展的布隆过滤器应对数据动态增长,并定期重建过滤器,避免误判率随时间累积升高。
(9)可从以下几个方面进行说明:
(A)掌握 Linux 的基本命令,比如 mkdir、pwd、chmod、cp、mv、history、crontab、vi 编辑器的使用等等;
(B)能熟练使用 Linux 的系统管理命令,比如 useradd、passwd、ps、top、kill、systemctl、df、apt、yum 等;
(C)能熟练使用 Linux 的命令行工具,比如 tar、ping、ifconfig、netstat、grep、awk 等等;
(D)能编写 Shell 脚本实现自动化任务,常用语法包括:使用 #!/bin/bash 指定脚本解释器,使用 $ 符号引用变量值,使用 -eq、-lt、-gt 来比较数字大小,使用 echo 来输出字符串或覆盖文件内容等等;
(E)了解 Linux 的内存管理,它是 Linux 内核的一个关键部分,负责内存的分配与回收,使用了虚拟内存技术。
1 | 查找后台运行的程序 |
(10)RPC(Remote Procedure Call,远程过程调用)是一种用于实现分布式系统中不同节点间通信的技术。实现一个 RPC 框架通常包括以下步骤:
(A)设计协议:约定好服务端与客户端双方代理的消息格式,包括网络传输协议、序列化协议和消息编码协议;
(B)实现服务端代理:接收和处理客户端代理的请求,调用对应的本地方法,将结果返回给客户端代理;
(C)实现客户端代理:发起调用,发送请求给远程服务器,然后接收并处理服务端代理返回的结果;
(D)服务注册与发现:将服务提供者的地址和端口等信息注册到注册中心,消费者通过注册中心发现服务;
(E)负载均衡与容错机制:实现负载均衡策略分摊请求,增加容错机制保证调用的稳定性。
一次完整的 RPC 调用流程如下:
- 服务消费者(Client)以本地调用方式调用服务;
- Client stub 将调用的参数等信息封装为消息体,然后找到远程服务地址,将消息发送;
- Server stub 收到消息后进行解码,根据解码结果调用服务提供者(Server)的方法;
- Server stub 将服务提供者的执行结果打包成消息发送给 Client stub;
- Client stub 收到返回消息后进行解码,将结果发给服务消费者。
RPC 框架的目标就是将上面 Client stub 和 Server stub 要做的事情都封装起来,让用户感知不到它们的存在,使用时就像调用本地方法一样简单,无需关心网络通信的细节。RPC 主要用到的技术有动态代理、序列化、NIO 等,常见的 RPC 框架有 Dubbo、gRPC(基于 HTTP/2 实现,支持多路复用,消除了 HTTP/1.1 存在的队头阻塞问题(同一 TCP 连接内的请求必须串行响应);支持多种编程语言,通过 .proto 文件定义的接口可以自动生成代码;使用 ProtoBuf 进行序列化,数据格式为二进制,相比于 HTTP/1.1 使用的 JSON 文本来说传输效率更高)等,支持 RPC 功能的框架有 WebService、Spring Cloud 等。
HTTP/2 基于 TCP,可靠但有延迟(当前主流),TCP 数据传输的可靠性主要由以下机制保证:
(I)序列号与确认机制(Seq / Ack):每个发送的数据包携带唯一序列号,标识数据字节流的起始位置,接收方正确接收后回复 ACK=下一个期望的序列号,并按序列号对数据进行重组和去重(例如发送方发送 Seq=100,数据长度 100 字节,则下一个包 Seq=200,接收方回复 ACK=200);如果发送方超时未收到 ACK 则重发。
(II)超时重传(RTO):通常根据 RTT 动态调整超时时间;若发送方连续收到 3 个重复 ACK,则立即重发。
(III)滑动窗口(Flow Control):接收方通过 Window Size 字段告知发送方剩余缓冲区大小(如 Win = 5000 表示最多接收 5000 字节),发送方限制数据量不超过 Min(接收窗口,拥塞窗口),避免接收方缓冲区溢出,这样既可以解决因接收方处理能力不足导致的丢包,还可以实现流量控制。
(IV)拥塞控制(Congestion Control):初始拥塞窗口 cwnd = 1 MSS,每收到一个 ACK 则 cwnd 翻倍(慢启动,cwnd 决定一次能发送多少数据,MSS 为最大报文段长度,典型值如 1460 字节);当 cwnd 超过阈值 ssthresh 时,每一 RTT 时间 cwnd + 1(线性增长);丢包时 ssthresh = cwnd/2,然后将 cwnd 重置为 1(或新值),重新慢启动。用这种方式动态适应网络带宽,避免网络拥塞引发大规模丢包。
(V)校验和(Checksum):发送方计算数据包的校验和写入 Header,接收方重新计算,若不匹配,则丢弃该包。
(VI)连接管理:三次握手是为了确保双方收发能力正常(① 客户端 -> SYN(Seq=x) -> 服务端、② 服务端 -> SYN(Seq=y)+ACK(Ack=x+1) -> 客户端、③ 客户端 -> ACK(Ack=y+1) -> 服务端);四次挥手是为了确保双方数据收发完全结束,无残留数据且安全释放双工通道(① Client -> FIN(Seq=x) -> Server(收到 FIN 须立即响应,但 Server 可能仍有数据要发送)、② Server -> ACK(Ack=x+1) -> Client、Server 发送完剩余数据后才能发送自己的 FIN……、③ Server -> FIN(Seq=y) -> Client、④ Client -> ACK(Ack=y+1) -> Server)。
HTTP/3 基于 UDP,延迟低但需额外依赖 QUIC 协议保证可靠性(还在逐步普及),另外它还结合 QUIC 和 TLS 1.3 减少握手次数(0-RTT 或 1-RTT,RTT(Round-Trip Time)指数据从客户端发送到服务器并返回响应所需的时间,首次建立连接或密钥未缓存时 1 次往返,密钥缓存后 0 次往返)。
实时聊天软件底层使用 UDP,但消息很少丢失,也是因为它在应用层设计可靠性机制弥补了 UDP 的不足:
(I)UDP 不可靠、会丢包,那就模仿 TCP 的 ACK 确认和超时重传自定义一套重传机制;
(II)UDP 会乱序,那就让每条消息携带一个递增的序号,接收方按序号重组消息;
(III)在弱网络环境下,丢包率逐渐增长,那就采用指数退避自动降低发送频率,或使用 FEC 冗余传输(发送原始数据时携带冗余纠错码,丢失部分可通过算法还原),必要时甚至可直接切换为 TCP 传输,待网络恢复后再还原为 UDP。
(11)缓存技术是提高系统性能和响应速度的关键组件,在高并发的场景下尤为重要。它的作用有:
(A)减轻数据库压力:缓存频繁访问的数据,减少查询数据库的次数,避免数据库成为性能瓶颈;
(B)提高数据读取速度:缓存通常存储在内存中,而内存的访问速度远快于磁盘;
(C)降低网络延迟:对于需要从远程服务器获取的数据,缓存可以减少对远程服务器的请求次数。
为什么需要缓存(Cache)?加快访问速度,本质原因是速度的不匹配。CPU 的运行速度比内存快百倍,比硬盘快百万倍,缓存技术的核心原理是将频繁访问的数据存储在读写速度更快的存储介质中(如内存),以减少 CPU 对慢速存储介质的访问次数(如磁盘或数据库),那么哪些数据会被频繁访问呢?基于局部性原理,最近被访问过的指令或数据很可能在不久之后再次被访问,并且与它相邻的数据也很可能被访问,这个原理用处很大,比如操作系统会把经常需要用到的数据从硬盘取到内存中,CPU 会把经常用到的数据从内存取到寄存器中,有时也会一并读取与之相邻的数据。
当用户请求数据时,系统会首先检查缓存中是否存在,若存在,则直接返回缓存中的数据,从而大大缩短数据访问时间;若不存在,则查询并添加至缓存。常见的缓存类型有:
(I)本地缓存:将数据缓存在进程内存中,如 Caffeine、Guava Cache。
(II)分布式缓存:Redis、Memcached。
(III)CDN 缓存:内容分发网络,是一种通过分布式节点服务器将图片、视频、网页等内容缓存到全球多个地理位置,使用户能够从离自己最近的节点快速获取资源的技术。
(IV)数据库缓存:如 Buffer Pool,它将磁盘上的数据页和索引页缓存到内存中,数据修改时会先更新缓存池中的页而成为脏页,通过后台线程异步刷盘解决。在数据库之上的持久层框架也会有缓存,比如 Mybatis 提供了一级缓存和二级缓存机制,一级缓存默认开启且无法关闭,作用域为 SqlSession 级别,即在同一个数据库会话内有效,不同 SqlSession 的缓存相互隔离,它随 SqlSession 的创建而创建,在 SqlSession 关闭或执行 INSERT/UPDATE/DELETE 操作时失效;二级缓存的作用域为 Mapper / namespace 级别,是跨 SqlSession 共享的,需通过 <cache/> 标签或注解手动开启,执行同 namespace 下的 INSERT/UPDATE/DELETE 操作时缓存失效。
Spring Cache 是一个缓存抽象层,支持集成上面提到的多种缓存技术,通过注解统一管理(如 @Cacheable、@CachePut),通过 CacheManager 接口切换具体实现。
缓存技术的缺点:
(A)额外的硬件支出:缓存需要额外的磁盘空间和内存空间来存储数据,增加了硬件成本;
(B)数据一致性问题:缓存与数据库之间可能存在数据不一致的问题,需要采取额外的措施来保证数据一致性;
(C)高并发缓存失效问题:在高并发场景下,缓存失效可能会导致数据库访问量瞬间增大,甚至导致数据库崩溃。
缓存的数据一致性问题主要由以下原因引起:
(B1)数据更新与缓存同步不一致:更新数据源后,缓存中的数据未能及时更新,常见的缓存更新策略有:
Cache-Aside(首选):应用层管理缓存读写(读:先查缓存,未命中则查库并回填;写:直接写库,再删缓存);
Write-Through:写操作先更新缓存,由缓存层更新数据库,Guava Cache 的 CacheLoader.write 采用;
Read-Through:缓存层自动加载数据库数据,应用层仅与缓存交互,注解 @Cacheable 采用此策略;
Write-Back / Write-Behind(慎用):写操作仅更新缓存,由缓存层标记脏数据,再由后台线程异步批量刷入数据库。
延迟双删:更新前先删缓存,避免旧数据被读,完成数据写入后,等待短暂时间后(如 500ms)再次删除缓存。
(B2)并发更新:多个并发的更新操作导致缓存中的数据更新顺序与数据源中的更新顺序不一致(使用分布式锁)。
(B3)缓存失效策略不当:缓存中的数据已过期,但由于没有有效的失效策略而仍然存在(设置合理的失效策略,常见的策略有 LRU(最近最少使用)、LFU(最不经常使用)、FIFO(先进先出)、TTL(设置过期时间))。
(12)Spring Boot 集成并自动配置了 Spring MVC,因此与 Spring MVC 的过程类似:
(A)请求首先到达 Servlet 容器(如 Tomcat,已被内嵌在 Spring Boot 中,简化开发流程);
(B)然后依次经过过滤器链(Filter Chain),每个过滤器可以对请求进行预处理,并决定是否将请求继续传递(即执行 chain.doFilter() 方法之前的代码,如对请求进行认证、校验等);
(C)到达核心控制器 DispatcherServlet,它负责分发请求,根据请求的 URL 和请求方式找到对应的控制器;
(D)依次经过拦截器链(Interceptor Chain),每个拦截器可以对请求进行预处理,并决定是否将请求继续传递(即执行 preHandle() 方法中的代码,如对请求进行权限校验、参数绑定等,但这步是可选的,因为拦截器目前只在 Spring MVC 中有效);
(E)执行 Controller 中的具体方法,并返回响应数据;
(F)响应数据逆向经过拦截器链,执行 postHandle() 方法中的代码,进行后置处理;
(G)响应到达 DispatcherServlet,由 DispatcherServlet 统一调度视图解析器并渲染视图(可选,因为响应可能是纯 JSON),若有异常抛出,也由 DispatcherServlet 调用异常处理器来处理异常(如全局异常处理);
(H)响应数据逆向经过过滤器链,执行 chain.doFilter() 方法之后的代码,最终返回给客户端。
(13)先回顾一下 SQL 语句的执行过程:
- (I)连接:客户端认证并建立连接。
- (II)解析:对 SQL 进行:
词法分析:识别 SQL 关键字、表名、列名等;
语法分析:验证 SQL 结构是否符合规范;
语义分析:验证表/列是否存在、检查用户是否有访问权限等。 - (III)优化:选择索引、确定多表 JOIN 顺序等,生成执行计划。
- (IV)执行:调用数据库引擎接口,按执行计划执行,数据库引擎通过索引结构定位到目标位置:
读操作(FROM->WHERE->GROUP BY->SELECT->ORDER BY):缓存池中有则直接返回,否则从磁盘加载;
写操作(UPDATE/INSERT/DELETE):写 undo log(INSERT 不用写 undo log)、更新缓存池、写 redo log。 - (V)读操作组装结果集返回给客户端;写操作提交事务。
优化措施如下:
(A)定位问题:
(a)启用慢查询日志:通过设置阈值来快速定位执行时间过长的 SQL,然后可使用 mysqldumpslow 命令进行分析。
1 | -- MySQL示例 |
(b)使用 EXPLAIN 关键字分析 SQL 语句的执行计划,确保使用到了索引,重点关注:(每个公司使用的数据库版本不同,可能存在一些奇怪的问题,所以强烈建议用 EXPLAIN 查看一下主要 SQL 的执行计划)
- type 字段:表的访问方式(如 system(表仅有一行数据) > const(通过主键或唯一索引定位单行) > ref(使用非唯一索引或索引前缀匹配多行) > range(索引范围扫描) > index(全索引扫描) > ALL(全表扫描)等);
- key 字段:实际使用的索引(若 key 为 NULL,需检查索引是否缺失或失效);
- rows 字段:预估扫描行数(值越小性能越好,若值过大则需优化索引或查询条件)。
1 | EXPLAIN SELECT * FROM employees WHERE department_id = 10; |
(B)索引优化:
(a)为区分度高(唯一性高)且查询频率高的列(如 WHERE、ORDER BY 后的字段)创建索引,并定期检查。
1 | CREATE INDEX idx_department ON employees(department_id); |
(b)不要为区分度低(比如性别,近乎扫描全表)或者会频繁更新的列(频繁维护索引会消耗性能)创建索引;
(c)避免索引失效:
- 避免隐式类型转换(比如索引列为字符串类型,但查询时忘加引号:WHERE name = 136);
- 避免索引列在 WHERE 子句中参与运算(如 WHERE YEAR(create_time) = 2025);
- 避免使用左模糊匹配(如 LIKE ‘%keyword’),如必须使用,可以考虑用全文索引代替;
- 避免使用 !=、<>、NOT IN 和 NOT EXISTS 等负向查询方式,会引发全表扫描。
(C)调整 SQL 结构:
- 避免使用 SELECT *,明确指定所需字段以减少 I/O 开销;
- 用 ORDER BY 和 LIMIT 等子句减少查询结果的数量;
- 将多层子查询拆解为临时表或内存计算;
- 用 JOIN 替代 IN 或 EXISTS 子查询,降低执行复杂度。
(D)配置调优:
- 通过 innodb_buffer_pool_size 增大缓存池的大小,提升热点数据访问速度(默认大小为 128MB,这在生产环境中通常不足,可设置为系统可用物理内存的 40% ~ 50%),通过 SET GLOBAL 修改参数后需验证效果;
- 通过 innodb_buffer_pool_instances 将缓存池划分为多个实例(建议为 CPU 核心数的 1-2 倍),减少锁竞争;
- 通过 max_connections 优化连接池配置,避免资源争用;
- 根据业务场景选择合适的事务隔离级别。
(E)若单机性能已达瓶颈,可考虑进行架构级优化:
读写分离,建立集群,分担主库压力,由一个可读可写的 master 库(以写为主)和多个只读的 slave 库组成,slave 库通过复制 master 库的数据与其保持一致(比如请求 MySQL 的 binlog 日志文件及 position 之后的内容,获取数据更新事件),如果 master 库挂掉了,还能将 slave 库升级为新的 master 库继续工作,提高可用性;
随之而来的问题是需把代码中的读操作和写操作区分开,区分开了才知道 SQL 是要发给 master 库还是发给 slave 库,而且由于 slave 库有多个,还得决定应该发给哪个 slave 库。为避免大量修改已有代码,于是新增一个中间层 MySQL Proxy(分层可以隔离变化),它位于应用程序代码和数据库集群之间,由它来处理这些事。
向 master 库写入数据后马上读,如何保证读到的数据是正确的?
(I)强制读主库:对于关键业务(如支付),可通过代码强制将读请求路由到主库,如 setMasterRouteOnly()。
(II)延迟读取:写入后,通过业务逻辑短暂延迟读取操作(如支付成功后跳转中间页),避免立即查询从库。
(III)缓存标记:写入后,在缓存中设置标记及其过期时间(预估主从同步延迟时间),读取时先检查标记,存在则读主库,否则读从库。
(IV)半同步复制:确保至少一个从库同步完成后再返回写入成功。
(V)全同步复制(MGR,MySQL Group Replication):确保所有从库同步完成才返回成功,牺牲了写入性能。分库分表,对超大数据表进行水平拆分(如按时间或 ID 范围分),分散单表压力。
(F)工具辅助:
- 使用压测工具 sysbench 模拟高并发场景,验证优化效果;
- 集成 Prometheus + Grafana 监控 QPS、TPS、慢查询、锁等待等关键指标。
(14)防止被提前读取的方案有:
(A)目标文件上传完成后(服务端是不知道何时传完的),再上传一个跟它名称相同、但后缀不同的临时文件,通过这个临时文件判断目标文件的上传情况。比如要上传的目标文件是 users.xlsx,则上传完成后再另外上传一个 users.tmp,先判断 users.tmp 是否存在,若存在,则说明 users.xlsx 已经上传完成,可以放心去读了。
(B)改目标文件名:比如要上传的目标文件是 data.txt,那么在文件上传完成前,我们可以用脚本将文件名改为 data.txt.uploading 这样的格式,等文件上传完成后,再将其改回原来的 data.txt,外部程序仅监控 data.txt,从而避免提前读取。
(C)将上传中的文件存放在临时目录(如 /temp),并设置严格的访问权限(如仅上传进程可读写),待文件上传完成后,再将其移动至正确的存储目录,确保文件仅在完整上传后才可见。
了解下客户端与服务端通信时,防止消息被中途抓包的方法:
(A)强制使用 HTTPS(内部使用了数字签名和数字证书)加密传输数据,防止明文被拦截;并对敏感数据(如账号、交易信息)使用 AES、RSA 等算法进行二次加密,这样即使 HTTPS 被破解也仍然无法解密业务数据;
(B)在使用 HTTPS 的基础上,再对数据加一层数字签名和数字证书机制,对身份进行二次验证;
(C)自定义私有的二进制协议并增加校验位,替代 JSON / XML 等明文协议;
(D)将数据包拆分并乱序传输,接收端按预设规则重组,降低抓包工具解析成功率;
(E)关键算法(如 AES 密钥派生)通过 JNI 调用 C++ 实现,增加逆向分析难度;
(F)通过自动化工具模拟抓包,检测协议漏洞并及时修复,必要时更新加密算法和证书。