Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions notes/HTTP.md
Original file line number Diff line number Diff line change
Expand Up @@ -532,7 +532,11 @@ JWT 与 Session 的区别:

要求更高时可以采用面向前端的后端(Backend for Frontend, BFF)模式:令牌只保存在后端,浏览器只持有会话 Cookie,由后端代为调用接口。RFC 10017 对业务应用和处理个人数据的应用强烈推荐这种做法。

需要注意的是,服务器验证时要固定使用的签名算法,不能接受头部中 alg 为 none 的令牌;HMAC 的密钥要随机生成,长度不少于 256 位。签名也不能防止窃听和重放,令牌被截获后,在过期前任何人都能拿它冒充用户,所以必须通过 HTTPS 传输;签名只证明令牌是服务器签发的,服务器仍要检查令牌中的用户是否有权访问所请求的资源。
需要注意的是,服务器要事先配置允许的签名算法,不能只凭令牌头部的 alg 决定用哪种算法验证,每个密钥只用于一种算法,并检查 alg 与之一致(RFC 8725)。头部由客户端提供:验证端无条件接受 alg 为 none 时,攻击者可以绕过签名验证;验证端还会把 RSA 公钥误当作 HMAC 的密钥时,攻击者可以把 RS256 改成 HS256,用公开的公钥伪造签名。

HMAC 的密钥要随机生成,长度不少于 256 位。

签名也不能防止窃听和重放,令牌被截获后,在过期前任何人都能拿它冒充用户,所以必须通过 HTTPS 传输;签名只证明令牌是服务器签发的,服务器仍要检查令牌中的用户是否有权访问所请求的资源。

JWT 只是令牌的格式,而 OAuth 2.0 是授权框架,规定第三方应用如何获得访问令牌,这个访问令牌可以用 JWT 表示。

Expand Down Expand Up @@ -574,7 +578,7 @@ grant_type=authorization_code&code=8XNe0QukdUBz_2wmZ34JLA
- state:笔记网站生成的随机值,回调时检查是否与发出的一致,防止攻击者用自己账号的授权码构造回调地址,让用户登录到攻击者的账号上,这是一种 [CSRF](攻击技术.md#二跨站请求伪造);
- access_token:访问令牌,由笔记网站的后端直接向授权服务器换取,不经过浏览器。

早期还有一种隐式模式,授权服务器直接把访问令牌放在重定向的地址中返回,令牌会出现在浏览器历史记录和日志中。RFC 9700(OAuth 2.0 安全最佳实践)规定不应该再使用隐式模式,没有能力保存密钥的客户端(例如单页应用、手机 App)必须使用 PKCE。
早期还有一种隐式模式,授权服务器直接把访问令牌放在重定向的地址中返回,令牌会出现在浏览器历史记录和日志中。RFC 9700(OAuth 2.0 安全最佳实践)建议不再使用隐式模式(除非已防止授权响应中的访问令牌被注入,并缓解令牌泄露的风险),没有能力保存密钥的客户端(例如单页应用、手机 App)必须使用 PKCE。

OAuth 2.0 本身只负责授权,访问令牌表示“可以读取昵称”,并不说明当前用户是谁。OpenID Connect 在它的基础上增加了 ID Token,这是一个包含用户 ID 的 JWT,专门用于登录,很多“第三方账号登录”使用的就是它。

Expand Down Expand Up @@ -994,6 +998,10 @@ HTTPS 只防网络中的窃听、冒充服务器和篡改。SQL 注入、XSS、C

非对称密钥除了用来加密,还可以用来进行签名。因为私有密钥无法被其他人获取,因此通信发送方使用其私有密钥进行签名,通信接收方使用发送方的公开密钥对签名进行验证,就能判断这个签名是否正确。

签名常被说成“私钥加密、公钥解密”,这并不准确。RSA 签名与解密用的是相同的底层私钥运算,但编码和处理流程不同:对 PKCS#1 v1.5 的签名做一次公钥运算,可以从得到的编码块中解析出哈希算法的标识和消息摘要;RSA-PSS 的签名没有这种结构。

Ed25519 这类签名算法只能用公钥、消息和签名一起验证,不提供从签名中恢复消息或摘要的操作。

- 优点:公开密钥可以公开传输,被截获也无法用来解密;
- 缺点:运算速度慢。

Expand Down Expand Up @@ -1214,7 +1222,7 @@ GET 用于获取资源,而 POST 用于传输实体主体。

### 参数

GET 和 POST 的请求都能使用额外的参数,但是 GET 的参数是以查询字符串出现在 URL 中,而 POST 的参数存储在实体主体中。不能因为 POST 参数存储在实体主体中就认为它的安全性更高,因为照样可以通过一些抓包工具(Fiddler)查看。
GET 和 POST 的请求都能使用额外的参数,但是 GET 的参数是以查询字符串出现在 URL 中,而 POST 的参数存储在实体主体中。不能因为 POST 参数存储在实体主体中就认为它的安全性更高,因为照样可以通过一些抓包工具(Fiddler)查看。不过 URL 可能被保存在浏览器的历史记录、书签以及服务器和代理的日志中,还可能通过 Referer 首部泄露给其它网站,所以密码这类敏感数据不放在 URL 中(RFC 9110 §17.9);防窃听则要靠 HTTPS。

因为 URL 只支持 ASCII 码,因此 GET 的参数中如果存在中文等字符就需要先进行编码。例如 `中文` 会转换为 `%E4%B8%AD%E6%96%87`,而空格会转换为 `%20`。表单采用 application/x-www-form-urlencoded 编码时(GET 提交的表单和 POST 的默认方式),规则相同,只是空格写成 `+`;POST 请求体的编码由 Content-Type 决定,JSON 则直接用 UTF-8。

Expand Down Expand Up @@ -1791,6 +1799,7 @@ AES-128-GCM 6280411.97k
- [wikipedia: URL](https://en.wikipedia.org/wiki/URL)
- [RFC 9110:4.2.1 http URI Scheme](https://www.rfc-editor.org/rfc/rfc9110#section-4.2.1)
- [RFC 9110:9 Methods](https://www.rfc-editor.org/rfc/rfc9110#section-9)
- [RFC 8725: JSON Web Token Best Current Practices](https://www.rfc-editor.org/rfc/rfc8725)
- [RFC 5246: The Transport Layer Security (TLS) Protocol Version 1.2](https://www.rfc-editor.org/rfc/rfc5246)
- [RFC 10015: Deprecating Obsolete Key Exchange Methods in TLS 1.2 and DTLS 1.2](https://www.rfc-editor.org/rfc/rfc10015)
- [RFC 7568: Deprecating Secure Sockets Layer Version 3.0](https://www.rfc-editor.org/rfc/rfc7568)
Expand Down
4 changes: 2 additions & 2 deletions notes/Redis.md
Original file line number Diff line number Diff line change
Expand Up @@ -598,7 +598,7 @@ QUEUED
3) OK
```

命令入队时就能发现的错误(例如命令不存在),EXEC 会放弃整个事务,返回 EXECABORT。
命令入队时就能发现的错误(例如命令不存在、参数个数不对,或者内存超过 maxmemory、无法通过淘汰释放足够的空间时,SET 这类可能增加内存的命令被拒绝),EXEC 会放弃整个事务,返回 EXECABORT(Redis 7.4 实测)。

WATCH 可以实现乐观锁:从 WATCH 到 EXEC 之间被 WATCH 的键被修改,EXEC 返回 nil,事务中的命令都不执行,由客户端决定是否重试。这里的修改不只是其它客户端的写入,写入相同的值、本客户端在 MULTI 之前的写入、键过期也算(Redis 7.4 实测);EXEC 之后无论成败,WATCH 都会取消。

Expand Down Expand Up @@ -706,7 +706,7 @@ Sentinel(哨兵)可以监听集群中的服务器,并在主服务器进入

down-after-milliseconds 为 5 秒时,从主服务器宕机到客户端查到新地址约 6 秒,其中 5 秒是超时检测。新的主服务器保留了旧的复制 ID,另一个从服务器转向它时只需要部分重同步,只发送了 156 字节的积压数据;重启的旧主服务器复制 ID 不匹配,只能全量复制(Redis 7.4 实测,见[附录 A5](#a5-sentinel-故障转移))。

需要注意的是,主从之间是异步复制,切换时还没有复制到从服务器的写入会丢失。网络分区时,旧的主服务器还可能继续接受写入(脑裂),恢复后它变为从服务器,这段时间的写入也会丢失。分布式锁同样会因此丢失,见 [Redis 的 SET NX 指令](分布式.md#redis-的-set-nx-指令)。
需要注意的是,主从之间是异步复制,切换时还没有复制到从服务器的写入会丢失。网络分区时,没有配置下面的 min-replicas 写入限制的旧主服务器仍可继续接受写入(脑裂),分区恢复后也要等哨兵把它改为从服务器才停止(Redis 7.4 实测恢复后约 10 秒),其中没有复制到新主服务器的写入会丢失。分布式锁同样会因此丢失,见 [Redis 的 SET NX 指令](分布式.md#redis-的-set-nx-指令)。

可以配置 min-replicas-to-write 1 和 min-replicas-max-lag 10:延迟不超过 10 秒的从服务器少于 1 个时,主服务器拒绝写入并返回 NOREPLICAS,旧的主服务器与所有从服务器断开后最多再接受约 10 秒的写入;它不保证每次写入都已复制,分区后还有从服务器和旧主在同一侧时也挡不住写入。代价是即使主服务器本身正常,只要没有满足延迟要求的从服务器,它也会拒绝写入。

Expand Down
61 changes: 59 additions & 2 deletions notes/Spring.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
* [A16 Bean 的生命周期回调](#a16-bean-的生命周期回调)
* [A17 事件监听方法抛出异常](#a17-事件监听方法抛出异常)
* [A18 只写在 spring.factories 中的自动配置类](#a18-只写在-springfactories-中的自动配置类)
* [A19 事务超时](#a19-事务超时)
* [参考资料](#参考资料)
<!-- GFM-TOC -->

Expand Down Expand Up @@ -82,7 +83,7 @@ Spring 的容器用两个接口表示:BeanFactory 是根接口,负责创建

@Autowired 和 @Resource 都能注入依赖,区别在于查找的顺序:

- @Autowired 是 Spring 的注解,先按类型查找,找到多个时依次看 @Primary、字段名或参数名、@Priority(Spring 6.1 及以前 @Priority 先于名称),仍然无法确定就报错,可以用 @Qualifier 指定 Bean 的名称;
- @Autowired 是 Spring 的注解,先按类型查找,找到多个时依次看 @Primary、字段名或参数名、@Priority(Spring 6.1 及以前 @Priority 先于名称),仍然无法确定就报错,可以用 @Qualifier 指定 Bean 的名称。Spring 6.1 起,按构造器或方法的参数名匹配要求编译时开启 -parameters,Spring Boot 的 Maven parent 和 Gradle 插件默认配置了这个选项;没有开启、又不能按其它规则确定唯一的候选时,注入失败,按字段名匹配不受影响(Spring 6.2 实测);
- @Resource 是 Jakarta EE(原 JSR-250)的注解,指定了 name 时按名称查找,否则先按字段名查找,找不到再按类型查找。它只能用在字段和 Setter 上,不能用在构造器上。

Bean 的名称默认是 @Bean 的方法名,或者 @Component 类的类名首字母小写(类名前两个字母都大写时保持原样,例如 URLService),也可以在注解中指定。例如容器中有两个 PayService 类型的 Bean,名称分别是 alipay 和 wechat:
Expand Down Expand Up @@ -420,6 +421,12 @@ REQUIRED 的内层方法加入了外层的事务,用的是同一个连接。RE

使用 HikariCP 时,连接池耗尽后获取连接最多等待 connectionTimeout(默认 30 秒),期间有连接归还就返回,超时仍未取得才抛出 SQLTransientConnectionException。排查连接一直不归还的代码时,可以设置 leakDetectionThreshold,连接借出超过这个时间就在日志中打印借出处的调用栈,它只报告、不回收连接。

事务属性中的超时(@Transactional(timeout = 2))不是到点就中断方法的定时器。开启事务时记下截止时间,之后执行 SQL 时检查:截止时间已过就抛出 TransactionTimedOutException 并回滚;还没到就把剩余的秒数设为这条语句的查询超时(JDBC 的 setQueryTimeout),MyBatis 配置了更短的语句超时时取较小的一个。Java 代码和调用外部接口同样消耗事务的剩余时间,但不会被中断,外部调用仍要单独设置超时。

例如超时为 2 秒:先在 Java 中耗时 3 秒再执行 SQL,事务回滚;先执行 SQL 再在 Java 中耗时 3 秒、之后没有 SQL,事务正常提交;执行 SELECT SLEEP(5) 时,语句约 2 秒被驱动取消(Spring 6.2 实测,见[附录 A19](#a19-事务超时))。

需要注意的是,语句被取消抛出的 QueryTimeoutException 本身不会把事务标记为只能回滚:附录的例子在事务方法中直接捕获它、之后不再执行 SQL,之前的修改照常提交。超时也只对新开启的事务生效,加入外层事务的方法上设置的超时不起作用。

### 传播行为

传播行为指的是一个事务方法调用另一个事务方法时,事务如何传递。常用的有以下三种:
Expand Down Expand Up @@ -585,6 +592,16 @@ List<Order> byUserUnsafe(@Param("userId") String userId);

第一条语句的结构没有变,整个字符串只是一个参数值,仍然只返回用户 1024 的订单:user_id 是整数列,MySQL 把字符串和数字作为浮点数比较,'1024 OR 1=1' 被截断成 1024 并产生一个警告;第二条语句的条件恒为真,返回了其他用户的订单(见[附录 A10](#a10-mybatis-的参数与拦截器的回调))。${} 只用于表名、列名、ORDER BY 的字段这类不能使用占位符的地方,并且要用白名单校验参数。

例如按用户选择的列排序时,写成 ORDER BY #{sort} 会变成 ORDER BY ?,传入的 amount 只是一个字符串常量,语句不报错,结果也没有排序;改成 ORDER BY ${sort} 能排序,但参数可以是任意表达式,例如传入 (CASE WHEN (SELECT LEFT(amount,1) FROM orders WHERE user_id=1024)='9' THEN amount ELSE id END),按结果的顺序就能判断用户 1024 的订单金额是否以 9 开头,逐位猜出数据。白名单可以直接写在 SQL 中,只输出固定的列名(见[附录 A10](#a10-mybatis-的参数与拦截器的回调)):

```xml
ORDER BY
<choose>
<when test="sort == 'amount'">amount</when>
<otherwise>id</otherwise>
</choose>
```

### Mapper 接口为什么不需要实现类

MyBatis 在启动时为每个 Mapper 接口注册一个代理工厂 MapperProxyFactory,获取 Mapper 时由它创建 JDK 动态代理;Spring 中注入的就是这个代理,它怎样放进容器见本节最后一段。例如上面的 OrderMapper 注入的对象类型是 jdk.proxy2.$Proxy63,调用处理器是 MapperProxy。调用接口方法时,MapperProxy 按“接口全名 + 方法名”找到对应的 SQL,绑定参数后交给 SqlSession(MyBatis 中执行 SQL、提交和回滚所用的会话对象)执行,再把结果映射成返回类型。
Expand Down Expand Up @@ -636,9 +653,11 @@ List<Order> byStatuses(@Param("userId") Integer userId, @Param("statuses") List<

### 缓存

- 一级缓存:默认开启,作用范围是一个 SqlSession,同一个会话中相同的查询只执行一次,执行增删改或提交后清空。与 Spring 集成时,不在事务中的每次调用都会使用新的 SqlSession,一级缓存基本不起作用;
- 一级缓存:默认开启,作用范围是一个 SqlSession,同一个会话中相同的查询只执行一次,执行增删改或提交后清空。命中时返回的是同一个对象,修改它会影响这个会话之后查到的结果。与 Spring 集成时,不在事务中的每次调用都会使用新的 SqlSession,一级缓存基本不起作用;
- 二级缓存:作用范围是一个 Mapper 的命名空间(Mapper XML 中 namespace 的值,即 Mapper 接口的全名),多个会话共享,在 Mapper XML 中加 `<cache/>` 或在接口上加 @CacheNamespace 开启,结果对象要实现 Serializable。查询结果在会话提交后才写入(只有查询的会话关闭时也会写入;执行过增删改的会话回滚时丢弃),同一命名空间的增删改提交后清空(MyBatis 3.5.14 实测,见[附录 A12](#a12-mybatis-的结果映射重载与二级缓存))。缓存在单个应用的内存中,多个实例之间不一致,实际项目中很少使用,通常用 Redis 等缓存代替。

对开启了二级缓存的普通查询(没有传入 ResultHandler,也没有设置 useCache=false),先查二级缓存,未命中再查一级缓存和数据库。这是因为负责二级缓存的 CachingExecutor 包在执行 SQL 的 Executor 外面,一级缓存在里面的 Executor 中(MyBatis 3.5 源码)。

## 七、Spring 中的设计模式

| 模式 | Spring 中的例子 | 解决的问题 |
Expand Down Expand Up @@ -1124,6 +1143,18 @@ Spring Boot 3.3.5、MyBatis-Spring 3.0、MySQL 8.0.44、JDK 21,Apple M1 Pro。
<== Row: 3, 3, 3
```

按用户选择的列排序(MyBatis 3.5.14,不使用 Spring):orders 表有三行,(id, user_id, amount) 依次为 (1, 1001, 300)、(2, 1024, 950)、(3, 1002, 100),查询 SELECT id FROM orders ORDER BY …,连续运行两次结果相同:

```text
#{sort} sort=amount -> [1, 2, 3] # 没有排序
${sort} sort=amount -> [3, 1, 2]
${sort} sort=id -> [1, 2, 3]
${sort} 猜第一位是 9 -> [3, 1, 2] # 按 amount 排序,说明猜对了
${sort} 猜第一位是 5 -> [1, 2, 3] # 按 id 排序
choose sort=amount -> [3, 1, 2]
choose 猜第一位是 9 -> [1, 2, 3] # 不认识的值按 id 排序
```

同一类应用中按名称获取 Mapper(Spring Boot 3.3.5、mybatis-spring 3.0.3,代理类的编号随应用变化):

```text
Expand Down Expand Up @@ -1514,6 +1545,32 @@ exit=0
exit=0
```

### A19 事务超时

Spring 6.2.19、MyBatis 3.5.14(MyBatis-Spring 3.0.3)、HikariCP 6.3.3、MySQL Connector/J 9.4.0、MySQL 8.0.44、JDK 21,Apple M1 Pro。表 t(id, v) 中 id = 1 的行 v 为 0,事务管理器为 DataSourceTransactionManager,用 TransactionTemplate.setTimeout(2) 执行(@Transactional(timeout = 2) 同样经过事务管理器)。T2 提交后、T7 开始前都在事务外把 v 重置为 0:

```java
tt.executeWithoutResult(s -> { sleep(3000); jdbc.update("UPDATE t SET v=11 WHERE id=1"); }); // T1
tt.executeWithoutResult(s -> { jdbc.update("UPDATE t SET v=22 WHERE id=1"); sleep(3000); }); // T2
tt.executeWithoutResult(s -> { jdbc.update("UPDATE t SET v=33 WHERE id=1");
jdbc.queryForObject("SELECT SLEEP(5)", Integer.class); }); // T3
tt.executeWithoutResult(s -> { jdbc.update("UPDATE t SET v=77 WHERE id=1");
try { jdbc.queryForObject("SELECT SLEEP(5)", Integer.class); }
catch (QueryTimeoutException e) { } }); // T7
```

T4 在事务中用 MyBatis 先把 v 改为 44,再执行 SELECT SLEEP(5);T5、T6 设置 MyBatis 的 defaultStatementTimeout 为 1 秒,T5 在事务外执行 SELECT SLEEP(3),T6 在超时为 2 秒的事务中执行 SELECT SLEEP(5)。连续运行两次结果相同,下面是第一次:

```text
T1 3027ms TransactionTimedOutException: Transaction timed out: deadline was ... v=0
T2 3012ms 提交 v=22
T3 2034ms QueryTimeoutException <- MySQLTimeoutException v=0
T4 2175ms QueryTimeoutException <- MySQLTimeoutException(MyBatis) v=0
T5 1005ms QueryTimeoutException(事务外,defaultStatementTimeout=1)
T6 1003ms QueryTimeoutException(事务剩余 2 秒,defaultStatementTimeout=1,取较小者)
T7 2010ms 捕获 QueryTimeoutException,rollbackOnly=false,2015ms 提交 v=77
```

## 参考资料

- [Spring Framework: Dependency Injection](https://docs.spring.io/spring-framework/reference/core/beans/dependencies/factory-collaborators.html)
Expand Down
Loading
Loading