7.9 KiB
Project-2 Balancebeam 实现记录
综述
这东西本质上是一个类似于反向代理之类的东西,
每个 Milestone 的说明
Milestone 0
依然是传统的看代码环节。
首先是 main.rs 里面的东西。
CmdOption用的是clap这个 crate,用非常奇葩的 macro 语法来定义命令行参数并解析。ProxyState是执行线程的共享参数,不知道有啥特别的意义,可能是为后面的东西预留的TCPListener是标准库提供的东西,提供了一个简单的 TCP server。它通过incoming迭代器来返回构造的 TCP stream 抽象,这种字节流抽象最终会被分发给执行线程。- 执行线程:
- 首先,它随机选择一个上游服务器并打开与该服务器的连接。如果服务器无了,返回502。
- 然后循环从客户端读取请求,客户端可以在一个连接上发送任何数量的请求,而
balancebeam会将每个请求转发到上游服务器。如果客户端发送了一个错误的请求,会有一些错误处理。 - 一旦请求被读取,
balancebeam就会添加一个X-Forwarded-For HTTP头,以便上游服务器能够知道客户端的IP地址。(这对理解并不特别重要;它只是一个事实上的标准头。) - 接下来,
balancebeam将请求转发给上游服务器。如果在传输请求时有错误,它就会通知客户端。 - 接下来,
balancebeam试图从上游服务器读取响应。如果响应被破坏或在接收时有其他问题,它将通知客户端。 - 最后,
balancebeam将响应转发给客户。
然后是封装库 request.rs response.rs,就像 project 1 里面的 gimili wrapper 一样,又臭又长。分别用来基于 TCP 字节流来处理 HTTP 请求和响应。分别提供了 read_from_stream 和 write_to_stream 这两个接口供 main 处理两个 TCP 链接( Client HTTP req -> Proxy HTTP req -> Server, Server HTTP resp -> Proxy HTTP resp -> Client)。具体的里面的接口我也没细看,等到 Milestone 2 需要修改的时候再说吧。
Milestone 1
讲义上没有提出明确的实现目标,简单的把代码改成多线程而已。主要就是把 state 用 Arc 给共享掉(反正是不可变借用,十分的安全)。
ThreadPool 和 std::thread 没啥太大区别,都封装的很好了,直接看一下 doc.rs 里面的例子就行了。
Milestone 2
又是一个奇怪的 task point,要求把所有的 IO 换成异步的。但是依然没有任何的难度,根据 IDE 提示加 async 和 await 就可以了。
Milestone 3
实现故障转移(failover):如果一个 upstream 挂了,将连接选一个别的(一开始有一堆可用的 upstream),同时还要标记这个挂掉的 upstream 防止后面的连接继续选择它;如果所有的 upstream 都挂了,这时候才返回错误。实现方法是修改 main.rs::connect_to_upstream,因为这个过程仅在建立连接的时候执行。
实现了一个最简单的,在 ProxyState 里面加了一个 Arc<Mutex<Vec<usize>>>,用来存放可用的 upstream 的 index。
选择的时候就在这个加了锁的 Vec 里面选,然后用这个里面的 index 来取对应的 upstream 的地址。如果一开始就发现这个可用列表为空,那么直接返回错误;否则尝试连接。如果连接失败,需要将这个 index 从可用列表里面删除。唯一需要注意的就是,可以在 TcpStream::connect 之前释放一次锁,然后需要修改可用列表的时候再加锁,因为 connect 比较耗时。
感觉 RwLock 和这个东西差不多,但是可能在某些情况下的并发性能会更好一些。至于它说的用 channel,感觉挺麻烦的,所以没写。
Milestone 4
实现周期性的主动连接测试(active health check):就是我们的代理程序隔一段时间向每个 upstream 的某一个特定地址(参数里面的 active_health_check_path)发一个 HTTP 请求,如果返回 200 就说明没问题;如果之前有问题现在没问题,就可以继续用;出现问题则需要标记为下线。
由于上个 Milestone 里面用到的极简数据结构,导致实现起来有点生草。这里我是用 tokio::spawn 来生成了一个 task,根据文档的说明,这是一个 green thread,鬼知道是什么东西。然后一个大循环,先 sleep 一波(不能放到后面,因为他的测试比较智障,放到后面会出错);遍历所有的 upstream(不管有没有被 disable),建立 tcpstream ,构造空请求,然后接受响应,最后判断状态码是不是200。以上任何一步出错,都必须从可用列表中删除(注意有可能已经删除了);如果是 200,则需要将它重新加入到可用列表中(如果之前被删除了的话)。
一个坑:不要使用前面实现的 connect_to_upstream,因为它无法连接到已经被移出可用列表的 upstream(不如说它根本就不让自己选 upstream),但是我们这里需要测试每个 upstream。
Milestone 5
实现请求速率限制。针对每个 IP 限制一段时间内的请求数量,如果次数过多就返回 HTTP 429。提到了三个算法,leaky bucket、fixed window 和 sliding window。leaky bucket 不太适合这里的要求,就不管了。
这里实现了 fixed window 和 sliding window。这两个玩意基本上一样,fixed window 简单一点,单纯记录每个 time unit 里面请求的次数,达到上限就丢掉,开始下一个 unit 的时候就重置计数器。这就导致了一个问题,如果请求集中在计数器重置的时间点附近,就会导致短时间内的请求速率翻倍,实际上超过了限制。 sliding window 在此基础上考虑了上一个 unit,按照一定比例将上一次 unit 的请求计数折算到当前的请求计数中,从而避免了上述的问题。一种简单的实现方式就是,记录当前 unit 的计数和上个 unit 的计数,考虑当前时刻向前推一个 unit 的窗口,用窗口和上个 unit 重叠的比例进行折算。写成算式就是 $\frac{UNIT - (time.now - cur_unit_start)}{UNIT} \times prev_cnt + cur_ cnt$。
具体实现上,用一个 HashMap<String, RateRecord> 来维护 IP 地址到请求次数的信息。因为一个 IP 地址会发起多个 connection,所以得把数据放到全局的 ProxyState 里面再用 Arc<Mutex<>> 裹起来。封装了 RateRecord 类型来存放前面提到的这些数据,然后写了几个简单的小函数来让 handler 代码更加简洁一些。这些都不是什么难绷的事情,最难绷的是 HashMap 居然不支持直接修改内容,下面细说遇到的问题。
遇到的问题
HashMap不能直接修改value的内容,说是要实现IndexMut这个 trait,但是HashMap没实现,所以不能像实现了这个 trait 的Vec类型那样直接map[key] = newvalue,而是要用非常别扭的(*map.get_mut(&key).unwrap()).field = new_val_for_field来写。- 对于 primitive 类型之间的显式类型转换好像可以直接用
as xx,这个东西好像是编译器实现的,因此既不需要标准库也不需要什么其他的转换方法。不过剩下的转换就要通过From和Into这些 trait 来整了。 - 最后就是,直接运行
cargo test的时候有可能会在assert_eq!(total_request_count, rate_limit_threshold);这个断言上报错,不过单独测这一个点的时候从来不会错。根据打印信息判断,这大概是由于前面实现的 health check 的线程发送的检查请求也被计算在累计请求数中,所以导致这个东西爆炸。至于为啥会出现这个事情,可能是随机数(或者是 cpu 性能问题)导致测试时间过长,然后 health check 的线程开始执行了。
附加任务?
实验设计者提供了不少的可选项,看上去都挺棒的,但是我懒得自己写测试了,所以就不做了捏,啦啦啦。