DarkYellowCat's Blog

分享技术与思考

开发工具和平台

开发微信小程序,有一些刚需的网站和工具。

微信开发者平台

微信公众平台是小程序管理入口。注册、认证、备案、成员管理、服务器域名配置、版本管理和审核发布都在这里完成。开发前建议先检查requestuploadFile等合法域名是否已经配置,否则真机环境很容易出现请求失败。

微信开发者工具用来编写、预览、调试和上传小程序代码。模拟器适合快速看页面效果,但部分能力仍要以真机为准;遇到网络请求、支付、登录等问题时,最好结合调试面板和手机端表现一起排查。

微信支付商户平台

微信支付商户平台用于申请和管理商户号、API密钥、证书、退款、账单等。小程序支付还需要把小程序AppID与商户号绑定,否则前端即使写对了调用参数,也可能因为权限问题拉不起支付。

微信支付与支付宝支付

这里说的“原生支持”,指的是小程序运行在微信生态内时,前端能直接调起对应支付方式的能力。微信小程序不能直接调起支付宝支付;反过来,支付宝小程序也不能直接调起微信支付。聚合支付本质上是通过平台侧封装或跳转方案来统一入口,是否适用要看业务形态和平台规则。

如果你不打算使用易支付/聚合支付(已经由其他平台封装好,支持微信/支付宝/银行卡等多种支付,优点是支持多平台,一键引入,缺点是平台会额外抽成),那么需要注意:微信小程序原生不支持支付宝支付。也就是说,如果你同时需要微信支付和支付宝支付,又不想被抽成,最好的办法是做成两套系统,分别在微信和支付宝上线。

微信支付

需要的参数极多

相比于支付宝,微信小程序的支付功能开发更为繁琐,且需要完成小程序认证或关联已认证的主体。微信认证费用通常为300元/年。

具体需要的参数见文档

沙箱被禁止

微信官方取消了沙箱,支付等微信API功能无法像普通接口那样只在开发工具里完整模拟测试。

实际验证时,可以先在微信开发者工具中选择上传代码,然后在微信公众平台把这个开发版本设置为体验版,再用手机扫码进入体验版进行真机测试。此时要注意小程序与商户号是否已绑定、交易类小程序是否按要求接入订单发货管理等条件,否则可能出现支付权限受限的问题。

由于测试走的是真实支付链路,建议准备小额商品,并保留订单号方便后续查单和退款。

微信审核

当你的小程序做好之后,需要在微信公众平台进行审核。

在审核时我被卡了一道,微信要求小程序中不能出现微信、微信logo等。

大家在提交时一定要先看审核要求再提交。

另外,涉及支付、内容展示、用户信息获取的功能,通常还要准备好对应的资质说明和页面入口。审核被驳回时不要急着反复重提,先仔细阅读驳回原因,对照规范修改后再提交会更省时间。

在大二暑假,我在一家小公司实习了一个半月,接触了实际开发后,可以说是受益匪浅,对计算机软件的理解有所提升。

系统设计

管理员权限

一个完善的产品应该给非专业管理员留下足够的管理空间。

在我第一次做项目时,我更注重于系统的可行性,但忽略了客户并不是专业人员。我眼中简单的操作并不适合他们进行管理。在沟通后,我在后台加入了大量供管理员修改的权限。

如:管理员可以调整页面的布局、决定什么能够展示,什么不能展示、新增或修改法务文件等。

系统的后续可修改性

在早期设计时,可能有些功能是不必要的,但我们仍应该将这些变量纳入考虑的范围中。

这样,当相关功能需要拓展时,就能更加游刃有余的应对。

git管理

一次我将修改提交后,提醒运维组员就走了。第二天队友跟我说自己搞砸了,希望我重新提交一遍。我查了Git记录后发现他和往常一样拉取了远程仓库,但我提的新功能都没有出现。

最后我排查原因时发现,是因为我在提交时没有修改代理端口,导致代码实际上没有推送到远程仓库。

这件事也让我意识到,“本地已经 commit”并不等于“远端已经更新”。关键改动推送后,最好确认远端分支的最新提交哈希已经变化,再通知其他人拉取代码。

线上与开发环境

在本地能运行的代码不一定能在线上环境正常工作。

在一次任务中,我实现了一个功能:统计商品点击量并进行排名。在开发环境的测试是没有问题的,但部署后客户却表示没有统计点击量。

通过浏览器网络追踪,我发现接口没有被触发,在网络层上根本找不到这个请求。

结合之前的经验,我判断问题和HTTPS有关:如果页面已经走HTTPS,但接口仍然是HTTP,浏览器会按混合内容策略直接拦截这类请求。也就是说,问题不只是“没有证书”,而是线上页面的协议和接口协议不一致。

安全重于一切

网络攻击的方式多种多样:SQL注入、供应链投毒(我记得这个很频繁,Apifox就中招过),很难做到绝对安全,但我们应该尽力保证系统的安全性,让破解成本高于对方攻击成功的利润。

测试要涵盖各种情况

在编写代码和测试时一定要考虑到所有情况。

比如用户注册时的昵称,一定要限长。按正常人的思维,用户名一般都不会太长,但这不代表这种情况不会发生。

开发工具

开源项目

若依

二开神器,地址

若依是一套比较成熟的Java后台管理系统脚手架,内置用户、角色、菜单、部门、字典、日志、定时任务等常见后台功能,也提供代码生成器。对于中小型管理系统来说,很多基础模块不需要从零写,直接基于它做二次开发,可以省下大量前期搭建时间。

我实习时接触的这个ruoyi-vue-pro,是在若依基础上扩展出来的版本,功能比官方版更丰富,比如多租户、支付、商城、工作流等模块都有对应实现。它的优点是生态和资料比较多,遇到问题时更容易找到参考;缺点是引入的功能多,结构也更重,实际使用时最好按业务裁剪依赖,而不是把所有模块都原样搬进项目。

各类SDK

WxJava

WxJava是一个封装了微信开放能力的Java SDK,覆盖公众号、小程序、微信支付、开放平台和企业微信等常用场景。它把access_token管理、签名、请求发送和结果解析这些重复工作封装好了,开发者可以直接调用对应的Service完成登录、获取手机号、下单、退款等操作。

以小程序支付为例,自己对接微信API需要处理证书、签名、回调验签等细节;使用WxJava后,业务代码只需要准备好商户号、订单号、金额和openid等参数,再调用支付服务生成调起支付的参数即可。这样不仅开发速度快,也能减少手写协议时的低级错误。

Docker

我本地的MySQL是5.7的,但上线要求是8.0。如何在保持本地不做修改的同时测试8.0环境下的程序运行?这正是Docker解决的问题。

例如可以用一个独立的容器启动 MySQL 8.0,把端口映射到本地,再把应用的连接信息指向这个容器。这样既不会污染本机的 MySQL 5.7 环境,也能提前暴露不同数据库版本带来的 SQL 兼容性问题。

1Panel

相较于宝塔,我认为1Panel的开源属性和容器化管理体验更符合我的需求。它可以直接管理Docker容器、镜像、网络和卷,也能配置网站、反向代理与SSL证书,让本地测试环境和线上部署环境更容易保持一致。

AI使用

一定要人为检查

在使用agent开发时,使用较强的模型、配置skill等方法可以提高AI开发的正确率,但是仍然会有风险,必须要进行人为校验。代码可以由AI开发,但提交在人,要为自己提交的代码负责。

我定义了一个Spring Boot的skill,每当有任务时,必须要跑JUnit和mvn test,全部成功后才能结束任务,有时候一轮任务需要跑70~90个test,人工校验和测试都没有出现问题。因此我逐渐放松了审查,一些简单的任务直接让agent去实现。

但有一天,客户突然@我,询问站中查询的商品只有24条,怎么看到剩余的商品。我复现后理解去审查代码,发现分页功能只留了接口占位,没有实际实现。这一块我没有做代码审查,测试时本地数据量小,没有触发分页,导致这个问题直接暴露给了客户。

逻辑缺失问题

新增一个排行的功能,AI返回了商品的id。但搜索界面不支持通过id进行搜索,导致实际上排行是失败的,因为客户仍不能知道排行上的商品具体是什么。

skill的去留

现在的一些旗舰模型,如GPT-5.6-Sol、Claude Ops 5等综合能力已经很强,已经不太需要skill来指导它们怎么做任务。

我的想法是:现在可以放弃那些简单的,只是指导AI怎么进行常规任务的skill,而留下那些用于专业任务特化、优化人机交互(JavaGuide在一篇公众号中推荐了)的skills。

之前在测试一个新模型时,我给它发布了一个简单的代码阅读任务,目标是我的一个SSM系统。但却触发了我Vivado的skill,这样反而会对agent的工作起反作用。所以我认为,skill在精不在多,应该有选择性的保留skill。

codegraph

codegraph是我比较推荐的一个MCP。它可以把项目中的类、方法、调用关系等建立成索引,帮助agent快速定位符号、分析依赖和理解代码结构,而不需要在每次任务里盲目搜索整个仓库。

对于较大的Java后端项目来说,这类索引型MCP尤其适合配合AI使用:先让agent通过codegraph找到入口方法和上下游关系,再阅读相关代码并执行修改,能明显减少误改范围过大的情况。

前言

这是《SQL必知必会》学习随笔系列的进阶篇,承接基础篇的内容,主要聚焦于数据库性能优化相关的知识点。

进阶篇的核心内容包括:

  • 数据库调优:从多个维度分析如何提升数据库性能
  • 范式与反范式:理解数据库设计原则及其权衡
  • 索引原理与应用:深入理解索引的底层实现和使用场景
  • 锁机制:掌握不同粒度和类型的锁
  • 性能优化实战:使用慢查询日志、EXPLAIN等工具定位和解决性能问题

如果你还没有阅读基础篇,建议先从基础篇开始,那里介绍了SQL的基本概念和MySQL的执行流程。

数据库调优

调优方向

  1. 用户的反馈
  2. 日志分析
  3. 服务器资源使用监控
  4. 数据库内部状况监控

调优维度

选择适合的DBMS

列式存储数据库可以大幅度降低系统的I/O,适合于分布式文件系统和OLAP,但不适用于数据需要频繁增删改的场景。

优化表设计

  1. 表结构要尽量遵循第三范式的原则(关于第三范式,我在后面章节会讲)。这样可以让数据结构更加清晰规范,减少冗余字段,同时也减少了在更新,插入和删除数据时等异常情况的发生。
  2. 如果分析查询应用比较多,尤其是需要进行多表联查的时候,可以采用反范式进行优化。反范式采用空间换时间的方式,通过增加冗余字段提高查询的效率。
  3. 表字段的数据类型选择,关系到了查询效率的高低以及存储空间的大小。一般来说,如果字段可以采用数值类型就不要采用字符类型;字符长度要尽可能设计得短一些。针对字符类型来说,当确定字符长度固定时,就可以采用CHAR类型;当长度不固定时,通常采用VARCHAR类型。

优化逻辑查询

SQL查询优化,可以分为逻辑查询优化和物理查询优化。逻辑查询优化就是通过改变SQL语句的内容让SQL执行效率更高效,采用的方式是对SQL语句进行等价变换,对查询进行重写。重写查询的数学基础就是关系代数。

SQL的查询重写包括了子查询优化、等价谓词重写、视图重写、条件简化、连接消除和嵌套连接消除等。

EXISTS子查询和IN子查询时,会根据小表驱动大表的原则选择适合的子查询。在WHERE子句中会尽量避免对字段进行函数运算,它们会让字段的索引失效。

假设我想对商品评论表中的评论内容进行检索,查询评论内容开头为abc的内容都有哪些,如果在WHERE子句中使用了函数,语句就会写成下面这样:

1
SELECT comment_id, comment_text, comment_time FROM product_comment WHERE SUBSTRING(comment_text, 1,3)='abc'

我们可以采用查询重写的方式进行等价替换:

1
SELECT comment_id, comment_text, comment_time FROM product_comment WHERE comment_text LIKE 'abc%'

在数据量大的情况下,第二条SQL语句的执行时间为前者的1/10。

范式

范式-3NF

这个算是数据库系统原理必学内容了,我简单说一下我的理解。

首先,从1NF -> 到 3NF 是逐级递进的, 2NF 包含了1NF的特性,而 3NF 又包含了 2NF 的特性.作为一种约束来说,1NF 对数据库的约束性最弱, 3NF 对数据库的约束最强。

在理解范式之前,需要先了解几个基本概念:

  • 候选键(Candidate Key):能够唯一标识一条记录的最小属性集
  • 主属性:包含在任一候选键中的属性
  • 非主属性:不包含在任何候选键中的属性
  • 函数依赖:如果通过属性A可以唯一确定属性B,则称B函数依赖于A,记作A→B
  • 完全函数依赖:如果A→B,且A的任何真子集都不能决定B,则称B完全函数依赖于A
  • 传递依赖:如果A→B,B→C,且B不依赖于A,则称C传递依赖于A

基于这些概念,三个范式可以这样理解:

1NF(第一范式):每个字段都是原子性的,不可再分。

2NF(第二范式):在1NF的基础上,非主属性完全函数依赖于候选键(消除了非主属性对候选键的部分依赖)。

3NF(第三范式):在2NF的基础上,非主属性不传递依赖于候选键(消除了非主属性对候选键的传递依赖)。

但是,3NF仍是有问题的。

3NF存在的问题

即使满足了3NF,仍然可能存在主属性对候选键的部分依赖或传递依赖。例如,在某些情况下,一个主属性可能依赖于候选键的一部分,或者通过其他主属性传递依赖于候选键,这会导致数据冗余和更新异常。

为了解决这个问题,人们提出了一种新的范式:巴斯-科德范式,即BCNF。

BCNF(Boyce-Codd范式):在3NF的基础上消除了主属性对候选键的部分依赖或者传递依赖关系。简单来说,就是要求每个决定因素都必须是候选键。

反范式设计

范式为数据库提供了约束规则,这避免了许多问题,但这也对性能造成了一定影响。

反范式就是相对范式化而言的,允许少量的冗余,通过空间来换时间。

反范式的适用情况

当冗余信息有价值或者能大幅度提高查询效率的时候,如:

订单中的收货人信息,包括姓名、电话和地址等。每次发生的订单收货信息都属于历史快照,需要进行保存,但用户可以随时修改自己的信息,这时保存这些冗余信息是非常有必要的。

索引

简而言之,索引就是帮助数据库管理系统高效获取数据的数据结构。好比一本书的目录,它可以帮我们快速进行特定值的定位与查找,从而加快数据查询的效率。

不适用索引的场景

作为一种数据结构,索引本身也会占据存储空间.因此,在一些情况下,索引对搜素速率的优化比不上自己对性能的影响。这个时候我们就不能建立索引。

  1. 数据表中的数据行数比较少的情况下,比如不到1000行。

数据库本身查询速率就很高,千条数据不会对数据库产生压力,也就不需要用索引优化。

  1. 数据重复度大,比如高于10%的时候。

性别只分为男女。假设男女数量相同,如果想要在100万行数据中查找性别为男的数据,一旦创建了索引,你需要先访问50万次索引,然后再访问50万次数据表,

索引的分类

按属性的类型分类

聚集索引:属性为主键。

非聚集索引:属性不为主键。

聚集索引比使用非聚集索引的查询效率略高,通常使用聚集索引。

按属性的数量分类

单一索引:索引列为一列时为单一索引。

联合索引:多个列组合在一起创建的索引叫做联合索引。

联合索引的最左原则

在使用联合索引(复合索引)时,查询条件必须从索引的最左列开始,并且连续匹配,索引才能被有效利用。

当遇到范围查询(>、<、between、like)就会停止匹配。

根据这个特点,在设计索引时,应该注意:

  1. 区分度高的列放左边:优先将选择性(cardinality)高的列放在前面
  2. 等值查询在前,范围查询在后:
1
2
3
-- 推荐顺序: (status, create_time)
-- 而非: (create_time, status)
WHERE status = 1 AND create_time > '2024-01-01'
  1. 尽量使用覆盖索引:减少回表操作
  2. 避免索引列参与运算:保持索引列”纯净”
  3. 用 EXPLAIN 验证:
1
EXPLAIN SELECT * FROM user WHERE a = 1 AND b = 2;

索引的底层

不想看优化过程可以直接看B=树。

二叉树

二叉树是一种常见的树形存储结构,时间复杂度为O(log2n),它的特点是:

  • 如果key大于根节点,则在右子树中进行查找;
  • 如果key小于根节点,则在左子树中进行查找;
  • 如果key等于根节点,也就是找到了这个节点,返回根节点即可。

但它有一个漏洞:

如果构造出来的树是链表型的,那么时间复杂度会退化为O(n).如果用二叉树作为索引的实现结构,会让树变得很高,增加硬盘的I/O次数,所以我们不使用这种结构。

B树(平衡的多路搜索树)

它的每一个节点最多可以包括M个子节点,M称为B树的阶。

  • 根节点的儿子数的范围是[2,M]。
  • 每个中间节点包含k-1个关键字和k个孩子,孩子的数量=关键字的数量+1,k的取值范围为[ceil(M/2), M]。
  • 叶子节点包括k-1个关键字(叶子节点没有孩子),k的取值范围为[ceil(M/2), M]。
  • 假设中间节点节点的关键字为:Key[1], Key[2], …, Key[k-1],且关键字按照升序排序,即Key[i]<Key[i+1]。此时k-1个关键字相当于划分了k个范围,也就是对应着k个指针,即为:P[1], P[2], …, P[k],其中P[1]指向关键字小于Key[1]的子树,P[i]指向关键字属于(Key[i-1], Key[i])的子树,P[k]指向关键字大于Key[k-1]的子树。
  • 所有叶子节点位于同一层。

B+树

B+树基于B树做出了改进,主流的DBMS都支持B+树的索引方式,比如MySQL。B+树和B树的差异在于以下几点:

  • 有 k 个孩子的节点就有k个关键字。也就是孩子数量=关键字数,而B树中,孩子数量=关键字数+1。
  • 非叶子节点的关键字也会同时存在在子节点中,并且是在子节点中所有关键字的最大(或最小)。
  • 非叶子节点仅用于索引,不保存数据记录,跟记录有关的信息都放在叶子节点中。而B树中,非叶子节点-既保存索引,也保存数据记录。
  • 所有关键字都在叶子节点出现,叶子节点构成一个有序链表,而且叶子节点本身按照关键字的大小从小到大顺序链接。

B+树和B树有个根本的差异在于,B+树的中间节点并不直接存储数据。这样的好处是:

  1. B+树查询效率更稳定。因为B+树每次只有访问到叶子节点才能找到对应的数据,而在B树中,非叶子节点也会存储数据,这样就会造成查询效率不稳定的情况,有时候访问到了非叶子节点就可以找到关键字,而有时需要访问到叶子节点才能找到关键字。

  2. B+树的查询效率更高,这是因为通常B+树比B树更矮胖(阶数更大,深度更低),查询所需要的磁盘I/O也会更少。同样的磁盘页大小,B+树可以存储更多的节点关键字。

  3. 在查询范围上,B+树的效率也比B树高。这是因为所有关键字都出现在B+树的叶子节点中,并通过有序链表进行了链接。而在B树中则需要通过中序遍历才能完成查询范围的查找,效率要低很多。

哈希索引

用Hash进行检索效率非常高,逻辑为:

值key通过Hash映射找到桶bucket。桶(bucket)指的是一个能存储一条或多条记录的存储单位。一个桶的结构包含了一个内存指针数组,桶中的每行数据都会指向下一行,形成链表结构,当遇到Hash冲突时,会在桶中进行键值的查找。

Hash冲突

如果桶的空间小于输入的空间,不同的输入可能会映射到同一个桶中,这时就会产生Hash冲突,如果Hash冲突的量很大,就会影响读取的性能。

通常Hash值的字节数比较少,简单的4个字节就够了。在Hash值相同的情况下,就会进一步比较桶(Bucket)中的键值,从而找到最终的数据行。

Hash值的字节数多的话可以是16位、32位等,比如采用MD5函数就可以得到一个16位或者32位的数值,32位的MD5已经足够安全,重复率非常低。

哈希索引与B+树索引的区别

  1. Hash索引不能进行范围查询,而B+树可以。这是因为Hash索引指向的数据是无序的,而B+树的叶子节点是个有序的链表。
  2. Hash索引不支持联合索引的最左侧原则(即联合索引的部分索引无法使用),而B+树可以。对于联合索引来说,Hash索引在计算 Hash 值的时候是将索引键合并后再一起计算 Hash 值,所以不会针对每个索引单独计算Hash值。因此如果用到联合索引的一个或者几个索引时,联合索引无法被利用。
  3. Hash索引不支持ORDER BY排序,因为Hash索引指向的数据是无序的,因此无法起到排序优化的作用,而B+树索引数据是有序的,可以起到对该字段ORDER BY排序优化的作用。同理,我们也无法用Hash索引进行模糊查询,而B+树使用LIKE进行模糊查询的时候,LIKE后面前模糊查询(比如%开头)的话就可以起到优化作用。

索引的使用

什么时候创建索引

  1. 字段的数值有唯一性的限制,比如用户名
  2. 频繁作为WHERE查询条件的字段,尤其在数据表大的情况下
  3. 需要经常GROUP BY和ORDER BY的列
  4. UPDATE、DELETE的WHERE条件列,一般也需要创建索引
  5. DISTINCT字段需要创建索引
  6. 多表JOIN的连接字段需要创建索引
    • 连接表的数量尽量不要超过3张,因为每增加一张表就相当于增加了一次嵌套的循环,数量级增长会非常快,严重影响查询的效率。
    • 其次,对WHERE条件创建索引,因为WHERE才是对数据条件的过滤。如果在数据量非常大的情况下,没有WHERE条件过滤是非常可怕的。
    • 最后,对用于连接的字段创建索引,并且该字段在多张表中的类型必须一致。比如user_id在product_comment表和user表中都为int(11)类型,而不能一个为int另一个为varchar类型。

索引失效

  1. 索引进行了表达式计算

需要把索引字段的取值都取出来,然后依次进行表达式的计算来进行条件判断,因此采用的就是全表扫描的方式;

  1. 对索引使用函数
  2. 在WHERE子句中,如果在OR前的条件列进行了索引,而在OR后的条件列没有进行索引

OR的含义就是两个只要满足一个即可,因此只有一个条件列进行了索引是没有意义的,只要有条件列没有进行索引,就会进行全表扫描;

  1. 使用LIKE进行模糊查询的时候,前面不能是%
  2. 索引列尽量设置为NOT NULL约束

判断索引列是否为NOT NULL,往往需要走全表扫描,因此我们最好在设计数据表的时候就将字段设置为NOT NULL约束。

锁的分类

按照锁粒度进行划分

我们从锁定对象的粒度大小来对锁进行划分,分别为行锁、页锁和表锁。

  1. 行锁

按照行的粒度对数据进行锁定。锁定力度小,发生锁冲突概率低,可以实现的并发度高,但是对于锁的开销比较大,加锁会比较慢,容易出现死锁情况。

  1. 页锁

是在页的粒度上进行锁定,锁定的数据资源比行锁要多,因为一个页中可以有多个行记录。当我们使用页锁的时候,会出现数据浪费的现象,但这样的浪费最多也就是一个页上的数据行。页锁的开销介于表锁和行锁之间,会出现死锁。锁定粒度介于表锁和行锁之间,并发度一般。

  1. 表锁

对数据表进行锁定,锁定粒度很大,同时发生锁冲突的概率也会较高,数据访问的并发度低。不过好处在于对锁的使用开销小,加锁会很快。

从数据库管理的角度对锁进行划分

我们还可以从数据库管理的角度对锁进行划分,分为共享锁和排它锁。

  1. 共享锁

也叫读锁或S锁,共享锁锁定的资源可以被其他用户读取,但不能修改。在进行SELECT的时候,会将对象进行共享锁锁定,当数据读取完毕之后,就会释放共享锁,这样就可以保证数据在读取时不被修改。

  1. 排它锁

也叫独占锁、写锁或X锁。排它锁锁定的数据只允许进行锁定操作的事务使用,其他事务无法对已锁定的数据进行查询或修改。

  1. 意向锁(Intent Lock)

简单来说就是给更大一级别的空间示意里面是否已经上过锁。举个例子,你可以给整个房子设置一个标识,告诉它里面有人,即使你只是获取了房子中某一个房间的锁。这样其他人如果想要获取整个房子的控制权,只需要看这个房子的标识即可,不需要再对房子中的每个房间进行查找。这样是不是很方便?

返回数据表的场景,如果我们给某一行数据加上了排它锁,数据库会自动给更大一级的空间,比如数据页或数据表加上意向锁,告诉其他人这个数据页或数据表已经有人上过排它锁了,这样当其他人想要获取数据表排它锁的时候,只需要了解是否有人已经获取了这个数据表的意向排他锁即可。

如果事务想要获得数据表中某些记录的共享锁,就需要在数据表上添加意向共享锁。同理,事务想要获得数据表中某些记录的排他锁,就需要在数据表上添加意向排他锁。这时,意向锁会告诉其他事务已经有人锁定了表中的某些记录,不能对整个表进行全表扫描。

从程序员的角度进行划分

如果从程序员的视角来看锁的话,可以将锁分成乐观锁和悲观锁。

  1. 乐观锁(Optimistic Locking)

认为对同一数据的并发操作不会总发生,属于小概率事件,不用每次都对数据上锁,也就是不采用数据库自身的锁机制,而是通过程序来实现。在程序上,我们可以采用版本号机制或者时间戳机制实现。

  1. 悲观锁(Pessimistic Locking)

也是一种思想,对数据被其他事务的修改持保守态度,会通过数据库自身的锁机制来实现,从而保证数据操作的排它性。

避免死锁的发生

  1. 如果事务涉及多个表,操作比较复杂,那么可以尽量一次锁定所有的资源,而不是逐步来获取,这样可以减少死锁发生的概率;
  2. 如果事务需要更新数据表中的大部分数据,数据表又比较大,这时可以采用锁升级的方式,比如将行级锁升级为表级锁,从而减少死锁产生的概率;
  3. 不同事务并发读写多张数据表,可以约定访问表的顺序,采用相同的顺序降低死锁发生的概率。

优化步骤

慢查询定位执行慢的SQL

1
mysql > show variables like '%slow_query_log';

开启慢查询日志

1
mysql > set global slow_query_log='ON';

EXPLAIN查看执行计划

EXPLAIN可以帮助我们了解数据表的读取顺序、SELECT子句的类型、数据表的访问类型、可使用的索引、实际使用的索引、使用的索引长度、上一个表的连接匹配条件、被优化器查询的行的数量以及额外的信息(比如是否使用了外部排序,是否使用了临时表等)等。

EXPLAIN会返回一个表格:其中type是关键信息。

all是最坏的情况,因为采用了全表扫描的方式。index和all差不多,只不过index对索引表进行全扫描,这样做的好处是不再需要对数据进行排序,但是开销依然很大。如果我们在Extral列中看到Using index,说明采用了索引覆盖,也就是索引可以覆盖所需的SELECT字段,就不需要进行回表,这样就减少了数据查找的开销。

SHOW PROFILE查看SQL的具体执行成本

看下当前会话都有哪些profiles:

1
mysql > show profiles;

想要查看上一个查询的开销,可以使用:

1
mysql > show profile;

缓冲池和查询缓存的区别

对比

对比维度 缓冲池(Buffer Pool) 查询缓存(Query Cache)
适用场景 几乎所有场景,是数据库高性能的绝对核心。 极少更新、大量完全相同读取的静态表(如配置表、字典表)。
缓存内容 数据页和索引页(通常每页 16KB),即底层的物理/逻辑数据块。 完整的 SQL 查询语句及其对应的完整结果集。
工作原理 InnoDB 在内存中开辟的一块连续空间。当你执行查询时,InnoDB 会先检查需要的数据页是否在 Buffer Pool 中。如果在(缓存命中),直接返回内存数据;如果不在(缓存未命中),则从磁盘读取该页到 Buffer Pool 中,然后再返回数据。 在 Server 层维护一个哈希表,Key 是 SQL 语句的哈希值,Value 是查询结果集。收到 SELECT 请求时,先计算哈希值去查表,命中则直接返回,跳过解析、优化、执行和存储引擎交互的所有步骤。
写入机制 当执行更新操作时,InnoDB 会先修改 Buffer Pool 中的数据页,并将其标记为”脏页(Dirty Page)”,随后由后台线程异步将这些脏页刷入磁盘(Flush)。这保证了写入的高性能。 -

查询缓存被淘汰

MySQL 官方在 8.0 版本中彻底移除了 Query Cache,主要原因包括:

  1. 扩展性差:查询缓存的全局锁机制在多核 CPU 和高并发环境下成为严重的性能瓶颈。
  2. 失效过于频繁:现代互联网应用读写频繁,查询缓存命中率极低,大部分时间都在做”缓存-失效”的无用功。
  3. 更优的替代方案:应用层缓存(如 Redis 或 Memcached)提供了更细粒度、更灵活、更高性能的缓存控制能力,完全取代了数据库层做结果集缓存的需求。

总结

进阶篇主要围绕数据库性能优化展开,涵盖了从宏观的调优方向到微观的索引实现细节。核心要点包括:

  1. 调优要多维度考虑:选择合适的DBMS、优化表设计、改进查询逻辑,而不是只盯着SQL语句本身
  2. 范式不是越高越好:需要在规范性和性能之间找到平衡,该反范式时就反范式
  3. 索引是把双刃剑:能加速查询但会增加写入成本和存储开销,要根据实际场景判断是否创建
  4. 理解底层原理:B+树、哈希索引、锁机制等原理能帮助我们更好地理解性能问题的根源
  5. 工具辅助诊断:慢查询日志、EXPLAIN、SHOW PROFILE等工具是定位性能瓶颈的利器

数据库优化是一个系统工程,需要结合业务特点、数据规模、硬件资源等多方面因素综合考虑。没有一招鲜吃遍天的银弹,只有不断实践、测试、验证,才能找到最适合自己项目的优化方案。

下一步可以继续深入学习事务、MVCC、分库分表等更高级的话题,也欢迎查看本系列的其他文章:

前言

在学习阶段,我一般不会刻意注意代码规范或潜在漏洞。毕竟代码不正式上线,也没有人专门做审查,很多问题只要“能跑”就被我放过去了。

但现在既然开始做正式项目,就不能再只看功能是否实现。代码能运行只是第一步,后面还要考虑:它是否容易维护、有没有明显缺陷、提交后会不会破坏原有功能,以及团队成员能不能快速看懂。

最近我了解并试用了几种代码质量工具,这里分享给大家。

先说结论:不同工具解决不同问题

工具 主要作用 适用
SpotBugs 发现 Java 字节码中的潜在 Bug IDEA、本地构建、CI
Checkstyle 检查命名、缩进、导入顺序等代码规范 IDEA、本地构建、CI
CodeRabbit 对 Pull Request 做 AI 辅助审查 GitHub PR
reviewdog 把各种检查结果统一评论到代码变更上 GitHub Actions 等 CI

这四个工具的作用领域不同:

  • SpotBugs 更关心“代码可能有问题”;
  • Checkstyle 更关心“代码是否符合团队规范”;
  • CodeRabbit 更接近自动化的代码审查助手;
  • reviewdog 本身通常不负责分析代码,而是负责收集其他工具的输出,并把问题准确标到 PR 对应行上。

SpotBugs:发现潜在 Bug

SpotBugs 是 FindBugs 的后继项目。它会分析 Java 编译后的字节码,寻找空指针、错误的对象比较、资源未关闭、可变对象暴露等潜在问题。

它既有 IDEA 插件,也支持 Maven、Gradle 和 CI。对我来说,IDEA 插件适合在本地快速查看,Maven 插件更适合放进项目流程,避免出现“我的电脑装了插件,但其他人没有装”的情况。

运行完成后,下方会按类型展示检查结果:

选择具体问题后,可以查看对应代码、问题分类和解释:

{spotbugs-detail.png}

spotbugs-detail.png

Maven 配置

下面给出一个比较基础的配置。插件版本建议统一放在项目的 properties 或父 POM 中管理,不要每个模块各写一份。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<plugin>
<groupId>com.github.spotbugs</groupId>
<artifactId>spotbugs-maven-plugin</artifactId>
<configuration>
<effort>Max</effort>
<threshold>Medium</threshold>
</configuration>
<executions>
<execution>
<goals>
<goal>check</goal>
</goals>
</execution>
</executions>
</plugin>

执行命令:

1
mvn spotbugs:check

需要注意的是,SpotBugs 报告的是“可疑模式”,不代表每一条都一定是 Bug。正确的处理方式是先理解提示,再决定修改、抑制还是调整规则,而不是看到警告就机械改代码。

Checkstyle:统一代码规范

Checkstyle 主要检查代码风格,例如命名、格式、缩进、导入顺序、代码块写法等。

它不会判断业务逻辑是否正确,但可以减少大量没有意义的格式争论。特别是在多人协作时,最好把规范写成配置文件并提交到仓库,而不是只依赖每个人 IDE 里的个人设置。

IDEA 中使用

安装 Checkstyle 插件后,可以进入:

1
2
3
Settings
-> Tools
-> Checkstyle

我这里选择了内置的 Google Checks。点击 Apply 后,可以对单个 Java 文件、目录或整个项目执行检查。

例如下面这段结果,就是在提示等号和花括号附近缺少空格:

1
2
3
4
5
6
Running style checker on 1 file(s) (config: fa26)...
ProductAdminUpdate.java:8:20: '=' 前应有空格。
ProductAdminUpdate.java:8:20: '=' 后应有空格。
ProductAdminUpdate.java:10:3: '{' 后应有空格。
ProductAdminUpdate.java:10:4: '}' 前应有空格。
Style checker completed with 4 errors.

Maven 配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-checkstyle-plugin</artifactId>
<configuration>
<configLocation>checkstyle.xml</configLocation>
<consoleOutput>true</consoleOutput>
<failsOnError>true</failsOnError>
</configuration>
<executions>
<execution>
<phase>verify</phase>
<goals>
<goal>check</goal>
</goals>
</execution>
</executions>
</plugin>

执行命令:

1
mvn checkstyle:check

如果团队准备长期使用,建议从现有代码能够接受的规则开始,再逐步收紧。直接套一份特别严格的规则,往往会一下出现几千条历史问题,最后大家只能选择关闭检查。

CodeRabbit:辅助审查 Pull Request

CodeRabbit 的定位和前两个工具不太一样。它主要接入 GitHub 等代码托管平台,在 Pull Request 创建或更新后分析变更,并给出摘要、逐行建议和潜在问题。

我认为它比较适合发现下面这些问题:

  • 修改范围较大,人工审查容易漏看;
  • 代码能够编译,但边界条件没有处理;
  • 方法命名、异常处理或重复逻辑不够合理;
  • PR 描述不完整,需要先快速了解本次改动。

不过 AI 审查只能作为辅助,不能代替开发者负责。它不了解全部业务背景,也可能给出看似合理但并不适合当前项目的建议。最终是否修改,仍然要结合需求、测试和上下文判断。

reviewdog:把检查结果送到 PR

reviewdog 是我在 GitHub 的 Code Quality 分类中较早发现的项目。

我一开始以为它也是一个代码检查器,后来才发现它更像一个“结果转发器”:Checkstyle、静态分析器或 Linter 负责发现问题,reviewdog 负责读取这些工具的输出,再把问题作为 PR 评论或检查结果展示出来。

例如可以在 CI 中执行 Checkstyle,再把结果交给 reviewdog。这样开发者不必翻完整日志,就能直接在改动行附近看到提示。

它的价值主要有两点:

  1. 统一不同检查工具的展示方式;
  2. 只关注本次代码变更,避免历史问题淹没新的问题。

我目前推荐的组合

如果是一个普通 Java 项目,我会按下面的顺序接入:

  1. IDE 阶段:使用 Checkstyle 和 SpotBugs 插件,尽量在提交前解决问题;
  2. Maven 阶段:把规则写入 pom.xml,保证任何人执行构建都使用同一套标准;
  3. CI 阶段:运行测试、Checkstyle 和 SpotBugs,不通过就阻止合并;
  4. PR 阶段:根据项目情况接入 CodeRabbit,或用 reviewdog 展示已有工具的结果;
  5. 人工审查:检查业务逻辑、架构影响和需求是否真正实现。

工具链不宜一次堆得太满。先解决项目当前最明显的问题,再逐步增加规则,比安装一堆工具却没有人看结果更有效。

前言

专栏在完结多年后又增加了几篇加餐,主要分成两类内容:

  • Text to SQL:使用自然语言生成 SQL;
  • 行业实战:银行、保险、证券、新能源车企和快消场景中的查询与优化。

行业实战部分已经写得很具体,我就不重复搬运原文了。这篇主要整理 Text to SQL 的思路,并补充一些我认为真正落地时必须注意的问题。

Text to SQL 是什么

Text to SQL,简单来说就是把自然语言问题转换成 SQL 查询。

例如用户提出:

查询最近 30 天销量最高的 10 个商品,并显示商品名称和销售额。

模型需要先理解“最近 30 天”“销量最高”“销售额”等业务含义,再结合数据表、字段、关联关系和数据库类型,生成可以执行的 SQL。

它降低了 SQL 的使用门槛,但不意味着完全不需要懂 SQL。特别是涉及复杂查询、权限控制、性能优化和数据修改时,最终仍然需要有人检查生成结果。

不要只看模型榜单

原内容列举了当时常见的闭源模型、开源模型和代码模型。但模型版本更新很快,半年后榜单可能就已经没有参考价值了,所以这里不再按名称排一个容易过时的名次。

如果要选择 Text to SQL 模型,我更关心下面几点:

  1. Schema 理解能力:能否理解表结构、主外键、字段注释和业务含义;
  2. 复杂查询能力:能否正确生成多表连接、聚合、窗口函数和子查询;
  3. 方言支持:是否知道 MySQL、PostgreSQL、SQL Server 等数据库的语法差异;
  4. 结构化输出能力:能否稳定地只返回 SQL 或指定 JSON,而不是夹杂大段说明;
  5. 上下文长度与成本:面对大量表结构时,能否在成本可接受的情况下完成任务;
  6. 本地部署需求:敏感数据是否允许发送到外部服务。

对实际项目来说,用自己的数据库问题做一套测试集,通常比只看公开榜单更可靠。

Text to SQL 的完整流程

如果只是把一句自然语言和整个数据库结构一起丢给模型,简单问题可能能用,但表一多,准确率就会明显下降。

我目前理解的完整流程如下:

  1. 理解用户问题:识别指标、维度、筛选条件、时间范围和排序要求;
  2. 检索相关 Schema:只找与当前问题有关的表和字段,而不是把整个数据库全部塞进去;
  3. 补充业务语义:说明“有效订单”“销售额”“新增用户”等业务概念如何计算;
  4. 生成 SQL:明确数据库方言,并限制输出格式;
  5. 静态校验:检查表名、字段名、语法、危险语句和权限范围;
  6. 试执行或解释执行计划:优先使用只读账号,必要时先执行 EXPLAIN
  7. 根据错误修正:把数据库返回的错误信息交给模型进行有限次数的修复;
  8. 返回结果与说明:除了查询结果,还应说明口径和可能存在的限制。

这套流程中,真正困难的往往不是“写出一条看起来像 SQL 的字符串”,而是找到正确的表、理解业务口径,并保证执行安全。

提示词应该提供什么

原文给出了三种提示词。核心结论是:与一段模糊的中文表说明相比,结构化的建表语句通常能给模型更多信息。

我认为一个比较实用的提示至少应包含:

  • 数据库类型和版本;
  • 相关表的 DDL;
  • 字段注释和枚举含义;
  • 表之间的关联关系;
  • 用户问题;
  • 输出格式;
  • 安全限制;
  • 必要时提供一两个相似示例。

可以写成下面这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
你是一名 SQL 助手,请根据给定的数据库结构生成查询。

数据库方言:MySQL 8.0

要求:
1. 只生成 SELECT 或 WITH 查询;
2. 不允许生成 INSERT、UPDATE、DELETE、DROP、ALTER、TRUNCATE;
3. 只使用提供的表和字段;
4. 无法确定业务口径时先提出问题,不要自行猜测;
5. 最终只在一个 sql 代码块中返回 SQL。

用户问题:
{query}

数据库结构:
```sql
{create_sql}
```

业务说明:
{business_context}

这里的 create_sql 不只是表名列表,最好包含字段类型、主键、外键、唯一约束和注释。因为这些信息能帮助模型判断字段用途,也能减少编造不存在字段的情况。

为什么“只给 DDL”仍然不够

DDL 适合描述数据库结构,但它不一定能表达业务语义。

例如订单表中可能同时存在:

  • created_at:订单创建时间;
  • paid_at:支付时间;
  • finished_at:完成时间;
  • cancelled_at:取消时间。

用户问“本月订单量”时,到底应该使用哪个时间字段?只看 DDL 很难确定。因此在真实项目中,还需要补充指标定义、字段说明,或者让模型在不确定时先追问。

另一个问题是表太多。假设数据库中有几百张表,把所有 DDL 都放进提示词不仅浪费上下文,还会增加模型选错表的概率。更合理的方式是先检索相关表,再生成 SQL,也就是把问题拆成“找表”和“写 SQL”两个阶段。

安全问题比生成能力更重要

Text to SQL 最危险的地方,不是 SQL 写错后报语法错误,而是它能够执行,但查询口径错误、扫描数据过多,甚至修改了不该修改的数据。

我认为至少要做下面几层限制:

使用只读账号

面向查询的 Text to SQL 服务不应该连接拥有写权限或 DDL 权限的数据库账号。即使提示词要求“只生成 SELECT”,也不能把安全完全寄托在模型听话上。

限制语句类型

在执行前解析 SQL,只允许 SELECTWITH 和必要的解释语句。不能只用简单的字符串包含判断,因为注释、大小写和嵌套语法都可能绕过这种检查。

控制查询成本

可以设置超时时间、最大返回行数和资源限制。对可能扫描大量数据的语句,先使用 EXPLAIN 检查执行计划。

记录审计日志

保留用户问题、模型生成的 SQL、执行结果、耗时和错误信息。后续出现问题时,至少能够知道是哪一步出了错。

如何评估生成质量

只看 SQL 能不能执行是不够的。一条 SQL 即使语法正确,也可能回答了另一个问题。

可以从下面几个角度评估:

维度 需要检查的问题
可执行性 SQL 是否能够在目标数据库中执行
结果正确性 返回结果是否符合预期口径
Schema 一致性 是否使用了真实存在的表和字段
安全性 是否包含越权查询或危险操作
性能 是否出现不必要的全表扫描、笛卡尔积或重复子查询
稳定性 同类问题换一种说法后,结果是否仍然正确

如果准备把 Text to SQL 用到正式项目,最好先收集一批真实问题和标准 SQL,做成固定测试集。每次更换模型、提示词或 Schema 检索方式后都重新跑一遍,才能知道效果到底变好了还是变差了。

加餐 02~06:行业查询与优化

这部分主要是原作者从真实开发场景中提炼的经验,针对性很强。我想了一下,原文已经整理得比较精简,我就不班门弄斧了,直接把对应链接列出来,请有需要的朋友自行查看。

前言

在这里推荐一下 my-geektime,里面收录了不少开发类学习资料,我一般把它当作在线阅读索引使用。

《SQL 必知必会》专栏地址

作者将课程分成了四个模块:

  • 基础篇:以 NBA 球队、球员数据和游戏数据为案例,讲解 SQL 基础语法;
  • 进阶篇:从执行效率出发,分析常见的 SQL 性能问题;
  • 高级篇:介绍不同关系型数据库管理系统中的 SQL 使用场景;
  • 实战篇:把前面的内容用于数据清洗、数据集成和分析项目。

这篇文章按照作者的思路整理基础篇,但我会重新调整顺序,省略一部分太基础的语法,并补上我自己查资料后对原笔记的修正。如有漏误,欢迎指正。

SQL 基本概念

SQL 语言的常见分类

学习资料中通常会把 SQL 按功能分成下面几类:

分类 英文 主要用途 常见语句
DDL Data Definition Language 定义数据库对象 CREATEALTERDROP
DML Data Manipulation Language 新增、修改和删除数据 INSERTUPDATEDELETE
DQL Data Query Language 查询数据 SELECT
DCL Data Control Language 权限与安全控制 GRANTREVOKE
TCL Transaction Control Language 控制事务 COMMITROLLBACKSAVEPOINT

不同资料的分类方式可能略有差异,例如有些资料会把 SELECT 也归入广义的 DML。这类分类主要是为了学习方便,不必太纠结边界。

大小写与命名风格

SQL 关键字通常不区分大小写,但为了可读性,我习惯:

  • 表名、表别名、字段名和字段别名使用小写;
  • SQL 关键字和内置函数使用大写;
  • 字符串使用单引号包裹。
1
2
3
SELECT name, hp_max
FROM heros
WHERE role_main = '战士';

这是一种代码风格,不是所有数据库都强制要求。标识符是否区分大小写,还会受到数据库类型、操作系统和是否使用引号等因素影响,所以团队最好统一规范。

关系型数据库与 NoSQL

关系型数据库建立在关系模型之上,使用表、行、列和约束来组织数据,常见产品包括 MySQL、PostgreSQL、Oracle 和 SQL Server。

NoSQL 一般指非关系型数据库,常见类型包括:

  • 键值数据库;
  • 文档数据库;
  • 列族数据库;
  • 图数据库;
  • 搜索与分析引擎。

它们不是简单的“先进”和“落后”关系。关系型数据库擅长事务、约束和复杂查询,NoSQL 往往针对特定访问模式、扩展方式或数据结构进行优化。实际选型还是要看业务需求。

MySQL 中一条 SQL 如何执行

MySQL 采用客户端/服务器架构。客户端建立连接并发送 SQL,服务器完成解析、优化和执行,存储引擎负责真正读取或写入数据。

为了便于理解,可以把一次查询粗略分成下面几步:

  1. 连接与权限上下文:建立连接、认证用户,并准备会话环境;
  2. 解析与语义检查:检查语法,识别表、字段和表达式;
  3. 查询优化:选择表连接顺序、访问方式和可用索引;
  4. 执行:执行器调用存储引擎接口读取数据;
  5. 返回结果:将结果集发送给客户端。

需要修正原笔记中的一点:MySQL 5.7 还保留查询缓存,但默认关闭;MySQL 8.0 已经移除了查询缓存。因此在 MySQL 8.0 的执行流程中,不应该再把“查询缓存”作为固定步骤。

优化器给出的执行计划不一定永远最优,因为它依赖统计信息和成本估算。遇到慢查询时,应该使用 EXPLAINEXPLAIN ANALYZE 检查实际访问方式,而不是只凭 SQL 表面判断。

SELECT 的书写顺序与逻辑顺序

书写顺序

常见查询的书写顺序如下:

1
2
3
4
5
6
7
8
SELECT ...
FROM ...
JOIN ... ON ...
WHERE ...
GROUP BY ...
HAVING ...
ORDER BY ...
LIMIT ...;

逻辑处理顺序

从理解查询的角度,可以近似记成:

1
2
3
4
5
6
7
8
FROM / JOIN
-> WHERE
-> GROUP BY
-> HAVING
-> SELECT
-> DISTINCT
-> ORDER BY
-> LIMIT

这个顺序主要帮助我们理解为什么:

  • WHERE 中通常不能直接使用当前层 SELECT 定义的别名;
  • HAVING 可以过滤聚合后的分组;
  • ORDER BY 通常可以使用 SELECT 中的别名。

它不是 MySQL 源码中每一步的机械执行顺序。优化器可能重写查询,只要最终语义保持一致。

常见查询细节

为什么生产代码不推荐随手写 SELECT *

SELECT * 在临时查看数据时很方便,但在正式查询中有几个问题:

  • 读取不需要的列,增加网络传输和对象映射开销;
  • 表结构新增字段后,接口返回可能悄悄发生变化;
  • 无法直接看出查询真正依赖哪些字段;
  • 在部分场景下不利于使用覆盖索引。

因此业务代码中最好明确列名:

1
2
3
SELECT id, name, hp_max
FROM heros
WHERE role_main = '战士';

常见函数分类

内置函数可以粗略分为:

  • 数值函数;
  • 字符串函数;
  • 日期与时间函数;
  • 类型转换函数;
  • 聚合函数;
  • 窗口函数。

不同数据库的函数名和行为可能不同,特别是日期处理、字符串拼接和类型转换,迁移数据库时要重点检查。

WHERE 与 HAVING 的区别

WHERE 在分组和聚合之前过滤数据行,HAVING 在分组之后过滤分组结果。

1
2
3
4
5
SELECT role_main, COUNT(*) AS hero_count
FROM heros
WHERE hp_max > 5000
GROUP BY role_main
HAVING COUNT(*) >= 3;

能放进 WHERE 的普通条件通常应该尽量提前过滤,以减少后续需要参与分组的数据量。

IN 与 EXISTS 怎么选

常见写法如下:

1
2
3
SELECT *
FROM a
WHERE a.cc IN (SELECT b.cc FROM b);
1
2
3
4
5
6
7
SELECT *
FROM a
WHERE EXISTS (
SELECT 1
FROM b
WHERE b.cc = a.cc
);

原笔记使用“外表大就用 IN,外表小就用 EXISTS”来判断,这个经验过于绝对。现代优化器可能把两种写法改写成相近的半连接计划,实际性能还取决于索引、数据分布、空值、选择性和数据库版本。

更稳妥的做法是:

  1. 先保证写法表达正确语义;
  2. 给关联列建立合适索引;
  3. 使用 EXPLAIN ANALYZE 对真实数据进行比较。

另外,NOT IN 遇到 NULL 时容易产生不符合直觉的结果。需要排除不存在的数据时,我通常更倾向于明确处理空值,或使用 NOT EXISTS

视图

视图可以理解为保存下来的查询。普通视图通常不单独保存查询结果,而是在使用时基于底层表执行对应 SQL。

它的常见作用包括:

  1. 简化查询:把复杂连接和计算封装起来;
  2. 复用逻辑:让多个调用方使用同一套查询定义;
  3. 控制暴露字段:只向特定用户开放允许访问的列;
  4. 兼容接口:底层表变化时,通过视图保持上层查询相对稳定。

视图是否可更新取决于数据库和视图定义。包含聚合、分组、DISTINCT、集合运算或复杂连接的视图通常不能直接更新,不能简单认为“所有视图都只读”或“所有单表视图都可写”。

存储过程

存储过程是保存在数据库服务器中的一组 SQL 和流程控制语句。创建后可以像调用函数一样执行。

下面用 MySQL 存储过程计算从 1 到 n 的累加值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
DELIMITER //

CREATE PROCEDURE add_num(IN n INT)
BEGIN
DECLARE i INT DEFAULT 1;
DECLARE total INT DEFAULT 0;

WHILE i <= n DO
SET total = total + i;
SET i = i + 1;
END WHILE;

SELECT total;
END //

DELIMITER ;

MySQL 存储过程常见参数类型:

参数类型 作用
IN 向存储过程传入参数
OUT 把存储过程中的结果返回给调用方
INOUT 同时作为输入和输出参数

存储过程的优点是靠近数据、便于封装固定数据库逻辑;缺点是数据库方言差异大、调试和版本管理不如应用代码方便,也容易把业务逻辑过度集中到数据库中。

因此它并不是“高并发一定不能用”,而是需要结合团队维护能力、数据库压力、部署方式和扩展需求判断。

游标

SQL 更擅长面向集合处理,游标则允许逐行读取查询结果。它适合确实需要逐条处理的场景,但如果能用一条集合 SQL 完成,通常不要优先写游标。

下面是 MySQL 存储过程中的一个示例,用游标累计所有英雄的最大生命值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
DELIMITER //

CREATE PROCEDURE calc_hp_sum()
BEGIN
DECLARE done BOOLEAN DEFAULT FALSE;
DECLARE hp INT;
DECLARE hp_sum BIGINT DEFAULT 0;

DECLARE cur_hero CURSOR FOR
SELECT hp_max FROM heros;

DECLARE CONTINUE HANDLER FOR NOT FOUND
SET done = TRUE;

OPEN cur_hero;

read_loop: LOOP
FETCH cur_hero INTO hp;

IF done THEN
LEAVE read_loop;
END IF;

SET hp_sum = hp_sum + hp;
END LOOP;

CLOSE cur_hero;
SELECT hp_sum;
END //

DELIMITER ;

原笔记中写了 DEALLOCATE cursor_name,这不是 MySQL 存储过程游标的语法。MySQL 游标使用 DECLAREOPENFETCHCLOSE,并且只能在存储程序中声明。

数据库设计:原则不是越少越好

原文提到了“三少一多”,但如果直接记成“表越少越好、字段越少越好、外键越多越好”,很容易走向另一个极端。

我现在更愿意把数据库设计理解成下面几条平衡原则。

一个表尽量表达一个清晰主题

表不是越少越好。把用户、订单、商品全部塞进一张大表,虽然表少了,但会产生大量重复数据和更新异常。

合理拆表的目标是让实体和关系清晰,同时避免为了“看起来规范”而拆出大量没有实际价值的小表。

字段需要保持原子性和明确含义

字段也不是越少越好。应该避免把多个含义塞进一个字符串字段,也要谨慎保存能够稳定计算出的重复数据。

但在读取压力大、计算成本高的场景中,适度冗余又可能是合理优化。关键是明确一致性如何维护。

主键要稳定,联合主键不要滥用

主键应当唯一、非空并尽量稳定。联合主键不是错误,但字段过多会让外键引用、索引和应用代码变复杂。

外键约束要结合架构选择

外键能保证引用完整性,但也会增加写入和迁移时的约束。在单体系统或数据一致性要求高的系统中,外键很有价值;在分库分表或跨服务场景中,关系可能需要由应用和审计机制维护。

多表连接:优先使用显式 JOIN

旧式写法常把多张表放在 FROM 中,再在 WHERE 中写连接条件:

1
2
3
SELECT h.name, r.role_name
FROM heros h, roles r
WHERE h.role_id = r.id;

现在更推荐显式 JOIN ... ON ...

1
2
3
SELECT h.name, r.role_name
FROM heros AS h
JOIN roles AS r ON r.id = h.role_id;

显式 JOIN 能把连接条件和过滤条件分开,层次更清晰,也能减少漏写连接条件导致笛卡尔积的风险。

NATURAL JOINUSING 虽然更短,但会依赖同名列。表结构变化后可能悄悄改变连接行为,所以在业务 SQL 中我更倾向于明确写出 ON 条件。

MySQL 5.7 与 8.0 的几个重要区别

MySQL 8.0 相比 5.7 的变化很多,这里只记我认为最常见的部分:

方面 MySQL 5.7 MySQL 8.0
查询缓存 保留,但默认关闭 已移除
默认字符集 latin1 utf8mb4
窗口函数 不支持 支持
公用表表达式 CTE 不支持 支持 WITH,包括递归 CTE
数据字典 主要依赖文件和系统表 使用事务型数据字典
默认认证插件 mysql_native_password 早期 8.0 默认使用 caching_sha2_password

从 5.7 升级到 8.0 时,除了语法和功能,还要检查字符集、排序规则、保留字、认证方式和已废弃配置,不能只看 SQL 能不能执行。

COUNT(*)、COUNT(1) 与 COUNT(字段)

这三个写法最重要的区别不是谁更快,而是语义。

  • COUNT(*):统计结果集中的行数;
  • COUNT(1):表达式 1 对每一行都不为 NULL,通常也统计行数;
  • COUNT(column):只统计该字段不为 NULL 的行数。

在 MySQL InnoDB 中,COUNT(*)COUNT(1) 通常会得到相同的执行计划,没有必要为了所谓的性能差异把所有代码改成 COUNT(1)

对于没有 WHEREGROUP BY 的精确行数统计,InnoDB 不能像 MyISAM 那样直接返回一个始终准确的固定行数,因为它需要考虑事务和 MVCC。优化器会选择合适的索引进行扫描,通常倾向于较小的可用二级索引;没有二级索引时才扫描聚簇索引。

这里要避免另一个误区:不要只为了 COUNT(*) 就盲目创建一个没有业务价值的二级索引。索引会占空间,也会增加写入成本。是否建立索引,应该结合整个查询和写入负载判断。

我的结论是:

  1. 统计行数时优先写语义清楚的 COUNT(*)
  2. 统计某字段非空数量时使用 COUNT(column)
  3. 真正遇到性能问题时,用执行计划和真实数据验证,不要只背固定结论。

前言

今天也是有幸第一次约到了面试,下面进行分享。

这篇文章主要分为三部分:自我介绍的组织方式、面试中被问到的问题、面试后的复盘感悟。其中问题部分我按照主题重新整理了一下,并补上了面试后复盘出的参考回答。

自我介绍

要求 3 分钟自我介绍,我大致按照下面的顺序讲了一轮:

  1. 目标岗位
  2. 奖项经历
  3. 技术栈
  4. 项目经历
  5. 开源经历
  6. 简短总结

整体还是有点考验记忆的,不过这些内容都是自己做过的,所幸也没有卡词。等自我介绍结束后,我也差不多适应了面试氛围,刚好进入问答环节。

提问

顺序可能不太一样,我将同类型的问题放在一块了,实际面试时是混着来的。一共有三个面试官,第一位主要问缓存相关的问题,第二位主要问事务相关问题以及一些实际生产问题,第三位则是问一些 Java 基础相关的问题。

网络基础

HTTP 和 HTTPS 的区别

这一问直接炸了。因为我们大三才学计网,虽然自己在上学期自学过一点,但到现在早已经没印象了,只知道 HTTPS 的安全性高一些,索性直接爆了。

HTTP 是明文传输协议,默认端口是 80;HTTPS 可以理解为 HTTP + TLS/SSL,默认端口是 443

主要区别有几点:

  • 安全性不同:HTTP 明文传输,容易被窃听、篡改;HTTPS 通过 TLS 加密传输。
  • 身份认证不同:HTTPS 依赖 CA 证书,可以验证服务端身份,降低中间人攻击风险。
  • 完整性保护不同:HTTPS 可以校验数据是否被篡改。
  • 性能成本不同:HTTPS 需要额外的握手和加解密过程,但现在硬件和协议优化后,这个成本通常可以接受。

如果面试时继续追问 HTTPS 的过程,可以从“客户端发起请求、服务端返回证书、客户端校验证书、协商会话密钥、后续使用对称加密通信”这个流程展开。

缓存与 Redis

什么是缓存穿透、缓存击穿和缓存雪崩?

这三个问题都和缓存失效有关,但原因不一样。

问题 典型场景 核心原因 常见解决方案
缓存穿透 查询一个根本不存在的数据 缓存和数据库都没有该数据,请求直接打到数据库 缓存空值、布隆过滤器、参数校验
缓存击穿 某个热点 key 突然过期 大量请求同时访问同一个失效热点 key 互斥锁、逻辑过期、热点 key 永不过期
缓存雪崩 大量 key 同时过期 大面积缓存同时失效,请求集中打到数据库 过期时间加随机值、缓存预热、多级缓存、限流降级

简单记忆就是:穿透是查不存在,击穿是热点 key 失效,雪崩是大量 key 一起失效。

商城中大量伪造商品 ID、热门商品突然失效、大量商品突然失效分别是什么问题?

这道题其实是把上面的概念放到业务场景里问。

  1. 大量伪造商品 ID 被发送到服务器:属于缓存穿透。因为这些商品 ID 可能根本不存在,缓存查不到,数据库也查不到。

    • 可以在入口做参数校验。
    • 可以用布隆过滤器提前判断商品 ID 是否可能存在。
    • 对不存在的数据缓存空值,并设置较短过期时间。
  2. 热门商品突然失效:属于缓存击穿。热门商品访问量很大,一旦缓存失效,请求会瞬间打到数据库。

    • 可以给热点商品设置逻辑过期。
    • 可以用互斥锁保证只有一个线程回源重建缓存。
    • 对极热数据可以考虑不过期,而是后台异步刷新。
  3. 大量商品突然失效:属于缓存雪崩。大量 key 同一时间失效,数据库压力会瞬间升高。

    • 给缓存过期时间加随机值,避免同一时间集中过期。
    • 做缓存预热。
    • 增加限流、降级和熔断保护。
    • Redis 高可用部署,避免单点故障。

商品详情这种数据如何构建缓存?

商品详情页通常读多写少,很适合缓存,但也要考虑一致性问题。

一种比较常见的方式是 Cache-Aside 思想。刚好我在项目中写到过类似逻辑,所以面试时就直接拉过来说,顺便往项目经历上展开了一下:

  1. 用户请求商品详情。
  2. 先查 Redis。
  3. Redis 命中则直接返回。
  4. Redis 未命中则查数据库。
  5. 数据库查到后写入 Redis,并设置过期时间。
  6. 如果商品被修改,先更新数据库,再删除缓存。

这里更推荐“更新数据库后删除缓存”,而不是直接更新缓存。因为商品详情可能由多个表拼装而来,直接更新缓存容易漏字段或写入脏数据。删除缓存后,下次查询再重新构建,逻辑会更简单。

需要注意的点:

  • 缓存 key 要设计清楚,比如 product:detail:{id}
  • 过期时间不要完全相同,可以加随机值。
  • 热点商品可以做预热或逻辑过期。
  • 对不存在的商品可以缓存空值,防止穿透。
  • 修改商品信息时要保证数据库和缓存的最终一致性。

秒杀系统

如果让你设计一个商城秒杀系统,你会怎么做?从数据库设计层面讲一下。

秒杀系统的核心问题是:高并发下不能超卖,系统不能被瞬间打崩。

但当时还是比较有压力,没能仔细思考,只说了一下可以用加锁、事务、分布式缓存、多服务器并发来在一定程度上缓解这个问题。

从数据库设计角度,可以拆成几张核心表:

作用
product 普通商品信息
seckill_activity 秒杀活动信息,如开始时间、结束时间、状态
seckill_product 秒杀商品信息,如商品 ID、秒杀价、秒杀库存
seckill_order 秒杀订单,记录用户、商品、活动、订单状态

关键约束:

  • seckill_order 中可以对 (user_id, activity_id, product_id) 建唯一索引,防止同一用户重复抢购。
  • 库存扣减要用原子条件更新,例如:
1
2
3
UPDATE seckill_product
SET stock = stock - 1
WHERE id = #{id} AND stock > 0;

如果影响行数为 1,说明扣减成功;如果为 0,说明库存不足。

实际系统里不会让所有请求都直接打数据库。一般会把库存提前加载到 Redis,用 Redis 先做预扣减,再通过消息队列异步创建订单,数据库作为最终落库。

从事前、事中、事后的层面讲一下秒杀系统

这道题更像是在考系统设计思路。

事前:削峰和预热

这里我回答的是用 Spring 的 @Scheduled 注解做定时任务,在活动开始前定期把数据加载到缓存中。

  • 活动开始前把商品、库存、活动信息预热到 Redis。
  • 对接口做防刷设计,比如验证码、登录校验、限流、隐藏秒杀地址。
  • 静态资源走 CDN,减少应用服务器压力。

事中:限流和异步化

这里还是往并发上去说。

  • 网关层或接口层限流,超过系统承载能力的请求直接拒绝。
  • Redis 原子扣减库存,避免大量请求进入数据库。
  • 用消息队列异步下单,削平瞬时流量。
  • 对用户维度做幂等控制,防止重复下单。

事后:补偿和对账

这一步没什么思路,就说了说打日志和哨兵节点。复盘后发现,Redis 哨兵更偏向高可用保障,不是秒杀“事后补偿”的核心答案。这里更应该围绕补偿、对账和问题追踪来讲。

  • 消费消息失败时要有重试和死信队列。
  • 定时校验订单、库存、支付状态是否一致。
  • 对超时未支付订单进行关闭并回补库存。
  • 记录完整日志,方便排查问题。

数据库与事务

你认为什么是接口?

我当时第一反应是从前后端分离项目里的 API 接口去答:接口就是前端调用后端能力的入口,通过接口完成业务功能,比较常见的设计风格是 RESTful API。

复盘后发现,这道题最好先区分语境:

  1. Java 语法层面的接口

    • interface 是一种抽象规范,只定义能力,不关心具体实现。
    • 实现类通过 implements 实现接口。
    • 它常用于解耦、面向接口编程和多态。
  2. 系统设计层面的接口

    • 接口是系统对外暴露能力的契约。
    • 对前端来说,后端接口通常表现为 HTTP API。
    • 一个好的接口需要定义清楚请求路径、请求方法、参数、响应结构、错误码、鉴权方式和幂等规则。

如果面试官没有限定上下文,可以先说:“接口这个词有两层含义,一个是 Java 里的 interface,一个是系统对外暴露的 API,我分别说一下。”这样回答会更稳。

SQL 注入在事前、事中、事后如何防护

我当时回答了两部分:一是对前端的数据进行预处理,二是通过白名单防止 SSRF(服务端请求伪造)。这里其实答偏了,SSRF 和 SQL 注入不是同一类问题。

SQL 注入的本质是:用户输入被当成 SQL 语句的一部分执行了。比如把用户输入直接拼接进 SQL,就可能让攻击者改变原本的查询逻辑。

可以按事前、事中、事后三个阶段来回答:

事前:开发阶段避免注入点

  • 使用预编译语句和参数绑定,比如 PreparedStatement
  • MyBatis 中优先使用 #{},避免把用户输入直接放进 ${}
  • 不手写字符串拼接 SQL,尤其是登录、搜索、排序、筛选这类接口。
  • 对动态表名、排序字段这种无法参数化的位置做白名单校验。
  • 数据库账号最小权限,不给业务账号过高权限。

事中:运行阶段拦截异常请求

  • 对明显异常的参数做校验,比如超长输入、非法枚举值。
  • 对高风险接口做限流,避免被批量探测。
  • 可以接入 WAF 或网关规则,拦截典型注入 payload。
  • 记录关键 SQL 异常和异常请求来源,方便追踪。

事后:发现问题后的修复和复盘

  • 先下线或封禁异常入口,避免继续扩大影响。
  • 排查日志,确认被访问的数据范围。
  • 修复注入点,补充参数化查询和白名单校验。
  • 轮换可能泄露的账号密码或密钥。
  • 补充安全测试用例,避免同类问题再次出现。

这里还要注意一点:前端校验只能提升用户体验,不能作为安全边界。真正的防护必须放在后端和数据库访问层。

索引是什么?

这里回答上了,并往Explain性能分析上发散了一下,但把占用性能忘了,也是比较遗憾。

索引可以理解为数据库为了提高查询效率而维护的一种数据结构。没有索引时,数据库可能需要全表扫描;有索引后,可以通过索引快速定位到目标数据。

MySQL InnoDB 中常见的索引结构是 B+ 树。它的特点是:

  • 非叶子节点主要存索引值,用来导航。
  • 叶子节点存放完整数据或主键值。
  • 叶子节点之间有链表,适合范围查询。

索引不是越多越好。它会占用存储空间,并且在 INSERTUPDATEDELETE 时需要维护索引结构,所以会影响写入性能。

什么是事务?

这就是很简单的概念了,还可以往 Spring 的 AOP 特性上发散一下。

事务是一组数据库操作的逻辑单元,要么全部成功,要么全部失败。它的经典特性是 ACID

  • A 原子性:事务中的操作要么全部成功,要么全部回滚。
  • C 一致性:事务执行前后,数据要满足业务约束。
  • I 隔离性:并发事务之间不能互相随意干扰。
  • D 持久性:事务提交后,数据修改要持久保存。

比如转账场景中,A 扣钱和 B 加钱必须放在一个事务里,否则就可能出现一边成功、一边失败的问题。

支付模块分为下订单、扣费、加积分,怎么确保一致性?

这一问我当时只想到了开事务,但没有继续往分布式场景展开。现在复盘来看,应该先判断这三个动作是不是在同一个服务、同一个数据库里。

如果这三个操作都在同一个服务、同一个数据库里,可以直接使用本地事务,把下订单、扣费、加积分放到同一个事务中。

但实际业务里,订单、支付、积分往往是不同服务,这时就变成了分布式事务问题。常见方案有:

  1. 本地事务 + 可靠消息

    • 订单和消息写入同一个本地事务。
    • 事务提交后,通过消息队列通知积分服务。
    • 积分服务消费消息时要保证幂等。
  2. TCC

    • Try 阶段预留资源。
    • Confirm 阶段确认提交。
    • Cancel 阶段释放资源。
    • 一致性更强,但业务改造成本更高。
  3. 最终一致性

    • 允许短时间不一致。
    • 通过消息重试、补偿任务、对账任务保证最终一致。

面试回答时可以先说明:强一致性成本高,实际业务里通常会结合可靠消息、幂等、重试和补偿来保证最终一致性。

下订单后保留 30 分钟,未支付恢复库存,怎么实现?各有什么优缺点?

这个问题本质是延迟任务。我现场只想到了 Redis 过期监听 和 轮询,还是缺乏开发经验。

方案 做法 优点 缺点
定时任务轮询 定时扫描超时未支付订单 实现简单,容易理解 不够实时,扫描压力大
Redis 过期监听 给订单 key 设置 30 分钟过期,监听过期事件 实现不复杂,实时性较好 Redis 过期事件不保证绝对可靠
延迟队列 下单后发送 30 分钟后投递的延迟消息 可靠性较好,适合生产 依赖 MQ,系统复杂度更高
时间轮 用时间轮管理大量延迟任务 性能好,适合大规模定时任务 实现和维护成本更高

生产上更推荐 延迟队列 + 幂等关闭订单

  1. 创建订单时锁定库存,并发送一条 30 分钟后触发的延迟消息。
  2. 消息到期后检查订单状态。
  3. 如果仍未支付,则关闭订单并恢复库存。
  4. 如果已经支付,则不做处理。

这里必须注意幂等。因为消息可能重复投递,关闭订单和恢复库存都不能重复执行。

生产故障排查

如果一台服务器的内存意外跑到了 100%,你会怎么排查?

这一问我在掘金上看到有人发过帖,可惜只是走马观花看了一下,没能回答上,比较遗憾。

可以按照“先止血、再定位、最后复盘”的思路回答。

第一步:确认现象

  • tophtop 看整体内存、CPU 情况。
  • free -h 看内存和 swap 使用情况。
  • ps aux --sort=-%mem | head 找出最占内存的进程。

第二步:定位进程

如果是 Java 进程,可以继续看:

  • jps -l 找 Java 进程。
  • jstat -gcutil <pid> 1000 观察 GC 情况。
  • jmap -histo:live <pid> 看对象数量和大小。
  • 必要时用 jmap -dump 导出堆快照,再用 MAT、VisualVM 等工具分析。

第三步:结合日志和业务判断

  • 最近是否上线了新版本。
  • 是否有大流量请求或异常接口。
  • 是否有大对象缓存、集合无限增长、线程池堆积。
  • 是否存在文件读取、批量查询一次性加载过多数据的问题。

第四步:临时止血

  • 如果服务已不可用,可以先扩容、限流或重启异常实例。
  • 如果是某个接口导致的,可以临时下线入口或降级。
  • 止血后再保留现场信息,避免重启后证据丢失。

Java 基础

OOP 特点

OOP 主要有四个特点:

  • 封装:把属性和行为封装到对象内部,对外暴露必要接口。
  • 继承:子类复用父类的属性和方法。
  • 多态:同一个接口或父类引用,在运行时可以表现出不同实现。
  • 抽象:提取共性,隐藏不必要的细节。

面试里可以重点讲封装、多态和抽象。继承虽然常见,但实际开发中过度继承也容易导致类层级复杂。也可以刚好引出 Java 为什么没有多继承:避免菱形继承问题。

类和对象的区别

类是模板,对象是类的实例。

比如 User 是一个类,它定义了用户应该有哪些属性和行为;而 new User() 创建出来的某一个具体用户,就是对象。

换句话说,类描述“是什么”,对象表示“具体是哪一个”。

构造函数有哪几种?

在 Java 里常见的说法有:

  • 无参构造函数:没有参数,用于创建默认对象。
  • 有参构造函数:通过参数初始化对象属性。
  • 默认构造函数:如果类里没有显式定义任何构造函数,编译器会自动生成一个无参构造函数。

需要注意的是:如果已经手写了有参构造函数,Java 就不会再自动生成无参构造函数。这时如果框架或代码需要无参构造,就要自己补上。

这一问我当时答得比较窄,只答了 Java 里最常见的构造函数。复盘后可以再补一层:构造对象不一定只靠构造函数,很多框架和设计模式会把对象创建过程封装起来。

从 Java 基础角度看,构造函数主要看是否有参数、是否由编译器默认生成;从工程实践角度看,对象还可能通过工厂方法、Builder、反射、反序列化、Spring 容器等方式创建。面试时如果能区分“构造函数”和“对象创建方式”,回答会更完整。

你怎么看待 new 一个对象?

我理解的 new 不只是“创建一个对象”这么简单,它背后至少包含几个步骤:

  1. 在堆内存中为对象分配空间。
  2. 对对象字段进行零值初始化。
  3. 执行构造方法中的初始化逻辑。
  4. 返回对象引用,让栈上的变量可以指向这个对象。

从 JVM 层面看,对象创建还可能涉及类加载检查、内存分配方式、对象头、引用指向等内容。面试时如果能从“语法层面、内存层面、JVM 层面”逐层回答,会比只说 new 是创建对象更完整。

反问环节

我的问题是:我这次面试还有什么能改进的地方?

对方回答(大致)是:“作为一个大二学生,能回答上大部分问题已经很好了,但底层方面还是比较匮乏。如果想要做一个好的后端工程师,还是应该向 JVM 内存、Spring 原理、汇编等方面加深学习。”

感悟

整体上大概回答了 2/3,剩下的能编就编,要么就往自己的项目或熟悉的领域去扯,实在不了解的部分就直接 gg 投降。

平时刷博客还是有用的。服务器跑满内存的示例我刚好在掘金看到有人发过,虽然具体记不太清了,但还是勉强扯了一下。

说实话没想到对方会问到这些,因为大家都是大二的,虽然对方也表示了没期望有人能全部回答上。我原本以为大致 10 分钟就能结束战斗,结果硬生生聊了三十几分钟。

这次面试给我的最大提醒是:项目经历只能证明“做过”,但面试会继续追问“为什么这么做、底层怎么实现、出问题怎么排查”。后面还是要把 JVM、Spring、Redis、MySQL、Linux 排查这些内容系统补一遍,不能只停留在会用 API 的层面。

总体而言,第一次面试还是受益匪浅,面试官也很友善,在知道我没有学习计网后就直接换了题型。三位面试官也都在我思考问题时耐心等待,也没有压力面,可以说体验很好。

出于隐私原因,这里就不放公司名称了。

前言

我个人在大学生活中的感受是:虽然有小部分人能勇争桥头,但大部分同学是与热门技术脱节的(无歧义)。我在计算机学院发现,很多同学对ai的使用还停留在和网页版豆包扯皮,更进一步的则是设法使用gpt或其他更好用的模型,有些人为了更好的体验会选择找代充开会员。但我认为,一方面,这些工具提供的生产力与我们专业的需要还是不够的。虽然体验起来可能觉得已经很强了,但和agent比起来差别太大了;另一方面,我不认为普通学生有场景用完会员的额度,相比之下,随用随买的token模式在我看来是更适合的(家庭条件好可以忽略)。

因此,我想要分享我对智能体的使用经验,希望能帮到同样想要尝试新技术的朋友。也希望大家能敢于尝试新事物,不要畏难(我帮助一位同学从头配置好了所有流程,但当我在命令行中唤出claude code时,他却觉得自己用不好命令行,主动放弃了,去找代充开网页端的会员)。

本文主要介绍三个方面:cc-switch(管理工具)、skills(技能插件)、MCP(模型上下文协议服务)。


cc-switch

介绍

cc-switch 是一个跨平台的桌面端 All-in-One 管理工具,支持 Claude Code、Codex、OpenCode、Gemini CLI 等多种 AI 编程 CLI 工具的统一管理。它的核心价值在于:你不再需要手动编辑各种 JSON、TOML 配置文件,所有操作都可以在图形界面中完成。

简单来说,cc-switch 就是这些命令行 AI 工具的”控制面板”。

具体功能

配置智能体

cc-switch 支持对 Claude Code / Codex 的 API Provider 进行可视化配置和一键切换。你可以:

  • 配置多个 API 提供商(官方 Anthropic、OpenRouter、各种中转站等)
  • 一键切换当前使用的模型和 Provider
  • 管理 API Key,避免手动修改环境变量

对于使用中转 API 的同学来说,这个功能非常实用——不用每次都去改配置文件。

下载并管理skills

cc-switch支持下载并管理skills
alt text
点击发现技能即可进入下载页面,它会自行在Github中查找对应的仓库,点击安装即可进行自动下载并配置。(有些skills无法使用cc-switch安装,点击安装左侧的查看即可跳转到GitHub下载,安装好后在cc-switch选择导入已有即可进行管理)

alt text

管理MCP

cc-switch 提供了 MCP Server 的可视化管理界面,可以方便地添加、启用、禁用各种 MCP 服务,不需要手动编辑 settings.json

alt text

管理历史对话

cc-switch 可以浏览和搜索 Claude Code 的历史会话记录,方便你回顾之前的工作内容。

alt text

管理提示词

支持管理自定义的系统提示词(System Prompt),可以为不同项目或场景配置不同的提示词模板,实现快速切换。


skills推荐

skills更多的是根据自己工作的方向进行配置,这里附带一份我自己的配置供参考。

注:有些skills用cc-switch无法下载,需要自己手动安装。而我发现Claude code在GitHub上的生态远好于codex,建议大家用Claude-code下载,然后在cc-switch中 Skills 管理 -》 导入已有 -》 勾选其他智能体。

karpathy-guideline

仓库地址forrestchang/andrej-karpathy-skills

这是一个源自 Andrej Karpathy(前 Tesla AI 总监、OpenAI 联合创始人)对 LLM 编程陷阱观察的 CLAUDE.md 配置文件。它通过四条核心原则来约束 AI 的编码行为:

  1. 先读再写:修改代码前必须先阅读相关文件,理解上下文
  2. 最小改动:只做必要的修改,不要过度重构
  3. 保持简单:避免过度工程化,不引入不必要的抽象
  4. 验证有效:改完之后要验证代码能正常工作

简单来说,它让 AI 从一个”过度自信的初级程序员”变成一个”有纪律的工程师”。这个 skill 几乎是必装的,能显著减少 AI 写代码时的”幻觉”和过度修改问题。

prompt-optimizer

仓库地址Hashaam101/prompt-optimizer

这个 skill 有两种模式:

  • 自动模式(auto):在你每次输入 prompt 时,它会在后台静默优化你的提示词,让 AI 更好地理解你的意图
  • 手动模式(manual):通过 /optimize 命令,将你写的粗略 prompt 转化为结构化的、高质量的提示词

对于不太擅长写 prompt 的同学来说,这个 skill 能帮你把”帮我写个登录页面”这样模糊的需求,自动补充为包含技术栈、样式要求、错误处理等细节的完整指令。

baoyu-format-markdown

仓库地址JimLiu/baoyu-skills

这个 skill 用于将纯文本或格式混乱的 Markdown 文件整理为结构清晰、排版规范的格式。它会:

  • 添加和规范化 frontmatter(标题、摘要等元信息)
  • 规范标题层级
  • 添加适当的加粗、列表、代码块
  • 处理中英文之间的空格(CJK spacing)
  • 保持原始内容不变,只优化格式

写博客、整理笔记的时候非常好用。输出文件为 {filename}-formatted.md

agent-browser

仓库地址vercel-labs/agent-browser

这是 Vercel Labs 推出的浏览器自动化 CLI 工具,专为 AI Agent 设计。安装后,Claude Code 就能:

  • 打开网页并导航
  • 读取页面内容
  • 填写表单、点击按钮
  • 截图
  • 执行端到端测试

它使用基于 ref(引用标记)的交互方式,比传统的 CSS 选择器更可靠。当你需要 AI 帮你查资料、测试网页、或者做一些需要浏览器的操作时,这个 skill 就派上用场了。


MCP

注意:MCP与skills不同,它会占据上下文的大小,所以不建议同一时间开太多。MCP(Model Context Protocol)是 Anthropic 推出的开放标准,可以理解为”AI 的 USB-C 接口”——让 AI 能够标准化地连接各种外部工具和数据源。

codegraph

仓库地址aStudioPlus/codegraph

CodeGraph 会对你的代码库进行 AST(抽象语法树)解析,构建一个包含函数、类、导入关系、调用链的语义图谱。支持 37+ 种编程语言。

主要能力:

  • 符号搜索:快速定位函数/类的定义位置
  • 调用链追踪:查看谁调用了某个函数、某个函数又调用了谁
  • 影响分析:修改某个符号时,分析哪些代码会受影响
  • 代码探索:一次调用就能获取多个文件的相关源码,替代反复 grep + read 的低效循环

对于大型项目来说,codegraph 能让 AI 更快地理解代码结构,减少不必要的文件读取,节省上下文空间。

GitHub MCP Server

来源:GitHub 官方维护

GitHub MCP Server 让 AI 能够直接与 GitHub 平台交互,提供 90+ 种工具,包括:

  • 创建和管理 Issue、Pull Request
  • 搜索代码和仓库
  • 读取文件内容和提交历史
  • 管理 Branch 和 Release
  • 操作 GitHub Actions

配置方式:

1
claude mcp add github --scope user

对于日常开发来说,有了它你可以直接让 AI 帮你创建 PR、查看 Issue、搜索相关代码,不用再手动切换到浏览器操作。

agentmemory

仓库地址kishan0725/AgentMemory

AI 编程工具的一个痛点是:每次新会话都会”失忆”。agentmemory 通过 MCP 协议为 AI 提供持久化记忆能力:

  • 跨会话记忆:上一次对话中做的决策、偏好设置,下次还能记住
  • 项目上下文:记住项目的架构决策、技术选型、编码规范
  • 本地存储:所有数据存在本地,不上传到云端,保护隐私

有了它,AI 不会每次都问你”这个项目用什么框架”、”你喜欢什么代码风格”这类重复问题。


总结

类型 工具 一句话描述
管理工具 cc-switch 图形化管理 Claude Code/Codex 的一切配置
Skill karpathy-guideline 让 AI 写代码更有纪律
Skill prompt-optimizer 自动优化你的提示词
Skill baoyu-format-markdown 一键美化 Markdown 格式
Skill agent-browser 让 AI 能操作浏览器
MCP codegraph 代码语义图谱,加速代码理解
MCP GitHub MCP Server 直接操作 GitHub
MCP agentmemory 跨会话持久记忆

这套配置的核心思路是:用 cc-switch 降低配置门槛,用 skills 增强 AI 的行为质量,用 MCP 扩展 AI 的能力边界

如果你是刚接触 AI 编程工具的同学,建议按这个顺序来:先装 cc-switch 把环境配好,再装 karpathy-guideline 这个基础 skill,然后根据自己的需要逐步添加其他插件。不要一次性全装上——记住 MCP 会占上下文,按需开启就好。

前言

本文是我自学时记录的笔记,有我个人的理解并部分地方调整过,并没有记录我认为不是我学习重心的内容,如有分歧请以具体课程内容为主。


智能体工作流简介

Non-Agentic vs Agentic Workflow

Non-Agentic Workflow(非代理型)

用 prompt 让 AI 去完成任务,一次性输出结果。

例:为我写一篇实验报告 → AI 思考后返回内容

Agentic Workflow(代理型)

LLM 执行多步操作来完成任务,具备迭代和自我修正能力。

例:为我写一篇实验报告 → 是否需要网络搜索 → 第一版草稿 → 判断哪些地方需要修改 → 重复直至完成任务

Agentic Workflow 示意


自主性程度

低自主性

低自主性流程

写一篇关于黑洞的论文 → LLM 进行网络搜索和抓取 → 写论文

特点:

  • 所有步骤都是设定好的
  • 工具都是硬编码
  • 智能仅体现在文本生成

高自主性

高自主性流程

写一篇关于黑洞的论文 → LLM 进行网络搜索(决定从哪里获取资料、获取什么形式的资料)→ 抓取(抓取多少、是否需要格式转换)→ 写论文 → 反复审查

特点:

  • 自主决定执行路径
  • 能创建新工具

对比

自主性对比


优势

  1. 代理模式可以提高 AI 的性能
  2. 通过并行加快速度
    并行优势示意
  3. 允许增加或更新模块

任务分解:识别工作流中的步骤

构建初步工作流 → 表现不好 → 细化操作流程


智能体评估

常规做法:先构建系统 → 检查输出 → 修复漏洞 → 添加评估来检测是否修复

问题:有些情况难以用代码进行检测

解决:用智能体进行评估计分


设计模式

  1. Reflection(反思)
  2. Tool Use(工具使用)
  3. Planning(规划)
  4. Multi-Agent Collaboration(多智能体协作)

Reflection 反思

与直接输出相比的优势

直接输出是一次性生成;Reflection 让 AI 对自己的输出进行审视和改进,类似于人类写完文章后的自我检查。

如何正确评估

给出具体细分的标准,二元/多元计分,比较分数总和。

为什么不直接选择?
→ AI 倾向于选择第一个选项(位置偏差)

为什么要多元化标准?
→ 单一标准校准不佳,细分维度后评估更准确

外部反馈

在大模型进行工作时,提供正确的输出作为参考,或对它的错误进行指正。


Tool Use 工具使用

Tool Use 示意

概念

大模型不实际调用工具,而是去请求工具。LLM 生成工具调用的意图,由外部系统负责实际执行。

工具语法

Tool Use 的核心流程:定义工具 → LLM 生成调用请求 → 外部系统执行 → 结果返回给 LLM

1. 定义工具(Tool Definition)

以 JSON Schema 的形式告诉 LLM 有哪些工具可用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
{
"type": "function",
"function": {
"name": "get_weather",
"description": "获取指定城市的当前天气信息",
"parameters": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "城市名称,如:北京"
},
"unit": {
"type": "string",
"enum": ["celsius", "fahrenheit"],
"description": "温度单位"
}
},
"required": ["city"]
}
}
}

关键字段:

  • name:工具名称,LLM 通过它来调用
  • description:描述工具的用途,帮助 LLM 判断何时使用
  • parameters:参数的 JSON Schema,定义输入格式

2. LLM 生成工具调用(Tool Call)

当 LLM 判断需要使用工具时,它不会直接执行,而是返回一个结构化的调用请求:

1
2
3
4
5
6
7
8
9
10
11
12
{
"tool_calls": [
{
"id": "call_abc123",
"type": "function",
"function": {
"name": "get_weather",
"arguments": "{\"city\": \"北京\", \"unit\": \"celsius\"}"
}
}
]
}

3. 外部系统执行并返回结果

应用程序拿到调用请求后,实际执行函数,再把结果喂回给 LLM:

1
2
3
4
5
{
"role": "tool",
"tool_call_id": "call_abc123",
"content": "{\"temperature\": 22, \"condition\": \"晴\"}"
}

4. LLM 基于结果生成最终回复

“北京现在 22°C,天气晴朗。”

整个过程中 LLM 本身不执行任何代码,它只负责决定调什么工具、传什么参数,实际执行权在应用层。

MCP(Model Context Protocol)

MCP 是 Anthropic 提出的开放协议,目的是统一 LLM 与外部工具/数据源的连接方式

解决的问题:每个工具提供商都有自己的接入方式,LLM 应用需要为每个工具单独写适配代码。MCP 就像 USB 接口一样,定义了一套标准协议,让任何工具只要实现 MCP Server,就能被任何支持 MCP 的 LLM 客户端调用。

核心架构:

1
LLM 应用(MCP Client)  ←→  MCP Server(工具/数据源)
  • MCP Client:嵌入在 LLM 应用中,负责发现和调用工具
  • MCP Server:封装具体工具的能力,暴露标准化接口

与普通 Function Calling 的区别:

  • Function Calling 是一次性定义好工具列表,写死在代码里
  • MCP 是动态发现,LLM 可以在运行时连接新的工具服务器,获取更多能力

使用技巧

构建 MVP(Minimum Viable Product,最小可行产品)

在构建 Agent 系统时,不要一开始就追求完美,先搭一个最小可行版本:

  • 仅保留最核心功能,剔除所有非必需代码/依赖
  • 能完整跑通主要业务流程,无关键缺失
  • 可直接运行、测试或交付给早期用户/验证想法
  • 常用于敏捷开发、快速原型验证、教学示例或初创项目

先让它跑起来,再让它跑得好。

进行错误审查

当 Agent 输出不符合预期时,不要直接改 prompt 碰运气,而是:

  1. 把 Agent 的中间过程完整打印出来(每一步的输入输出)
  2. 逐步检查,定位到具体是哪一步出了问题
  3. 针对性地修复那一步,而不是全局调整

这和 debug 代码的思路一样:先定位,再修复。

评估优化

为 Agent 的每个模块单独构建评估方法:

  • 搜索模块:返回的结果是否相关?覆盖率如何?
  • 摘要模块:是否丢失关键信息?是否引入幻觉?
  • 决策模块:选择的工具是否合理?参数是否正确?

针对性评估比端到端评估更容易发现问题,也更容易迭代优化。


多智能体

为什么要用多智能体

单个 Agent 处理复杂任务时会遇到瓶颈:

  • 上下文窗口有限:一个 Agent 塞太多职责,prompt 会变得又长又混乱
  • 专业化更高效:就像公司里有不同岗位,让每个 Agent 专注一件事,效果更好
  • 并行处理:多个 Agent 可以同时工作,提高整体速度
  • 易于调试:出问题时能快速定位是哪个 Agent 的责任

类比:一个人既当产品经理又当开发又当测试,不如三个人各司其职。

多智能体的通信模式

1. 顺序传递(Pipeline)

1
Agent A → Agent B → Agent C → 最终输出

每个 Agent 处理完后把结果传给下一个。适合流程明确的任务,比如:研究 → 写作 → 审校。

2. 分层委派(Hierarchical)

1
2
3
      Supervisor Agent
/ | \
Agent A Agent B Agent C

一个主管 Agent 负责分配任务、汇总结果。适合需要协调和决策的场景。

3. 协作讨论(Debate/Discussion)

1
2
Agent A ←→ Agent B ←→ Agent C
(多轮对话)

多个 Agent 互相讨论、质疑、补充,最终达成共识。适合需要多角度分析的复杂问题。

4. 广播模式(Broadcast)

1
Orchestrator → 同时通知所有 Agent → 收集结果 → 合并

适合可以并行处理的独立子任务。


知识图谱

概念

知识图谱解决的核心问题:让 AI 理解实体之间的关系

传统数据库存储的是表格数据(行和列),而知识图谱存储的是实体(节点)和关系(边),形成一张网络。

例:「吴恩达」—[创办]→「DeepLearning.AI」—[提供]→「AI Agent 课程」

为什么 Agent 需要知识图谱

  • LLM 的知识是静态的(训练时截止),知识图谱可以提供实时更新的结构化知识
  • 复杂推理需要多跳关系(A 认识 B,B 在 C 公司工作,所以 A 可能了解 C 公司),图谱天然支持这种查询
  • 减少幻觉:有明确的事实依据,而不是让 LLM 凭记忆回答

模式查询

使用知识图谱时的核心思考框架:

  1. 目标是什么 — 要回答什么问题?需要找到什么实体或关系?
  2. 什么样的数据是有效的 — 图谱中哪些节点和边与问题相关?如何过滤噪声?
  3. 数据被怎样分析 — 用什么查询模式?单跳查询还是多跳推理?是否需要聚合?

常见查询语言:Cypher(Neo4j)、SPARQL(RDF 图谱)

1
2
3
// Cypher 示例:查找吴恩达创办的所有组织
MATCH (p:Person {name: "吴恩达"})-[:FOUNDED]->(org:Organization)
RETURN org.name
0%