大型网站技术架构

说两句

这本书其实是我3年前读过的一本书,用它来进行第一次视频专栏的尝试其实本质原因是因为我感觉经过3年的知识储备后我有能力讲好它。

2019年的读书笔记

目录

名称 文档 链接 更新时间
介绍 文稿 视频 2022-06-14 12:59:29
演化历程 文稿 视频 2022-06-14 13:32:05
架构套路 文稿 视频 2022-06-16 00:49:29
性能的定义 文稿 视频 2022-08-07 14:16:40

ps:道歉断更了,原因如下

其实我做短视频没多久,断断续续去更新目前700来粉。并没有带来什么经济价值,并且在平台上讲话要小心翼翼,这不是我想要的我是个口无遮拦的人没法时时刻刻的唱赞歌。道歉!

又拍云上传文件漏洞

背景

客服小姐姐突然告诉我,我们网站域名下有一段带颜色的视频。比较奇怪,网站上好像没有开放用户上传MP4的口。排查下来发现是又拍的一个 文件上传漏洞(其实也怪自己没仔细读文档),找了公司对接的又拍售后给到的答复大概意思是 影响大无法修改

知识扩展

上传漏洞定义

文件上传漏洞是指用户上传功能没有做好检验或者过滤不严,用户上传了可执行脚本或者未经约定的文件类型。以往的定义中此类漏洞要一直完成到提权才算一次完整的渗透注入,个人认为只要用户行为与我们约定的行为相违背就可以称为一次成功的操作不一定是需要提权。拿本次事件举例,我们使用CDN并没有提权一说但是公司可能面临管局约谈的风险,并且攻击者达到了自己的目的。

常见手段

限制方法 绕过方法
服务端设置blaklist 寻找list以外的扩展名别名或者混淆大小写
filename后缀白名单 双重后缀 xxx.png.php
MIME类型检测 修改上传http包content-type值
头文件内容检测 修改头部那几个字节让它看着像正常的

又拍上传漏洞复现

我们先计算出form api 需要的 policyauthorization

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
32
33
34
35
36
37
38
<?php
function sign($key, $secret, $method, $uri, $date, $policy=null, $md5=null)
{
$elems = array();
foreach (array($method, $uri, $date, $policy) as $v)
{
if ($v)
{
$elems[] = $v;
}
}
$value = implode('&', $elems);
//echo $value."\n";
$sign = base64_encode(hash_hmac('sha1', $value, $secret, true));
return 'UPYUN ' . $key . ':' . $sign;
}
function main()
{
$key = '操作员名';
$secret = '密码';
$uri = '/服务空间';
$method = 'POST';
$date = gmdate('D, d M Y H:i:s \G\M\T');
$md = md5($secret);
$mybucket = "phpzjj"; //空间名
$mytime = time() + 600;
$mykey = '/opinion/1.png'; //详情看官方文档
$allow = 'jpg,jpeg,png';//文件类型限制
$limit = 1024000 * 3;//大小限制
$leng = "0,{$limit}";
$option = array('x-upyun-meta-ttl'=>1,'bucket'=>$mybucket,'expiration'=>$mytime,'save-key'=>$mykey,'allow-file-type'=>$allow,'content-length-range'=>$leng,'date'=>$date);
$option = json_encode($option,JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$policy = base64_encode($option);
// 上传,处理,内容识别有存储
echo sign($key, md5($secret), $method, $uri, $date,$policy) . "\n";
echo $policy;
}
main();

然后用 CURL 上传不同的文件

1
2
3
4
5
curl http://v0.api.upyun.com/phpzjj \
-F authorization="UPYUN blogtest:JxWvy+4Wi/THxzpMzW2v6U/vSxg=
" \
-F file=@黑产 \
-F policy=eyJ4LXVweXVuLW1ldGEtdHRsIjoxLCJidWNrZXQiOiJwaHB6amoiLCJleHBpcmF0aW9uIjoxNjU5MjUyMTM1LCJzYXZlLWtleSI6Ii9vcGluaW9uLzEucG5nIiwiYWxsb3ctZmlsZS10eXBlIjoianBnLGpwZWcscG5nIiwiY29udGVudC1sZW5ndGgtcmFuZ2UiOiIwLDMwNzIwMDAiLCJkYXRlIjoiU3VuLCAzMSBKdWwgMjAyMiAwNzoxMjoxNSBHTVQifQ==

正常情况下


符合预期

传一个html试试

符合预期

我们把html换成jpg再看下

已经上传成功并且在又拍的控制台也识别为了html文件(浏览器打开也是html)

当时黑产也是利用这种方式给我们挂了个小黄页,打开这个html文件发现不仅是我们这样的小站,像小红书这样的大站也没幸免 m3u8的播放list就放他们的CND上

这里我找过又拍的企业对接技术,他告诉我们是通过filename的方式来限制文件上传类型的,表示理解但是,既然是filename区分我也确实限制死了文件后缀类型。为什么去掉后缀依然能上传???

不过又拍也是积极响应修改了文档并且增加了 file-ext-required 作为弥补措施

我事后也仔细阅读了又拍的文档

https://help.upyun.com/knowledge-base/form_api/#_2

好的,严格上说起来除了无后缀可上传外其他的好像是我们的问题 :-(

修复建议

对于这个只需要传图的业务场景修复方案其实很简单

  1. 走后端上传,先服务器判断下上传类型 (我都用form api干嘛还要传到自己服务器?
  2. 顺便加个图片处理参数比如 x-gmkerl-quality 又拍会去强验证是否是一张图片(推荐)
  3. 只让上传一种图片格式并且写死content-type

总结

事后我对比其他做CND的厂商,他们的做法其实大体和又拍差不多。唯一不同的是他们有些策略降低了程序员对安全方面的心智负担,个人认为在做一个需求时除非对安全方面知识腌制入味要不很难从文档中读出什么影响安全的猫腻,特别是我这种蹩脚的三流野生PHPER。

oss 测试

1
2
3
policy

{"expiration":"2022-07-31T15:47:50Z","conditions":[["content-length-range",0,20971520],["starts-with","$key","ips_user_asset\/16\/59\/25\/36\/40\/78\/788028ff7b085391fee5967bd982d500.jpg"]]}

不过值得一提的是比如阿里的oss虽然在用户上传时候可以通过修改后缀的方式成功上传一个恶意源文件,但是它返回的content-type头是根据后缀来的而不是读头文件,这样避免了恶意文件被用户直接打开。其实常见的媒体头也没多少,我们做个返回时后缀匹配content-type就可以在大多场景下解决这个问题。

1
2
3
4
5
6
text/html : HTML格式
text/plain :纯文本格式
text/xml : XML格式
image/gif :gif图片格式
image/jpeg :jpg图片格式
image/png:png图片格式

文件后缀 优先级 大于读文件头这种方式更适合直觉,毕竟jpg的后缀给 html的content-type是不合理的。

《自控力》

感受

从我感觉自己有严重的学生综合征起就会有意识的提高自己的自控力,但苦于方式方法是顺从直觉去完成的始终事与愿违。读了这本书后受到了蛮多启发,很多提高自控力的行为都是反自觉的(如书中提到的原谅自己之前的行为及时止损注意以后的行为,白熊测试等)。

《边城》

感受

读它的背景是在上海封闭隔离的第30天。《边城》很忠实的记载了故事的地理人文历史,生动的刻画出湘西边境小山区的人民的淳朴形象,它是一个凄凉的爱情故事能激发很多感叹,同时沈老的文字也把最近在魔都愤愤不平的我带到了一个民风淳朴的小山村缓解了些许心理上的不适。

《Go语言底层原理剖析》

感受

这本书我会再重新看一次并且整理出视频专栏,因为讲得太好了!

个人感觉写得很好的一本书,断断续续读了不止一个月,书的内容和书名一样就是剖析Go的实现和底层的一些原理。每小节都分成了两部分 使用实战和实现讲解。读完全书收益颇多,印象最深的是对基础类型的讲解(字符串,数组,切片,哈希表),反射的使用 GMP的调度机理 线程进程协程的区别

笔记

点开GO子树

《UNIX网络编程_卷1》

感受

放在工位的一本书,看了前面8章,打算后面再通读一遍做下笔记。不管是出于兴趣爱好或者是在现在内卷的环境下对基本功的提升这本书都是值得静下心来安安静静的阅读并且实践的一本书,书厚是因为讲得真的很细,即使这厚但是涉及到其中的关键字也需要后续自己再去进一步了解

笔记

网络部分

《钝感力》

感受

书前半部分很有意思,全书就一个论点降低敏感度是有益的不管是从进化过程和机理反应。后面几节就有点牵强了有点强行凑数的感觉。钝感力有点像之前读的反脆弱,我也更偏向于反脆弱,不是用阿Q法去治愈或者忽略伤害带来的痛苦而是靠强大的自愈能力去加强巩固自身变得更强大

IEEE754 换算和代码实现

问题

有没有想过为什么在很多语言里面简单的浮点数计算会丢失掉精度,这个精度又是如何丢失和保证的,在计算机里面怎么用有限的空间去保存无限的数

PHP

1
2
3
4
<?php
echo (float)(0.1+0.2).PHP_EOL;
var_dump(0.1+0.2);
var_dump((0.1 + 0.2 ) == 0.3);

结果

1
2
3
0.3
float(0.3)
bool(false)

JS

1
2
0.1+0.2
0.30000000000000004

抄一段维基百科对IEEE754上的解释

IEEE二进制浮点数算术标准(IEEE 754)是20世纪80年代以来最广泛使用的浮点数运算标准,为许多CPU与浮点运算器所采用。这个标准定义了表示浮点数的格式(包括负零-0)与反常值(denormal number),一些特殊数值((无穷(Inf)与非数值(NaN)),以及这些数值的“浮点数运算符”;它也指明了四种数值舍入规则和五种例外状况(包括例外发生的时机与处理方式)。

IEEE 754规定了四种表示浮点数值的方式:单精确度(32位)、双精确度(64位)、延伸单精确度(43比特以上,很少使用)与延伸双精确度(79比特以上,通常以80位实现)。只有32位模式有强制要求,其他都是选择性的。大部分编程语言都提供了IEEE浮点数格式与算术,但有些将其列为非必需的。例如,IEEE 754问世之前就有的C语言,现在包括了IEEE算术,但不算作强制要求(C语言的float通常是指IEEE单精确度,而double是指双精确度)。

定义

浮点类型 符号位 阶码 接码偏移值 尾数
单精度(float32) 1 8 127 23
双精度(float64) 1 11 1023 52

演算

其实网上有很多讲解IEEE754的文章,但是至少我没找到一个比较详细的计算过程。

  • 整数位除二取余
  • 小数位乘二取整

我们先换算 0.1转换为二进制

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
 1 0.1 X 2 = 0.2
2 0.2 X 2 = 0.4
3 0.4 x 2 = 0.8
4 0.8 x 2 = 1.6
5 0.6 x 2 = 1.2
6 0.2 x 2 = 0.4
7 0.4 x 2 = 0.8
8 0.8 x 2 = 1.6
9 0.6 x 2 = 1.2
10 0.2 x 2 = 0.4
11 0.4 x 2 = 0.8
12 0.8 x 2 = 1.6
...

可以看出进入了一个 0.4 0.8 1.6 1.2 的死循环

用科学计数法即保留一位有效整数位,往后取23位为位数,往后移动的位数是加上偏移量是阶码

  • 尾数 = 0001.10011001100110011001101

  • 阶码 = (-4 + 127) = 123 = 01111011 (2022年11月16日补充:阶码为保留1个整数位小数点左右移动的位数 公式为 (位数+127) )

  • 符号位 = 0

0.1用二进制表示为

符号位 + 阶码 + 尾数

0_01111011_10011001100110011001101

GO实现

float32转换为IEEE754

1
2
3
4
5
6
7
8

bits := math.Float32bits(0.1) //依靠库
binary := fmt.Sprintf("%.32b", bits)


fmt.Printf("binary: %s \n",binary)
fmt.Printf("IEEE754:%s_%s_%s \n", binary[0:1], binary[1:9], binary[9:])//按协议输出

输出

1
2
binary: 00111101110011001100110011001101 
IEEE754:0_01111011_10011001100110011001101

IEEE754 转换为float32

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
32
33

func ieeeToFloat (bits uint32) {

bias := 127
binary := fmt.Sprintf("%.32b", bits)
sign := bits & (1<<31)

exponentRaw := int( bits >> 23 )

var exponent int

if sign != 0 {
exponent = exponentRaw - bias - 256 //减去高位符号位
} else {
exponent = exponentRaw - bias
}


var mantissa float64
for index, bit := range binary[9:32]{
if bit == 49 {
position := index + 1
bitValue := math.Pow(2, float64(position))
fractional := 1/bitValue
mantissa = mantissa + fractional
}
}

value := (1+mantissa) * math.Pow(2, float64(exponent))

fmt.Printf("exponent:%d\nmantissa:%.32f\nvalue:%.32f\nsign:%b\n",exponent,mantissa, value, sign)
}

输出

1
2
3
mantissa:0.60000002384185791015625000000000
value:0.10000000149011611938476562500000
sign:0

精度丢失

浮点数的精度丢失主要发生在两个地方

进制转换时的精度损失

我们可以看出在采取IEEE754存储浮点数时小数会出现无限循环的情况而我们定义的存储长度是有限的,这样就会产生进度误差,比如之前提到的

0.1 -> 0_01111011_10011001100110011001101 -> 0.10000000149011611938476562500000

0.2 -> 0_01111100_10011001100110011001101->0.20000000298023223876953125000000

0.10000000149011611938476562500000 + 0.20000000298023223876953125000000 = 0.300000004470348

运算过程中的精度丢失

0.5*0.75

0.5 = 0_01111110_00000000000000000000000
0.75 = 0_01111110_10000000000000000000000

尾数部分直接相乘 = 10000000000000000000000
指数相加-127 = 01111110 + 01111110 - 127 = 126+126-127 = 125
最终
0_01111101_10000000000000000000000

1
2
c,_ := strconv.ParseUint("00111110110000000000000000000000", 2, 64)
btod(ieeeToFloat(uint32(c)))

用上面的 ieeeToFloat 测试下可以得到 0.375

对于加减法需要先调整指数大小一致,再进行小数部分的相加。
列如 1.23x10^28 + 1.00x20^25
转换为 1.23x10^28 + 0.001x10^28
小数求和 1.231x10^28
如果浮点数只能精确表示 1.23 那么这个操作就会被舍弃丢失精度

参考

https://time.geekbang.org/column/article/439782

https://www.h-schmidt.net/FloatConverter/IEEE754.html

《GO语言底层原理剖析》

缓存更新军体拳

前言

在日常工作中说实话我没细想过缓存更新会有这么多的细节,一个原因是因为我本身所在平台的量级并发量被不大,二是对自己要求还是不够严谨。

会出现什么样的问题

缓存更新出现问题其实归根揭底是因为维护了 redis 和 mysql 两份数据,出现问题也是他们一致性的问题。常见的场景如下

场景一

一般DataStore更新或者删除会删掉cache而不是去更新它,但是在一些高并发场景这样做会让大量请求打到DataStore上,一种做法还是去更新这个缓存。

如图,当redis缓存过期重新从mysql取值得当前值cache=A1,同一时间运营修改了数据cache的值应该变为A2,脚本获得需要更新的值cache=A2。这样问题就来了 set cache = A1set cache = A2 的执行顺序会影响最终的结果。

场景二

先删除缓存再修改数据库,而后续的操作会把数据再装载的缓存中。这种操作本身就是有问题的。

场景三

先更新数据库,再删除缓存,而后续的操作会把数据再装载的缓存中。这种场景如图

session-1 session-2
update cache=A2 where cache=A1 缓存过期
transaction ing get cache=A1
commit
delete cache=A1
set cache=A1

依然会缓存脏数据,如果感觉例子中的update不是很好理解可以也可以想象成类似主从延时等其他情况。

常见解决方法

Cache Aside

这种方式是通用型的解决方案遵循

  • 读请求

先读chache 如果 miss 用 DataStore 数据回填chache。

  • 写请求

先删除/更新 数据库,再删除cache

主要要点是
 
 1. 读的顺序
 2. 删除cache而不是update(为了避免并发写产生脏数据

Read Through

Cache Aside 是调用方去解决缓存miss的情况, Read Through会依赖其它操作把需要的数据写入cache, 比如异步服务或者依靠运营手动更新缓存。

Write Through

有修改或者删除操作,先操作cache然后cache去更新DataStore,如果miss就直接操作DataStore

Write Behind Caching

page cache 更新策略,操作全部放入cache 按策略后台异步去写DataStore。(参考mysql rodolog 或者 redis AOF)

场景解决方案

场景一

这个场景是违反了Cache Aside 的只删不更新原则,是一个反范式的设计。目的是在高并发下不想缓存miss增加数据库的压力,毛老师在redis中的解决感觉很精巧所以特地记录下来了。

  1. 数据库同步到缓存的脚本用 setex 覆盖写,读缓存失效用 setnx 存在就不写。 setex > setnx, 用类似写优先级的方案解决了这种问题。
  2. Read Through, 把写缓存的操作交给运营手动去执行或者订阅binlog用脚本去更新,程序只用去关心读场景miss了返回空即可。

场景二

先删缓存再更新数据库这种方式尽量避免

场景三

session-1 session-2
update cache=A2 where cache=A1 缓存过期
transaction ing get cache=A1
commit
delete cache=A1
set cache=A1

延时双删,在第一次删除缓存后延时一定时间,再删一次。

session-1 session-2
update cache=A2 where cache=A1 缓存过期
transaction ing get cache=A1
commit
delete cache=A
set cache=A1
sleep(2s)
delete cache=A1

延时删除的时间根据业务场景决定是一种减少概率的规避做法

参考

  1. 极客时间毛老师第4期缓存策略分享
  2. https://coolshell.cn/articles/17416.html

《PHP 7底层设计与源码实现》

感受

读完这本书马上来写评论,在不翻看的情况下回忆它给我带来的好处有如下几点

  1. zval 结构体的巧妙
  2. opcode 与 opcache的关系
  3. PHP zend 运行机制 (词法解析->语法解析-> AST->opline->opcode->汇编)
  4. php array 5.6 与 7的区别
  5. GC
    很多C实现的宏表达因为功力有限没法一次看懂应该会再看一遍,总的来说值回书价

请我喝杯咖啡吧~