Docker
Docker是在容器的基础上,进行了进一步的封装,从文件系统、网络互联到进程隔离等,极大的简化了容器的创建和维护。使得docker技术比虚拟机更为轻便、快捷。
与传统虚拟机比较
- 传统虚拟机:虚拟出一套硬件,在其上运行一个完整的操作系统,在该系统上再运行应用程序
- docker:容器内的应用程序直接运行于宿主的内核中,容器内没有自己的内核,而且没有进行硬件虚拟;每个容器内都有一个属于自己的文件系统,互不影响。
Docker优势
- 更高效的利用系统资源:内核级别的虚拟化,可以在一个物理机上运行很多的容器实例
- 更快速的启动速度
- 一致的运行环境
- 持续交付和部署
- 更轻松地迁移
- 更轻松的维护和扩展
Docker概念
- 镜像(Image):docker镜像就像一个模板,可以通过这个模板来创建容器服务。通过这个镜像可以创建多个容器(最终服务运行或者项目运行就是在容器中)
- 容器(Container):Docker利用容器技术,独立运行一个或者一个组应用,通过镜像来创建的。具有启动、停止、删除等一些基本命令。可以把容器理解为一个简易的linux系统。
- 仓库(Repository):仓库就是存放镜像的地方。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-eGnI3ogZ-1602224518932)(C:/Users/free/Desktop/面试/interviewmd/assets/post/others/docker.png)]
底层原理
Docker是怎么工作的?
Docker 是一个Client-Server结构的系统,Docker的守护进程运行在主机上。通过Socket从客户端访问。
Docker相对于VM有更少的抽象层。他利用的是宿主机的内核,VM需要guest OS
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-VdSEsPG1-1602224518935)(C:/Users/free/Desktop/面试/interviewmd/assets/post/others/vmdocker.png)]
常用命令
帮助命令
docker version
docker info // 显示docker系统信息,包括镜像和容器的数量
docker 命令 --help // 帮助命令
镜像命令
docker images // 查看主机上的所有镜像
docker search image-name // 搜索远程仓库镜像
docker pull image-name[:tag] // 下载远程镜像
docker rmi -f image-name/image-id // 删除指定镜像
docker rmi -f $(docker images -aq) // 删除所有镜像
容器命令
docker pull image-name // 新建容器
docker run [可选参数] image // 启动一个全新的容器
docker run -d --name nginx01 -p 3344:80 nginx
-d // 后台运行
--name // 名字
-p // 外部端口映射,使用主机的3344端口映射到容器内的80端口docker ps // 列出运行中的容器
-a // 列出所有运行过的容器
exit // 退出容器
ctrl+p+q // 不停止容器退出
docker attach container-id // 进入一个正在运行的容器终端,不会开启一个新的终端
docker exec -it container-id /bin/bash // 进入容器并开启一个新的终端docker start 容器id // 启动一个停止的容器
docker restart 容器id // 重启
docker stop 容器id // 停止当前容器
docker kill 容器id // 强制停止当前容器
其他命令
docker run -d centos // 后台启动
// docker容器使用后台运行,就必须有一个前台进程,如果没有应用,docker就会自动停止
docker logs // 查看日志
-tf // 显示日志
--tail number // 要显示的日志条数
docker inspect container-id // 查看容器信息docker cp container-id:path dstpath
Docker镜像
镜像是一种轻量级、可执行的独立软件包,用来打包软件运行环境和基于运行环境开发的软件,它包含运行某个软件所需要的所有内容,包括代码、运行时库、环境变量和配置文件等。
所有的应用,直接打包docker镜像,可以直接跑起来。
Docker镜像加载原理
- UnionFS(联合文件系统)
- 一种分层、轻量级并且高性能的文件系统,它支持对文件系统的修改作为一次提交来一层层的叠加,同时可以将不同目录挂载到同一个虚拟文件系统下(unite several directories into a single virtual filesystem)
- Union文件系统是Docker镜像的基础。镜像可以通过分层来进行继承,基于基础镜像(没有父镜像),可以制作各种具体的应用镜像。
- 特性:一次同时加载多个文件系统,但从外面看起来,只能看到一个文件系统,联合加载会把各层文件系统叠加起来,这样最终的文件系统会包含所有底层文件和目录。
- Docker镜像加载原理
- Docker的镜像实际上有一层一层的文件系统组成,这种层级的文件系统UnionFS。
- bootfs(boot file system)主要包含bootloader和kernel,bootloader主要是引导加载kernel,Linux刚启动时会加载bootfs文件系统,在Docker镜像的最底层是bootfs。这一层与典型的Linux/Unix系统是一样的,包含bootloader和kernel。当boot加载完成之后整个内核就都在内存中了,此时内存的使用权已由bootfs转交给内核,此时系统会卸载bootfs。
- rootfs(root file system),在bootfs之上,包含的就是典型linux系统中的/dev,/proc,/bin,/etc等标准目录和文件。rootfs就是各种不同的操作系统发行版,比如ubuntu、centos等等。
- 对于一个精简的OS,rootfs可以很小,只需要包含最基本的命令,工具和程序库就可以了,因为底层直接用host的kernel,自己只需要提供rootfs就可以了。由此可见对于不同的linux发行版,bootfs基本是一致的,rootfs会有差别,因此不同的发行版可以公用bootfs。
- Docker镜像都是只读的,当容器启动时,一个新的可写层被加载到镜像的顶部。这一层被称为容器层,容器之下的都叫做镜像层。
容器数据卷
容器之间的一个数据共享的技术,docker容器产生的数据,同步到本地。
目录的挂载,将我们容器内的目录,挂载到linux上面。
容器数据卷技术是为了容器的持久化和同步操作。容器间数据也是可以共享的。
使用数据卷
-
创建容器时直接使用命令来挂载
-v
-
docker run -it -v 主机目录:容器内目录
具名和匿名挂载
- 匿名挂载:只指定容器内路径
- 具名挂载:
-v 卷名:容器内路径
- 指定路径挂载:
-v 宿主机路径:容器内路径
- 指定权限:
-v :容器内路径:ro/rw
,ro表示只读,rw表示可读可写,是针对容器内的文件 - ro表示只读,容器内无权限进行修改,只能在宿主机操作。
Dockerfile
dockerfile就是用来构建docker镜像的构建文件,命令脚本。
通过这个脚本可以生成一个镜像。镜像是一层一层的,那么脚本也是一个一个的命令,一个命令就是一层。
FROM centos
VOLUME ["volume01", "volume02"]
CMD echo "--end--"
CMD /bin/bash
// 每一句就是一层
可以使用docker inspect container-id
查看卷挂载的目录
基础知识
- 每个保留关键字(指令)都必须是大写字母
- 执行从上到下顺序执行
- #表示注释
- 每一个指令都会创建提交一个新的镜像层,并提交。
Dockerfile是面向开发的,以后发布项目,做镜像,就需要编写dockerfile文件。
Dockerfile指令
FROM # 基础镜像,一切从这里开始构建
MAINTAIN # 镜像是谁写的,一般为名字+邮箱
RUN # 镜像构建的时候需要运行的命令
ADD # 向里面添加内容,比如tomcat等
WORKDIR # 镜像的工作目录
VOLUME # 挂载卷目录
EXPOSE # 指定暴露的端口
RUN # 开始运行
CMD # 指定启动的时候要运行的命令,只有最后一个会生效,可以被替代,不能追加命令
ENTRYPOINT # 指定启动的时候要运行的命令,可以追加命令
ONBUILD # 当构建一个被继承dockerfile时候会运行这个指令。是一个触发指令
COPY # 类似于COPY,将文件拷贝到镜像
ENV # 构建的时候设置环境变量
例子:
FROM centos
# 设置维护者名称
MAINTAINER elbow95
# 设置环境变量
ENV MYPATH /usr/local
# 设置工作目录
WORKDIR $MYPATH
# 安装软件
RUN yum -y install vim
RUN yum -y install net-tools
# 暴露端口
EXPOSE 80
CMD echo $MYPATH
CMD echo "---end---"
CMD /bin/bash
海量数据应用场景
找共同url
-
有10个文件,每个文件1G, 每个文件的每一行都存放的是用户的query,每个文件的query都可能重复。要你按照query的频度排序
1.所有数据有100亿条数据,大小一共为640G,可以考虑把这些文件分成一个个小文件来处理。
2.用hash对每一个url求取他的哈希值,然后存放到对应的小文件里,这样的话a和b重复的url只会出现在哈希值相同的两个文件里
3.在分别对哈希值相同的两个小文件求相同url,可以将一个文件中的url全部用bitmap存储,然后再遍历另一个,如果bitmap中存在就存放到文件里,如果不存在就跳过。继续遍历。
按照query的出现频率排序
-
有一个1G大小的一个文件,里面每一行是一个词,词的大小不超过16个字节,内存限制大小是1M。返回频数最高的100个词
- 顺序读取每一个文件,对于每一个词语对5000取模,分成5000分小文件,平均每一份文件200k左右。
- 然后对每一个小文件都用hash_map统计每个文件中出现的词以及相应的频率
- 从每一个文件里面取出出现最多的100个词语在分别存入5000个文件里面,然后对这5000个文件中出现的词进行归并排序。得到出现频率最大的100个词语。
- hash_map存储query和出现的次数,维护一个存放100个query的数组,一次遍历就可以了
统计用户在线分布情况
-
有一个论坛,其注册ID有两亿个,每个ID登陆和退出都会向日志中写入登陆和退出的时间
-
**现要求:**写一个算法统计一天中论坛的用户在线分布(取样粒度为秒),也就是在指定秒
-
**第一步:**一天总共有 3600*24=86400 秒
-
**第二步:**定义一个长度为 86400 的整数数组int delta[86400],每个索引对应这一秒的人数变化值,也就是该秒登陆人数与退出人数的差值(因此可能为正也可能为负)
-
第三步:
开始时将数组元素都初始化为 0,然后依次读入每个用户的登录时间和退出时间
- 找到这个用户的登陆时间对应在一天中的秒数,然后将delta数组对应的索引处值加1
- 找到这个用户的退出时间对应在一天中的秒数,然后将delta数组对应的索引处值减1
-
**第四步:**这样处理一遍后delta数组中存储了每秒中的人数变化情况(可为正可为负)
-
**第五步:**定义另外一个长度为 86400 的整数数组 int online_num[86400],每个整数对应这一秒的论坛在线人数
-
第六步:
假设一天开始时论坛在线人数为 0,则:
- 第1秒的人数online_num[0] = delta[0]
- 第2秒的人数online_num[1] = online_num[0] + delta[1](如果delta[1]为负,则分布线下降(在线人数变少);如果delta[1]为正,则分布线上升(在线人数变多))
- …以此类推
- 第n+1秒的人数online_num[n] = online_num[n-1] + delta[n]
-
这样我们就获得了一天中任意时间的在线人数
-
找出被删除的数
- 现在有1到10W这10W个数字,现在我们从中去除两个数组,然后剩余的数字全部打乱
- **现问:**如何找出哪两个数从这10W个数字中被去除了
- 解法一:bitmap
- 用bitmap,每一个bitmap表示一个数字是否出现过
- 遍历一遍之后那几个位为0那么就是缺失的数字
- 解法二:用二元方程
- 计算所有数字的和以及所有数字的和以及平方和
- 计算去除后所有数字的和以及平方和
- 列二元方程求解
判断数是否出现过
- 现在有40亿个不重复的unsigned int的整数,并且是没有排过序的。然后现在再随机给出几个数字
- **现问:**如何快速判断这几个数字是否在这40亿个数中?
- 解法一:
- bitmap存储所有出现过的数
- 找目标数对应的位
- 解法二:
- 先根据第一位将所有数分成两部分,找到目标数所在的那个文件
- 将找到地文件继续迭代二分,直到找到目标数为止
设计一个高并发系统
负载均衡和反向代理(nginx)
- Nginx可以充当代理的角色,对后端的所有服务器进行反向代理,服务端连接到Nginx,Nginx再将所有请求转发到后端的服务器
- Nginx还可以实现负载均衡,负载均衡就是Nginx将客户端的请求按照一定的规则分配给后端服务器进行处理
中间件的使用
**中间件有:**消息队列(ZeroMQ、RocketMQ、ActiveMQ、Kafka)、分布式协同发现(ZooKeepr)、分布式文件系统等等
消息队列
- 我们知道队列是先进先出的规则,一端向消息队列存入数据,另一端从消息队列中取出数据
- 并且消息队列还有很多种方式,例如:请求-响应模式、发布-订阅模式等
- 例如,一些服务器获取数据之后向MQ写入数据,另外一些服务器需要获取这些数据,那么就可以从MQ中取出数据
异步、削峰、解耦
- 异步:生产者生产和消费者消费是异步的(和多线程和线程池的区别:耦合性高,添加一个功能就需要重新发布系统,bug排查也会出现问题)
- 削峰:生产者同时生产大量的请求,服务端、redis、mysql承受能力都不一样,全部接受那很可能就挂了。用消息队列就可以将这个请求峰值通过消息队列降低。服务器每秒能消费多少qps你就消费多少,不至于挂掉服务器。
- 解耦:同一个流程中将不同的服务请求送入消息队列,交给其他的模块解决、
问题
数据一致性:分布式事务
可用性:
FastDfs
- FastDFS可以充当分布式的文件系统,其内部的数据是直接存储在硬盘上的
- Nginx有一个fastdfs-nginx-module模块,可以配置在Nginx中,直接通过URL进行访问
- 主要优点有:
- 备Tracker服务,增强系统的可用性
- 系统不需要支持POSIX,这样的话就降低了系统的复杂度,使得处理的速度会更高
- 支持主从文件,支持自定义扩展名
- 支持在线扩容机制,增强了系统的可扩展性
- 实现了软RAID,增强了系统的并发处理能力和数据容错恢复能力
zookeeper
- 提供数据的强一致性,强调cp,忽略a的分布式系统
数据库的设计
MySQL
- 对于高并发下的数据的读写与存取,单台MySQL显然是无法满足需求的,因此****此时就需要搭建MySQL的集群****
- 集群的方式有很多,下面以主从模式为例,Master用来提供写服务,多台Slave用来提供读服务,****从而实现读写分离****
- 当然,****对于MySQL内部的细节还有很多是需要优化的,****比如MySQL索引的设置、MySQL的分库分表
Redis
- Redis作为当今最火的缓存系统,高并发的系统当然少不了
- Redis作为服务端数据读写的缓存,不仅仅可以减轻后端数据库MySQL的压力,而且还提高了服务端数据的读写速度,Redis与MySQL的搭配是非常好的解决方案
分布式锁的三种实现原理
为什么要使用分布式锁
使用分布式锁的目的,无外乎就是保证同一时间只有一个客户端可以对共享资源进行操作。
(1)允许多个客户端操作共享资源
这种情况下,对共享资源的操作一定是幂等性操作,无论你操作多少次都不会出现不同结果。在这里使用锁,无外乎就是为了避免重复操作共享资源从而提高效率。
(2)只允许一个客户端操作共享资源
这种情况下,对共享资源的操作一般是非幂等性操作。在这种情况下,如果出现多个客户端操作共享资源,就可能意味着数据不一致,数据丢失。
分布式锁应该具备哪些条件
1、在分布式系统环境下,一个方法在同一时间只能被一个机器的一个线程执行;
2、高可用的获取锁与释放锁;
3、高性能的获取锁与释放锁;
4、具备可重入特性;
5、具备锁失效机制,防止死锁;
6、具备非阻塞锁特性,即没有获取到锁将直接返回获取锁失败。
单机情形比较
redis
加锁:set 资源的名字 客户端生成的随机字符串 NX EX 30000
解锁:防止客户端一的锁被客户端二释放,可以采用下面的lua脚本来释放锁(lua脚本具有原子性)
if redis.call("get",KEYS[1]) == ARGV[1] thenreturn redis.call("del",KEYS[1])
elsereturn 0
end
将redis中保存的随机字符串和客户端的随机字符串作比较,如果相等确认是释放自己的锁,就可以执行,避免锁被他人释放。
注意1:如果释放锁不是原子性操作,在客户端一判断是自己的锁后发出解锁命令,但是此时网络延时了,del命令延迟到达,而客户一的锁已经过期释放了,那么这个延迟的del命令就会将下一个客户的锁释放,造成混乱。
注意2:如果因为延时等原因,导致客户一的业务还在执行,但是锁过期失效了,那么客户二就可以获得锁,导致两个客户端共同操作共享资源的情况。可以开一个子线程实施监控主线程是否还持有锁,如果持有则更新锁过期时间,如果不持有就结束线程。
Zookeeper
原理是利用了临时顺序节点的特性
-
获取锁
- 首先,在Zookeeper当中创建一个持久节点ParentLock。当第一个客户端想要获得锁时,需要在ParentLock这个节点下面创建一个临时顺序节点 Lock1。
- 之后,Client1查找ParentLock下面所有的临时顺序节点并排序,判断自己所创建的节点Lock1是不是顺序最靠前的一个。如果是第一个节点,则成功获得锁。
- 这时候,如果再有一个客户端 Client2 前来获取锁,则在ParentLock下载再创建一个临时顺序节点Lock2。
- Client2查找ParentLock下面所有的临时顺序节点并排序,判断自己所创建的节点Lock2是不是顺序最靠前的一个,结果发现节点Lock2并不是最小的。于是,Client2向排序仅比它靠前的节点Lock1注册Watcher,用于监听Lock1节点是否存在。这意味着Client2抢锁失败,进入了等待状态。
- 这时候,如果又有一个客户端Client3前来获取锁,则在ParentLock下载再创建一个临时顺序节点Lock3。
- 这恰恰形成了一个等待队列
-
释放锁
-
1.任务完成,客户端显示释放
当任务完成时,Client1会显示调用删除节点Lock1的指令。
-
2.任务执行过程中,客户端崩溃
获得锁的Client1在任务执行过程中,如果Duang的一声崩溃,则会断开与Zookeeper服务端的链接。根据临时节点的特性,相关联的节点Lock1会随之自动删除。
-
前一个节点被删除,那么监视他的client就会获得锁。
-
注意1:这种情况下,虽然避免了设置了有效时间问题,然而还是有可能出现多个客户端操作共享资源的。
大家应该知道,zookeeper如果长时间检测不到客户端的心跳的时候(Session时间),就会认为Session过期了,那么这个Session所创建的所有的ephemeral类型的znode节点都会被自动删除。这种时候会有如下情形出现:如上图所示,客户端1发生GC停顿的时候,zookeeper检测不到心跳,也是有可能出现多个客户端同时操作共享资源的情形。
集群情形比较
redis
问题:为了redis的高可用,一般都会给redis的节点挂一个slave,然后采用哨兵模式进行主备切换。但由于Redis的主从复制(replication)是异步的,这可能会出现在数据同步过程中,master宕机,slave来不及同步数据就被选为master,从而数据丢失。
具体情形:
- (1)客户端1从Master获取了锁。
- (2)Master宕机了,存储锁的key还没有来得及同步到Slave上。
- (3)Slave升级为Master。
- (4)客户端2从新的Master获取到了对应同一个资源的锁。
解决:
RedLock算法,步骤如下(该流程出自官方文档),假设我们有N个master节点(官方文档里将N设置成5,其实大等于3就行)
- (1)获取当前时间(单位是毫秒)。
- (2)轮流用相同的key和随机值在N个节点上请求锁,在这一步里,客户端在每个master上请求锁时,会有一个和总的锁释放时间相比小的多的超时时间。比如如果锁自动释放时间是10秒钟,那每个节点锁请求的超时时间可能是5-50毫秒的范围,这个可以防止一个客户端在某个宕掉的master节点上阻塞过长时间,如果一个master节点不可用了,我们应该尽快尝试下一个master节点。
- (3)客户端计算第二步中获取锁所花的时间,只有当客户端在大多数master节点上成功获取了锁(在这里是3个),而且总共消耗的时间不超过锁释放时间,这个锁就认为是获取成功了。
- (4)如果锁获取成功了,那现在锁自动释放时间就是最初的锁释放时间减去之前获取锁所消耗的时间。
- (5)如果锁获取失败了,不管是因为获取成功的锁不超过一半(N/2+1)还是因为总消耗时间超过了锁释放时间,客户端都会到每个master节点上释放锁,即便是那些他认为没有获取成功的锁。
RedLock算法细想一下还存在下面的问题
节点崩溃重启,会出现多个客户端持有锁
假设一共有5个Redis节点:A, B, C, D, E。设想发生了如下的事件序列:
(1)客户端1成功锁住了A, B, C,获取锁成功(但D和E没有锁住)。
(2)节点C崩溃重启了,但客户端1在C上加的锁没有持久化下来,丢失了。
(3)节点C重启后,客户端2锁住了C, D, E,获取锁成功。
这样,客户端1和客户端2同时获得了锁(针对同一资源)。
为了应对节点重启引发的锁失效问题,redis的作者antirez提出了延迟重启的概念,即一个节点崩溃后,先不立即重启它,而是等待一段时间再重启,等待的时间大于锁的有效时间。采用这种方式,这个节点在重启前所参与的锁都会过期,它在重启后就不会对现有的锁造成影响。这其实也是通过人为补偿措施,降低不一致发生的概率。
时间跳跃问题
(1)假设一共有5个Redis节点:A, B, C, D, E。设想发生了如下的事件序列:
(2)客户端1从Redis节点A, B, C成功获取了锁(多数节点)。由于网络问题,与D和E通信失败。
(3)节点C上的时钟发生了向前跳跃,导致它上面维护的锁快速过期。
客户端2从Redis节点C, D, E成功获取了同一个资源的锁(多数节点)。
客户端1和客户端2现在都认为自己持有了锁。
为了应对始终跳跃引发的锁失效问题,redis的作者antirez提出了应该禁止人为修改系统时间,使用一个不会进行“跳跃”式调整系统时钟的ntpd程序。这也是通过人为补偿措施,降低不一致发生的概率。
zookeeper
zookeeper在集群部署中,zookeeper节点数量一般是奇数,且一定大等于3
那么写数据流程步骤如下
1.在Client向Follwer发出一个写的请求
2.Follwer把请求发送给Leader
3.Leader接收到以后开始发起投票并通知Follwer进行投票
4.Follwer把投票结果发送给Leader,只要半数以上返回了ACK信息,就认为通过
5.Leader将结果汇总后如果需要写入,则开始写入同时把写入操作通知给Leader,然后commit;
6.Follwer把请求结果返回给Client
还有一点,zookeeper采取的是全局串行化操作
集群同步
client给Follwer写数据,可是Follwer却宕机了,会出现数据不一致问题么?不可能,这种时候,client建立节点失败,根本获取不到锁。
client给Follwer写数据,Follwer将请求转发给Leader,Leader宕机了,会出现不一致的问题么?不可能,这种时候,zookeeper会选取新的leader,继续上面的提到的写流程。
总之,采用zookeeper作为分布式锁,你要么就获取不到锁,一旦获取到了,必定节点的数据是一致的,不会出现redis那种异步同步导致数据丢失的问题。
时间跳跃问题
不依赖全局时间,怎么会存在这种问题
超时导致锁失效问题
不依赖有效时间,怎么会存在这种问题
第三回合,锁的其他特性比较
(1)redis的读写性能比zookeeper强太多,如果在高并发场景中,使用zookeeper作为分布式锁,那么会出现获取锁失败的情况,存在性能瓶颈。
(2)zookeeper可以实现读写锁,redis不行。
(3)zookeeper的watch机制,客户端试图创建znode的时候,发现它已经存在了,这时候创建失败,那么进入一种等待状态,当znode节点被删除的时候,zookeeper通过watch机制通知它,这样它就可以继续完成创建操作(获取锁)。这可以让分布式锁在客户端用起来就像一个本地的锁一样:加锁失败就阻塞住,直到获取到锁为止。这套机制,redis无法实现
总结
无论是redis还是zookeeper,其实可靠性都存在一点问题。但是,zookeeper的分布式锁的可靠性比redis强太多!但是,zookeeper读写性能不如redis,存在着性能瓶颈。大家在生产上使用,可自行进行评估使用。
游戏服务端
游戏服务端大体可以分为以下几个部分:
1、网络层
负责客户端和服务端,以及服务端集群内部之间的网络数据包收发,目前在游戏中广泛应用的两种协议,一种是TCP协议,还有一种则是HTTP协议。一般而言,那些网络数据包交互频繁的游戏采用TCP会更合适,比如MMORPG,而一些客户端和服务端交互不频繁的弱联网游戏,则采用HTTP协议,这些游戏一般以客户端为主,且通常防范作弊的能力有限。
数据包交互频繁的游戏,大多采用TCP协议,然而对于手游来说TCP却未必是最好的选择,TCP最大的特点,首先是按序到达,超时重传,此外TCP协议还有流量控制和拥塞控制。这里我对拥塞控制做一下简要说明,虽然TCP设计拥塞控制的初衷是,通过控制发送方往网络注入字节序的数量,来缓解拥塞,也就是说,如果发送方多次发送数据包都没收到确认,则判定为网络拥塞,此时TCP的滑动窗口可能会减少到原来的一半,而恢复往往却很慢,那些对网络有着苛刻要求的Moba类游戏来说,TCP可能就没那么友好了。因此有些项目会采用UDP协议来传输数据包,虽然UDP无连接的,可能会导致丢包,但是可以在应用层加以控制,实现一套TCP like协议,UDP是用户报文协议,基本上应用层下传多少数据包,就会发送多少数据包,应用层需要实现UDP包的超时重传和按序到达等机制,在遇到网络拥塞时,仍然不会让数据包的发送量减半,因此能够有效缓解网络传输问题。
我记得github上有一个开源的KCP协议,可以实现UDP的可靠传输
-
kcp的拥塞控制可以取消
-
ack回复可以设置成无延迟ack回复;
-
kcp的快速重传门限可以控制;
上一段是网络协议在游戏上的一些应用的概述,对于服务端而言,网络层模型也是需要了解的,目前常用的是epoll模型,典型的使用情形是,服务端进程开一条socket线程,并且将包括监听、读取和发送事件在内的所有事件交给epoll来处理,在没有事件时,epoll_wait会阻塞socket线程,当有事件过来时,则唤醒socket线程进行相应的事件处理,此外,要网络层还需要进行分包和粘包处理,将数据包收齐以后再转给业务层。
2、协议层
这里的协议层和网络协议并非一个概念,游戏客户端和服务端在进行数据包交互的时候,往往需要将游戏内存中的数据结构,转成网络字节序,才能通过网络发送到另外一端。而这个转化的过程发送端要遵循一个序列化的规则,而接收方则需要需要遵循一个反序列化规则,遵循规则才能将数据转成网络字节序,传输,然后在另一端还原,这些被共同遵循的规则,则是协议。比较知名的序列化,反序列化的库是Google的Protobuf。
3、存库机制
游戏中,玩家的操作比较频繁,涉及的数据变更也很频繁,如果每次都要进行写库,那么会导致游戏服务器的性能极具下降,因此,一般的游戏设计是,玩家的操作都是基于内存的,并且定时将数据写库,从而减缓数据库的压力。存库的方式,这里比较推荐用大blob的方式,比如将玩家所有的数据序列化为json字符串,然后作为blob存入数据库中,这样做的好处是,开发过程中,可以做到变更玩家存储结构而不用修改数据表。
4、逻辑层架构
对于逻辑层架构,不同的游戏有不同的设计,这里就不举例说明了,不过大体上,都需要思考以下几个流程
1)启服关服流程:这一点很重要,在游戏进行停服维护时,要确保玩家以及和玩家相关的数据均落地以后,才能够杀进程,关服流程需要作出这种保证。
2)玩家登录登出流程:这也是一个比较容易出问题的流程,比如是否允许玩家在两个端同时登录服务器,如果允许如何避免数据被相互覆盖,导致玩家数据错乱等问题,如果不允许,如果改玩家实例已经在线,要不要踢下线?是立刻踢,还是发送一个延时踢的指令,并且这段时间内操纵这个实例的客户端所有请求均会被拒绝,并且给请求登录的玩家返回一个不能登录的错误码?这些都是需要考虑的问题,而且是需要慎重考虑。
3)容灾、扩容机制:对于高可用的服务端集群来说,7*24小时地进行运作,难免有时候会出一些状况,比如某个进程挂掉了,对于这种情况,我们需要有一种容灾的机制,保证进程宕掉,不会轻易地就要重启整个集群。如何设计扩容机制,直接影响到,当玩家大量涌入时,将部分玩家分流到新开的进程内,并且不影响原来的玩家?这些都需要考虑,虽然不难,但是需要慎重考虑
4)游戏同步机制:不同的游戏,会采用不同的同步机制,不过最典型的就是状态同步和帧同步,如果题主没有了解过,可以去找找资料研究一下。
5)配置机制:这点无需多言,只要将策划的配置表,转成程序中可用的文件格式即可。
以上是我对游戏服务端的一些理解,至于学习路线
网络层方面:精读《计算机网络(谢希仁著)》《TCP/IP详解》打好理论基础,同时可以结合《Unix网络编程》作为实践。
协议层方面:可以去Google的Protobuf官网看看其用法和说明。
数据库:说起来惭愧,我并没有看过很多关于数据库的著作,不过《Redis设计与实现》这本不错,很多人推荐,至于MySql,题主可以网上找找。
此外,选择一个成熟的服务端框架,也是非常重要的,最好不要自己写,我推荐云风的skynet,有时间可以多看看框架的源码,去理解人家的设计思路,开发思想,绝对是大有裨益的。多上网找找开源的demo来研究,然后自己手写一些demo,实践很重要。
自学服务端还是很辛苦的,需要自己写客户端,自己写服务端才能够跑起一套流程,还是做单机“简单一些”。虽然实际工作中,服务端要比客户端轻松一些,囧
序列化和反序列化
概念
序列化:将对象变成字节流的形式传出去。
反序列化:从字节流恢复成原来的对象。
应用场景
(1) 将对象存储于硬盘上 ,便于以后反序列化使用(节省不常变的复杂数据结构数据的重复生成耗费大量时间,直接将数据序列化后存储再磁盘上)
(2)在网络上传送对象的字节序列(网络传输的便捷性,灵活性)
C++对象序列化的四种方法
Google Protocol Buffers(protobuf)
Google Protocol Buffers (GPB)是Google内部使用的数据编码方式,旨在用来取代XML进行数据交换。可用于数据序列化与反序列化。主要特性有:
- 高效
- 语言中立(Cpp, Java, Python)
- 可扩展
Boost.Serialization
Boost.Serialization能够创建或重建程序中的等效结构,并保存为二进制数据、文本数据、XML或者实用户自己定义的其它文件。该库具有下面吸引人的特性:
- 代码可移植(实现仅依赖于ANSI C++)。
- 深度指针保存与恢复。
- 能够序列化STL容器和其它经常使用模版库。
- 数据可移植。
- 非入侵性。
状态同步和帧同步
区别
- 最大的区别就是战斗核心逻辑写在哪,状态同步的战斗逻辑在服务端,帧同步的战斗逻辑在客户端
- 状态同步客户端就像是一个数据端数据的显示器
- 帧同步的服务端主要负责转发命令,不做任何的逻辑处理
- 耗费的流量
- 状态同步耗费的流浪更大,需要同步客户端的所有属性
- 帧同步不需要同步属性,只需要转发一次操作
- 回放
- 帧同步简单,只要保存所有人的操作就可以了
- 状态同步需要额外有一个观战服务器
- 安全性
- 状态同步安全,所有逻辑都在服务端,想要作弊就必须要攻击服务器。
- 帧同步的所有逻辑判断都在客户端,可以轻松开挂-
- 服务器压力
- 状态同步压力更大
- 开发效率
- 状态同步开发时间长
- 断线重连
- 状态同步的断线重连很好做,因为所有任务属性数值都在服务器,客户端只需要把所有场景和人物全部重新生成一遍就可以了
- 帧同步的断线就比较麻烦,服务器需要将断线之前的所有消息一次性发给客户端
区别
网盘项目这个网络层用的是http协议,底层是tcp在网络拥塞的情况下实时性会大打折扣(游戏服务端对实时性和数据一致性要求应该很高)
业务逻辑很简单,秒传判断,用户登录判断等(游戏的话业务玩家操作,物理碰撞逻辑应该会很复杂)
项目整体架构简单,没有实现反向代理和web服务端的mq异步削峰和解耦合处理,所有的web端都是拷贝,服务没有拆分;myslq和redis都没有做集群处理,只是在单机下实现一些数据一致性的处理;集群下的分布式事务和分布式锁没有实际实现,也不知道在实际业务场景下会发生哪些错误。
redis缓存没有使用,游戏服务器的大多数操作应该都是放在内存数据库中,或者以二进制json格式放在文档型数据库里面,定时持久化一下。
还有就是对zk的分布式锁没有使用过,可以实现redis不能实现的分布式读写锁的功能。
希望根据部门的实际业务来增加自己的技术深度和实际处理业务的能力。
如何实现查找附近的人
问题:给定一个用户A,返回与此用户相距小于d的所有用户。支持GEO的后端存储有MongoDB,Redis等。那么如果让我们实现,我们应该怎么做呢?
思路:围绕此用户生成一个圆形,半径是d,返回所有被此园覆盖的用户。
*方法1:* *先求方,再求园。*
如果直接求园,每一个用户都要计算距离值,无法利用到索引,可以先求方,将经度值和纬度值分别差值小于半径的点拿出来,然后在求园,将不符合点的用户过滤。
方法2: 位置敏感hash
MongoDB采用的就是这种方式:具体方式是先将整个地图分为四个相等的区域,然后给每个区域赋值,比如左下方是00,左上方是01,右下方是10,右上方是11.
a. 给定一个点,算出此点在哪个区域,比如在右上方,则是11,那么geohash的值的前两位就是11.
b. 将右上方区域继续切分为四个相等的区域,然后算出此点在哪个区域,如果在右下方,那么geohash的接下来两位就是10.
c 就这样迭代n次,geohash的值就是2n位的。n越大,精度越高,MongoDB的默认值是26次。
然后计算相邻的时候就比较容易了,因为hash值是位置敏感hash,所以越是相近的点那么他们的差值就越小。
方法2的优点显而易见,求相邻的速度非常快,是O(1), 缺点是模拟相邻,并不是真正的圆形等距离,不过这一缺点可以靠增加迭代次数实现,也就是精度提高。方法1的优点是严格的圆形等距离,但缺点是算法复杂度根附近的用户数相关。
目前已知解决方案有:
- mysql 自定义函数计算
- mysql geo索引
- mongodb geo索引
- postgresql PostGis索引
- redis geo
- ElasticSearch
游戏排行榜
需求背景:
查看前top N的排名用户
查看自己的排名
用户积分变更后,排名及时更新
方案一:
利用MySQL来实现,存放一张用户积分表user_score,结构如下:
取前top N,自己的排名都可以通过简单的sql语句搞定。
算法简单,利用sql的功能,不需要其他复杂逻辑,对于数据量比较少、性能要求不高,可以使用。但是对于海量数据,性能是无法接受的。
**方案二:**积分排名数组实现
如有1百万用户进行排名,就用一个大小为1,000,000的数组表示积分和排名的对应关系,其中rank[ s ]表示积分s所对应的排名。初始化时,rank数组可以由user_score表在O(n)的复杂度内计算而来。用户排名的查询和更新基于这个数组来进行。查询积分s所对应的排名直接返回rank[ s ]即可,复杂度为O(1);当用户积分从s变为s+n,只需要把rank[ s ]到rank[s+n-1]这n个元素的值增加1即可,复杂度为O(n)。
**方案四:**自己实现排序树
**方案五:**skiplist的实现
基本实现原理:
1、排行榜用的数据结构是跳表 SkipList (跳表是一种有序的链表,随机检索、插入和删除的性能非常高,Redis和LevelDB都有采用跳表这种数据结构,是一种空间换时间的算法)
2、通过玩家ID快速检索用一个Map<ID,SkipListNode>
3、数据库只存储上榜的人不存储排名(也可以定期备份,可以把1的有序排行定期备份)
过程描述:
1、服务器启动从DB中加载N个上榜的玩家
2、用跳表对其进行插入。插入完跳表是个有序的自然形成排行
3、当有玩家数据变动
1)如果排行榜已满,先判断Score是否比最后一名低,如果是直接抛弃
2)如果自己在排行榜,是 ,如果在帮就把自己的SkipListNode删除,然后插入
3)如果自己不在排行榜,则直接插入自己,删除最后一面,并向数据库发出存储指令(新增自己,删除最后一名,【如果自己和最后一名是一个人则什么也不做】)
**方案六:**基于redis的 sort set的实现
后来看redis发现redis的zset天生是用来做排行榜的、好友列表, 去重, 历史记录等业务需求。接口使用非常简单。接口非常丰富,基本上需要的实现都能满足,说明如下:
ZAdd/ZRem是O(log(N)),ZRangeByScore/ZRemRangeByScore是O(log(N)+M),N是Set大小,M是结果/操作元素的个数。
ZSET的实现用到了两个数据结构:hash table 和 skip list(跳跃表),其中hash table是具体使用redis中的dict来实现的,主要是为了保证查询效率为O(1) ,而skip list(跳跃表)主要是保证元素有序并能够保证INSERT和REMOVE操作是O(logn)的复杂度。
路径算法
迪杰斯塔拉
- 思路
- 用广度优先搜索解决单源最短路径问题,找到目标点后回溯一条最短路径树。
A*算法
- 思路
- f(n)=g(n)+h(n) :估价函数=起点到节点n的代价函数+节点到目标节点的估计代价函数
- 维持一个open表,根据f(n)排列,值越小优先级越高。
- 启发函数 不能太大,不能大于节点 到目标节点的实际代价;但如果 ,则A算法退化为Dijkstra算法,虽能保证得到最优路径,但算法效率低;如果 恰好等于节点 到目标节点的实际代价,则A算法探索的节点恰好就是最优路径上的节点
人工势场法
其基本思想是将目标和障碍物对机器人运动的影响具体化成人造势场。目标处势能低,障碍物处势能高。这种势差产生了目标对机器人的引力和障碍物对机器人的斥力,其合力控制机器人沿势场的负梯度方向向目标点运动。人工势场法计算方便,得到的路径安全平滑,但是复杂的势场环境可能在目标点之外产生局部极小点导致机器人无法到达目标。
局部最小点解决方法:当遇到局部极小点的时候就忽略引力和斥力的作用,沿着等势面移动,直到离开局部极小区域。
智力题
甲乙两人玩掷硬币的游戏。两人连续抛掷硬币,如果最近三次硬币的抛掷结果为“正反反”,则甲胜;如果是“反反正”,则乙胜。问:谁胜的概率更高?
我们再看一下甲乙的获胜子序列,“正反反”和“反反正”,甲获胜的可能性比较复杂,但是乙胜的情况却只能是从一开始就一直是反,如下:
反反正
反反反正
反反反反正
......
1234
如果两个反之前一但有正,就会造成甲的胜利。因此乙获胜的概率就是:(1/2)**3 + (1/2)**4+(1/2)**5+…。
这是一个收敛的几何级数,也就是等比数列,可以通过公式求和:a1/(1-r)=(1/8)/(1-1/2) = 1/4。
如何判断怪物进入攻击距离
- 方式1:通过主角和场景中的所有敌人比较
- 搜索所有敌人列表(在动态创建敌人时生成的)
- 列表存储的并非敌人的GameObject而是自定义的Enemy类
- 敌人的坐标向量减去Player的坐标向量的长度(使用magnitude)
- 敌人向量减去Player向量就能得到Player指向敌人的一个向量
- 求出Player指向敌人和Player指向正前方两向量的夹角,其实就是Player和敌人的夹角(不分左右)
- 缺点:双方各100人;那么使用方式1就是双方各有100个类实例,而每个类实例都包含这个判断方法,是循环对方类实例列表(大小100),那么双方加起来就是(2100100)的计算量了,当然手游的话不应该这么极端同时出现这么多模型的。即使是一个主角加100的情况下,主角类作这样的判断也是浪费的,因为一般主角旁边最多几个敌人的,不应该每次都查找所有敌人啊。
- 方式2:通过主角和射线检测到的敌人比较
- 球形射线检测周围怪物,不用循环所有怪物类列表,无法获取“Enemy”类
洗牌算法随机性的证明
对于N张牌,用大小为N的数组a[N];
for(i=0;i<N;i++)x=rand()%(n-i)+i;
if(x!=i)swap(a[i],a[x])
*如果: 1. 一张牌出现在任何位置的概率是相等的。*
*2. 一个位置出现任何牌的概率是相等的。*
*那么:我们可以认为他的随机性是可以保证的。*
下面就来证明这两点。
洗牌算法的过程可以简单的看做:先对n张牌中随机的抽取一张作为第一张牌,然后在N-1张牌中抽取一张作为第二张牌,以此类推…
1 很明显任何一张牌出现在第一个位置的概率都是相等的。P=1/N;
那任何一张牌出现在第二张的概率如何计算呢?首先该张牌不能出现在第一张,P=(N-1)/N; 而后需要从N-1张牌中被选中,概率 P=((N-1)/N)*(1/(N-1))=1/N;
以此类推可以知道,任何一张牌出现在任何位置的概率是相等的。条件1证明完毕。
2 同样的道理:
位置1出现任何牌的概率是1/N;
位置2出现任何牌的概率同样也是((N-1)/N)*(1/(N-1))=1/N;
评论系统
一个评论系统,支持大规模同时写入和随机翻页。
一、楼中楼模式
所谓楼中楼模式,就是每条评论占一楼,针对该评论的所有回复都在该楼里展现,比如百度贴吧、简书的评论系统。
**优势:**回复评论的内容集中展现,易于了解评论引发的对话。
**劣势:**内容过多时需要做分页处理,较为复杂。
数据表设计:
- id(自增主键)
- target_id (评论主题的id,可根据需要修改为article_id、course_id等等)
- parent_id(主评论id)
- reply_uid (记录被评论的用户id,回复主评论时可以0)
- uid(发表评论的用户id)
- content (评论内容)
- 其他字段… (时间、状态等)
二、流模式
流模式,顾名思义,展现形式类似于信息流,不管是评论还是回复,每条信息都占一层,比如laravel-china社区的评论系统。
优势: 逻辑简单,实现较为容易
劣势: 对话内容不能集中呈现,不便于了解对话内容。
数据表设计:
- id(自增主键)
- target_id (评论主题的id,可根据需要修改为article_id、course_id等等)
- reply_uid (记录被评论的用户id,回复主评论时可以0)
- uid(发表评论的用户id)
- content (评论内容)
- 其他字段… (时间、状态等)
三、引用模式
引用模式与流模式相似,只是回复的内容发布时会带上引用的内容。
**优势:**可以理解回复针对的是哪条评论,有助于了解对话内容。实现相对容易。
**劣势:**与流模式相似,不能完整呈现整个对话内容。
通过分析优劣势可以发现,引用模式是介于楼中楼以及流模式之间的一个折中方案。
总结
一般UGC系统的设计方针就是通过降低系统次要环节的实时一致性,在合理的成本范围内,尽量提高系统响应性能,而提高响应性能的手段归根结底就是三板斧:队列(Queue)、缓存(Cache)和分区(Sharding):
- 队列:可以缓解并发写操作的压力,提高系统伸缩性,同时也是异步化系统的最常见实现手段;
- 缓存:从文件系统到数据库再到内存的各级缓存模块,解决了数据就近读取的需求;
- 分区:保证了系统规模扩张和长期数据积累时,频繁操作的数据集规模在合理范围。
关于数据库:
- 区分冷热数据,按照读写操作规律合理拆分存储,一般UGC系统近期数据才是热点,历史数据是冷数据;
- 区分索引和实体数据,索引数据是Key,易变,一般用于筛选和定位,要保证充分的拆分存储,极端情况下要把关系数据库当NoSQL用;实体数据是Value,一般是正文文本,通常不变,一般业务下只按主键查询;两者要分开;
- 区分核心业务和附加业务数据,每一项附加的新业务数据都单独存储,与核心业务数据表分开,既可以降低核心业务数据库的变更成本,还可以避免新业务频繁调整上下线时影响核心业务。