wip: 保存 Go 练习和技术笔记
This commit is contained in:
@@ -0,0 +1,286 @@
|
||||
# MySQL 空表 JOIN 导致 ORDER BY 索引失效——实验笔记
|
||||
|
||||
> 环境:MySQL 8.0.18+(hash join 自 8.0.18 引入,实测 8.0.28)
|
||||
> 现象:一条 `ORDER BY ... LIMIT` 查询原本走主表复合索引(免排序),LEFT JOIN 一张**空表**后,执行计划退化为全量读取 + `Using temporary; Using filesort`,主表索引"失效"。
|
||||
|
||||
---
|
||||
|
||||
## 一、一句话结论
|
||||
|
||||
**不是空表禁用了别的表的索引,而是空表让优化器为该 JOIN 选择了 hash join;hash join 不保序,导致"借主表索引序完成 ORDER BY"的整个计划作废**,主表随之退化为全量读取 + 临时表排序。
|
||||
|
||||
---
|
||||
|
||||
## 二、原理
|
||||
|
||||
### 1. 为什么嵌套循环(Nested Loop Join)能"免排序"
|
||||
|
||||
```
|
||||
按 idx(created_at, id) 倒序逐行读主表 → 每行对被 join 表做索引点查(eq_ref)→ 输出
|
||||
```
|
||||
|
||||
- 嵌套循环的输出顺序 = 驱动表(主表)的访问顺序
|
||||
- 主表走 `(created_at, id)` 复合索引倒序扫描,输出天然满足 `ORDER BY created_at DESC, id DESC`
|
||||
- `LIMIT 10` 可以提前终止:读到 10 行即停,`ORDER BY` 零成本(EXPLAIN 中体现为 `type=index` + `Backward index scan`,无 temporary/filesort)
|
||||
|
||||
### 2. hash join 为什么会破坏这一切
|
||||
|
||||
- hash join 把被 join 表整棵读进内存哈希表,再用驱动表逐行**探测**,输出顺序不再对应任何一方的索引序
|
||||
- 一旦计划中任何一个 JOIN 环节是 hash join,结果集都必须整体重排
|
||||
- 优化器权衡:"反正要排序,主表走哪个访问路径都无所谓" → 放弃排序专用索引,选最便宜的读取方式(全表扫或全索引扫,见第四节变体)
|
||||
|
||||
### 3. 为什么偏偏是空表触发 hash join(反直觉)
|
||||
|
||||
- 空表没有统计信息,优化器估算行数 ≈ 1
|
||||
- 估算"把空表整棵读进哈希表(≈1 次 IO)再探测" 比 "对主表每行做一次 B+ 树点查(N 次树遍历)" 更便宜
|
||||
- 于是选了 hash join —— 对空表本身这是**正确的成本决策**(0 行数据怎么访问都对),副作用是拖垮了主表的排序计划
|
||||
- 表有了真实数据后,统计信息更新,优化器改回索引点查(eq_ref),保序计划自动恢复
|
||||
|
||||
---
|
||||
|
||||
## 三、建表与造数(可直接执行)
|
||||
|
||||
### 1. 主表:10 万行,带 `(created_at, id)` 复合索引
|
||||
|
||||
```sql
|
||||
DROP TABLE IF EXISTS users, user_profiles, user_tags_empty, user_tags_noindex;
|
||||
|
||||
CREATE TABLE users (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
username VARCHAR(64) NOT NULL,
|
||||
nickname VARCHAR(64) NOT NULL DEFAULT '',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
KEY idx_created_id (created_at, id)
|
||||
) ENGINE=InnoDB;
|
||||
|
||||
-- 递归 CTE 造数需要先放开深度限制(仅当前会话)
|
||||
SET SESSION cte_max_recursion_depth = 100001;
|
||||
|
||||
INSERT INTO users (username, nickname, created_at)
|
||||
WITH RECURSIVE seq AS (
|
||||
SELECT 1 AS n
|
||||
UNION ALL
|
||||
SELECT n + 1 FROM seq WHERE n < 100000
|
||||
)
|
||||
SELECT
|
||||
CONCAT('user_', n),
|
||||
CONCAT('nick_', n),
|
||||
DATE_ADD('2025-01-01 00:00:00', INTERVAL FLOOR(RAND() * 31536000) SECOND)
|
||||
FROM seq;
|
||||
```
|
||||
|
||||
### 2. 对照用被 join 表:有数据,join 列唯一索引
|
||||
|
||||
```sql
|
||||
CREATE TABLE user_profiles (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id BIGINT UNSIGNED NOT NULL,
|
||||
city VARCHAR(32) NOT NULL DEFAULT '',
|
||||
UNIQUE KEY uk_user_id (user_id)
|
||||
) ENGINE=InnoDB;
|
||||
|
||||
INSERT INTO user_profiles (user_id, city)
|
||||
WITH RECURSIVE seq AS (
|
||||
SELECT 1 AS n
|
||||
UNION ALL
|
||||
SELECT n + 1 FROM seq WHERE n < 100000
|
||||
)
|
||||
SELECT n, CONCAT('city_', FLOOR(RAND() * 100)) FROM seq;
|
||||
```
|
||||
|
||||
### 3. "罪魁"空表:结构与 user_profiles 完全同构,只是**不插入任何数据**
|
||||
|
||||
```sql
|
||||
CREATE TABLE user_tags_empty (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id BIGINT UNSIGNED NOT NULL,
|
||||
tag VARCHAR(32) NOT NULL DEFAULT '',
|
||||
UNIQUE KEY uk_user_id (user_id)
|
||||
) ENGINE=InnoDB;
|
||||
-- 故意不插数据
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、对照实验
|
||||
|
||||
> 每组实验观察四处:主表 users 的 `type` / `key` / `rows` / `Extra`,以及被 join 表的访问方式。
|
||||
> **注意**:实验 SQL 故意 SELECT 了 `username`/`nickname` 等**索引外列**,原因见实验二的"变体"说明。
|
||||
|
||||
### 实验一(对照组):只 JOIN 有数据的表 → 索引完美生效
|
||||
|
||||
```sql
|
||||
EXPLAIN
|
||||
SELECT u.id, u.username, u.nickname, u.created_at, p.city
|
||||
FROM users u
|
||||
LEFT JOIN user_profiles p ON p.user_id = u.id
|
||||
ORDER BY u.created_at DESC, u.id DESC
|
||||
LIMIT 10;
|
||||
```
|
||||
|
||||
**预期结果:**
|
||||
|
||||
| table | type | key | rows | Extra |
|
||||
|---|---|---|---|---|
|
||||
| u | **index** | **idx_created_id** | **10** | **Backward index scan** |
|
||||
| p | eq_ref | uk_user_id | 1 | (空) |
|
||||
|
||||
主表倒序走复合索引只读 10 行(LIMIT 提前终止),被 join 表逐行点查,无排序开销。保序计划下 SELECT 索引外列不影响走索引(逐行回表而已)。
|
||||
|
||||
### 实验二:仅追加一个 LEFT JOIN 空表 → 计划全面崩坏
|
||||
|
||||
```sql
|
||||
EXPLAIN
|
||||
SELECT u.id, u.username, u.nickname, u.created_at, p.city, t.tag
|
||||
FROM users u
|
||||
LEFT JOIN user_profiles p ON p.user_id = u.id
|
||||
LEFT JOIN user_tags_empty t ON t.user_id = u.id
|
||||
ORDER BY u.created_at DESC, u.id DESC
|
||||
LIMIT 10;
|
||||
```
|
||||
|
||||
**预期结果(SELECT 含索引外列时):**
|
||||
|
||||
| table | type | key | rows | Extra |
|
||||
|---|---|---|---|---|
|
||||
| u | **ALL** | NULL | **100003** | **Using temporary; Using filesort** |
|
||||
| p | eq_ref | uk_user_id | 1 | (空) |
|
||||
| t | **ALL** | NULL | 1 | **Using where; Using join buffer (hash join)** |
|
||||
|
||||
三个观察点:
|
||||
|
||||
1. 空表 t 的索引 `uk_user_id` 明明存在,却 `type=ALL` + hash join(空表上这是合理的成本选择)
|
||||
2. 主表 u 的 `idx_created_id` 被**整个放弃**:全表扫描 + 临时表 + 文件排序,rows 从 10 涨到全量(LIMIT 提前终止失效)
|
||||
3. 注意 p 的 eq_ref 没有受影响——**别的表的索引并没有被禁用,坏掉的是主表的"排序专用访问路径"**
|
||||
|
||||
**变体:主表退化的两种形态(重要,实测踩过的坑)**
|
||||
|
||||
如果 SELECT **只查索引内的列**(如仅 `u.id, u.created_at`),主表退化后不是 `ALL`,而是:
|
||||
|
||||
```
|
||||
u: type=index, key=idx_created_id, rows=100003, Extra: Using index; Using temporary; Using filesort
|
||||
```
|
||||
|
||||
机制:排序计划作废后优化器选"最便宜的读取方式"——查询列全在索引里时,扫**窄的二级索引**(覆盖扫描,`Using index` 免回表)比扫**宽的聚簇索引**(ALL)更便宜。`type=index` 全索引扫描与 `ALL` 全表扫本质相同:**都是全量读取 + 显式排序**,同样没有 LIMIT 提前终止。
|
||||
|
||||
判断"是否退化"的可靠标志是 `rows` 是否涨到全量、`Using temporary; Using filesort` 是否出现,而**不是** type 显示 ALL 还是 index。生产查询通常 SELECT 大量业务列(覆盖不成立),多表现为 ALL 形态;本实验因此故意带上索引外列。
|
||||
|
||||
> 加 `FORMAT=TREE` 可以更直观看到 `Left (hash join)` 节点:
|
||||
> `EXPLAIN FORMAT=TREE <实验二的查询>;`
|
||||
|
||||
### 实验三:FORCE INDEX 把空表钉回点查 → 计划恢复
|
||||
|
||||
```sql
|
||||
EXPLAIN
|
||||
SELECT u.id, u.username, u.nickname, u.created_at, p.city, t.tag
|
||||
FROM users u
|
||||
LEFT JOIN user_profiles p ON p.user_id = u.id
|
||||
LEFT JOIN user_tags_empty t FORCE INDEX (uk_user_id) ON t.user_id = u.id
|
||||
ORDER BY u.created_at DESC, u.id DESC
|
||||
LIMIT 10;
|
||||
```
|
||||
|
||||
**预期结果:** t 变为 `eq_ref`,主表 u 恢复 `index + Backward index scan, rows=10`,temporary/filesort 消失。
|
||||
|
||||
结论:只要所有 JOIN 都是保序的嵌套循环点查,主表就能借索引序免排序。
|
||||
|
||||
### 实验四:空表填入数据 + 刷新统计信息 → 自愈
|
||||
|
||||
```sql
|
||||
INSERT INTO user_tags_empty (user_id, tag)
|
||||
WITH RECURSIVE seq AS (
|
||||
SELECT 1 AS n
|
||||
UNION ALL
|
||||
SELECT n + 1 FROM seq WHERE n < 100000
|
||||
)
|
||||
SELECT n, 'vip' FROM seq;
|
||||
|
||||
ANALYZE TABLE user_tags_empty;
|
||||
|
||||
-- 重跑实验二的 EXPLAIN
|
||||
```
|
||||
|
||||
**预期结果:** 与实验一/三相同,t 走 `eq_ref`,主表走 `idx_created_id`,无需任何 hint。
|
||||
|
||||
补充观察:只插入少量行(比如 50 行)时优化器可能仍选 hash join——对几十行的表,hash join 确实可能更便宜,这属于**合理决策**而非问题;关注点应是"数据量与计划是否匹配",而不是"必须永远走索引"。
|
||||
|
||||
### 进阶实验五:与空无关——任何原因导致的 hash join 都会触发同样的退化
|
||||
|
||||
证明"根因是 hash join 破坏保序计划,空表只是诱因之一":
|
||||
|
||||
```sql
|
||||
-- 有 10 万行、但 join 列上【没有索引】的表
|
||||
CREATE TABLE user_tags_noindex (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id BIGINT UNSIGNED NOT NULL,
|
||||
tag VARCHAR(32) NOT NULL DEFAULT ''
|
||||
) ENGINE=InnoDB;
|
||||
|
||||
INSERT INTO user_tags_noindex (user_id, tag)
|
||||
WITH RECURSIVE seq AS (
|
||||
SELECT 1 AS n
|
||||
UNION ALL
|
||||
SELECT n + 1 FROM seq WHERE n < 100000
|
||||
)
|
||||
SELECT n, 'new' FROM seq;
|
||||
|
||||
EXPLAIN
|
||||
SELECT u.id, u.username, u.nickname, u.created_at, p.city, t.tag
|
||||
FROM users u
|
||||
LEFT JOIN user_profiles p ON p.user_id = u.id
|
||||
LEFT JOIN user_tags_noindex t ON t.user_id = u.id
|
||||
ORDER BY u.created_at DESC, u.id DESC
|
||||
LIMIT 10;
|
||||
```
|
||||
|
||||
**预期结果:** 与实验二相同的崩坏形态(t 为 `ALL + hash join`,主表全量读取 + temporary + filesort)。
|
||||
|
||||
这说明日常更常见的场景——**join 列漏建索引**——和空表是同一个病:hash join 一出现,依赖主表索引序的 ORDER BY + LIMIT 计划就没了。
|
||||
|
||||
---
|
||||
|
||||
## 五、机制小结(对照表)
|
||||
|
||||
| JOIN 实现方式 | 输出是否保序 | LIMIT 能否提前终止 | 对主表排序索引的影响 |
|
||||
|---|---|---|---|
|
||||
| 嵌套循环 + eq_ref 点查 | ✅ 顺序=驱动表访问序 | ✅ 读够即停 | 可借索引序免排序 |
|
||||
| hash join | ❌ 哈希探测序 | ❌ 需物化后整体排序 | 排序计划作废,主表退化为全量读取 |
|
||||
|
||||
主表退化后的具体形态由 SELECT 列集合决定:
|
||||
|
||||
| SELECT 列 | 退化形态 | 特征 |
|
||||
|---|---|---|
|
||||
| 含索引外列(生产常态) | `type=ALL` 全表扫 | key=NULL |
|
||||
| 全在索引内 | `type=index` 全索引扫 | key=该索引,`Using index`(覆盖免回表) |
|
||||
|
||||
两种形态共同点:rows ≈ 全量、`Using temporary; Using filesort`、LIMIT 无法提前终止。
|
||||
|
||||
优化器的行为链条:
|
||||
|
||||
```
|
||||
被 join 表选了 hash join
|
||||
→ 结果集顺序不可保证
|
||||
→ ORDER BY 必须显式排序(temporary + filesort)
|
||||
→ "主表走排序专用索引"失去意义
|
||||
→ 主表改用最便宜的读取方式(ALL 或覆盖索引全扫)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、实践建议
|
||||
|
||||
1. **排查口诀**:`ORDER BY ... LIMIT` 查询变慢时,先看 EXPLAIN 里是否出现 `Using join buffer (hash join)`——它常常是主表索引"无故失效"的信号
|
||||
2. **判断退化看 rows 和 temporary/filesort**,不要只盯 type:`type=index` 也可能是全索引扫描的退化形态(对比正常时 rows 应接近 LIMIT 值)
|
||||
3. **根治**:给被 join 表的 join 列建索引(唯一列建唯一索引,非唯一建普通索引),让点查比 hash join 更便宜
|
||||
4. **空表/新表场景**(刚上线、还没数据的表):如果线上已出现该退化且影响明显,可用 `FORCE INDEX` 过渡;表有数据后可移除 hint(低频后台查询甚至可以不处理)
|
||||
5. **不要盲信"必须走索引"**:空表/极小表走全表扫本身是正确的成本决策,要修的是"hash join 破坏排序计划"这个副作用,而不是全表扫本身
|
||||
6. **改动 join 结构后**(新增关联表、调整关联条件)重新 EXPLAIN 一次带排序的查询,确认 `Backward index scan` / rows 接近 LIMIT 值的计划仍在
|
||||
|
||||
---
|
||||
|
||||
## 七、清理
|
||||
|
||||
```sql
|
||||
DROP TABLE IF EXISTS users, user_profiles, user_tags_empty, user_tags_noindex;
|
||||
SET SESSION cte_max_recursion_depth = DEFAULT;
|
||||
```
|
||||
@@ -1,4 +1,6 @@
|
||||
## `errors.AsType`
|
||||
## `errors`
|
||||
|
||||
### `errors.AsType`
|
||||
|
||||
> *用泛型把* `errors.As` *的“先声明变量再传指针”简化为“直接返回* `(E, bool)`*”,是* `errors.As` *的语法糖升级*
|
||||
|
||||
@@ -20,3 +22,6 @@ if mysqlErr, ok := errors.AsType[*mysql.MySQLError](err); ok {
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
|
||||
## `net/http/pprof`
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
## 1.27版本变化
|
||||
|
||||
### 支持泛型方法
|
||||
|
||||
Go 1.27 虽然开放了泛型方法,但有明确的限制:
|
||||
|
||||
1.接口定义中不允许包含泛型方法。
|
||||
|
||||
2.拥有泛型方法的方法,不能用于实现接口。
|
||||
|
||||
```go
|
||||
// 普通结构体,本身没有任何泛型参数
|
||||
type Serializer struct{}
|
||||
|
||||
// T 属于方法 Serialize,每个调用可以独立拥有不同的 T
|
||||
func (s *Serializer) Serialize[T any](data T) string {
|
||||
return fmt.Sprintf("%v", data)
|
||||
}
|
||||
|
||||
// 使用:
|
||||
sz := Serializer{}
|
||||
|
||||
// 同一个 sz 实例,每次调用都可以处理完全不同的类型!
|
||||
str1 := sz.Serialize("hello") // 自动推导 T = string
|
||||
str2 := sz.Serialize(123) // 自动推导 T = int
|
||||
str3 := sz.Serialize(true) // 自动推导 T = bool
|
||||
```
|
||||
|
||||
### 结构体字面量:字段选择器当 key
|
||||
|
||||
嵌套或匿名结构体中的字段,现在可以直接初始化,不必再一层层嵌套字面量
|
||||
|
||||
```go
|
||||
type Person struct {
|
||||
Sex string
|
||||
Name string
|
||||
}
|
||||
type Student struct {
|
||||
StudentID int
|
||||
Person
|
||||
}
|
||||
|
||||
// Go 1.27 之前:必须显式嵌套 Person
|
||||
s1 := Student{
|
||||
StudentID: 1,
|
||||
Person: Person{
|
||||
Sex: "male",
|
||||
Name: "张三",
|
||||
},
|
||||
}
|
||||
// Go 1.27:嵌套结构体Person的字段可以直接当 key 用
|
||||
s1 := Student{
|
||||
StudentID: 1,
|
||||
Sex: "male",
|
||||
Name: "张三",
|
||||
}
|
||||
```
|
||||
|
||||
### 函数类型推断:从“手动挡”到“自动挡”
|
||||
|
||||
```go
|
||||
// Go 1.27 之前:必须手动写出类型实参 [int],否则编译报错
|
||||
fn := IntToString(FormatToString[int])
|
||||
formatter:= []IntToString{FormatToString[int]}
|
||||
|
||||
// Go 1.27 后:自动推导,不用手写
|
||||
fn := IntToString(FormatToString)
|
||||
formatter := []IntToString{FormatToString}
|
||||
```
|
||||
|
||||
### 标准库更新:`encoding/json/v2`
|
||||
|
||||
> Go 1.27 标准库中的 v1 `encoding/json` 内部已经被改用 v2 引擎驱动,保留了 v1 的原始 API 签名和行为
|
||||
|
||||
新旧版本主要行为对比
|
||||
|
||||
| **场景** | **encoding/json (v1)** | **encoding/json/v2** |
|
||||
| ------------------------------- | -------------------------- | -------------------------------------------- |
|
||||
| **`nil` 切片/Map 序列化** | 转为 `null` | 转为 `[]` 和 `{}` *(符合语义,防前端空指针)* |
|
||||
| **重复键处理 (Duplicate Keys)** | 允许,后面的覆盖前面的 | **直接报错** *(增强安全性,防 JSON 注入)* |
|
||||
| **非法 UTF-8 字符** | 格式化替换,静默忽略 | **直接报错** *(要求严格符合规范)* |
|
||||
| **字段名匹配 (大小写敏感)** | 默认**忽略**大小写匹配 | 默认**严格匹配**大小写 *(提升解析性能)* |
|
||||
| **固定长度数组解包** | 长度不一致时自动截断或补零 | 必须与 JSON 数组长度严格相等,否则报错 |
|
||||
|
||||
支持可选项
|
||||
|
||||
| Options | 作用 |
|
||||
| -------------------------------------- | ----------------------- |
|
||||
| `json.FormatNilSliceAsNull(true)` | 允许使用 v1 的 nil 行为 |
|
||||
| `json.MatchCaseInsensitiveNames(true)` | 允许忽略大小写 |
|
||||
|
||||
新增标签`omitzero`
|
||||
|
||||
| **维度** | **omitempty** | **omitzero** |
|
||||
| ----------------------- | ------------------------------------------------------------ | ------------------------------------------------------------ |
|
||||
| **判断标准** | **是否为“空 (Empty)”** (如:空字符串 `""`、长度为 0 的切片/Map、`false`、`0`、指针 `nil`) | **是否为“ Go 类型的零值 (Zero Value)”** (满足 `reflect.Value.IsZero()` 或实现 `IsZero() bool` 接口) |
|
||||
| **结构体/时间类型处理** | **有重大缺陷**,对非指针的结构体(如 `time.Time`)无效!依然会被序列化。 | **完美支持** 未初始化的 `time.Time{}` 或任何嵌套结构体零值均会被正确跳过。 |
|
||||
| **自定义类型零值** | 不支持自定义零值判断逻辑。 | 支持只要实现 `IsZero() bool` 方法即可自定义是否跳过。 |
|
||||
|
||||
|
||||
|
||||
## 官方扩展库 `bcrypt`
|
||||
|
||||
### `bcrypt` 是什么
|
||||
|
||||
`bcrypt` 是专门用于**密码哈希**的算法,不是可逆加密。
|
||||
|
||||
```text
|
||||
password
|
||||
↓
|
||||
bcrypt
|
||||
↓
|
||||
hash
|
||||
```
|
||||
|
||||
#### 核心原则
|
||||
|
||||
```text
|
||||
数据库只存 hash
|
||||
bcrypt 自动处理 salt
|
||||
使用 CompareHashAndPassword 验证
|
||||
cost 根据服务器性能决定
|
||||
不要自己用 MD5 / SHA256 存密码
|
||||
```
|
||||
|
||||
------
|
||||
|
||||
#### cost
|
||||
|
||||
`cost` 是 bcrypt 的计算难度。
|
||||
|
||||
生产环境通常可以从 `10 ~ 12` 开始测试,具体值应根据服务器性能决定。单次 bcrypt 计算控制在大约 `100ms ~ 300ms` 是比较常见的工程范围。
|
||||
|
||||
`cost` 每增加 1,计算量大约接近翻倍。
|
||||
|
||||
cost 越高:
|
||||
|
||||
- 计算越慢
|
||||
- 暴力破解成本越高
|
||||
- 服务器 CPU 消耗也越高
|
||||
|
||||
bcrypt 生成的 hash 本身会记录当时使用的 `cost`。
|
||||
|
||||
因此,即使生产环境将当前配置从:
|
||||
|
||||
```text
|
||||
cost = 10
|
||||
```
|
||||
|
||||
升级为:
|
||||
|
||||
```text
|
||||
cost = 12
|
||||
```
|
||||
|
||||
数据库中原本使用 `cost = 10` 生成的旧 hash,仍然可以通过:
|
||||
|
||||
```go
|
||||
bcrypt.CompareHashAndPassword(...)
|
||||
```
|
||||
|
||||
正常验证。
|
||||
|
||||
`CompareHashAndPassword` 会根据已有 hash 中记录的信息进行密码验证,不依赖当前系统配置的 cost。
|
||||
|
||||
登录成功后,可以通过 `bcrypt.Cost()` 获取旧 hash 使用的 cost。如果低于当前配置,可以重新生成 hash 并更新数据库。
|
||||
|
||||
------
|
||||
|
||||
#### 72-byte 限制
|
||||
|
||||
bcrypt 密码输入最多支持约 `72 bytes`。
|
||||
|
||||
如果只允许 ASCII:
|
||||
|
||||
```text
|
||||
72 bytes ≈ 72 个字符
|
||||
```
|
||||
|
||||
通常已经足够。
|
||||
|
||||
建议业务层主动限制:
|
||||
|
||||
```go
|
||||
if len([]byte(password)) > 72 {
|
||||
return errors.New("password too long")
|
||||
}
|
||||
```
|
||||
|
||||
------
|
||||
|
||||
### 基础使用
|
||||
|
||||
#### 依赖导入
|
||||
|
||||
```go
|
||||
import "golang.org/x/crypto/bcrypt"
|
||||
```
|
||||
|
||||
`golang.org/x/crypto/bcrypt` 属于 Go 官方维护的扩展库,但不属于 Go 标准库。
|
||||
|
||||
------
|
||||
|
||||
#### 生成 hash
|
||||
|
||||
```go
|
||||
hash, err := bcrypt.GenerateFromPassword(
|
||||
[]byte(password),
|
||||
12,
|
||||
)
|
||||
```
|
||||
|
||||
生成的 hash 类似:
|
||||
|
||||
```text
|
||||
$2a$12$xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
```
|
||||
|
||||
其中大致包含:
|
||||
|
||||
```text
|
||||
$2a$ → bcrypt 版本
|
||||
12$ → cost
|
||||
后续内容 → 编码后的 salt + hash
|
||||
```
|
||||
|
||||
bcrypt 会自动生成随机 salt,因此不需要自己生成或额外保存 salt。
|
||||
|
||||
------
|
||||
|
||||
#### 验证密码
|
||||
|
||||
```go
|
||||
err := bcrypt.CompareHashAndPassword(
|
||||
[]byte(passwordHash),
|
||||
[]byte(password),
|
||||
)
|
||||
|
||||
if err == nil {
|
||||
// 密码正确
|
||||
}
|
||||
```
|
||||
|
||||
------
|
||||
|
||||
#### 判断密码是否相同
|
||||
|
||||
不能直接比较两个 bcrypt hash,因为 bcrypt 每次都会生成新的随机 salt。
|
||||
|
||||
即使密码完全相同:
|
||||
|
||||
```text
|
||||
hash1 != hash2
|
||||
```
|
||||
|
||||
判断某个密码是否和旧密码相同时,应使用:
|
||||
|
||||
```go
|
||||
err := bcrypt.CompareHashAndPassword(
|
||||
[]byte(oldHash),
|
||||
[]byte(password),
|
||||
)
|
||||
|
||||
if err == nil {
|
||||
// password 与 oldHash 对应的密码相同
|
||||
}
|
||||
```
|
||||
|
||||
------
|
||||
|
||||
#### 获取 hash 的 cost
|
||||
|
||||
```go
|
||||
cost, err := bcrypt.Cost(
|
||||
[]byte(user.PasswordHash),
|
||||
)
|
||||
```
|
||||
|
||||
通常用于**用户登录成功后**,判断当前密码 hash 是否使用了旧的 cost。
|
||||
|
||||
例如:
|
||||
|
||||
```text
|
||||
数据库 hash cost = 10
|
||||
当前系统 cost = 12
|
||||
↓
|
||||
登录成功
|
||||
↓
|
||||
重新生成 cost = 12 的 hash
|
||||
↓
|
||||
更新数据库
|
||||
```
|
||||
|
||||
这样可以在用户正常登录过程中逐步升级旧密码 hash。
|
||||
@@ -0,0 +1,45 @@
|
||||
# squirrel 的坑
|
||||
|
||||
> `github.com/Masterminds/squirrel`,纯 SQL 查询构建器(query builder),2026-09 查证。
|
||||
|
||||
## 坑 1:Like 家族多 key 不排序,SQL 输出不确定(真雷)
|
||||
|
||||
`Eq` / `NotEq` / `Lt` / `Gt` 等 map 条件生成 SQL 前会对 key 做 `sort.Strings` 字典序排序(v1.1.0 起,`expr.go` 的 `getSortedKeys`),输出确定。
|
||||
|
||||
但 `Like` / `NotLike` / `ILike` / `NotILike` 的 `toSql` 是直接 `for key, val := range lk`——跟随 Go map 随机遍历序,**多 key 时每次生成的 SQL 条件顺序随机**:
|
||||
|
||||
```
|
||||
第1次: WHERE nickname LIKE ? AND mobile LIKE ? | args=[%张% %138%]
|
||||
第3次: WHERE mobile LIKE ? AND nickname LIKE ? | args=[%138% %张%] ← 顺序翻转
|
||||
```
|
||||
|
||||
### 后果
|
||||
|
||||
- 查询结果没错(AND 交换律),但 **SQL 字符串 + args 顺序在变**
|
||||
- 如果用「SQL 字符串 + 参数」生成缓存 key(hash 后当 Redis key 之类),同一查询会打出多个 key:
|
||||
- 命中率暴跌:n 个 Like 条件最多 n! 种排列 → n! 个缓存 key
|
||||
- 同一份数据存多份副本,TTL 内占内存
|
||||
- 依赖 SQL diff 的单测会随机失败
|
||||
|
||||
### 规避
|
||||
|
||||
- `Like` 只放**单个 key**
|
||||
- 多模糊条件拆成多次 `.Where(...)`(内部是 append 切片,天然保序)或用 `sq.And{...}` 显式控制顺序
|
||||
|
||||
### 上游状态
|
||||
|
||||
- 无任何 issue 报告过此事(全量搜索过,含 closed),作者从未回应
|
||||
- 当年修 Eq 排序的 PR #141 只改了 Eq/Lt 两处,紧邻的 Like 一行未动,讨论里也没人发现——纯遗漏,非有意设计(作者在 #135 明确说过支持排序)
|
||||
- 库近乎停滞:最新 release v1.5.4(2023-03),master 最后 commit 2024-02,**别指望上游修**
|
||||
|
||||
## 坑 2:Eq 排序按字典序,与联合索引列序无关(知道即可)
|
||||
|
||||
`Eq{"b":2, "a":1}` 永远生成 `WHERE a = ? AND b = ?`,与你书写的顺序无关(issue #321)。
|
||||
|
||||
对主流数据库**基本无影响**:MySQL / PostgreSQL / SQL Server / Oracle 的 CBO 都会重排谓词,索引选择看的是「条件覆盖了哪些列(最左前缀)、选择度、类型是否隐式转换」,不是 WHERE 里的书写顺序。只有对谓词顺序敏感的引擎/老优化器才会踩到。
|
||||
|
||||
## 顺带:这个库的亮点
|
||||
|
||||
- builder 是**值语义不可变**的(基于 lann/builder 持久化数据结构),每次链式调用返回新副本,可安全分叉复用,不像 GORM 那样共享 Statement、分叉前要 `Session()` 克隆
|
||||
- `Eq` 家族的确定性输出有源码保证(`expr.go` 排序逻辑),可放心用于缓存 key 场景
|
||||
- 子查询:`FromSelect(builder, alias)`,或任何 builder 作为 `Sqlizer` 嵌进 `Where/Having/Join`
|
||||
Reference in New Issue
Block a user