关于七牛云正确使用姿势探索

业务场景

需求

我们项目有一个文件上传需求,需要从客户端上传到七牛云的对象存储和自己的应用服务器上。这里使用七牛云主要是实现下载分发。应用服务器需要留一份是因为后续需要做文件分析(并且是上传后需要立马分析出结果展现给客户端)。另外,由于是初期项目,暂时没考虑用独立服务器来分析。

所用技术栈

服务器:Centos7
开发语言:PHP
框架:Laravel
前端上传组件:百度的WebUploader

解决方案

准确的说我经过了三个阶段才真正完美的实现了需求(主要解决上传速度)。

一期解决方案及细节

初期面对需求很容易想到的思路是:客户端先上传文件到应用服务器(因为上传完成可以及时做分析),然后再上传到七牛云上。

所以我的解决方案是:前端用webuploader,后端的七牛云文件处理方面使用了Laravel的一个插件:overtrue/flysystem-qiniu (https://github.com/overtrue/flysystem-qiniu),该插件的接口很简洁好用(但是有坑,后面会说到)。

然后为了解决性能问题,我还做了以下工作:
1,使用分片上传
2,后续上传七牛云使用异步的方式(因为文件上传到其他应用来下载这个文件,中间有许多时间来让上传任务的完成)

关于分片上传

这里讲下分片上传的实现思路,客户端主要是把大文件按一定size进行分片,然后上传到服务器,所以会有多个请求,并且每个请求还需带上关键的信息:当前chunk(从0开始)和chunks(总分片数)。由于我用的是webuploader组件,所以客户端不用自己做什么,只需配置下简单信息(是否分片及分片大小)。

服务端处理逻辑为:
客户端一个请求过来,分两种情况:
1,文件总size小于要分片的size,这时候直接处理文件。
2,处理分片情况。

具体逻辑是判断chunk和chunks,如果相等说明为第一种情况,直接处理上传,其他走处理分片逻辑。

处理分片的逻辑为:保存当前分片到临时目录(按分片命名),然后判断当所有分片完成时,就合并文件。具体逻辑是判断 chunk + 1 是否等于chunks。 合并逻辑就是循环读取临时文件,然后写入到一个新的文件(合并后的),这里可以顺便删除临时文件。

所遇的坑:
这里处理碎片文件时,当初图方便使用了Laravel的文件处理接口Storage::append,但是这个接口有个坑就是它自作主张的文件结尾加入换行符。导致合并后的文件还原不成原始文件。解决办法是老老实实使用php的fopen、fwrite、fclose这一套。

关于PHP异步处理

关于PHP的异步实现可以参考鸟哥写的文章:http://www.laruence.com/2008/04/14/318.html

主要方法为:客户端AJAX、popen函数、curl、fsocketopen等

不过这篇文章比较老了,局限性也大,现在有了协程等处理方案(现在Swoole也提供协程方案了,并且client-server task分发这种也可以用swoole的),而且往架构方面考虑可以使用队列等(感觉靠谱的还是队列)。

PS: 我这里前期用的是简单粗暴的popen,后来使用的是Laravel提供的队列。

一期方案的问题

通过上述所说的方案,很容易就实现了一个版本。但是没高兴多久。。,在后续测试时遇到一个诡异bug,当文件过大时,任务脚本上传到七牛云失败。

这里脚本是写在Laravel的artisan中的,当我把脚本命令直接在终端调试时也是没有任何异常(准确讲是看不了任何异常)

。前面我说过七牛这块SDK用的是overtrue/flysystem-qiniu ,并且为了考虑性能问题用的是他的writeStream接口。

1
2
3
4
5
6
7
$disk = Storage::disk('qiniu');
$stream = fopen($localFileName, 'r');
$disk->writeStream($fileName, $stream);

if (is_resource($stream)) {
fclose($stream);
}

代码表面上看起来很理想,用的是文件流上传(怕吃内存)。但结果证明一切只是表面上的。。

当我遇到大文件无法上传到七牛云时,断点调试到$disk->writeStream这里,发现返回的是false。 继而调试到overtrue/flysystem-qiniu这个扩展的源代码。然后发现了一个大坑。。

主要是两个问题:
1,writeStream只是个假的流写入

具体源码在扩展的QiniuAdapter.php文件中,这里贴段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public function writeStream($path, $resource, Config $config)
{
$contents = '';

while (!feof($resource)) {
$contents .= fread($resource, 1024);
}

$response = $this->write($path, $contents, $config);

if (false === $response) {
return $response;
}

return compact('path');
}

注意这里的$contents变量,最终还是等价于一个大文件内容的大小(服务器为此变量开辟的内存)。并且后续还要在方法间传递。所以这里是假的流!

2,接口对调试不友好

还有在write方法中,屏蔽了$error,只返回false,这样不便于我们查问题,最终我是断点打印这个$error才知道报的错误是:“invalid multipart format: multipart: message too large”,这个应该是七牛那边真正返回的,但这么重要的信息被这个扩展屏蔽了。

二期解决方案

知道了一期方案的具体问题所在,我就一直在思考(那个扩展就不提了。。我现在怀疑它的存在意义。。),甚至在想也许一开始整个思路就错了(通过SDK上传文件的方案)。后来还真被我找到了,七牛云官方提供一个脚本工具:Qshell(https://github.com/qiniu/qshell)。这个是命令行运行脚本,具体操作看文档就可以了。放到我的项目也是集成到七牛的任务脚本中。

后来测试可以了,整个流程可以跑通。

但是无意中发现二期的重要问题,这个上传走的是服务器的上行带宽!而我们平常付费买的带宽就是买的上行带宽!(下行是一般是免费的)。这还怎么搞!由于我们上传业务是商户端使用的,平时使用频次也不会太少,这会导致在上传时影响前端网站的访问速度。

这里具体讲下服务器带宽问题(网上查询后整理的):

首先对服务器带宽方向的描述一般是用上行和下行,上传和下载是指动作。

上行是指从服务器流出的带宽,如果是在其他机器下载服务器上的文件,用的主要是服务器的上行带宽(这里说下我们平时的网页浏览,其实也是不同客户端从服务器下数据, html文件、css等然后渲染,所以网页浏览占用的也是上行带宽)。

下行是指流入到服务器的带宽,如果是在其他机器上传文件到服务器,比如用FTP上传文件,用的主要是服务器的下行带宽(服务器上下载文件用的也是下行带宽)。

现在的云提供商比如阿里云不限制的是下行带宽,大部分服务器的使用环境,都是上行带宽用的多,下行带宽用的少。

通过对带宽的理解,再回到我们项目的上传实现思路,可以看到一开始就错了(不该用应用服务器作为中转)!

三期(最终)方案

当初为了节省时间,直接跳过官方文档,而使用第三方扩展。 现在看来,不得不又回到官方文档了。

通过把七牛的文档过一遍,发现是有方案可以避开那个占用服务器上行带宽的问题的。

主体思路是要避开应用服务器上行带宽的使用,因为上行带宽很宝贵,尽量使用下行带宽(免费、速度很快!阿里的大概60M多每秒)。

具体实现是通过七牛的表单上传方案直接把客户端的文件先上传到七牛(这一步根本不关应用服务器什么事,所以避开了,而且直接上传到七牛的速度非常快,基本只取决于用户端的网速,而且对于一般需求,七牛提供了对于到我们应用服务器的回调方法)。然后由于我们应用服务器也需要文件,所以方案是直接在我们应用服务器直接下载七牛的文件(这里可以同步阻塞住,前端做个等待效果解决用户体验问题)。因为前面说到流入到服务器占用的是下行带宽。所以这里速度也会非常快(而且是免费的^_^)。

这种方案基本是完美的了。

总结

首先是对个人的反省,前期调研不充足,但是项目初期有点紧,这里也说明投入时间的重要性。

其次关于项目经验:上传第三方云存储,千万不要使用应用服务器做中转!可以直接上传到第三方云服务器,如果有后续处理逻辑的,可以使用他们的回调接口。

Share Comments

Linux磁盘挂载、分区、扩容操作

注:以下操作系统环境为CentOS7

基本概念

在操作前,首先要了解一些基本概念

磁盘

在Linux系统中所有的设备都会以文件的形式存储。设备一般保存在/dev目录下面,以sda、sda1、sda2 …,sdb、sdb1…,hda,hdb。现在的设备一般都是sd命名,以前的很老的硬盘是以ha命名。
sda:第一块硬盘,如果对磁盘进行了分区会有sda1(第一个分区),sda2等。
sdb:第二个硬盘,同样对硬盘分区后有sdb1,sdb2等。

分区

分区的目的就是便于管理,比如在Windows系统我们一般会分C盘,D盘,E盘等。

Linux只能创建4个主分区,如果需要创建更多的分区那么久必须创建逻辑分区,其中逻辑分区需要占用一个主分区。

文件系统

Linux中的文件系统也就是分区类型,在Windows中有NTEF,FAT32等,linux中常见的有Ext2、Ext3、Ext4、Linux swap、proc、sysfs、tmpfs等,可以通过mount命名查看当前已挂载的文件系统。

格式化

在前面创建完分区后有一步是要对分区进行格式化,其实在Windows系统中也是一样,在创建好一个分区后也需要将分区格式化,只有格式化成具体的文件类型才能使用。

挂载

在Windows中分区格式化后就可以使用,但是在Linux系统中必须将分区挂载到具体的路径下才可以。

常用命令

1
2
3
4
lsblk  查看当前磁盘情况
df -lh 查看文件系统情况 -l 查看挂载点
parted -l 会列出文件系统类型
fdisk -l 查看当前未挂载硬盘

挂载新硬盘

挂载一个新硬盘基本思路是:创建分区、创建文件系统、挂载。

一、查看新硬盘

首先,查看硬盘状况:

1
fdisk -l

其中:
如果磁盘下面有类似:Disk /dev/sdc doesn’t contain a valid partition table;或者说磁盘下面没有类似于:sdb1 sdb2 说明该磁盘未挂载

这里假设看到硬盘名为 /dev/sdb

二、创建分区

1
dfisk /dev/sdb

根据提示,依次输入”n”,”p” “1”,两次回车,”wq”
意思就是新建一个主分区(1),大小是整个sdb磁盘,然后写入。

注:上述操作为了简便,只是创建一个主分区。其实一个磁盘最多有四个主分区(包括一个扩展分区),1-4都是主分区,我们也可以把一个分区作为扩展分区(通过df -lh 查看到的system为Extended)

此时磁盘已经分区,但是还没有文件系统,磁盘依然不能用

三、写入系统

1
mkfs.ext4 /dev/sdb

该命令会格式化磁盘并写入文件系统

四、挂载

比如挂载到/data下面

1
2
mkdir /data # 如果存在此步省略
mount /dev/sdb /data

五、设置开机自动挂载

以上只是临时挂载,还需设置为开机自动挂载

1
2
3
4
5
6
vim /etc/fstab


# 然后在内容结尾处增加一行(注意文件类型要对应):

/dev/sdb /data ext4 defaults 0 0

扩容

关于挂载到已有目录

如果你要用来挂载的目录里面并不是空的,那么挂载了文件系统之后,原目录下的东西就会暂时的消失。并不是被覆盖掉,而是暂时的隐藏了起来,等到新分割槽被卸除之后,则原目录原本的内容就会再次出来。

如果要永久挂载已有目录,可以在新硬盘创建文件系统后,先挂载到一个临时目录,然后把要扩展的目录复制到这临时目录,然后删除要扩展的目录,再卸载临时挂载点,重新挂载到要扩展的目录上。举例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# 比如要扩充 /var

# 在创建好文件系统后 新建临时挂载点 storage
mkdir /storage

# 将/dev/sdb1挂载到/storage下
mount /dev/sdb1 /storage

# 拷贝/var下的所有内容到新的硬盘
cp -pdr /var /storage
# 或在/var 目录下执行:find . -depth -print | cpio - pldvm /temp
# 删除当前/var目录下的内容
rm -rf /var/*
# 重新挂载硬盘到/var目录
umount /dev/sdb1
mount /dev/sdb1 /var

# 过程中若提示磁盘忙,使用fuser找出将正在使用磁盘的程序并结束掉;

fuser -m -v /var
fuser -m -v -i -k /var

扩展

如果扩容比较频繁,那推荐使用LVM管理(最后一个参考链接)

参考

http://www.cnblogs.com/chenmh/p/5096592.html
https://segmentfault.com/a/1190000004585900
https://blog.csdn.net/wzb56_earl/article/details/7580601
https://www.cnblogs.com/sourceforge/p/mount-folder-to-new-disk-in-centos.html
http://www.cnblogs.com/gaojun/archive/2012/08/22/2650229.html

Share Comments

Laravel关联模型中过滤结果为空的结果集(has和with区别)

首先看代码:

1
2
3
4
5
6
$userCoupons = UserCoupons::with(['coupon' => function($query) use($groupId){
return $query->select('id', 'group_id', 'cover', 'group_number', 'group_cover')->where([
'group_id' => $groupId,
]);
}])
// 更多查询省略...

数据结构是三张表用户优惠券表(user_coupons)、优惠券表(coupons),商家表(corps),组优惠券表(group_coupons) (为了方便查看,后两项已去除)

这里我本意想用模型关联查出用户优惠券中属于给定组gourpId的所有数据(如果为空该条数据就不返回)。

但有些结果不是我想要的:

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
39
40
41
42
array(20) {
["id"]=>
int(6)
["user_id"]=>
int(1)
["corp_id"]=>
int(1)
["coupon_id"]=>
int(4)
["obtain_time"]=>
int(1539739569)
["receive_time"]=>
int(1539739569)
["status"]=>
int(1)
["expires_time"]=>
int(1540603569)
["is_selling"]=>
int(0)
["from_id"]=>
int(0)
["sell_type"]=>
int(0)
["sell_time"]=>
int(0)
["sell_user_id"]=>
int(0)
["is_compose"]=>
int(0)
["group_cover"]=>
string(0) ""
["is_delete"]=>
int(0)
["score"]=>
int(100)
["created_at"]=>
NULL
["updated_at"]=>
NULL
["coupon"]=>
NULL // 注意返回了coupons为空的数据
}

记录中有的coupon有记录,有的为空。想想也是,with只是用sql的in()实现的所谓预加载。无论怎样主user_coupons的数据都是会列出的。

它会有两条sql查询,第一条查主数据,第二条查关联,这里第二条sql如下:

1
select `id`, `group_id`, `cover`, `group_number`, `group_cover` from `youquan_coupons` where `youquan_coupons`.`id` in (1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 13, 14) and (`group_id` = 1) and `youquan_coupons`.`deleted_at` is null

如果第二条为空,主记录的关联字段就是NULL。

后来看到了Laravel关联的模型的has()方法,has()是基于存在的关联查询,下面我们用whereHas()(一样作用,只是更高级,方便写条件)

这里我们思想是把判断有没有优惠券数据也放在第一次查询逻辑中,所以才能实现筛选空记录。

加上whereHas()后的代码如下

1
2
3
4
5
6
7
$userCoupons = UserCoupons::whereHas('coupon', function($query) use($groupId){
return $query->select('id', 'group_id', 'cover', 'group_number', 'group_cover')->where([
'group_id' => $groupId,
]);
})->with(['coupon' => function($query) use($groupId){
return $query->select('id', 'group_id', 'cover', 'group_number', 'group_cover');
}])-> // ...

看下最终的SQL:

1
select * from `youquan_user_coupons` where exists (select `id`, `group_id`, `cover`, `group_number`, `group_cover` from `youquan_coupons` where `youquan_user_coupons`.`coupon_id` = `youquan_coupons`.`id` and (`group_ids` = 1) and `youquan_coupons`.`deleted_at` is null) and (`status` = 1 and `user_id` = 1)

这里实际上是用exists()筛选存在的记录。然后走下一步的with()查询,因为此时都筛选一遍了,所以with可以去掉条件。

显然区分这两个的作用很重要,尤其是在列表中,不用特意去筛选为空的数据,而且好做分页。

Share Comments