first commit
This commit is contained in:
parent
2393162ba9
commit
c47809d1ff
@ -0,0 +1,710 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
29 加餐:HTTP 协议 + JSON-RPC,Dubbo 跨语言就是如此简单
|
||||
在前面课时介绍 Protocol 和 Invoker 实现时,我们重点介绍了 AbstractProtocol 以及 DubboInvoker 实现。其实,Protocol 还有一个实现分支是 AbstractProxyProtocol,如下图所示:
|
||||
|
||||
|
||||
|
||||
AbstractProxyProtocol 继承关系图
|
||||
|
||||
从图中我们可以看到:gRPC、HTTP、WebService、Hessian、Thrift 等协议对应的 Protocol 实现,都是继承自 AbstractProxyProtocol 抽象类。
|
||||
|
||||
目前互联网的技术栈百花齐放,很多公司会使用 Node.js、Python、Rails、Go 等语言来开发 一些 Web 端应用,同时又有很多服务会使用 Java 技术栈实现,这就出现了大量的跨语言调用的需求。Dubbo 作为一个 RPC 框架,自然也希望能实现这种跨语言的调用,目前 Dubbo 中使用“HTTP 协议 + JSON-RPC”的方式来达到这一目的,其中 HTTP 协议和 JSON 都是天然跨语言的标准,在各种语言中都有成熟的类库。
|
||||
|
||||
下面我们就重点来分析 Dubbo 对 HTTP 协议的支持。首先,我会介绍 JSON-RPC 的基础,并通过一个示例,帮助你快速入门,然后介绍 Dubbo 中 HttpProtocol 的具体实现,也就是如何将 HTTP 协议与 JSON-RPC 结合使用,实现跨语言调用的效果。
|
||||
|
||||
JSON-RPC
|
||||
|
||||
Dubbo 中支持的 HTTP 协议实际上使用的是 JSON-RPC 协议。
|
||||
|
||||
JSON-RPC 是基于 JSON 的跨语言远程调用协议。Dubbo 中的 dubbo-rpc-xml、dubbo-rpc-webservice 等模块支持的 XML-RPC、WebService 等协议与 JSON-RPC 一样,都是基于文本的协议,只不过 JSON 的格式比 XML、WebService 等格式更加简洁、紧凑。与 Dubbo 协议、Hessian 协议等二进制协议相比,JSON-RPC 更便于调试和实现,可见 JSON-RPC 协议还是一款非常优秀的远程调用协议。
|
||||
|
||||
在 Java 体系中,有很多成熟的 JSON-RPC 框架,例如 jsonrpc4j、jpoxy 等,其中,jsonrpc4j 本身体积小巧,使用方便,既可以独立使用,也可以与 Spring 无缝集合,非常适合基于 Spring 的项目。
|
||||
|
||||
下面我们先来看看 JSON-RPC 协议中请求的基本格式:
|
||||
|
||||
{
|
||||
|
||||
"id":1,
|
||||
|
||||
"method":"sayHello",
|
||||
|
||||
"params":[
|
||||
|
||||
"Dubbo json-rpc"
|
||||
|
||||
]
|
||||
|
||||
}
|
||||
|
||||
|
||||
JSON-RPC请求中各个字段的含义如下:
|
||||
|
||||
|
||||
id 字段,用于唯一标识一次远程调用。
|
||||
method 字段,指定了调用的方法名。
|
||||
params 数组,表示方法传入的参数,如果方法无参数传入,则传入空数组。
|
||||
|
||||
|
||||
在 JSON-RPC 的服务端收到调用请求之后,会查找到相应的方法并进行调用,然后将方法的返回值整理成如下格式,返回给客户端:
|
||||
|
||||
{
|
||||
|
||||
"id":1,
|
||||
|
||||
"result":"Hello Dubbo json-rpc",
|
||||
|
||||
"error":null
|
||||
|
||||
}
|
||||
|
||||
|
||||
JSON-RPC响应中各个字段的含义如下:
|
||||
|
||||
|
||||
id 字段,用于唯一标识一次远程调用,该值与请求中的 id 字段值保持一致。
|
||||
result 字段,记录了方法的返回值,若无返回值,则返回空;若调用错误,返回 null。
|
||||
error 字段,表示调用发生异常时的异常信息,方法执行无异常时该字段为 null。
|
||||
|
||||
|
||||
jsonrpc4j 基础使用
|
||||
|
||||
Dubbo 使用 jsonrpc4j 库来实现 JSON-RPC 协议,下面我们使用 jsonrpc4j 编写一个简单的 JSON-RPC 服务端示例程序和客户端示例程序,并通过这两个示例程序说明 jsonrpc4j 最基本的使用方式。
|
||||
|
||||
首先,我们需要创建服务端和客户端都需要的 domain 类以及服务接口。我们先来创建一个 User 类,作为最基础的数据对象:
|
||||
|
||||
public class User implements Serializable {
|
||||
|
||||
private int userId;
|
||||
|
||||
private String name;
|
||||
|
||||
private int age;
|
||||
|
||||
// 省略上述字段的getter/setter方法以及toString()方法
|
||||
|
||||
}
|
||||
|
||||
|
||||
接下来创建一个 UserService 接口作为服务接口,其中定义了 5 个方法,分别用来创建 User、查询 User 以及相关信息、删除 User:
|
||||
|
||||
public interface UserService {
|
||||
|
||||
User createUser(int userId, String name, int age);
|
||||
|
||||
User getUser(int userId);
|
||||
|
||||
String getUserName(int userId);
|
||||
|
||||
int getUserId(String name);
|
||||
|
||||
void deleteAll();
|
||||
|
||||
}
|
||||
|
||||
|
||||
UserServiceImpl 是 UserService 接口的实现类,其中使用一个 ArrayList 集合管理 User 对象,具体实现如下:
|
||||
|
||||
public class UserServiceImpl implements UserService {
|
||||
|
||||
// 管理所有User对象
|
||||
|
||||
private List<User> users = new ArrayList<>();
|
||||
|
||||
@Override
|
||||
|
||||
public User createUser(int userId, String name, int age) {
|
||||
|
||||
System.out.println("createUser method");
|
||||
|
||||
User user = new User();
|
||||
|
||||
user.setUserId(userId);
|
||||
|
||||
user.setName(name);
|
||||
|
||||
user.setAge(age);
|
||||
|
||||
users.add(user); // 创建User对象并添加到users集合中
|
||||
|
||||
return user;
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
public User getUser(int userId) {
|
||||
|
||||
System.out.println("getUser method");
|
||||
|
||||
// 根据userId从users集合中查询对应的User对象
|
||||
|
||||
return users.stream().filter(u -> u.getUserId() == userId).findAny().get();
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
public String getUserName(int userId) {
|
||||
|
||||
System.out.println("getUserName method");
|
||||
|
||||
// 根据userId从users集合中查询对应的User对象之后,获取该User的name
|
||||
|
||||
return getUser(userId).getName();
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
public int getUserId(String name) {
|
||||
|
||||
System.out.println("getUserId method");
|
||||
|
||||
// 根据name从users集合中查询对应的User对象,然后获取该User的id
|
||||
|
||||
return users.stream().filter(u -> u.getName().equals(name)).findAny().get().getUserId();
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
public void deleteAll() {
|
||||
|
||||
System.out.println("deleteAll");
|
||||
|
||||
users.clear(); // 清空users集合
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
整个用户管理业务的核心大致如此。下面我们来看服务端如何将 UserService 与 JSON-RPC 关联起来。
|
||||
|
||||
首先,我们创建 RpcServlet 类,它是 HttpServlet 的子类,并覆盖了 HttpServlet 的 service() 方法。我们知道,HttpServlet 在收到 GET 和 POST 请求的时候,最终会调用其 service() 方法进行处理;HttpServlet 还会将 HTTP 请求和响应封装成 HttpServletRequest 和 HttpServletResponse 传入 service() 方法之中。这里的 RpcServlet 实现之中会创建一个 JsonRpcServer,并在 service() 方法中将 HTTP 请求委托给 JsonRpcServer 进行处理:
|
||||
|
||||
public class RpcServlet extends HttpServlet {
|
||||
|
||||
private JsonRpcServer rpcServer = null;
|
||||
|
||||
public RpcServlet() {
|
||||
|
||||
super();
|
||||
|
||||
// JsonRpcServer会按照json-rpc请求,调用UserServiceImpl中的方法
|
||||
|
||||
rpcServer = new JsonRpcServer(new UserServiceImpl(), UserService.class);
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
protected void service(HttpServletRequest request,
|
||||
|
||||
HttpServletResponse response) throws ServletException, IOException {
|
||||
|
||||
rpcServer.handle(request, response);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
最后,我们创建一个 JsonRpcServer 作为服务端的入口类,在其 main() 方法中会启动 Jetty 作为 Web 容器,具体实现如下:
|
||||
|
||||
public class JsonRpcServer {
|
||||
|
||||
public static void main(String[] args) throws Throwable {
|
||||
|
||||
// 服务器的监听端口
|
||||
|
||||
Server server = new Server(9999);
|
||||
|
||||
// 关联一个已经存在的上下文
|
||||
|
||||
WebAppContext context = new WebAppContext();
|
||||
|
||||
// 设置描述符位置
|
||||
|
||||
context.setDescriptor("/dubbo-demo/json-rpc-demo/src/main/webapp/WEB-INF/web.xml");
|
||||
|
||||
// 设置Web内容上下文路径
|
||||
|
||||
context.setResourceBase("/dubbo-demo/json-rpc-demo/src/main/webapp");
|
||||
|
||||
// 设置上下文路径
|
||||
|
||||
context.setContextPath("/");
|
||||
|
||||
context.setParentLoaderPriority(true);
|
||||
|
||||
server.setHandler(context);
|
||||
|
||||
server.start();
|
||||
|
||||
server.join();
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
这里使用到的 web.xml 配置文件如下:
|
||||
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<web-app
|
||||
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
|
||||
xmlns="http://xmlns.jcp.org/xml/ns/javaee"
|
||||
|
||||
xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_3_1.xsd"
|
||||
|
||||
version="3.1">
|
||||
|
||||
<servlet>
|
||||
|
||||
<servlet-name>RpcServlet</servlet-name>
|
||||
|
||||
<servlet-class>com.demo.RpcServlet</servlet-class>
|
||||
|
||||
</servlet>
|
||||
|
||||
<servlet-mapping>
|
||||
|
||||
<servlet-name>RpcServlet</servlet-name>
|
||||
|
||||
<url-pattern>/rpc</url-pattern>
|
||||
|
||||
</servlet-mapping>
|
||||
|
||||
</web-app>
|
||||
|
||||
|
||||
完成服务端的编写之后,下面我们再继续编写 JSON-RPC 的客户端。在 JsonRpcClient 中会创建 JsonRpcHttpClient,并通过 JsonRpcHttpClient 请求服务端:
|
||||
|
||||
public class JsonRpcClient {
|
||||
|
||||
private static JsonRpcHttpClient rpcHttpClient;
|
||||
|
||||
public static void main(String[] args) throws Throwable {
|
||||
|
||||
// 创建JsonRpcHttpClient
|
||||
|
||||
rpcHttpClient = new JsonRpcHttpClient(new URL("http://127.0.0.1:9999/rpc"));
|
||||
|
||||
JsonRpcClient jsonRpcClient = new JsonRpcClient();
|
||||
|
||||
jsonRpcClient.deleteAll(); // 调用deleteAll()方法删除全部User
|
||||
|
||||
// 调用createUser()方法创建User
|
||||
|
||||
System.out.println(jsonRpcClient.createUser(1, "testName", 30));
|
||||
|
||||
// 调用getUser()、getUserName()、getUserId()方法进行查询
|
||||
|
||||
System.out.println(jsonRpcClient.getUser(1));
|
||||
|
||||
System.out.println(jsonRpcClient.getUserName(1));
|
||||
|
||||
System.out.println(jsonRpcClient.getUserId("testName"));
|
||||
|
||||
}
|
||||
|
||||
public void deleteAll() throws Throwable {
|
||||
|
||||
// 调用服务端的deleteAll()方法
|
||||
|
||||
rpcHttpClient.invoke("deleteAll", null);
|
||||
|
||||
}
|
||||
|
||||
public User createUser(int userId, String name, int age) throws Throwable {
|
||||
|
||||
Object[] params = new Object[]{userId, name, age};
|
||||
|
||||
// 调用服务端的createUser()方法
|
||||
|
||||
return rpcHttpClient.invoke("createUser", params, User.class);
|
||||
|
||||
}
|
||||
|
||||
public User getUser(int userId) throws Throwable {
|
||||
|
||||
Integer[] params = new Integer[]{userId};
|
||||
|
||||
// 调用服务端的getUser()方法
|
||||
|
||||
return rpcHttpClient.invoke("getUser", params, User.class);
|
||||
|
||||
}
|
||||
|
||||
public String getUserName(int userId) throws Throwable {
|
||||
|
||||
Integer[] params = new Integer[]{userId};
|
||||
|
||||
// 调用服务端的getUserName()方法
|
||||
|
||||
return rpcHttpClient.invoke("getUserName", params, String.class);
|
||||
|
||||
}
|
||||
|
||||
public int getUserId(String name) throws Throwable {
|
||||
|
||||
String[] params = new String[]{name};
|
||||
|
||||
// 调用服务端的getUserId()方法
|
||||
|
||||
return rpcHttpClient.invoke("getUserId", params, Integer.class);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// 输出:
|
||||
|
||||
// User{userId=1, name='testName', age=30}
|
||||
|
||||
// User{userId=1, name='testName', age=30}
|
||||
|
||||
// testName
|
||||
|
||||
// 1
|
||||
|
||||
|
||||
AbstractProxyProtocol
|
||||
|
||||
在 AbstractProxyProtocol 的 export() 方法中,首先会根据 URL 检查 exporterMap 缓存,如果查询失败,则会调用 ProxyFactory.getProxy() 方法将 Invoker 封装成业务接口的代理类,然后通过子类实现的 doExport() 方法启动底层的 ProxyProtocolServer,并初始化 serverMap 集合。具体实现如下:
|
||||
|
||||
public <T> Exporter<T> export(final Invoker<T> invoker) throws RpcException {
|
||||
|
||||
// 首先查询exporterMap集合
|
||||
|
||||
final String uri = serviceKey(invoker.getUrl());
|
||||
|
||||
Exporter<T> exporter = (Exporter<T>) exporterMap.get(uri);
|
||||
|
||||
if (exporter != null) {
|
||||
|
||||
if (Objects.equals(exporter.getInvoker().getUrl(), invoker.getUrl())) {
|
||||
|
||||
return exporter;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// 通过ProxyFactory创建代理类,将Invoker封装成业务接口的代理类
|
||||
|
||||
final Runnable runnable = doExport(proxyFactory.getProxy(invoker, true), invoker.getInterface(), invoker.getUrl());
|
||||
|
||||
// doExport()方法返回的Runnable是一个回调,其中会销毁底层的Server,将会在unexport()方法中调用该Runnable
|
||||
|
||||
exporter = new AbstractExporter<T>(invoker) {
|
||||
|
||||
public void unexport() {
|
||||
|
||||
super.unexport();
|
||||
|
||||
exporterMap.remove(uri);
|
||||
|
||||
if (runnable != null) {
|
||||
|
||||
runnable.run();
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
exporterMap.put(uri, exporter);
|
||||
|
||||
return exporter;
|
||||
|
||||
}
|
||||
|
||||
|
||||
在 HttpProtocol 的 doExport() 方法中,与前面介绍的 DubboProtocol 的实现类似,也要启动一个 RemotingServer。为了适配各种 HTTP 服务器,例如,Tomcat、Jetty 等,Dubbo 在 Transporter 层抽象出了一个 HttpServer 的接口。
|
||||
|
||||
|
||||
|
||||
dubbo-remoting-http 模块位置
|
||||
|
||||
dubbo-remoting-http 模块的入口是 HttpBinder 接口,它被 @SPI 注解修饰,是一个扩展接口,有三个扩展实现,默认使用的是 JettyHttpBinder 实现,如下图所示:
|
||||
|
||||
|
||||
|
||||
JettyHttpBinder 继承关系图
|
||||
|
||||
HttpBinder 接口中的 bind() 方法被 @Adaptive 注解修饰,会根据 URL 的 server 参数选择相应的 HttpBinder 扩展实现,不同 HttpBinder 实现返回相应的 HttpServer 实现。HttpServer 的继承关系如下图所示:
|
||||
|
||||
|
||||
|
||||
HttpServer 继承关系图
|
||||
|
||||
这里我们以 JettyHttpServer 为例简单介绍 HttpServer 的实现,在 JettyHttpServer 中会初始化 Jetty Server,其中会配置 Jetty Server 使用到的线程池以及处理请求 Handler:
|
||||
|
||||
public JettyHttpServer(URL url, final HttpHandler handler) {
|
||||
|
||||
// 初始化AbstractHttpServer中的url字段和handler字段
|
||||
|
||||
super(url, handler);
|
||||
|
||||
this.url = url;
|
||||
|
||||
DispatcherServlet.addHttpHandler( // 添加HttpHandler
|
||||
|
||||
url.getParameter(Constants.BIND_PORT_KEY,
|
||||
|
||||
url.getPort()), handler);
|
||||
|
||||
// 创建线程池
|
||||
|
||||
int threads = url.getParameter(THREADS_KEY, DEFAULT_THREADS);
|
||||
|
||||
QueuedThreadPool threadPool = new QueuedThreadPool();
|
||||
|
||||
threadPool.setDaemon(true);
|
||||
|
||||
threadPool.setMaxThreads(threads);
|
||||
|
||||
threadPool.setMinThreads(threads);
|
||||
|
||||
// 创建Jetty Server
|
||||
|
||||
server = new Server(threadPool);
|
||||
|
||||
// 创建ServerConnector,并指定绑定的ip和port
|
||||
|
||||
ServerConnector connector = new ServerConnector(server);
|
||||
|
||||
String bindIp = url.getParameter(Constants.BIND_IP_KEY, url.getHost());
|
||||
|
||||
if (!url.isAnyHost() && NetUtils.isValidLocalHost(bindIp)) {
|
||||
|
||||
connector.setHost(bindIp);
|
||||
|
||||
}
|
||||
|
||||
connector.setPort(url.getParameter(Constants.BIND_PORT_KEY, url.getPort()));
|
||||
|
||||
server.addConnector(connector);
|
||||
|
||||
// 创建ServletHandler并与Jetty Server关联,由DispatcherServlet处理全部的请求
|
||||
|
||||
ServletHandler servletHandler = new ServletHandler();
|
||||
|
||||
ServletHolder servletHolder = servletHandler.addServletWithMapping(DispatcherServlet.class, "/*");
|
||||
|
||||
servletHolder.setInitOrder(2);
|
||||
|
||||
// 创建ServletContextHandler并与Jetty Server关联
|
||||
|
||||
ServletContextHandler context = new ServletContextHandler(server, "/", ServletContextHandler.SESSIONS);
|
||||
|
||||
context.setServletHandler(servletHandler);
|
||||
|
||||
ServletManager.getInstance().addServletContext(url.getParameter(Constants.BIND_PORT_KEY, url.getPort()), context.getServletContext());
|
||||
|
||||
server.start();
|
||||
|
||||
}
|
||||
|
||||
|
||||
我们可以看到 JettyHttpServer 收到的全部请求将委托给 DispatcherServlet 这个 HttpServlet 实现,而 DispatcherServlet 的 service() 方法会把请求委托给对应接端口的 HttpHandler 处理:
|
||||
|
||||
protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
|
||||
|
||||
// 从HANDLERS集合中查询端口对应的HttpHandler对象
|
||||
|
||||
HttpHandler handler = HANDLERS.get(request.getLocalPort());
|
||||
|
||||
if (handler == null) { // 端口没有对应的HttpHandler实现
|
||||
|
||||
response.sendError(HttpServletResponse.SC_NOT_FOUND, "Service not found.");
|
||||
|
||||
} else { // 将请求委托给HttpHandler对象处理
|
||||
|
||||
handler.handle(request, response);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
了解了 Dubbo 对 HttpServer 的抽象以及 JettyHttpServer 的核心之后,我们回到 HttpProtocol 中的 doExport() 方法继续分析。
|
||||
|
||||
在 HttpProtocol.doExport() 方法中会通过 HttpBinder 创建前面介绍的 HttpServer 对象,并记录到 serverMap 中用来接收 HTTP 请求。这里初始化 HttpServer 以及处理请求用到的 HttpHandler 是 HttpProtocol 中的内部类,在其他使用 HTTP 协议作为基础的 RPC 协议实现中也有类似的 HttpHandler 实现类,如下图所示:
|
||||
|
||||
|
||||
|
||||
HttpHandler 继承关系图
|
||||
|
||||
在 HttpProtocol.InternalHandler 中的 handle() 实现中,会将请求委托给 skeletonMap 集合中记录的 JsonRpcServer 对象进行处理:
|
||||
|
||||
public void handle(HttpServletRequest request, HttpServletResponse response) throws ServletException {
|
||||
|
||||
String uri = request.getRequestURI();
|
||||
|
||||
JsonRpcServer skeleton = skeletonMap.get(uri);
|
||||
|
||||
if (cors) { ... // 处理跨域问题 }
|
||||
|
||||
if (request.getMethod().equalsIgnoreCase("OPTIONS")) {
|
||||
|
||||
response.setStatus(200); // 处理OPTIONS请求
|
||||
|
||||
} else if (request.getMethod().equalsIgnoreCase("POST")) {
|
||||
|
||||
// 只处理POST请求
|
||||
|
||||
RpcContext.getContext().setRemoteAddress(
|
||||
|
||||
request.getRemoteAddr(), request.getRemotePort());
|
||||
|
||||
skeleton.handle(request.getInputStream(), response.getOutputStream());
|
||||
|
||||
} else {// 其他Method类型的请求,例如,GET请求,直接返回500
|
||||
|
||||
response.setStatus(500);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
skeletonMap 集合中的 JsonRpcServer 是与 HttpServer 对象一同在 doExport() 方法中初始化的。最后,我们来看 HttpProtocol.doExport() 方法的实现:
|
||||
|
||||
protected <T> Runnable doExport(final T impl, Class<T> type, URL url) throws RpcException {
|
||||
|
||||
String addr = getAddr(url);
|
||||
|
||||
// 先查询serverMap缓存
|
||||
|
||||
ProtocolServer protocolServer = serverMap.get(addr);
|
||||
|
||||
if (protocolServer == null) { // 查询缓存失败
|
||||
|
||||
// 创建HttpServer,注意,传入的HttpHandler实现是InternalHandler
|
||||
|
||||
RemotingServer remotingServer = httpBinder.bind(url, new InternalHandler(url.getParameter("cors", false)));
|
||||
|
||||
serverMap.put(addr, new ProxyProtocolServer(remotingServer));
|
||||
|
||||
}
|
||||
|
||||
// 创建JsonRpcServer对象,并将URL与JsonRpcServer的映射关系记录到skeletonMap集合中
|
||||
|
||||
final String path = url.getAbsolutePath();
|
||||
|
||||
final String genericPath = path + "/" + GENERIC_KEY;
|
||||
|
||||
JsonRpcServer skeleton = new JsonRpcServer(impl, type);
|
||||
|
||||
JsonRpcServer genericServer = new JsonRpcServer(impl, GenericService.class);
|
||||
|
||||
skeletonMap.put(path, skeleton);
|
||||
|
||||
skeletonMap.put(genericPath, genericServer);
|
||||
|
||||
return () -> {// 返回Runnable回调,在Exporter中的unexport()方法中执行
|
||||
|
||||
skeletonMap.remove(path);
|
||||
|
||||
skeletonMap.remove(genericPath);
|
||||
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
|
||||
介绍完 HttpProtocol 暴露服务的相关实现之后,下面我们再来看 HttpProtocol 中引用服务相关的方法实现,即 protocolBindinRefer() 方法实现。该方法首先通过 doRefer() 方法创建业务接口的代理,这里会使用到 jsonrpc4j 库中的 JsonProxyFactoryBean 与 Spring 进行集成,在其 afterPropertiesSet() 方法中会创建 JsonRpcHttpClient 对象:
|
||||
|
||||
public void afterPropertiesSet() {
|
||||
|
||||
... ... // 省略ObjectMapper等对象
|
||||
|
||||
try {
|
||||
|
||||
// 创建JsonRpcHttpClient,用于后续发送json-rpc请求
|
||||
|
||||
jsonRpcHttpClient = new JsonRpcHttpClient(objectMapper, new URL(getServiceUrl()), extraHttpHeaders);
|
||||
|
||||
jsonRpcHttpClient.setRequestListener(requestListener);
|
||||
|
||||
jsonRpcHttpClient.setSslContext(sslContext);
|
||||
|
||||
jsonRpcHttpClient.setHostNameVerifier(hostNameVerifier);
|
||||
|
||||
} catch (MalformedURLException mue) {
|
||||
|
||||
throw new RuntimeException(mue);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
下面来看 doRefer() 方法的具体实现:
|
||||
|
||||
protected <T> T doRefer(final Class<T> serviceType, URL url) throws RpcException {
|
||||
|
||||
final String generic = url.getParameter(GENERIC_KEY);
|
||||
|
||||
final boolean isGeneric = ProtocolUtils.isGeneric(generic) || serviceType.equals(GenericService.class);
|
||||
|
||||
JsonProxyFactoryBean jsonProxyFactoryBean = new JsonProxyFactoryBean();
|
||||
|
||||
... // 省略其他初始化逻辑
|
||||
|
||||
jsonProxyFactoryBean.afterPropertiesSet();
|
||||
|
||||
// 返回的是serviceType类型的代理对象
|
||||
|
||||
return (T) jsonProxyFactoryBean.getObject();
|
||||
|
||||
}
|
||||
|
||||
|
||||
在 AbstractProxyProtocol.protocolBindingRefer() 方法中,会通过 ProxyFactory.getInvoker() 方法将 doRefer() 方法返回的代理对象转换成 Invoker 对象,并记录到 Invokers 集合中,具体实现如下:
|
||||
|
||||
protected <T> Invoker<T> protocolBindingRefer(final Class<T> type, final URL url) throws RpcException {
|
||||
|
||||
final Invoker<T> target = proxyFactory.getInvoker(doRefer(type, url), type, url);
|
||||
|
||||
Invoker<T> invoker = new AbstractInvoker<T>(type, url) {
|
||||
|
||||
@Override
|
||||
|
||||
protected Result doInvoke(Invocation invocation) throws Throwable {
|
||||
|
||||
Result result = target.invoke(invocation);
|
||||
|
||||
// 省略处理异常的逻辑
|
||||
|
||||
return result;
|
||||
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
invokers.add(invoker); // 将Invoker添加到invokers集合中
|
||||
|
||||
return invoker;
|
||||
|
||||
}
|
||||
|
||||
|
||||
总结
|
||||
|
||||
本课时重点介绍了在 Dubbo 中如何通过“HTTP 协议 + JSON-RPC”的方案实现跨语言调用。首先我们介绍了 JSON-RPC 中请求和响应的基本格式,以及其实现库 jsonrpc4j 的基本使用;接下来我们还详细介绍了 Dubbo 中 AbstractProxyProtocol、HttpProtocol 等核心类,剖析了 Dubbo 中“HTTP 协议 + JSON-RPC”方案的落地实现。
|
||||
|
||||
|
||||
|
||||
|
@ -0,0 +1,378 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
25 PersistentVolume + NFS:怎么使用网络共享存储?
|
||||
你好,我是Chrono。
|
||||
|
||||
在上节课里我们看到了Kubernetes里的持久化存储对象PersistentVolume、PersistentVolumeClaim、StorageClass,把它们联合起来就可以为Pod挂载一块“虚拟盘”,让Pod在其中任意读写数据。
|
||||
|
||||
不过当时我们使用的是HostPath,存储卷只能在本机使用,而Kubernetes里的Pod经常会在集群里“漂移”,所以这种方式不是特别实用。
|
||||
|
||||
要想让存储卷真正能被Pod任意挂载,我们需要变更存储的方式,不能限定在本地磁盘,而是要改成网络存储,这样Pod无论在哪里运行,只要知道IP地址或者域名,就可以通过网络通信访问存储设备。
|
||||
|
||||
网络存储是一个非常热门的应用领域,有很多知名的产品,比如AWS、Azure、Ceph,Kubernetes还专门定义了CSI(Container Storage Interface)规范,不过这些存储类型的安装、使用都比较复杂,在我们的实验环境里部署难度比较高。
|
||||
|
||||
所以今天的这次课里,我选择了相对来说比较简单的NFS系统(Network File System),以它为例讲解如何在Kubernetes里使用网络存储,以及静态存储卷和动态存储卷的概念。
|
||||
|
||||
如何安装NFS服务器
|
||||
|
||||
作为一个经典的网络存储系统,NFS有着近40年的发展历史,基本上已经成为了各种UNIX系统的标准配置,Linux自然也提供对它的支持。
|
||||
|
||||
NFS采用的是Client/Server架构,需要选定一台主机作为Server,安装NFS服务端;其他要使用存储的主机作为Client,安装NFS客户端工具。
|
||||
|
||||
所以接下来,我们在自己的Kubernetes集群里再增添一台名字叫Storage的服务器,在上面安装NFS,实现网络存储、共享网盘的功能。不过这台Storage也只是一个逻辑概念,我们在实际安装部署的时候完全可以把它合并到集群里的某台主机里,比如这里我就复用了[第17讲]里的Console。
|
||||
|
||||
新的网络架构如下图所示:
|
||||
|
||||
|
||||
|
||||
在Ubuntu系统里安装NFS服务端很容易,使用apt即可:
|
||||
|
||||
sudo apt -y install nfs-kernel-server
|
||||
|
||||
|
||||
安装好之后,你需要给NFS指定一个存储位置,也就是网络共享目录。一般来说,应该建立一个专门的 /data 目录,这里为了简单起见,我就使用了临时目录 /tmp/nfs:
|
||||
|
||||
mkdir -p /tmp/nfs
|
||||
|
||||
|
||||
接下来你需要配置NFS访问共享目录,修改 /etc/exports,指定目录名、允许访问的网段,还有权限等参数。这些规则比较琐碎,和我们的Kubernetes课程关联不大,我就不详细解释了,你只要把下面这行加上就行,注意目录名和IP地址要改成和自己的环境一致:
|
||||
|
||||
/tmp/nfs 192.168.10.0/24(rw,sync,no_subtree_check,no_root_squash,insecure)
|
||||
|
||||
|
||||
改好之后,需要用 exportfs -ra 通知NFS,让配置生效,再用 exportfs -v 验证效果:
|
||||
|
||||
sudo exportfs -ra
|
||||
sudo exportfs -v
|
||||
|
||||
|
||||
|
||||
|
||||
现在,你就可以使用 systemctl 来启动NFS服务器了:
|
||||
|
||||
sudo systemctl start nfs-server
|
||||
sudo systemctl enable nfs-server
|
||||
sudo systemctl status nfs-server
|
||||
|
||||
|
||||
|
||||
|
||||
你还可以使用命令 showmount 来检查NFS的网络挂载情况:
|
||||
|
||||
showmount -e 127.0.0.1
|
||||
|
||||
|
||||
|
||||
|
||||
如何安装NFS客户端
|
||||
|
||||
有了NFS服务器之后,为了让Kubernetes集群能够访问NFS存储服务,我们还需要在每个节点上都安装NFS客户端。
|
||||
|
||||
这项工作只需要一条apt命令,不需要额外的配置:
|
||||
|
||||
sudo apt -y install nfs-common
|
||||
|
||||
|
||||
同样,在节点上可以用 showmount 检查NFS能否正常挂载,注意IP地址要写成NFS服务器的地址,我在这里就是“192.168.10.208”:
|
||||
|
||||
|
||||
|
||||
现在让我们尝试手动挂载一下NFS网络存储,先创建一个目录 /tmp/test 作为挂载点:
|
||||
|
||||
mkdir -p /tmp/test
|
||||
|
||||
|
||||
然后用命令 mount 把NFS服务器的共享目录挂载到刚才创建的本地目录上:
|
||||
|
||||
sudo mount -t nfs 192.168.10.208:/tmp/nfs /tmp/test
|
||||
|
||||
|
||||
最后测试一下,我们在 /tmp/test 里随便创建一个文件,比如 x.yml:
|
||||
|
||||
touch /tmp/test/x.yml
|
||||
|
||||
|
||||
再回到NFS服务器,检查共享目录 /tmp/nfs,应该会看到也出现了一个同样的文件 x.yml,这就说明NFS安装成功了。之后集群里的任意节点,只要通过NFS客户端,就能把数据写入NFS服务器,实现网络存储。
|
||||
|
||||
如何使用NFS存储卷
|
||||
|
||||
现在我们已经为Kubernetes配置好了NFS存储系统,就可以使用它来创建新的PV存储对象了。
|
||||
|
||||
先来手工分配一个存储卷,需要指定 storageClassName 是 nfs,而 accessModes 可以设置成 ReadWriteMany,这是由NFS的特性决定的,它支持多个节点同时访问一个共享目录。
|
||||
|
||||
因为这个存储卷是NFS系统,所以我们还需要在YAML里添加 nfs 字段,指定NFS服务器的IP地址和共享目录名。
|
||||
|
||||
这里我在NFS服务器的 /tmp/nfs 目录里又创建了一个新的目录 1g-pv,表示分配了1GB的可用存储空间,相应的,PV里的 capacity 也要设置成同样的数值,也就是 1Gi。
|
||||
|
||||
把这些字段都整理好后,我们就得到了一个使用NFS网络存储的YAML描述文件:
|
||||
|
||||
apiVersion: v1
|
||||
kind: PersistentVolume
|
||||
metadata:
|
||||
name: nfs-1g-pv
|
||||
|
||||
spec:
|
||||
storageClassName: nfs
|
||||
accessModes:
|
||||
- ReadWriteMany
|
||||
capacity:
|
||||
storage: 1Gi
|
||||
|
||||
nfs:
|
||||
path: /tmp/nfs/1g-pv
|
||||
server: 192.168.10.208
|
||||
|
||||
|
||||
现在就可以用命令 kubectl apply 来创建PV对象,再用 kubectl get pv 查看它的状态:
|
||||
|
||||
kubectl apply -f nfs-static-pv.yml
|
||||
kubectl get pv
|
||||
|
||||
|
||||
|
||||
|
||||
再次提醒你注意,spec.nfs 里的IP地址一定要正确,路径一定要存在(事先创建好),否则Kubernetes按照PV的描述会无法挂载NFS共享目录,PV就会处于“pending”状态无法使用。
|
||||
|
||||
有了PV,我们就可以定义申请存储的PVC对象了,它的内容和PV差不多,但不涉及NFS存储的细节,只需要用 resources.request 来表示希望要有多大的容量,这里我写成1GB,和PV的容量相同:
|
||||
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: nfs-static-pvc
|
||||
|
||||
spec:
|
||||
storageClassName: nfs
|
||||
accessModes:
|
||||
- ReadWriteMany
|
||||
|
||||
resources:
|
||||
requests:
|
||||
storage: 1Gi
|
||||
|
||||
|
||||
创建PVC对象之后,Kubernetes就会根据PVC的描述,找到最合适的PV,把它们“绑定”在一起,也就是存储分配成功:
|
||||
|
||||
|
||||
|
||||
我们再创建一个Pod,把PVC挂载成它的一个volume,具体的做法和[上节课]是一样的,用 persistentVolumeClaim 指定PVC的名字就可以了:
|
||||
|
||||
apiVersion: v1
|
||||
kind: Pod
|
||||
metadata:
|
||||
name: nfs-static-pod
|
||||
|
||||
spec:
|
||||
volumes:
|
||||
- name: nfs-pvc-vol
|
||||
persistentVolumeClaim:
|
||||
claimName: nfs-static-pvc
|
||||
|
||||
containers:
|
||||
- name: nfs-pvc-test
|
||||
image: nginx:alpine
|
||||
ports:
|
||||
- containerPort: 80
|
||||
|
||||
volumeMounts:
|
||||
- name: nfs-pvc-vol
|
||||
mountPath: /tmp
|
||||
|
||||
|
||||
Pod、PVC、PV和NFS存储的关系可以用下图来形象地表示,你可以对比一下HostPath PV的用法,看看有什么不同:
|
||||
|
||||
|
||||
|
||||
因为我们在PV/PVC里指定了 storageClassName 是 nfs,节点上也安装了NFS客户端,所以Kubernetes就会自动执行NFS挂载动作,把NFS的共享目录 /tmp/nfs/1g-pv 挂载到Pod里的 /tmp,完全不需要我们去手动管理。
|
||||
|
||||
最后还是测试一下,用 kubectl apply 创建Pod之后,我们用 kubectl exec 进入Pod,再试着操作NFS共享目录:
|
||||
|
||||
|
||||
|
||||
退出Pod,再看一下NFS服务器的 /tmp/nfs/1g-pv 目录,你就会发现Pod里创建的文件确实写入了共享目录:
|
||||
|
||||
|
||||
|
||||
而且更好的是,因为NFS是一个网络服务,不会受Pod调度位置的影响,所以只要网络通畅,这个PV对象就会一直可用,数据也就实现了真正的持久化存储。
|
||||
|
||||
如何部署NFS Provisoner
|
||||
|
||||
现在有了NFS这样的网络存储系统,你是不是认为Kubernetes里的数据持久化问题就已经解决了呢?
|
||||
|
||||
对于这个问题,我觉得可以套用一句现在的流行语:“解决了,但没有完全解决。”
|
||||
|
||||
说它“解决了”,是因为网络存储系统确实能够让集群里的Pod任意访问,数据在Pod销毁后仍然存在,新创建的Pod可以再次挂载,然后读取之前写入的数据,整个过程完全是自动化的。
|
||||
|
||||
说它“没有完全解决”,是因为PV还是需要人工管理,必须要由系统管理员手动维护各种存储设备,再根据开发需求逐个创建PV,而且PV的大小也很难精确控制,容易出现空间不足或者空间浪费的情况。
|
||||
|
||||
在我们的这个实验环境里,只有很少的PV需求,管理员可以很快分配PV存储卷,但是在一个大集群里,每天可能会有几百几千个应用需要PV存储,如果仍然用人力来管理分配存储,管理员很可能会忙得焦头烂额,导致分配存储的工作大量积压。
|
||||
|
||||
那么能不能让创建PV的工作也实现自动化呢?或者说,让计算机来代替人类来分配存储卷呢?
|
||||
|
||||
这个在Kubernetes里就是“动态存储卷”的概念,它可以用StorageClass绑定一个Provisioner对象,而这个Provisioner就是一个能够自动管理存储、创建PV的应用,代替了原来系统管理员的手工劳动。
|
||||
|
||||
有了“动态存储卷”的概念,前面我们讲的手工创建的PV就可以称为“静态存储卷”。
|
||||
|
||||
目前,Kubernetes里每类存储设备都有相应的Provisioner对象,对于NFS来说,它的Provisioner就是“NFS subdir external provisioner”,你可以在GitHub上找到这个项目(https://github.com/kubernetes-sigs/nfs-subdir-external-provisioner)。
|
||||
|
||||
NFS Provisioner也是以Pod的形式运行在Kubernetes里的,在GitHub的 deploy 目录里是部署它所需的YAML文件,一共有三个,分别是rbac.yaml、class.yaml和deployment.yaml。
|
||||
|
||||
不过这三个文件只是示例,想在我们的集群里真正运行起来还要修改其中的两个文件。
|
||||
|
||||
第一个要修改的是rbac.yaml,它使用的是默认的 default 名字空间,应该把它改成其他的名字空间,避免与普通应用混在一起,你可以用“查找替换”的方式把它统一改成 kube-system。
|
||||
|
||||
第二个要修改的是deployment.yaml,它要修改的地方比较多。首先要把名字空间改成和rbac.yaml一样,比如是 kube-system,然后重点要修改 volumes 和 env 里的IP地址和共享目录名,必须和集群里的NFS服务器配置一样。
|
||||
|
||||
按照我们当前的环境设置,就应该把IP地址改成 192.168.10.208,目录名改成 /tmp/nfs:
|
||||
|
||||
spec:
|
||||
template:
|
||||
spec:
|
||||
serviceAccountName: nfs-client-provisioner
|
||||
containers:
|
||||
...
|
||||
env:
|
||||
- name: PROVISIONER_NAME
|
||||
value: k8s-sigs.io/nfs-subdir-external-provisioner
|
||||
- name: NFS_SERVER
|
||||
value: 192.168.10.208 #改IP地址
|
||||
- name: NFS_PATH
|
||||
value: /tmp/nfs #改共享目录名
|
||||
volumes:
|
||||
- name: nfs-client-root
|
||||
nfs:
|
||||
server: 192.168.10.208 #改IP地址
|
||||
Path: /tmp/nfs #改共享目录名
|
||||
|
||||
|
||||
还有一件麻烦事,deployment.yaml的镜像仓库用的是gcr.io,拉取很困难,而国内的镜像网站上偏偏还没有它,为了让实验能够顺利进行,我不得不“曲线救国”,把它的镜像转存到了Docker Hub上。
|
||||
|
||||
所以你还需要把镜像的名字由原来的“k8s.gcr.io/sig-storage/nfs-subdir-external-provisioner:v4.0.2”改成“chronolaw/nfs-subdir-external-provisioner:v4.0.2”,其实也就是变动一下镜像的用户名而已。
|
||||
|
||||
把这两个YAML修改好之后,我们就可以在Kubernetes里创建NFS Provisioner了:
|
||||
|
||||
kubectl apply -f rbac.yaml
|
||||
kubectl apply -f class.yaml
|
||||
kubectl apply -f deployment.yaml
|
||||
|
||||
|
||||
使用命令 kubectl get,再加上名字空间限定 -n kube-system,就可以看到NFS Provisioner在Kubernetes里运行起来了。
|
||||
|
||||
|
||||
|
||||
如何使用NFS动态存储卷
|
||||
|
||||
比起静态存储卷,动态存储卷的用法简单了很多。因为有了Provisioner,我们就不再需要手工定义PV对象了,只需要在PVC里指定StorageClass对象,它再关联到Provisioner。
|
||||
|
||||
我们来看一下NFS默认的StorageClass定义:
|
||||
|
||||
apiVersion: storage.k8s.io/v1
|
||||
kind: StorageClass
|
||||
metadata:
|
||||
name: nfs-client
|
||||
|
||||
provisioner: k8s-sigs.io/nfs-subdir-external-provisioner
|
||||
parameters:
|
||||
archiveOnDelete: "false"
|
||||
|
||||
|
||||
YAML里的关键字段是 provisioner,它指定了应该使用哪个Provisioner。另一个字段 parameters 是调节Provisioner运行的参数,需要参考文档来确定具体值,在这里的 archiveOnDelete: "false" 就是自动回收存储空间。
|
||||
|
||||
理解了StorageClass的YAML之后,你也可以不使用默认的StorageClass,而是根据自己的需求,任意定制具有不同存储特性的StorageClass,比如添加字段 onDelete: "retain" 暂时保留分配的存储,之后再手动删除:
|
||||
|
||||
apiVersion: storage.k8s.io/v1
|
||||
kind: StorageClass
|
||||
metadata:
|
||||
name: nfs-client-retained
|
||||
|
||||
provisioner: k8s-sigs.io/nfs-subdir-external-provisioner
|
||||
parameters:
|
||||
onDelete: "retain"
|
||||
|
||||
|
||||
接下来我们定义一个PVC,向系统申请10MB的存储空间,使用的StorageClass是默认的 nfs-client:
|
||||
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: nfs-dyn-10m-pvc
|
||||
|
||||
spec:
|
||||
storageClassName: nfs-client
|
||||
accessModes:
|
||||
- ReadWriteMany
|
||||
|
||||
resources:
|
||||
requests:
|
||||
storage: 10Mi
|
||||
|
||||
|
||||
写好了PVC,我们还是在Pod里用 volumes 和 volumeMounts 挂载,然后Kubernetes就会自动找到NFS Provisioner,在NFS的共享目录上创建出合适的PV对象:
|
||||
|
||||
apiVersion: v1
|
||||
kind: Pod
|
||||
metadata:
|
||||
name: nfs-dyn-pod
|
||||
|
||||
spec:
|
||||
volumes:
|
||||
- name: nfs-dyn-10m-vol
|
||||
persistentVolumeClaim:
|
||||
claimName: nfs-dyn-10m-pvc
|
||||
|
||||
containers:
|
||||
- name: nfs-dyn-test
|
||||
image: nginx:alpine
|
||||
ports:
|
||||
- containerPort: 80
|
||||
|
||||
volumeMounts:
|
||||
- name: nfs-dyn-10m-vol
|
||||
mountPath: /tmp
|
||||
|
||||
|
||||
使用 kubectl apply 创建好PVC和Pod,让我们来查看一下集群里的PV状态:
|
||||
|
||||
|
||||
|
||||
从截图你可以看到,虽然我们没有直接定义PV对象,但由于有NFS Provisioner,它就自动创建一个PV,大小刚好是在PVC里申请的10MB。
|
||||
|
||||
如果你这个时候再去NFS服务器上查看共享目录,也会发现多出了一个目录,名字与这个自动创建的PV一样,但加上了名字空间和PVC的前缀:
|
||||
|
||||
|
||||
|
||||
我还是把Pod、PVC、StorageClass和Provisioner的关系画成了一张图,你可以清楚地看出来这些对象的关联关系,还有Pod是如何最终找到存储设备的:
|
||||
|
||||
|
||||
|
||||
小结
|
||||
|
||||
好了,今天的这节课里我们继续学习PV/PVC,引入了网络存储系统,以NFS为例研究了静态存储卷和动态存储卷的用法,其中的核心对象是StorageClass和Provisioner。
|
||||
|
||||
我再小结一下今天的要点:
|
||||
|
||||
|
||||
在Kubernetes集群里,网络存储系统更适合数据持久化,NFS是最容易使用的一种网络存储系统,要事先安装好服务端和客户端。
|
||||
可以编写PV手工定义NFS静态存储卷,要指定NFS服务器的IP地址和共享目录名。
|
||||
使用NFS动态存储卷必须要部署相应的Provisioner,在YAML里正确配置NFS服务器。
|
||||
动态存储卷不需要手工定义PV,而是要定义StorageClass,由关联的Provisioner自动创建PV完成绑定。
|
||||
|
||||
|
||||
课下作业
|
||||
|
||||
最后是课下作业时间,给你留两个思考题:
|
||||
|
||||
|
||||
动态存储卷相比静态存储卷有什么好处?有没有缺点?
|
||||
StorageClass在动态存储卷的分配过程中起到了什么作用?
|
||||
|
||||
|
||||
期待你的思考。如果觉得有收获,也欢迎你分享给朋友一起讨论。我们下节课再见。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
@ -0,0 +1,273 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
31 pdb & cProfile:调试和性能分析的法宝
|
||||
你好,我是景霄。
|
||||
|
||||
在实际生产环境中,对代码进行调试和性能分析,是一个永远都逃不开的话题。调试和性能分析的主要场景,通常有这么三个:
|
||||
|
||||
|
||||
一是代码本身有问题,需要我们找到root cause并修复;
|
||||
二是代码效率有问题,比如过度浪费资源,增加latency,因此需要我们debug;
|
||||
三是在开发新的feature时,一般都需要测试。
|
||||
|
||||
|
||||
在遇到这些场景时,究竟应该使用哪些工具,如何正确的使用这些工具,应该遵循什么样的步骤等等,就是这节课我们要讨论的话题。
|
||||
|
||||
用pdb进行代码调试
|
||||
|
||||
pdb的必要性
|
||||
|
||||
首先,我们来看代码的调试。也许不少人会有疑问:代码调试?说白了不就是在程序中使用print()语句吗?
|
||||
|
||||
没错,在程序中相应的地方打印,的确是调试程序的一个常用手段,但这只适用于小型程序。因为你每次都得重新运行整个程序,或是一个完整的功能模块,才能看到打印出来的变量值。如果程序不大,每次运行都非常快,那么使用print(),的确是很方便的。
|
||||
|
||||
但是,如果我们面对的是大型程序,运行一次的调试成本很高。特别是对于一些tricky的例子来说,它们通常需要反复运行调试、追溯上下文代码,才能找到错误根源。这种情况下,仅仅依赖打印的效率自然就很低了。
|
||||
|
||||
我们可以想象下面这个场景。比如你最常使用的极客时间App,最近出现了一个bug,部分用户无法登陆。于是,后端工程师们开始debug。
|
||||
|
||||
他们怀疑错误的代码逻辑在某几个函数中,如果使用print()语句debug,很可能出现的场景是,工程师们在他们认为的10个最可能出现bug的地方,都使用print()语句,然后运行整个功能块代码(从启动到运行花了5min),看打印出来的结果值,是不是和预期相符。
|
||||
|
||||
如果结果值和预期相符,并能直接找到错误根源,显然是最好的。但实际情况往往是,
|
||||
|
||||
|
||||
要么与预期并不相符,需要重复以上步骤,继续debug;
|
||||
要么虽说与预期相符,但前面的操作只是缩小了错误代码的范围,所以仍得继续添加print()语句,再一次运行相应的代码模块(又要5min),进行debug。
|
||||
|
||||
|
||||
你可以看到,这样的效率就很低下了。哪怕只是遇到稍微复杂一点的case,两、三个工程师一下午的时间可能就没了。
|
||||
|
||||
可能又有人会说,现在很多的IDE不都有内置的debug工具吗?
|
||||
|
||||
这话说的也没错。比如我们常用的Pycharm,可以很方便地在程序中设置断点。这样程序只要运行到断点处,便会自动停下,你就可以轻松查看环境中各个变量的值,并且可以执行相应的语句,大大提高了调试的效率。
|
||||
|
||||
看到这里,你不禁会问,既然问题都解决了,那为什么还要学习pdb呢?其实在很多大公司,产品的创造与迭代,往往需要很多编程语言的支持;并且,公司内部也会开发很多自己的接口,尝试把尽可能多的语言给结合起来。
|
||||
|
||||
这就使得,很多情况下,单一语言的IDE,对混合代码并不支持UI形式的断点调试功能,或是只对某些功能模块支持。另外,考虑到不少代码已经挪到了类似Jupyter的Notebook中,往往就要求开发者使用命令行的形式,来对代码进行调试。
|
||||
|
||||
而Python的pdb,正是其自带的一个调试库。它为Python程序提供了交互式的源代码调试功能,是命令行版本的IDE断点调试器,完美地解决了我们刚刚讨论的这个问题。
|
||||
|
||||
如何使用pdb
|
||||
|
||||
了解了pdb的重要性与必要性后,接下来,我们就一起来看看,pdb在Python中到底应该如何使用。
|
||||
|
||||
首先,要启动pdb调试,我们只需要在程序中,加入“import pdb”和“pdb.set_trace()”这两行代码就行了,比如下面这个简单的例子:
|
||||
|
||||
a = 1
|
||||
b = 2
|
||||
import pdb
|
||||
pdb.set_trace()
|
||||
c = 3
|
||||
print(a + b + c)
|
||||
|
||||
|
||||
当我们运行这个程序时时,它的输出界面是下面这样的,表示程序已经运行到了“pdb.set_trace()”这行,并且暂停了下来,等待用户输入。
|
||||
|
||||
> /Users/jingxiao/test.py(5)<module>()
|
||||
-> c = 3
|
||||
|
||||
|
||||
这时,我们就可以执行,在IDE断点调试器中可以执行的一切操作,比如打印,语法是"p <expression>":
|
||||
|
||||
(pdb) p a
|
||||
1
|
||||
(pdb) p b
|
||||
2
|
||||
|
||||
|
||||
你可以看到,我打印的是a和b的值,分别为1和2,与预期相符。为什么不打印c呢?显然,打印c会抛出异常,因为程序目前只运行了前面几行,此时的变量c还没有被定义:
|
||||
|
||||
(pdb) p c
|
||||
*** NameError: name 'c' is not defined
|
||||
|
||||
|
||||
除了打印,常见的操作还有“n”,表示继续执行代码到下一行,用法如下:
|
||||
|
||||
(pdb) n
|
||||
-> print(a + b + c)
|
||||
|
||||
|
||||
而命令”l“,则表示列举出当前代码行上下的11行源代码,方便开发者熟悉当前断点周围的代码状态:
|
||||
|
||||
(pdb) l
|
||||
1 a = 1
|
||||
2 b = 2
|
||||
3 import pdb
|
||||
4 pdb.set_trace()
|
||||
5 -> c = 3
|
||||
6 print(a + b + c)
|
||||
|
||||
|
||||
命令“s“,就是 step into 的意思,即进入相对应的代码内部。这时,命令行中会显示”--Call--“的字样,当你执行完内部的代码块后,命令行中则会出现”--Return--“的字样。
|
||||
|
||||
我们来看下面这个例子:
|
||||
|
||||
def func():
|
||||
print('enter func()')
|
||||
|
||||
a = 1
|
||||
b = 2
|
||||
import pdb
|
||||
pdb.set_trace()
|
||||
func()
|
||||
c = 3
|
||||
print(a + b + c)
|
||||
|
||||
# pdb
|
||||
> /Users/jingxiao/test.py(9)<module>()
|
||||
-> func()
|
||||
(pdb) s
|
||||
--Call--
|
||||
> /Users/jingxiao/test.py(1)func()
|
||||
-> def func():
|
||||
(Pdb) l
|
||||
1 -> def func():
|
||||
2 print('enter func()')
|
||||
3
|
||||
4
|
||||
5 a = 1
|
||||
6 b = 2
|
||||
7 import pdb
|
||||
8 pdb.set_trace()
|
||||
9 func()
|
||||
10 c = 3
|
||||
11 print(a + b + c)
|
||||
|
||||
(Pdb) n
|
||||
> /Users/jingxiao/test.py(2)func()
|
||||
-> print('enter func()')
|
||||
(Pdb) n
|
||||
enter func()
|
||||
--Return--
|
||||
> /Users/jingxiao/test.py(2)func()->None
|
||||
-> print('enter func()')
|
||||
|
||||
(Pdb) n
|
||||
> /Users/jingxiao/test.py(10)<module>()
|
||||
-> c = 3
|
||||
|
||||
|
||||
这里,我们使用命令”s“进入了函数func()的内部,显示”--Call--“;而当我们执行完函数func()内部语句并跳出后,显示”--Return--“。
|
||||
|
||||
另外,
|
||||
|
||||
|
||||
与之相对应的命令”r“,表示step out,即继续执行,直到当前的函数完成返回。
|
||||
命令”b [ ([filename:]lineno | function) [, condition] ]“可以用来设置断点。比方说,我想要在代码中的第10行,再加一个断点,那么在pdb模式下输入”b 11“即可。
|
||||
而”c“则表示一直执行程序,直到遇到下一个断点。
|
||||
|
||||
|
||||
当然,除了这些常用命令,还有许多其他的命令可以使用,这里我就不在一一赘述了。你可以参考对应的官方文档(https://docs.python.org/3/library/pdb.html#module-pdb),来熟悉这些用法。
|
||||
|
||||
用cProfile进行性能分析
|
||||
|
||||
关于调试的内容,我主要先讲这么多。事实上,除了要对程序进行调试,性能分析也是每个开发者的必备技能。
|
||||
|
||||
日常工作中,我们常常会遇到这样的问题:在线上,我发现产品的某个功能模块效率低下,延迟(latency)高,占用的资源多,但却不知道是哪里出了问题。
|
||||
|
||||
这时,对代码进行profile就显得异常重要了。
|
||||
|
||||
这里所谓的profile,是指对代码的每个部分进行动态的分析,比如准确计算出每个模块消耗的时间等。这样你就可以知道程序的瓶颈所在,从而对其进行修正或优化。当然,这并不需要你花费特别大的力气,在Python中,这些需求用cProfile就可以实现。
|
||||
|
||||
举个例子,比如我想计算斐波拉契数列,运用递归思想,我们很容易就能写出下面这样的代码:
|
||||
|
||||
def fib(n):
|
||||
if n == 0:
|
||||
return 0
|
||||
elif n == 1:
|
||||
return 1
|
||||
else:
|
||||
return fib(n-1) + fib(n-2)
|
||||
|
||||
def fib_seq(n):
|
||||
res = []
|
||||
if n > 0:
|
||||
res.extend(fib_seq(n-1))
|
||||
res.append(fib(n))
|
||||
return res
|
||||
|
||||
fib_seq(30)
|
||||
|
||||
|
||||
接下来,我想要测试一下这段代码总的效率以及各个部分的效率。那么,我就只需在开头导入cProfile这个模块,并且在最后运行cProfile.run()就可以了:
|
||||
|
||||
import cProfile
|
||||
# def fib(n)
|
||||
# def fib_seq(n):
|
||||
cProfile.run('fib_seq(30)')
|
||||
|
||||
|
||||
或者更简单一些,直接在运行脚本的命令中,加入选项“-m cProfile”也很方便:
|
||||
|
||||
python3 -m cProfile xxx.py
|
||||
|
||||
|
||||
运行完毕后,我们可以看到下面这个输出界面:
|
||||
|
||||
|
||||
|
||||
这里有一些参数你可能比较陌生,我来简单介绍一下:
|
||||
|
||||
|
||||
ncalls,是指相应代码/函数被调用的次数;
|
||||
tottime,是指对应代码/函数总共执行所需要的时间(注意,并不包括它调用的其他代码/函数的执行时间);
|
||||
tottime percall,就是上述两者相除的结果,也就是tottime / ncalls;
|
||||
cumtime,则是指对应代码/函数总共执行所需要的时间,这里包括了它调用的其他代码/函数的执行时间;
|
||||
cumtime percall,则是cumtime和ncalls相除的平均结果。
|
||||
|
||||
|
||||
了解这些参数后,再来看这张图。我们可以清晰地看到,这段程序执行效率的瓶颈,在于第二行的函数fib(),它被调用了700多万次。
|
||||
|
||||
有没有什么办法可以提高改进呢?答案是肯定的。通过观察,我们发现,程序中有很多对fib()的调用,其实是重复的,那我们就可以用字典来保存计算过的结果,防止重复。改进后的代码如下所示:
|
||||
|
||||
def memoize(f):
|
||||
memo = {}
|
||||
def helper(x):
|
||||
if x not in memo:
|
||||
memo[x] = f(x)
|
||||
return memo[x]
|
||||
return helper
|
||||
|
||||
@memoize
|
||||
def fib(n):
|
||||
if n == 0:
|
||||
return 0
|
||||
elif n == 1:
|
||||
return 1
|
||||
else:
|
||||
return fib(n-1) + fib(n-2)
|
||||
|
||||
|
||||
def fib_seq(n):
|
||||
res = []
|
||||
if n > 0:
|
||||
res.extend(fib_seq(n-1))
|
||||
res.append(fib(n))
|
||||
return res
|
||||
|
||||
fib_seq(30)
|
||||
|
||||
|
||||
这时,我们再对其进行profile,你就会得到新的输出结果,很明显,效率得到了极大的提高。
|
||||
|
||||
|
||||
|
||||
这个简单的例子,便是cProfile的基本用法,也是我今天想讲的重点。当然,cProfile还有很多其他功能,还可以结合stats类来使用,你可以阅读相应的 官方文档 来了解。
|
||||
|
||||
总结
|
||||
|
||||
这节课,我们一起学习了Python中常用的调试工具pdb,和经典的性能分析工具cProfile。pdb为Python程序提供了一种通用的、交互式的高效率调试方案;而cProfile则是为开发者提供了每个代码块执行效率的详细分析,有助于我们对程序的优化与提高。
|
||||
|
||||
关于它们的更多用法,你可以通过它们的官方文档进行实践,都不太难,熟能生巧。
|
||||
|
||||
思考题
|
||||
|
||||
最后,留一个开放性的交流问题。你在平时的工作中,常用的调试和性能分析工具是什么呢?有发现什么独到的使用技巧吗?你曾用到过pdb、cProfile或是其他相似的工具吗?
|
||||
|
||||
欢迎在下方留言与我讨论,也欢迎你把这篇文章分享出去。我们一起交流,一起进步。
|
||||
|
||||
|
||||
|
||||
|
@ -0,0 +1,256 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
34 RESTful & Socket:搭建交易执行层核心
|
||||
你好,我是景霄。
|
||||
|
||||
上一节,我们简单介绍了量化交易的历史、严谨的定义和它的基本组成结构。有了这些高层次的基本知识,接下来我们就分模块,开始讲解量化交易系统中具体的部分。
|
||||
|
||||
从这节课开始,我们将实打实地从代码出发,一步步设计出一套清晰完整、易于理解的量化交易系统。
|
||||
|
||||
一个量化交易系统,可以说是一个黑箱。这个黑箱连接交易所获取到的数据,通过策略运算,然后再连接交易所进行下单操作。正如我们在输入输出那节课说的那样,黑箱的特性是输入和输出。每一个设计网络交互的同学,都需要在大脑中形成清晰的交互状态图:
|
||||
|
||||
|
||||
知道包是怎样在网络间传递的;
|
||||
知道每一个节点是如何处理不同的输入包,然后输出并分发给下一级的。
|
||||
|
||||
|
||||
在你搞不明白的时候,可以先在草稿纸上画出交互拓扑图,标注清楚每个节点的输入和输出格式,然后想清楚网络是怎么流动的。这一点,对网络编程至关重要。
|
||||
|
||||
现在,我假设你对网络编程只有很基本的了解。所以接下来,我将先从 REST 的定义讲起,然后过渡到具体的交互方式——如何通过 Python 和交易所进行交互,从而执行下单、撤单、查询订单等网络交互方式。
|
||||
|
||||
REST 简介
|
||||
|
||||
什么是 REST API?什么是 Socket?有过网络编程经验的同学,一定对这两个词汇不陌生。
|
||||
|
||||
REST的全称是表征层状态转移(REpresentational State Transfer),本意是指一种操作资源方法。不过,你不用纠结于这个绕口的名字。换种方式来说,REST的实质可以理解为:通过URL定位资源,用GET、POST、PUT、DELETE等动词来描述操作。而满足REST要求的接口,就被称为RESTful的接口。
|
||||
|
||||
为了方便你更容易理解这些概念,这里我举个例子来类比。小明同学不是很聪明但很懂事,每天会在他的妈妈下班回来后给妈妈泡茶。刚开始,他的妈妈会发出这样的要求:
|
||||
|
||||
|
||||
用红色杯子,去厨房泡一杯放了糖的37.5度的普洱茶。
|
||||
|
||||
|
||||
可是小明同学不够聪明,很难理解这个定语很多的句子。于是,他妈妈为了让他更简单明白需要做的事情,把这个指令设计成了更简洁的样子:
|
||||
|
||||
|
||||
泡厨房的茶,要求如下:
|
||||
|
||||
|
||||
类型=普洱;
|
||||
杯子=红色;
|
||||
放糖=True;
|
||||
温度=37.5度。
|
||||
|
||||
|
||||
|
||||
这里的“茶”就是资源,“厨房的茶”就是资源的地址(URI);“泡”是动词;后面的要求,都是接口参数。这样的一个接口,就是小明提供的一个REST接口。
|
||||
|
||||
如果小明是一台机器,那么解析这个请求就会非常容易;而我们作为维护者,查看小明的代码也很简单。当小明把这个接口暴露到网上时,这就是一个RESTful的接口。
|
||||
|
||||
总的来说,RESTful接口通常以HTTP GET和POST形式出现。但并非所有的GET、POST请求接口,都是RESTful的接口。
|
||||
|
||||
这话可能有些拗口,我们举个例子来看。上节课中,我们获取了Gemini交易所中,BTC对USD价格的ticker接口:
|
||||
|
||||
GET https://api.gemini.com/v1/pubticker/btcusd
|
||||
|
||||
|
||||
这里的“GET”是动词,后边的URI是“Ticker”这个资源的地址。所以,这是一个RESTful的接口。
|
||||
|
||||
但下面这样的接口,就不是一个严格的RESTful接口:
|
||||
|
||||
POST https://api.restful.cn/accounts/delete/:username
|
||||
|
||||
|
||||
因为URI中包含动词“delete”(删除),所以这个URI并不是指向一个资源。如果要修改成严格的RESTful接口,我们可以把它改成下面这样:
|
||||
|
||||
DELETE https://api.rest.cn/accounts/:username
|
||||
|
||||
|
||||
然后,我们带着这个观念去看Gemini的取消订单接口:
|
||||
|
||||
POST https://api.gemini.com/v1/order/cancel
|
||||
|
||||
|
||||
|
||||
|
||||
你会发现,这个接口不够“RESTful”的地方有:
|
||||
|
||||
|
||||
动词设计不准确,接口使用“POST”而不是重用HTTP动词“DELETE”;
|
||||
URI里包含动词cancel;
|
||||
ID代表的订单是资源,但订单ID是放在参数列表而不是URI里的,因此URI并没有指向资源。
|
||||
|
||||
|
||||
所以严格来说,这不是一个RESTful的接口。
|
||||
|
||||
此外,如果我们去检查Gemini的其他私有接口(Private,私有接口是指需要附加身份验证信息才能访问的接口),我们会发现,那些接口的设计都不是严格RESTful的。不仅如此,大部分的交易所,比如Bitmex、Bitfinex、OKCoin等等,它们提供的“REST接口”,也都不是严格RESTful的。这些接口之所以还能被称为“REST接口”,是因为他们大部分满足了REST接口的另一个重要要求:无状态。
|
||||
|
||||
无状态的意思是,每个REST请求都是独立的,不需要服务器在会话(Session)中缓存中间状态来完成这个请求。简单来说,如果服务器A接收到请求的时候宕机了,而此时把这个请求发送给交易所的服务器B,也能继续完成,那么这个接口就是无状态的。
|
||||
|
||||
这里,我再给你举一个简单的有状态的接口的例子。服务器要求,在客户端请求取消订单的时候,必须发送两次不一样的HTTP请求。并且,第一次发送让服务器“等待取消”;第二次发送“确认取消”。那么,就算这个接口满足了RESTful的动词、资源分离原则,也不是一个REST接口。
|
||||
|
||||
当然,对于交易所的REST接口,你并不需要过于纠结“RESTful”这个概念,否则很容易就被这些名词给绕晕了。你只需要把握住最核心的一点:一个HTTP请求完成一次完整操作。
|
||||
|
||||
交易所 API 简介
|
||||
|
||||
现在,你对 REST 和 Web Socket 应该有一个大致了解了吧。接下来,我们就开始做点有意思的事情。
|
||||
|
||||
首先,我来介绍一下交易所是什么。区块链交易所是个撮合交易平台: 它兼容了传统撮合规则撮合引擎,将资金托管和交割方式替换为区块链。数字资产交易所,则是一个中心化的平台,通过 Web 页面或 PC、手机客户端的形式,让用户将数字资产充值到指定钱包地址(交易所创建的钱包),然后在平台挂买单、卖单以实现数字资产之间的兑换。
|
||||
|
||||
通俗来说,交易所就是一个买和卖的菜市场。有人在摊位上大声喊着:“二斤羊肉啊,二斤羊肉,四斤牛肉来换!”这种人被称为 maker(挂单者)。有的人则游走于不同摊位,不动声色地掏出两斤牛肉,顺手拿走一斤羊肉。这种人被称为 taker(吃单者)。
|
||||
|
||||
交易所存在的意义,一方面是为 maker 和 taker 提供足够的空间活动;另一方面,让一个名叫撮合引擎的玩意儿,尽可能地把单子撮合在一起,然后收取一定比例的保护费…啊不对,是手续费,从而保障游戏继续进行下去。
|
||||
|
||||
市场显然是个很伟大的发明,这里我们就不进行更深入的哲学讨论了。
|
||||
|
||||
然后,我再来介绍一个叫作 Gemini 的交易所。Gemini,双子星交易所,全球首个获得合法经营许可的、首个推出期货合约的、专注于撮合大宗交易的数字货币交易所。Gemini 位于纽约,是一家数字货币交易所和托管机构,允许客户交易和存储数字资产,并直接受纽约州金融服务部门(NYDFS)的监管。
|
||||
|
||||
Gemini 的界面清晰,API 完整而易用,更重要的是,还提供了完整的测试网络,也就是说,功能和正常的 Gemini 完全一样。但是他家的交易采用虚拟币,非常方便从业者在平台上进行对接测试。
|
||||
|
||||
另一个做得很好的交易所,是 Bitmex,他家的 API UI 界面和测试网络也是币圈一流。不过,鉴于这家是期货交易所,对于量化初学者来说有一定的门槛,我们还是选择 Gemini 更方便一些。
|
||||
|
||||
在进入正题之前,我们最后再以比特币和美元之间的交易为例,介绍四个基本概念(orderbook 的概念这里就不介绍了,你也不用深究,你只需要知道比特币的价格是什么就行了)。
|
||||
|
||||
|
||||
买(buy):用美元买入比特币的行为。
|
||||
卖(sell):用比特币换取美元的行为。
|
||||
市价单(market order):给交易所一个方向(买或者卖)和一个数量,交易所把给定数量的美元(或者比特币)换成比特币(或者美元)的单子。
|
||||
限价单(limit order):给交易所一个价格、一个方向(买或者卖)和一个数量,交易所在价格达到给定价格的时候,把给定数量的美元(或者比特币)换成比特币(或者美元)的单子。
|
||||
|
||||
|
||||
这几个概念都不难懂。其中,市价单和限价单,最大的区别在于,限价单多了一个给定价格。如何理解这一点呢?我们可以来看下面这个例子。
|
||||
|
||||
小明在某一天中午12:00:00,告诉交易所,我要用1000美元买比特币。交易所收到消息,在 12:00:01 回复小明,现在你的账户多了 0.099 个比特币,少了 1000 美元,交易成功。这是一个市价买单。
|
||||
|
||||
而小强在某一天中午 11:59:00,告诉交易所,我要挂一个单子,数量为 0.1 比特币,1个比特币的价格为 10000 美元,低于这个价格不卖。交易所收到消息,在11:59:01 告诉小强,挂单成功,你的账户余额中 0.1 比特币的资金被冻结。又过了一分钟,交易所告诉小强,你的单子被完全执行了(fully executed),现在你的账户多了 1000 美元,少了 0.1 个比特币。这就是一个限价卖单。
|
||||
|
||||
(这里肯定有人发现不对了:貌似少了一部分比特币,到底去哪儿了呢?嘿嘿,你不妨自己猜猜看。)
|
||||
|
||||
显然,市价单,在交给交易所后,会立刻得到执行,当然执行价格也并不受你的控制。它很快,但是也非常不安全。而限价单,则限定了交易价格和数量,安全性相对高很多。缺点呢,自然就是如果市场朝相反方向走,你挂的单子可能没有任何人去接,也就变成了干吆喝却没人买。因为我没有讲解 orderbook,所以这里的说辞不完全严谨,但是对于初学者理解今天的内容,已经够用了。
|
||||
|
||||
储备了这么久的基础知识,想必你已经跃跃欲试了吧?下面,我们正式进入正题,手把手教你使用API下单。
|
||||
|
||||
手把手教你使用 API 下单
|
||||
|
||||
手动挂单显然太慢,也不符合量化交易的初衷。我们就来看看如何用代码实现自动化下单吧。
|
||||
|
||||
第一步,你需要做的是,注册一个 Gemini Sandbox 账号。请放心,这个测试账号不需要你充值任何金额,注册后即送大量虚拟现金。这口吻是不是听着特像网游宣传语,接下来就是“快来贪玩蓝月里找我吧”?哈哈,不过这个设定确实如此,所以赶紧来注册一个吧。
|
||||
|
||||
注册后,为了满足好奇,你可以先尝试着使用 Web 界面自行下单。不过,事实上,未解锁的情况下是无法正常下单的,因此这样尝试并没啥太大意义。
|
||||
|
||||
所以第二步,我们需要来配置 API Key。菜单栏User Settings->API Settings,然后点 GENERATE A NEW ACCOUNT API KEY,记下 Key 和 Secret 这两串字符。因为窗口一旦消失,这两个信息就再也找不到了,需要你重新生成。
|
||||
|
||||
配置到此结束。接下来,我们来看具体实现。
|
||||
|
||||
先强调一点,在量化系统开发的时候,你的心中一定要有清晰的数据流图。下单逻辑是一个很简单的 RESTful 的过程,和你在网页操作的一样,构造你的请求订单、加密请求,然后 POST 给 gemini 交易所即可。
|
||||
|
||||
不过,因为涉及到的知识点较多,带你一步一步从零来写代码显然不太现实。所以,我们采用“先读懂后记忆并使用”的方法来学,下面即为这段代码:
|
||||
|
||||
import requests
|
||||
import json
|
||||
import base64
|
||||
import hmac
|
||||
import hashlib
|
||||
import datetime
|
||||
import time
|
||||
|
||||
base_url = "https://api.sandbox.gemini.com"
|
||||
endpoint = "/v1/order/new"
|
||||
url = base_url + endpoint
|
||||
|
||||
gemini_api_key = "account-zmidXEwP72yLSSybXVvn"
|
||||
gemini_api_secret = "375b97HfE7E4tL8YaP3SJ239Pky9".encode()
|
||||
|
||||
t = datetime.datetime.now()
|
||||
payload_nonce = str(int(time.mktime(t.timetuple())*1000))
|
||||
|
||||
payload = {
|
||||
"request": "/v1/order/new",
|
||||
"nonce": payload_nonce,
|
||||
"symbol": "btcusd",
|
||||
"amount": "5",
|
||||
"price": "3633.00",
|
||||
"side": "buy",
|
||||
"type": "exchange limit",
|
||||
"options": ["maker-or-cancel"]
|
||||
}
|
||||
|
||||
encoded_payload = json.dumps(payload).encode()
|
||||
b64 = base64.b64encode(encoded_payload)
|
||||
signature = hmac.new(gemini_api_secret, b64, hashlib.sha384).hexdigest()
|
||||
|
||||
request_headers = {
|
||||
'Content-Type': "text/plain",
|
||||
'Content-Length': "0",
|
||||
'X-GEMINI-APIKEY': gemini_api_key,
|
||||
'X-GEMINI-PAYLOAD': b64,
|
||||
'X-GEMINI-SIGNATURE': signature,
|
||||
'Cache-Control': "no-cache"
|
||||
}
|
||||
|
||||
response = requests.post(url,
|
||||
data=None,
|
||||
headers=request_headers)
|
||||
|
||||
new_order = response.json()
|
||||
print(new_order)
|
||||
|
||||
|
||||
########## 输出 ##########
|
||||
|
||||
{'order_id': '239088767', 'id': '239088767', 'symbol': 'btcusd', 'exchange': 'gemini', 'avg_execution_price': '0.00', 'side': 'buy', 'type': 'exchange limit', 'timestamp': '1561956976', 'timestampms': 1561956976535, 'is_live': True, 'is_cancelled': False, 'is_hidden': False, 'was_forced': False, 'executed_amount': '0', 'remaining_amount': '5', 'options': ['maker-or-cancel'], 'price': '3633.00', 'original_amount': '5'}
|
||||
|
||||
|
||||
我们来深入看一下这段代码。
|
||||
|
||||
RESTful 的 POST 请求,通过 requests.post 来实现。post 接受三个参数,url、data 和 headers。
|
||||
|
||||
这里的 url 等价于 https://api.sandbox.gemini.com/v1/order/new,但是在代码中分两部分写。第一部分是交易所 API 地址;第二部分,以斜杠开头,用来表示统一的 API endpoint。我们也可以在其他交易所的 API 中看到类似的写法,两者连接在一起,就构成了最终的 url。
|
||||
|
||||
而接下来大段命令的目的,是为了构造 request_headers。
|
||||
|
||||
这里我简单说一下 HTTP request,这是互联网中基于 TCP 的基础协议。HTTP 协议是 Hyper Text Transfer Protocol(超文本传输协议)的缩写,用于从万维网(WWW:World Wide Web)服务器传输超文本到本地浏览器的传送协议。而 TCP(Transmission Control Protocol)则是面向连接的、可靠的、基于字节流的传输层通信协议。
|
||||
|
||||
多提一句,如果你开发网络程序,建议利用闲暇时间认真读一读《计算机网络:自顶向下方法》这本书,它也是国内外计算机专业必修课中广泛采用的课本之一。一边学习,一边应用,对于初学者的能力提升是全面而充分的。
|
||||
|
||||
回到 HTTP,它的主要特点是,连接简单、灵活,可以使用“简单请求,收到回复,然后断开连接”的方式,也是一种无状态的协议,因此充分符合 RESTful 的思想。
|
||||
|
||||
HTTP 发送需要一个请求头(request header),也就是代码中的 request_headers,用 Python 的语言表示,就是一个 str 对 str 的字典。
|
||||
|
||||
这个字典里,有一些字段有特殊用途, 'Content-Type': "text/plain" 和 'Content-Length': "0" 描述 Content 的类型和长度,这里的 Content 对应于参数 data。但是 Gemini 这里的 request 的 data 没有任何用处,因此长度为 0。
|
||||
|
||||
还有一些其他字段,例如 'keep-alive' 来表示连接是否可持续化等,你也可以适当注意一下。要知道,网络编程很多 bug 都会出现在不起眼的细节之处。
|
||||
|
||||
继续往下走看代码。payload 是一个很重要的字典,它用来存储下单操作需要的所有的信息,也就是业务逻辑信息。这里我们可以下一个 limit buy,限价买单,价格为 3633 刀。
|
||||
|
||||
另外,请注意 nonce,这是个很关键并且在网络通信中很常见的字段。
|
||||
|
||||
因为网络通信是不可靠的,一个信息包有可能会丢失,也有可能重复发送,在金融操作中,这两者都会造成很严重的后果。丢包的话,我们重新发送就行了;但是重复的包,我们需要去重。虽然 TCP 在某种程度上可以保证,但为了在应用层面进一步减少错误发生的机会,Gemini 交易所要求所有的通信 payload 必须带有 nonce。
|
||||
|
||||
nonce 是个单调递增的整数。当某个后来的请求的 nonce,比上一个成功收到的请求的 nouce 小或者相等的时候,Gemini 便会拒绝这次请求。这样一来,重复的包就不会被执行两次了。另一方面,这样也可以在一定程度上防止中间人攻击:
|
||||
|
||||
|
||||
一则是因为 nonce 的加入,使得加密后的同样订单的加密文本完全混乱;
|
||||
二则是因为,这会使得中间人无法通过“发送同样的包来构造重复订单”进行攻击。
|
||||
|
||||
|
||||
这样的设计思路是不是很巧妙呢?这就相当于每个包都增加了一个身份识别,可以极大地提高安全性。希望你也可以多注意,多思考一下这些巧妙的用法。
|
||||
|
||||
接下来的代码就很清晰了。我们要对 payload 进行 base64 和 sha384 算法非对称加密,其中 gemini_api_secret 为私钥;而交易所存储着公钥,可以对你发送的请求进行解密。最后,代码再将加密后的请求封装到 request_headers 中,发送给交易所,并收到 response,这个订单就完成了。
|
||||
|
||||
总结
|
||||
|
||||
这节课我们介绍了什么是 RESTful API,带你了解了交易所的 RESTful API 是如何工作的,以及如何通过 RESTful API 来下单。同时,我简单讲述了网络编程中的一些技巧操作,希望你在网络编程中要注意思考每一个细节,尽可能在写代码之前,对业务逻辑和具体的技术细节有足够清晰的认识。
|
||||
|
||||
下一节,我们同样将从 Web Socket 的定义开始,讲解量化交易中数据模块的具体实现。
|
||||
|
||||
思考题
|
||||
|
||||
最后留一个思考题。今天的内容里,能不能使用 timestamp 代替 nonce?为什么?欢迎留言写下你的思考,也欢迎你把这篇文章分享出去。
|
||||
|
||||
|
||||
|
||||
|
@ -0,0 +1,334 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
35 RESTful & Socket:行情数据对接和抓取
|
||||
你好,我是景霄。
|
||||
|
||||
上一节课,我们介绍了交易所的交易模式,数字货币交易所RESTful接口的常见概念,以及如何调用RESTful接口进行订单操作。众所周知,买卖操作的前提,是你需要已知市场的最新情况。这节课里,我将介绍交易系统底层另一个最重要的部分,行情数据的对接和抓取。
|
||||
|
||||
行情数据,最重要的是实时性和有效性。市场的情况瞬息万变,合适的买卖时间窗口可能只有几秒。在高频交易里,合适的买卖机会甚至在毫秒级别。要知道,一次从北京发往美国的网络请求,即使是光速传播,都需要几百毫秒的延迟。更别提用Python这种解释型语言,建立HTTP连接导致的时间消耗。
|
||||
|
||||
经过上节课的学习,你对交易应该有了基本的了解,这也是我们今天学习的基础。接下来,我们先从交易所撮合模式讲起,然后介绍行情数据有哪些;之后,我将带你基于Websocket的行情数据来抓取模块。
|
||||
|
||||
行情数据
|
||||
|
||||
回顾上一节我们提到的,交易所是一个买方、卖方之间的公开撮合平台。买卖方把需要/可提供的商品数量和愿意出/接受的价格提交给交易所,交易所按照公平原则进行撮合交易。
|
||||
|
||||
那么撮合交易是怎么进行的呢?假设你是一个人肉比特币交易所,大量的交易订单往你这里汇总,你应该如何选择才能让交易公平呢?
|
||||
|
||||
显然,最直观的操作就是,把买卖订单分成两个表,按照价格由高到低排列。下面的图,就是买入和卖出的委托表。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
如果最高的买入价格小于最低的卖出价格,那就不会有任何交易发生。这通常是你看到的委托列表的常态。
|
||||
|
||||
如果最高的买入价格和最低的卖出价格相同,那么就尝试进行撮合。比如BTC在9002.01就会发生撮合,最后按照9002.01的价格,成交0.0330个BTC。当然,交易完成后,小林未完成部分的订单(余下0.1126 - 0.0330 = 0.0796 个 BTC 未卖出),还会继续在委托表里。
|
||||
|
||||
不过你可能会想,如果买入和卖出的价格有交叉,那么成交价格又是什么呢?事实上,这种情况并不会发生。我们来试想一下下面这样的场景。
|
||||
|
||||
如果你尝试给一个委托列表里加入一个新买入订单,它的价格比所有已有的最高买入价格高,也比所有的卖出价格高。那么此时,它会直接从最低的卖出价格撮合。等到最低价格的卖出订单吃完了,它便开始吃价格第二低的卖出订单,直到这个买入订单完全成交。反之亦然。所以,委托列表价格不会出现交叉。
|
||||
|
||||
当然,请注意,这里我说的只是限价订单的交易方式。而对于市价订单,交易规则会有一些轻微的区别,这里我就不详细解释了,主要是让你有个概念。
|
||||
|
||||
其实说到这里,所谓的“交易所行情”概念就呼之欲出了。交易所主要有两种行情数据:委托账本(Order Book)和活动行情(Tick data)。
|
||||
|
||||
我们把委托表里的具体用户隐去,相同价格的订单合并,就得到了下面这种委托账本。我们主要观察右边的数字部分,其中:
|
||||
|
||||
|
||||
上半部分里,第一列红色数字代表BTC的卖出价格,中间一列数字是这个价格区间的订单BTC总量,最右边一栏是从最低卖出价格到当前价格区间的积累订单量。
|
||||
中间的大字部分,9994.10 USD是当前的市场价格,也就是上一次成交交易的价格。
|
||||
下面绿色部分的含义与上半部分类似,不过指的是买入委托和对应的数量。
|
||||
|
||||
|
||||
|
||||
|
||||
Gemini的委托账本,来自https://cryptowat.ch
|
||||
|
||||
这张图中,最低的卖出价格比最高的买入价格要高 6.51 USD,这个价差通常被称为Spread。这里验证了我们前面提到的,委托账本的价格永不交叉; 同时,Spread很小也能说明这是一个非常活跃的交易所。
|
||||
|
||||
每一次撮合发生,意味着一笔交易(Trade)的发生。卖方买方都很开心,于是交易所也很开心地通知行情数据的订阅者:刚才发生了一笔交易,交易的价格是多少,成交数量是多少。这个数据就是活动行情Tick。
|
||||
|
||||
有了这些数据,我们也就掌握了这个交易所的当前状态,可以开始搞事情了。
|
||||
|
||||
Websocket介绍
|
||||
|
||||
在本文的开头我们提到过:行情数据很讲究时效性。所以,行情从交易所产生到传播给我们的程序之间的延迟,应该越低越好。通常,交易所也提供了REST的行情数据抓取接口。比如下面这段代码:
|
||||
|
||||
import requests
|
||||
import timeit
|
||||
|
||||
|
||||
def get_orderbook():
|
||||
orderbook = requests.get("https://api.gemini.com/v1/book/btcusd").json()
|
||||
|
||||
|
||||
n = 10
|
||||
latency = timeit.timeit('get_orderbook()', setup='from __main__ import get_orderbook', number=n) * 1.0 / n
|
||||
print('Latency is {} ms'.format(latency * 1000))
|
||||
|
||||
###### 输出 #######
|
||||
|
||||
Latency is 196.67642089999663 ms
|
||||
|
||||
|
||||
我在美国纽约附近城市的一个服务器上测试了这段代码,你可以看到,平均每次访问orderbook的延迟有0.25秒左右。显然,如果在国内,这个延迟只会更大。按理说,这两个美国城市的距离很短,为什么延迟会这么大呢?
|
||||
|
||||
这是因为,REST接口本质上是一个HTTP接口,在这之下是TCP/TLS套接字(Socket)连接。每一次REST请求,通常都会重新建立一次TCP/TLS握手;然后,在请求结束之后,断开这个链接。这个过程,比我们想象的要慢很多。
|
||||
|
||||
举个例子来验证这一点,在同一个城市我们试验一下。我从纽约附近的服务器和Gemini在纽约的服务器进行连接,TCP/SSL握手花了多少时间呢?
|
||||
|
||||
curl -w "TCP handshake: %{time_connect}s, SSL handshake: %{time_appconnect}s\n" -so /dev/null https://www.gemini.com
|
||||
|
||||
TCP handshake: 0.072758s, SSL handshake: 0.119409s
|
||||
|
||||
|
||||
结果显示,HTTP连接构建的过程,就占了一大半时间!也就是说,我们每次用REST请求,都要浪费一大半的时间在和服务器建立连接上,这显然是非常低效的。很自然的你会想到,我们能否实现一次连接、多次通信呢?
|
||||
|
||||
事实上,Python的某些HTTP请求库,也可以支持重用底层的TCP/SSL连接。但那种方法,一来比较复杂,二来也需要服务器的支持。该怎么办呢?其实,在有WebSocket的情况下,我们完全不需要舍近求远。
|
||||
|
||||
我先来介绍一下WebSocket。WebSocket是一种在单个TCP/TLS连接上,进行全双工、双向通信的协议。WebSocket可以让客户端与服务器之间的数据交换变得更加简单高效,服务端也可以主动向客户端推送数据。在WebSocket API中,浏览器和服务器只需要完成一次握手,两者之间就可以直接创建持久性的连接,并进行双向数据传输。
|
||||
|
||||
概念听着很痛快,不过还是有些抽象。为了让你快速理解刚刚的这段话,我们还是来看两个简单的例子。二话不说,先看一段代码:
|
||||
|
||||
import websocket
|
||||
import thread
|
||||
|
||||
# 在接收到服务器发送消息时调用
|
||||
def on_message(ws, message):
|
||||
print('Received: ' + message)
|
||||
|
||||
# 在和服务器建立完成连接时调用
|
||||
def on_open(ws):
|
||||
# 线程运行函数
|
||||
def gao():
|
||||
# 往服务器依次发送0-4,每次发送完休息0.01秒
|
||||
for i in range(5):
|
||||
time.sleep(0.01)
|
||||
msg="{0}".format(i)
|
||||
ws.send(msg)
|
||||
print('Sent: ' + msg)
|
||||
# 休息1秒用于接收服务器回复的消息
|
||||
time.sleep(1)
|
||||
|
||||
# 关闭Websocket的连接
|
||||
ws.close()
|
||||
print("Websocket closed")
|
||||
|
||||
# 在另一个线程运行gao()函数
|
||||
thread.start_new_thread(gao, ())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
ws = websocket.WebSocketApp("ws://echo.websocket.org/",
|
||||
on_message = on_message,
|
||||
on_open = on_open)
|
||||
|
||||
ws.run_forever()
|
||||
|
||||
#### 输出 #####
|
||||
Sent: 0
|
||||
Sent: 1
|
||||
Received: 0
|
||||
Sent: 2
|
||||
Received: 1
|
||||
Sent: 3
|
||||
Received: 2
|
||||
Sent: 4
|
||||
Received: 3
|
||||
Received: 4
|
||||
Websocket closed
|
||||
|
||||
|
||||
这段代码尝试和wss://echo.websocket.org建立连接。当连接建立的时候,就会启动一条线程,连续向服务器发送5条消息。
|
||||
|
||||
通过输出可以看出,我们在连续发送的同时,也在不断地接受消息。这并没有像REST一样,每发送一个请求,要等待服务器完成请求、完全回复之后,再进行下一个请求。换句话说,我们在请求的同时也在接受消息,这也就是前面所说的”全双工“。
|
||||
|
||||
|
||||
|
||||
REST(HTTP)单工请求响应的示意图
|
||||
|
||||
|
||||
|
||||
Websocket全双工请求响应的示意图
|
||||
|
||||
再来看第二段代码。为了解释”双向“,我们来看看获取Gemini的委托账单的例子。
|
||||
|
||||
import ssl
|
||||
import websocket
|
||||
import json
|
||||
|
||||
# 全局计数器
|
||||
count = 5
|
||||
|
||||
def on_message(ws, message):
|
||||
global count
|
||||
print(message)
|
||||
count -= 1
|
||||
# 接收了5次消息之后关闭websocket连接
|
||||
if count == 0:
|
||||
ws.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
ws = websocket.WebSocketApp(
|
||||
"wss://api.gemini.com/v1/marketdata/btcusd?top_of_book=true&offers=true",
|
||||
on_message=on_message)
|
||||
ws.run_forever(sslopt={"cert_reqs": ssl.CERT_NONE})
|
||||
|
||||
###### 输出 #######
|
||||
{"type":"update","eventId":7275473603,"socket_sequence":0,"events":[{"type":"change","reason":"initial","price":"11386.12","delta":"1.307","remaining":"1.307","side":"ask"}]}
|
||||
{"type":"update","eventId":7275475120,"timestamp":1562380981,"timestampms":1562380981991,"socket_sequence":1,"events":[{"type":"change","side":"ask","price":"11386.62","remaining":"1","reason":"top-of-book"}]}
|
||||
{"type":"update","eventId":7275475271,"timestamp":1562380982,"timestampms":1562380982387,"socket_sequence":2,"events":[{"type":"change","side":"ask","price":"11386.12","remaining":"1.3148","reason":"top-of-book"}]}
|
||||
{"type":"update","eventId":7275475838,"timestamp":1562380986,"timestampms":1562380986270,"socket_sequence":3,"events":[{"type":"change","side":"ask","price":"11387.16","remaining":"0.072949","reason":"top-of-book"}]}
|
||||
{"type":"update","eventId":7275475935,"timestamp":1562380986,"timestampms":1562380986767,"socket_sequence":4,"events":[{"type":"change","side":"ask","price":"11389.22","remaining":"0.06204196","reason":"top-of-book"}]}
|
||||
|
||||
|
||||
可以看到,在和Gemini建立连接后,我们并没有向服务器发送任何消息,没有任何请求,但是服务器却源源不断地向我们推送数据。这可比REST接口“每请求一次获得一次回复”的沟通方式高效多了!
|
||||
|
||||
因此,相对于REST来说,Websocket是一种更加实时、高效的数据交换方式。当然缺点也很明显:因为请求和回复是异步的,这让我们程序的状态控制逻辑更加复杂。这一点,后面的内容里我们会有更深刻的体会。
|
||||
|
||||
行情抓取模块
|
||||
|
||||
有了 Websocket 的基本概念,我们就掌握了和交易所连接的第二种方式。
|
||||
|
||||
事实上,Gemini 提供了两种 Websocket 接口,一种是 Public 接口,一种为 Private 接口。
|
||||
|
||||
Public 接口,即公开接口,提供 orderbook 服务,即每个人都能看到的当前挂单价和深度,也就是我们这节课刚刚详细讲过的 orderbook。
|
||||
|
||||
而 Private 接口,和我们上节课讲的挂单操作有关,订单被完全执行、被部分执行等等其他变动,你都会得到通知。
|
||||
|
||||
我们以 orderbook 爬虫为例,先来看下如何抓取 orderbook 信息。下面的代码详细写了一个典型的爬虫,同时使用了类进行封装,希望你不要忘记我们这门课的目的,了解 Python 是如何应用于工程实践中的:
|
||||
|
||||
import copy
|
||||
import json
|
||||
import ssl
|
||||
import time
|
||||
import websocket
|
||||
|
||||
|
||||
class OrderBook(object):
|
||||
|
||||
BIDS = 'bid'
|
||||
ASKS = 'ask'
|
||||
|
||||
def __init__(self, limit=20):
|
||||
|
||||
self.limit = limit
|
||||
|
||||
# (price, amount)
|
||||
self.bids = {}
|
||||
self.asks = {}
|
||||
|
||||
self.bids_sorted = []
|
||||
self.asks_sorted = []
|
||||
|
||||
def insert(self, price, amount, direction):
|
||||
if direction == self.BIDS:
|
||||
if amount == 0:
|
||||
if price in self.bids:
|
||||
del self.bids[price]
|
||||
else:
|
||||
self.bids[price] = amount
|
||||
elif direction == self.ASKS:
|
||||
if amount == 0:
|
||||
if price in self.asks:
|
||||
del self.asks[price]
|
||||
else:
|
||||
self.asks[price] = amount
|
||||
else:
|
||||
print('WARNING: unknown direction {}'.format(direction))
|
||||
|
||||
def sort_and_truncate(self):
|
||||
# sort
|
||||
self.bids_sorted = sorted([(price, amount) for price, amount in self.bids.items()], reverse=True)
|
||||
self.asks_sorted = sorted([(price, amount) for price, amount in self.asks.items()])
|
||||
|
||||
# truncate
|
||||
self.bids_sorted = self.bids_sorted[:self.limit]
|
||||
self.asks_sorted = self.asks_sorted[:self.limit]
|
||||
|
||||
# copy back to bids and asks
|
||||
self.bids = dict(self.bids_sorted)
|
||||
self.asks = dict(self.asks_sorted)
|
||||
|
||||
def get_copy_of_bids_and_asks(self):
|
||||
return copy.deepcopy(self.bids_sorted), copy.deepcopy(self.asks_sorted)
|
||||
|
||||
|
||||
class Crawler:
|
||||
def __init__(self, symbol, output_file):
|
||||
self.orderbook = OrderBook(limit=10)
|
||||
self.output_file = output_file
|
||||
|
||||
self.ws = websocket.WebSocketApp('wss://api.gemini.com/v1/marketdata/{}'.format(symbol),
|
||||
on_message = lambda ws, message: self.on_message(message))
|
||||
self.ws.run_forever(sslopt={'cert_reqs': ssl.CERT_NONE})
|
||||
|
||||
def on_message(self, message):
|
||||
# 对收到的信息进行处理,然后送给 orderbook
|
||||
data = json.loads(message)
|
||||
for event in data['events']:
|
||||
price, amount, direction = float(event['price']), float(event['remaining']), event['side']
|
||||
self.orderbook.insert(price, amount, direction)
|
||||
|
||||
# 整理 orderbook,排序,只选取我们需要的前几个
|
||||
self.orderbook.sort_and_truncate()
|
||||
|
||||
# 输出到文件
|
||||
with open(self.output_file, 'a+') as f:
|
||||
bids, asks = self.orderbook.get_copy_of_bids_and_asks()
|
||||
output = {
|
||||
'bids': bids,
|
||||
'asks': asks,
|
||||
'ts': int(time.time() * 1000)
|
||||
}
|
||||
f.write(json.dumps(output) + '\n')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
crawler = Crawler(symbol='BTCUSD', output_file='BTCUSD.txt')
|
||||
|
||||
###### 输出 #######
|
||||
|
||||
{"bids": [[11398.73, 0.96304843], [11398.72, 0.98914437], [11397.32, 1.0], [11396.13, 2.0], [11395.95, 2.0], [11395.87, 1.0], [11394.09, 0.11803397], [11394.08, 1.0], [11393.59, 0.1612581], [11392.96, 1.0]], "asks": [[11407.42, 1.30814001], [11407.92, 1.0], [11409.48, 2.0], [11409.66, 2.0], [11412.15, 0.525], [11412.42, 1.0], [11413.77, 0.11803397], [11413.99, 0.5], [11414.28, 1.0], [11414.72, 1.0]], "ts": 1562558996535}
|
||||
{"bids": [[11398.73, 0.96304843], [11398.72, 0.98914437], [11397.32, 1.0], [11396.13, 2.0], [11395.95, 2.0], [11395.87, 1.0], [11394.09, 0.11803397], [11394.08, 1.0], [11393.59, 0.1612581], [11392.96, 1.0]], "asks": [[11407.42, 1.30814001], [11407.92, 1.0], [11409.48, 2.0], [11409.66, 2.0], [11412.15, 0.525], [11412.42, 1.0], [11413.77, 0.11803397], [11413.99, 0.5], [11414.28, 1.0], [11414.72, 1.0]], "ts": 1562558997377}
|
||||
{"bids": [[11398.73, 0.96304843], [11398.72, 0.98914437], [11397.32, 1.0], [11396.13, 2.0], [11395.95, 2.0], [11395.87, 1.0], [11394.09, 0.11803397], [11394.08, 1.0], [11393.59, 0.1612581], [11392.96, 1.0]], "asks": [[11407.42, 1.30814001], [11409.48, 2.0], [11409.66, 2.0], [11412.15, 0.525], [11412.42, 1.0], [11413.77, 0.11803397], [11413.99, 0.5], [11414.28, 1.0], [11414.72, 1.0]], "ts": 1562558997765}
|
||||
{"bids": [[11398.73, 0.96304843], [11398.72, 0.98914437], [11397.32, 1.0], [11396.13, 2.0], [11395.95, 2.0], [11395.87, 1.0], [11394.09, 0.11803397], [11394.08, 1.0], [11393.59, 0.1612581], [11392.96, 1.0]], "asks": [[11407.42, 1.30814001], [11409.48, 2.0], [11409.66, 2.0], [11412.15, 0.525], [11413.77, 0.11803397], [11413.99, 0.5], [11414.28, 1.0], [11414.72, 1.0]], "ts": 1562558998638}
|
||||
{"bids": [[11398.73, 0.97131753], [11398.72, 0.98914437], [11397.32, 1.0], [11396.13, 2.0], [11395.95, 2.0], [11395.87, 1.0], [11394.09, 0.11803397], [11394.08, 1.0], [11393.59, 0.1612581], [11392.96, 1.0]], "asks": [[11407.42, 1.30814001], [11409.48, 2.0], [11409.66, 2.0], [11412.15, 0.525], [11413.77, 0.11803397], [11413.99, 0.5], [11414.28, 1.0], [11414.72, 1.0]], "ts": 1562558998645}
|
||||
{"bids": [[11398.73, 0.97131753], [11398.72, 0.98914437], [11397.32, 1.0], [11396.13, 2.0], [11395.87, 1.0], [11394.09, 0.11803397], [11394.08, 1.0], [11393.59, 0.1612581], [11392.96, 1.0]], "asks": [[11407.42, 1.30814001], [11409.48, 2.0], [11409.66, 2.0], [11412.15, 0.525], [11413.77, 0.11803397], [11413.99, 0.5], [11414.28, 1.0], [11414.72, 1.0]], "ts": 1562558998748}
|
||||
|
||||
|
||||
代码比较长,接下来我们具体解释一下。
|
||||
|
||||
这段代码的最开始,封装了一个叫做 orderbook 的 class,专门用来存放与之相关的数据结构。其中的 bids 和 asks 两个字典,用来存储当前时刻下的买方挂单和卖方挂单。
|
||||
|
||||
此外,我们还专门维护了一个排过序的 bids_sorted 和 asks_sorted。构造函数有一个参数 limit,用来指示 orderbook 的 bids 和 asks 保留多少条数据。对于很多策略,top 5 的数据往往足够,这里我们选择的是前 10 个。
|
||||
|
||||
再往下看,insert() 函数用于向 orderbook 插入一条数据。需要注意,这里的逻辑是,如果某个 price 对应的 amount 是 0,那么意味着这一条数据已经不存在了,删除即可。insert 的数据可能是乱序的,因此在需要的时候,我们要对 bids 和 asks 进行排序,然后选取前面指定数量的数据。这其实就是 sort_and_truncate() 函数的作用,调用它来对 bids 和 asks 排序后截取,最后保存回 bids 和 asks。
|
||||
|
||||
接下来的 get_copy_of_bids_and_asks()函数,用来返回排过序的 bids 和 asks 数组。这里使用深拷贝,是因为如果直接返回,将会返回 bids_sorted 和 asks_sorted 的指针;那么,在下一次调用 sort_and_truncate() 函数的时候,两个数组的内容将会被改变,这就造成了潜在的 bug。
|
||||
|
||||
最后来看一下 Crawler 类。构造函数声明 orderbook,然后定义 Websocket 用来接收交易所数据。这里需要注意的一点是,回调函数 on_message() 是一个类成员函数。因此,应该你注意到了,它的第一个参数是 self,这里如果直接写成 on_message = self.on_message 将会出错。
|
||||
|
||||
为了避免这个问题,我们需要将函数再次包装一下。这里我使用了前面学过的匿名函数,来传递中间状态,注意我们只需要 message,因此传入 message 即可。
|
||||
|
||||
剩下的部分就很清晰了,on_message 回调函数在收到一个新的 tick 时,先将信息解码,枚举收到的所有改变;然后插入 orderbook,排序;最后连同 timestamp 一并输出即可。
|
||||
|
||||
虽然这段代码看起来挺长,但是经过我这么一分解,是不是发现都是学过的知识点呢?这也是我一再强调基础的原因,如果对你来说哪部分内容变得陌生了(比如面向对象编程的知识点),一定要记得及时往前复习,这样你学起新的更复杂的东西,才能轻松很多。
|
||||
|
||||
回到正题。刚刚的代码,主要是为了抓取 orderbook 的信息。事实上,Gemini 交易所在建立数据流 Websocket 的时候,第一条信息往往非常大,因为里面包含了那个时刻所有的 orderbook 信息。这就叫做初始数据。之后的消息,都是基于初始数据进行修改的,直接处理即可。
|
||||
|
||||
总结
|
||||
|
||||
这节课我们继承上一节,从委托账本讲起,然后讲述了 WebSocket 的定义、工作机制和使用方法,最后以一个例子收尾,带你学会如何爬取 Orderbook 的信息。希望你在学习这节课的内容时,能够和上节课的内容联系起来,仔细思考 Websocket 和 RESTFul 的区别,并试着总结网络编程中不同模型的适用范围。
|
||||
|
||||
思考题
|
||||
|
||||
最后给你留一道思考题。WebSocket 会丢包吗?如果丢包的话, Orderbook 爬虫又会发生什么?这一点应该如何避免呢?欢迎留言和我讨论,也欢迎你把这篇文章分享出去。
|
||||
|
||||
|
||||
|
||||
|
@ -0,0 +1,574 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
36 Pandas & Numpy:策略与回测系统
|
||||
大家好,我是景霄。
|
||||
|
||||
上节课,我们介绍了交易所的数据抓取,特别是orderbook和tick数据的抓取。今天这节课,我们考虑的是,怎么在这些历史数据上测试一个交易策略。
|
||||
|
||||
首先我们要明确,对于很多策略来说,我们上节课抓取的密集的orderbook和tick数据,并不能简单地直接使用。因为数据量太密集,包含了太多细节;而且长时间连接时,网络随机出现的不稳定,会导致丢失部分tick数据。因此,我们还需要进行合适的清洗、聚合等操作。
|
||||
|
||||
此外,为了进行回测,我们需要一个交易策略,还需要一个测试框架。目前已存在很多成熟的回测框架,但是为了Python学习,我决定带你搭建一个简单的回测框架,并且从中简单一窥Pandas的优势。
|
||||
|
||||
OHLCV数据
|
||||
|
||||
了解过一些股票交易的同学,可能知道K线这种东西。K线又称“蜡烛线”,是一种反映价格走势的图线。它的特色在于,一个线段内记录了多项讯息,相当易读易懂且实用有效,因此被广泛用于股票、期货、贵金属、数字货币等行情的技术分析。下面便是一个K线示意图。
|
||||
|
||||
|
||||
|
||||
K线示意图
|
||||
|
||||
其中,每一个小蜡烛,都代表着当天的开盘价(Open)、最高价(High)、最低价(Low)和收盘价(Close),也就是我画的第二张图表示的这样。
|
||||
|
||||
|
||||
|
||||
K线的“小蜡烛” – OHLC
|
||||
|
||||
类似的,除了日K线之外,还有周K线、小时K线、分钟K线等等。那么这个K线是怎么计算来的呢?
|
||||
|
||||
我们以小时K线图为例,还记得我们当时抓取的tick数据吗?也就是每一笔交易的价格和数量。那么,如果从上午10:00开始,我们开始积累tick的交易数据,以10:00开始的第一个交易作为Open数据,11:00前的最后一笔交易作为Close值,并把这一个小时最低和最高的成交价格分别作为High和Low的值,我们就可以绘制出这一个小时对应的“小蜡烛”形状了。
|
||||
|
||||
如果再加上这一个小时总的成交量(Volumn),就得到了OHLCV数据。
|
||||
|
||||
所以,如果我们一直抓取着tick底层原始数据,我们就能在上层聚合出1分钟K线、小时K线以及日、周k线等等。如果你对这一部分操作有兴趣,可以把此作为今天的课后作业来实践。
|
||||
|
||||
接下来,我们将使用Gemini从2015年到2019年7月这个时间内,BTC对USD每个小时的OHLCV数据,作为策略和回测的输入。你可以在这里下载数据。
|
||||
|
||||
数据下载完成后,我们可以利用Pandas读取,比如下面这段代码。
|
||||
|
||||
def assert_msg(condition, msg):
|
||||
if not condition:
|
||||
raise Exception(msg)
|
||||
|
||||
def read_file(filename):
|
||||
# 获得文件绝对路径
|
||||
filepath = path.join(path.dirname(__file__), filename)
|
||||
|
||||
# 判定文件是否存在
|
||||
assert_msg(path.exists(filepath), "文件不存在")
|
||||
|
||||
# 读取CSV文件并返回
|
||||
return pd.read_csv(filepath,
|
||||
index_col=0,
|
||||
parse_dates=True,
|
||||
infer_datetime_format=True)
|
||||
|
||||
BTCUSD = read_file('BTCUSD_GEMINI.csv')
|
||||
assert_msg(BTCUSD.__len__() > 0, '读取失败')
|
||||
print(BTCUSD.head())
|
||||
|
||||
|
||||
########## 输出 ##########
|
||||
Time Symbol Open High Low Close Volume
|
||||
Date
|
||||
2019-07-08 00:00:00 BTCUSD 11475.07 11540.33 11469.53 11506.43 10.770731
|
||||
2019-07-07 23:00:00 BTCUSD 11423.00 11482.72 11423.00 11475.07 32.996559
|
||||
2019-07-07 22:00:00 BTCUSD 11526.25 11572.74 11333.59 11423.00 48.937730
|
||||
2019-07-07 21:00:00 BTCUSD 11515.80 11562.65 11478.20 11526.25 25.323908
|
||||
2019-07-07 20:00:00 BTCUSD 11547.98 11624.88 11423.94 11515.80 63.211972
|
||||
|
||||
|
||||
这段代码提供了两个工具函数。
|
||||
|
||||
|
||||
一个是read_file,它的作用是,用pandas读取csv文件。
|
||||
另一个是assert_msg,它的作用类似于assert,如果传入的条件(contidtion)为否,就会抛出异常。不过,你需要提供一个参数,用于指定要抛出的异常信息。
|
||||
|
||||
|
||||
回测框架
|
||||
|
||||
说完了数据,我们接着来看回测数据。常见的回测框架有两类。一类是向量化回测框架,它通常基于Pandas+Numpy来自己搭建计算核心;后端则是用MySQL或者MongoDB作为源。这种框架通过Pandas+Numpy对OHLC数组进行向量运算,可以在较长的历史数据上进行回测。不过,因为这类框架一般只用OHLC,所以模拟会比较粗糙。
|
||||
|
||||
另一类则是事件驱动型回测框架。这类框架,本质上是针对每一个tick的变动或者orderbook的变动生成事件;然后,再把一个个事件交给策略进行执行。因此,虽然它的拓展性很强,可以允许更加灵活的策略,但回测速度是很慢的。
|
||||
|
||||
我们想要学习量化交易,使用大型成熟的回测框架,自然是第一选择。
|
||||
|
||||
|
||||
比如Zipline,就是一个热门的事件驱动型回测框架,背后有大型社区和文档的支持。
|
||||
PyAlgoTrade也是事件驱动的回测框架,文档相对完整,整合了知名的技术分析(Techique Analysis)库TA-Lib。在速度和灵活方面,它比Zipline 强。不过,它的一大硬伤是不支持 Pandas 的模块和对象。
|
||||
|
||||
|
||||
显然,对于我们Python学习者来说,第一类也就是向量型回测框架,才是最适合我们练手的项目了。那么,我们就开始吧。
|
||||
|
||||
首先,我先为你梳理下回测流程,也就是下面五步:
|
||||
|
||||
|
||||
读取OHLC数据;
|
||||
对OHLC进行指标运算;
|
||||
策略根据指标向量决定买卖;
|
||||
发给模拟的”交易所“进行交易;
|
||||
最后,统计结果。
|
||||
|
||||
|
||||
对此,使用之前学到的面向对象思维方式,我们可以大致抽取三个类:
|
||||
|
||||
|
||||
交易所类( ExchangeAPI):负责维护账户的资金和仓位,以及进行模拟的买卖;
|
||||
策略类(Strategy):负责根据市场信息生成指标,根据指标决定买卖;
|
||||
回测类框架(Backtest):包含一个策略类和一个交易所类,负责迭代地对每个数据点调用策略执行。
|
||||
|
||||
|
||||
接下来,我们先从最外层的大框架开始。这样的好处在于,我们是从上到下、从外往内地思考,虽然还没有开始设计依赖项(Backtest的依赖项是ExchangeAPI和Strategy),但我们可以推测出它们应有的接口形式。推测接口的本质,其实就是推测程序的输入。
|
||||
|
||||
这也是我在一开始提到过的,对于程序这个“黑箱”,你在一开始设计的时候,就要想好输入和输出。
|
||||
|
||||
回到最外层Backtest类。我们需要知道,输出是最后的收益,那么显然,输入应该是初始输入的资金数量(cash)。
|
||||
|
||||
此外,为了模拟得更加真实,我们还要考虑交易所的手续费(commission)。手续费的多少取决于券商(broker)或者交易所,比如我们买卖股票的券商手续费可能是万七,那么就是0.0007。但是在比特币交易领域,手续费通常会稍微高一点,可能是千分之二左右。当然,无论怎么多,一般也不会超过5 %。否则我们大家交易几次就破产了,也就不会有人去交易了。
|
||||
|
||||
这里说一句题外话,不知道你有没有发现,无论数字货币的价格是涨还是跌,总有一方永远不亏,那就是交易所。因为只要有人交易,他们就有白花花的银子进账。
|
||||
|
||||
回到正题,至此,我们就确定了Backtest的输入和输出。
|
||||
|
||||
它的输入是:
|
||||
|
||||
|
||||
OHLC数据;
|
||||
初始资金;
|
||||
手续费率;
|
||||
交易所类;
|
||||
策略类。
|
||||
|
||||
|
||||
输出则是:
|
||||
|
||||
|
||||
最后剩余市值。
|
||||
|
||||
|
||||
对此,你可以参考下面这段代码:
|
||||
|
||||
class Backtest:
|
||||
"""
|
||||
Backtest回测类,用于读取历史行情数据、执行策略、模拟交易并估计
|
||||
收益。
|
||||
|
||||
初始化的时候调用Backtest.run来时回测
|
||||
|
||||
instance, or `backtesting.backtesting.Backtest.optimize` to
|
||||
optimize it.
|
||||
"""
|
||||
|
||||
def __init__(self,
|
||||
data: pd.DataFrame,
|
||||
strategy_type: type(Strategy),
|
||||
broker_type: type(ExchangeAPI),
|
||||
cash: float = 10000,
|
||||
commission: float = .0):
|
||||
"""
|
||||
构造回测对象。需要的参数包括:历史数据,策略对象,初始资金数量,手续费率等。
|
||||
初始化过程包括检测输入类型,填充数据空值等。
|
||||
|
||||
参数:
|
||||
:param data: pd.DataFrame pandas Dataframe格式的历史OHLCV数据
|
||||
:param broker_type: type(ExchangeAPI) 交易所API类型,负责执行买卖操作以及账户状态的维护
|
||||
:param strategy_type: type(Strategy) 策略类型
|
||||
:param cash: float 初始资金数量
|
||||
:param commission: float 每次交易手续费率。如2%的手续费此处为0.02
|
||||
"""
|
||||
|
||||
assert_msg(issubclass(strategy_type, Strategy), 'strategy_type不是一个Strategy类型')
|
||||
assert_msg(issubclass(broker_type, ExchangeAPI), 'strategy_type不是一个Strategy类型')
|
||||
assert_msg(isinstance(commission, Number), 'commission不是浮点数值类型')
|
||||
|
||||
data = data.copy(False)
|
||||
|
||||
# 如果没有Volumn列,填充NaN
|
||||
if 'Volume' not in data:
|
||||
data['Volume'] = np.nan
|
||||
|
||||
# 验证OHLC数据格式
|
||||
assert_msg(len(data.columns & {'Open', 'High', 'Low', 'Close', 'Volume'}) == 5,
|
||||
("输入的`data`格式不正确,至少需要包含这些列:"
|
||||
"'Open', 'High', 'Low', 'Close'"))
|
||||
|
||||
# 检查缺失值
|
||||
assert_msg(not data[['Open', 'High', 'Low', 'Close']].max().isnull().any(),
|
||||
('部分OHLC包含缺失值,请去掉那些行或者通过差值填充. '))
|
||||
|
||||
# 如果行情数据没有按照时间排序,重新排序一下
|
||||
if not data.index.is_monotonic_increasing:
|
||||
data = data.sort_index()
|
||||
|
||||
# 利用数据,初始化交易所对象和策略对象。
|
||||
self._data = data # type: pd.DataFrame
|
||||
self._broker = broker_type(data, cash, commission)
|
||||
self._strategy = strategy_type(self._broker, self._data)
|
||||
self._results = None
|
||||
|
||||
def run(self):
|
||||
"""
|
||||
运行回测,迭代历史数据,执行模拟交易并返回回测结果。
|
||||
Run the backtest. Returns `pd.Series` with results and statistics.
|
||||
|
||||
Keyword arguments are interpreted as strategy parameters.
|
||||
"""
|
||||
strategy = self._strategy
|
||||
broker = self._broker
|
||||
|
||||
# 策略初始化
|
||||
strategy.init()
|
||||
|
||||
# 设定回测开始和结束位置
|
||||
start = 100
|
||||
end = len(self._data)
|
||||
|
||||
# 回测主循环,更新市场状态,然后执行策略
|
||||
for i in range(start, end):
|
||||
# 注意要先把市场状态移动到第i时刻,然后再执行策略。
|
||||
broker.next(i)
|
||||
strategy.next(i)
|
||||
|
||||
# 完成策略执行之后,计算结果并返回
|
||||
self._results = self._compute_result(broker)
|
||||
return self._results
|
||||
|
||||
def _compute_result(self, broker):
|
||||
s = pd.Series()
|
||||
s['初始市值'] = broker.initial_cash
|
||||
s['结束市值'] = broker.market_value
|
||||
s['收益'] = broker.market_value - broker.initial_cash
|
||||
return s
|
||||
|
||||
|
||||
这段代码有点长,但是核心其实就两部分。
|
||||
|
||||
|
||||
初始化函数(init):传入必要参数,对OHLC数据进行简单清洗、排序和验证。我们从不同地方下载的数据,可能格式不一样;而排序的方式也可能是从前往后。所以,这里我们把数据统一设置为按照时间从之前往现在的排序。
|
||||
执行函数(run):这是回测框架的主要循环部分,核心是更新市场还有更新策略的时间。迭代完成所有的历史数据后,它会计算收益并返回。
|
||||
|
||||
|
||||
你应该注意到了,此时,我们还没有定义策略和交易所API的结构。不过,通过回测的执行函数,我们可以确定这两个类的接口形式。
|
||||
|
||||
策略类(Strategy)的接口形式为:
|
||||
|
||||
|
||||
初始化函数init(),根据历史数据进行指标(Indicator)计算。
|
||||
步进函数next(),根据当前时间和指标,决定买卖操作,并发给交易所类执行。
|
||||
|
||||
|
||||
交易所类(ExchangeAPI)的接口形式为:
|
||||
|
||||
|
||||
步进函数next(),根据当前时间,更新最新的价格;
|
||||
买入操作buy(),买入资产;
|
||||
卖出操作sell(),卖出资产。
|
||||
|
||||
|
||||
交易策略
|
||||
|
||||
接下来我们来看交易策略。交易策略的开发是一个非常复杂的学问。为了达到学习的目的,我们来想一个简单的策略——移动均值交叉策略。
|
||||
|
||||
为了了解这个策略,我们先了解一下,什么叫做简单移动均值(Simple Moving Average,简称为SMA,以下皆用SMA表示简单移动均值)。我们知道,N个数的序列 x[0]、x[1] .…… x[N] 的均值,就是这N个数的和除以N。
|
||||
|
||||
现在,我假设一个比较小的数K,比N小很多。我们用一个K大小的滑动窗口,在原始的数组上滑动。通过对每次框住的K个元素求均值,我们就可以得到,原始数组的窗口大小为K的SMA了。
|
||||
|
||||
SMA,实质上就是对原始数组进行了一个简单平滑处理。比如,某支股票的价格波动很大,那么,我们用SMA平滑之后,就会得到下面这张图的效果。
|
||||
|
||||
|
||||
|
||||
某个投资品价格的SMA,窗口大小为50
|
||||
|
||||
你可以看出,如果窗口大小越大,那么SMA应该越平滑,变化越慢;反之,如果SMA比较小,那么短期的变化也会越快地反映在SMA上。
|
||||
|
||||
于是,我们想到,能不能对投资品的价格设置两个指标呢?这俩指标,一个是小窗口的SMA,一个是大窗口的SMA。
|
||||
|
||||
|
||||
如果小窗口的SMA曲线从下面刺破或者穿过大窗口SMA,那么说明,这个投资品的价格在短期内快速上涨,同时这个趋势很强烈,可能是一个买入的信号;
|
||||
反之,如果大窗口的SMA从下方突破小窗口SMA,那么说明,投资品的价格在短期内快速下跌,我们应该考虑卖出。
|
||||
|
||||
|
||||
下面这幅图,就展示了这两种情况。
|
||||
|
||||
|
||||
|
||||
明白了这里的概念和原理后,接下来的操作就不难了。利用Pandas,我们可以非常简单地计算SMA和SMA交叉。比如,你可以引入下面两个工具函数:
|
||||
|
||||
def SMA(values, n):
|
||||
"""
|
||||
返回简单滑动平均
|
||||
"""
|
||||
return pd.Series(values).rolling(n).mean()
|
||||
|
||||
def crossover(series1, series2) -> bool:
|
||||
"""
|
||||
检查两个序列是否在结尾交叉
|
||||
:param series1: 序列1
|
||||
:param series2: 序列2
|
||||
:return: 如果交叉返回True,反之False
|
||||
"""
|
||||
return series1[-2] < series2[-2] and series1[-1] > series2[-1]
|
||||
|
||||
|
||||
如代码所示,对于输入的一个数组,Pandas的rolling(k)函数,可以方便地计算窗内口大小为K的SMA数组;而想要检查某个时刻两个SMA是否交叉,你只需要查看两个数组末尾的两个元素即可。
|
||||
|
||||
那么,基于此,我们就可以开发出一个简单的策略了。下面这段代码表示策略的核心思想,我做了详细的注释,你理解起来应该没有问题:
|
||||
|
||||
def next(self, tick):
|
||||
# 如果此时快线刚好越过慢线,买入全部
|
||||
if crossover(self.sma1[:tick], self.sma2[:tick]):
|
||||
self.buy()
|
||||
|
||||
# 如果是慢线刚好越过快线,卖出全部
|
||||
elif crossover(self.sma2[:tick], self.sma1[:tick]):
|
||||
self.sell()
|
||||
|
||||
# 否则,这个时刻不执行任何操作。
|
||||
else:
|
||||
pass
|
||||
|
||||
|
||||
说完策略的核心思想,我们开始搭建策略类的框子。
|
||||
|
||||
首先,我们要考虑到,策略类Strategy应该是一个可以被继承的类,同时应该包含一些固定的接口。这样,回测器才能方便地调用。
|
||||
|
||||
于是,我们可以定义一个Strategy抽象类,包含两个接口方法init和next,分别对应我们前面说的指标计算和步进函数。不过注意,抽象类是不能被实例化的。所以,我们必须定义一个具体的子类,同时实现了init和next方法才可以。
|
||||
|
||||
这个类的定义,你可以参考下面代码的实现:
|
||||
|
||||
import abc
|
||||
import numpy as np
|
||||
from typing import Callable
|
||||
|
||||
class Strategy(metaclass=abc.ABCMeta):
|
||||
"""
|
||||
抽象策略类,用于定义交易策略。
|
||||
|
||||
如果要定义自己的策略类,需要继承这个基类,并实现两个抽象方法:
|
||||
Strategy.init
|
||||
Strategy.next
|
||||
"""
|
||||
def __init__(self, broker, data):
|
||||
"""
|
||||
构造策略对象。
|
||||
|
||||
@params broker: ExchangeAPI 交易API接口,用于模拟交易
|
||||
@params data: list 行情数据数据
|
||||
"""
|
||||
self._indicators = []
|
||||
self._broker = broker # type: _Broker
|
||||
self._data = data # type: _Data
|
||||
self._tick = 0
|
||||
|
||||
def I(self, func: Callable, *args) -> np.ndarray:
|
||||
"""
|
||||
计算买卖指标向量。买卖指标向量是一个数组,长度和历史数据对应;
|
||||
用于判定这个时间点上需要进行"买"还是"卖"。
|
||||
|
||||
例如计算滑动平均:
|
||||
def init():
|
||||
self.sma = self.I(utils.SMA, self.data.Close, N)
|
||||
"""
|
||||
value = func(*args)
|
||||
value = np.asarray(value)
|
||||
assert_msg(value.shape[-1] == len(self._data.Close), '指示器长度必须和data长度相同')
|
||||
|
||||
self._indicators.append(value)
|
||||
return value
|
||||
|
||||
@property
|
||||
def tick(self):
|
||||
return self._tick
|
||||
|
||||
@abc.abstractmethod
|
||||
def init(self):
|
||||
"""
|
||||
初始化策略。在策略回测/执行过程中调用一次,用于初始化策略内部状态。
|
||||
这里也可以预计算策略的辅助参数。比如根据历史行情数据:
|
||||
计算买卖的指示器向量;
|
||||
训练模型/初始化模型参数
|
||||
"""
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def next(self, tick):
|
||||
"""
|
||||
步进函数,执行第tick步的策略。tick代表当前的"时间"。比如data[tick]用于访问当前的市场价格。
|
||||
"""
|
||||
pass
|
||||
|
||||
def buy(self):
|
||||
self._broker.buy()
|
||||
|
||||
def sell(self):
|
||||
self._broker.sell()
|
||||
|
||||
@property
|
||||
def data(self):
|
||||
return self._data
|
||||
|
||||
|
||||
为了方便访问成员,我们还定义了一些Python property。同时,我们的买卖请求是由策略类发出、由交易所API来执行的,所以我们的策略类里依赖于ExchangeAPI类。
|
||||
|
||||
现在,有了这个框架,我们实现移动均线交叉策略就很简单了。你只需要在init函数中,定义计算大小窗口SMA的逻辑;同时,在next函数中完成交叉检测和买卖调用就行了。具体实现,你可以参考下面这段代码:
|
||||
|
||||
from utils import assert_msg, crossover, SMA
|
||||
|
||||
class SmaCross(Strategy):
|
||||
# 小窗口SMA的窗口大小,用于计算SMA快线
|
||||
fast = 10
|
||||
|
||||
# 大窗口SMA的窗口大小,用于计算SMA慢线
|
||||
slow = 20
|
||||
|
||||
def init(self):
|
||||
# 计算历史上每个时刻的快线和慢线
|
||||
self.sma1 = self.I(SMA, self.data.Close, self.fast)
|
||||
self.sma2 = self.I(SMA, self.data.Close, self.slow)
|
||||
|
||||
def next(self, tick):
|
||||
# 如果此时快线刚好越过慢线,买入全部
|
||||
if crossover(self.sma1[:tick], self.sma2[:tick]):
|
||||
self.buy()
|
||||
|
||||
# 如果是慢线刚好越过快线,卖出全部
|
||||
elif crossover(self.sma2[:tick], self.sma1[:tick]):
|
||||
self.sell()
|
||||
|
||||
# 否则,这个时刻不执行任何操作。
|
||||
else:
|
||||
pass
|
||||
|
||||
|
||||
模拟交易
|
||||
|
||||
到这里,我们的回测就只差最后一块儿了。胜利就在眼前,我们继续加油。
|
||||
|
||||
我们前面提到过,交易所类负责模拟交易,而模拟的基础,就是需要当前市场的价格。这里,我们可以用OHLC中的Close,作为那个时刻的价格。
|
||||
|
||||
此外,为了简化设计,我们假设买卖操作都利用的是当前账户的所有资金、仓位,且市场容量足够大。这样,我们的下单请求就能够马上完全执行。
|
||||
|
||||
也别忘了手续费这个大头。考虑到有手续费的情况,此时,我们最核心的买卖函数应该怎么来写呢?
|
||||
|
||||
我们一起来想这个问题。假设,我们现在有1000.0元,此时BTC的价格是100.00元(当然没有这么好的事情啊,这里只是假设),并且交易手续费为1%。那么,我们能买到多少BTC呢?
|
||||
|
||||
我们可以采用这种算法:
|
||||
|
||||
买到的数量 = 投入的资金 * (1.0 - 手续费) / 价格
|
||||
|
||||
|
||||
那么此时,你就能收到9.9个BTC。
|
||||
|
||||
类似的,卖出的时候结算方式如下,也不难理解:
|
||||
|
||||
卖出的收益 = 持有的数量 * 价格 * (1.0 - 手续费)
|
||||
|
||||
|
||||
所以,最终模拟交易所类的实现,你可以参考下面这段代码:
|
||||
|
||||
from utils import read_file, assert_msg, crossover, SMA
|
||||
|
||||
class ExchangeAPI:
|
||||
def __init__(self, data, cash, commission):
|
||||
assert_msg(0 < cash, "初始现金数量大于0,输入的现金数量:{}".format(cash))
|
||||
assert_msg(0 <= commission <= 0.05, "合理的手续费率一般不会超过5%,输入的费率:{}".format(commission))
|
||||
self._inital_cash = cash
|
||||
self._data = data
|
||||
self._commission = commission
|
||||
self._position = 0
|
||||
self._cash = cash
|
||||
self._i = 0
|
||||
|
||||
@property
|
||||
def cash(self):
|
||||
"""
|
||||
:return: 返回当前账户现金数量
|
||||
"""
|
||||
return self._cash
|
||||
|
||||
@property
|
||||
def position(self):
|
||||
"""
|
||||
:return: 返回当前账户仓位
|
||||
"""
|
||||
return self._position
|
||||
|
||||
@property
|
||||
def initial_cash(self):
|
||||
"""
|
||||
:return: 返回初始现金数量
|
||||
"""
|
||||
return self._inital_cash
|
||||
|
||||
@property
|
||||
def market_value(self):
|
||||
"""
|
||||
:return: 返回当前市值
|
||||
"""
|
||||
return self._cash + self._position * self.current_price
|
||||
|
||||
@property
|
||||
def current_price(self):
|
||||
"""
|
||||
:return: 返回当前市场价格
|
||||
"""
|
||||
return self._data.Close[self._i]
|
||||
|
||||
def buy(self):
|
||||
"""
|
||||
用当前账户剩余资金,按照市场价格全部买入
|
||||
"""
|
||||
self._position = float(self._cash / (self.current_price * (1 + self._commission)))
|
||||
self._cash = 0.0
|
||||
|
||||
def sell(self):
|
||||
"""
|
||||
卖出当前账户剩余持仓
|
||||
"""
|
||||
self._cash += float(self._position * self.current_price * (1 - self._commission))
|
||||
self._position = 0.0
|
||||
|
||||
def next(self, tick):
|
||||
self._i = tick
|
||||
|
||||
|
||||
其中的current_price(当前价格),可以方便地获得模拟交易所当前时刻的商品价格;而market_value,则可以获得当前总市值。在初始化函数的时候,我们检查手续费率和输入的现金数量,是不是在一个合理的范围。
|
||||
|
||||
有了所有的这些部分,我们就可以来模拟回测啦!
|
||||
|
||||
首先,我们设置初始资金量为10000.00美元,交易所手续费率为0。这里你可以猜一下,如果我们从2015年到现在,都按照SMA来买卖,现在应该有多少钱呢?
|
||||
|
||||
def main():
|
||||
BTCUSD = read_file('BTCUSD_GEMINI.csv')
|
||||
ret = Backtest(BTCUSD, SmaCross, ExchangeAPI, 10000.0, 0.00).run()
|
||||
print(ret)
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
||||
|
||||
铛铛铛,答案揭晓,程序将输出:
|
||||
|
||||
初始市值 10000.000000
|
||||
结束市值 576361.772884
|
||||
收益 566361.772884
|
||||
|
||||
|
||||
哇,结束时,我们将有57万美元,翻了整整57倍啊!简直不要太爽。不过,等等,这个手续费率为0,实在是有点碍眼,因为根本不可能啊。我们现在来设一个比较真实的值吧,大概千分之三,然后再来试试:
|
||||
|
||||
初始市值 10000.000000
|
||||
结束市值 2036.562001
|
||||
收益 -7963.437999
|
||||
|
||||
|
||||
什么鬼?我们变成赔钱了,只剩下2000美元了!这是真的吗?
|
||||
|
||||
这是真的,也是假的。
|
||||
|
||||
我说的“真”是指,如果你真的用SMA交叉这种简单的方法去交易,那么手续费摩擦和滑点等因素,确实可能让你的高频策略赔钱。
|
||||
|
||||
而我说是“假”是指,这种模拟交易的方式非常粗糙。真实的市场情况,并非这么理想——比如买卖请求永远马上执行;再比如,我们在市场中进行交易的同时不会影响市场价格等,这些理想情况都是不可能的。所以,很多时候,回测永远赚钱,但实盘马上赔钱。
|
||||
|
||||
总结
|
||||
|
||||
这节课,我们继承上一节,介绍了回测框架的分类、数据的格式,并且带你从头开始写了一个简单的回测系统。你可以把今天的代码片段“拼”起来,这样就会得到一个简化的回测系统样例。同时,我们实现了一个简单的交易策略,并且在真实的历史数据上运行了回测结果。我们观察到,在加入手续费后,策略的收益情况发生了显著的变化。
|
||||
|
||||
思考题
|
||||
|
||||
最后,给你留一个思考题。之前我们介绍了如何抓取tick数据,你可以根据抓取的tick数据,生成5分钟、每小时和每天的OHLCV数据吗?欢迎在留言区写下你的答案和问题,也欢迎你把这篇文章分享出去。
|
||||
|
||||
|
||||
|
||||
|
@ -0,0 +1,243 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
37 Kafka & ZMQ:自动化交易流水线
|
||||
你好,我是景霄。
|
||||
|
||||
在进行这节课的学习前,我们先来回顾一下,前面三节课,我们学了些什么。
|
||||
|
||||
第 34 讲,我们介绍了如何通过 RESTful API 在交易所下单;第 35 讲,我们讲解了如何通过 Websocket ,来获取交易所的 orderbook 数据;第 36 讲,我们介绍了如何实现一个策略,以及如何对策略进行历史回测。
|
||||
|
||||
事实上,到这里,一个简单的、可以运作的量化交易系统已经成型了。你可以对策略进行反复修改,期待能得到不错的 PnL。但是,对于一个完善的量化交易系统来说,只有基本骨架还是不够的。
|
||||
|
||||
在大型量化交易公司,系统一般是分布式运行的,各个模块独立在不同的机器上,然后互相连接来实现。即使是个人的交易系统,在进行诸如高频套利等算法时,也需要将执行层布置在靠近交易所的机器节点上。
|
||||
|
||||
所以,从今天这节课开始,我们继续回到 Python 的技术栈,从量化交易系统这个角度切入,为你讲解如何实现分布式系统之间的复杂协作。
|
||||
|
||||
中间件
|
||||
|
||||
我们先来介绍一下中间件这个概念。中间件,是将技术底层工具和应用层进行连接的组件。它要实现的效果则是,让我们这些需要利用服务的工程师,不必去关心底层的具体实现。我们只需要拿着中间件的接口来用就好了。
|
||||
|
||||
这个概念听起来并不难理解,我们再举个例子让你彻底明白。比如拿数据库来说,底层数据库有很多很多种,从关系型数据库 MySQL 到非关系型数据库 NoSQL,从分布式数据库 Spanner 到内存数据库 Redis,不同的数据库有不同的使用场景,也有着不同的优缺点,更有着不同的调用方式。那么中间件起什么作用呢?
|
||||
|
||||
中间件,等于在这些不同的数据库上加了一层逻辑,这一层逻辑专门用来和数据库打交道,而对外只需要暴露同一个接口即可。这样一来,上层的程序员调用中间件接口时,只需要让中间件指定好数据库即可,其他参数完全一致,极大地方便了上层的开发;同时,下层技术栈在更新换代的时候,也可以做到和上层完全分离,不影响程序员的使用。
|
||||
|
||||
它们之间的逻辑关系,你可以参照下面我画的这张图。我习惯性把中间件的作用调侃为:没有什么事情是加一层解决不了的;如果有,那就加两层。
|
||||
|
||||
|
||||
|
||||
当然,这只是其中一个例子,也只是中间件的一种形式。事实上,比如在阿里,中间件主要有分布式关系型数据库 DRDS、消息队列和分布式服务这么三种形式。而我们今天,主要会用到消息队列,因为它非常符合量化交易系统的应用场景,即事件驱动模型。
|
||||
|
||||
消息队列
|
||||
|
||||
那么,什么是消息队列呢?一如其名,消息,即互联网信息传递的个体;而队列,学过算法和数据结构的你,应该很清楚这个 FIFO(先进先出)的数据结构吧。(如果算法基础不太牢,建议你可以学习极客时间平台上王争老师的“数据结构与算法之美”专栏,第 09讲即为队列知识)
|
||||
|
||||
简而言之,消息队列就是一个临时存放消息的容器,有人向消息队列中推送消息;有人则监听消息队列,发现新消息就会取走。根据我们刚刚对中间件的解释,清晰可见,消息队列也是一种中间件。
|
||||
|
||||
目前,市面上使用较多的消息队列有 RabbitMQ、Kafka、RocketMQ、ZMQ 等。不过今天,我只介绍最常用的 ZMQ 和 Kafka。
|
||||
|
||||
我们先来想想,消息队列作为中间件有什么特点呢?
|
||||
|
||||
首先是严格的时序性。刚刚说了,队列是一种先进先出的数据结构,你丢给它 1, 2, 3,然后另一个人从里面取数据,那么取出来的一定也是 1, 2, 3,严格保证了先进去的数据先出去,后进去的数据后出去。显然,这也是消息机制中必须要保证的一点,不然颠三倒四的结果一定不是我们想要的。
|
||||
|
||||
说到队列的特点,简单提一句,与“先进先出“相对的是栈这种数据结构,它是先进后出的,你丢给它 1, 2, 3,再从里面取出来的时候,拿到的就是3, 2, 1了,这一点一定要区分清楚。
|
||||
|
||||
其次,是分布式网络系统的老生常谈问题。如何保证消息不丢失?如何保证消息不重复?这一切,消息队列在设计的时候都已经考虑好了,你只需要拿来用就可以,不必过多深究。
|
||||
|
||||
不过,很重要的一点,消息队列是如何降低系统复杂度,起到中间件的解耦作用呢?我们来看下面这张图。
|
||||
|
||||
|
||||
|
||||
消息队列的模式是发布和订阅,一个或多个消息发布者可以发布消息,一个或多个消息接受者可以订阅消息。 从图中你可以看到,消息发布者和消息接受者之间没有直接耦合,其中,
|
||||
|
||||
|
||||
消息发布者将消息发送到分布式消息队列后,就结束了对消息的处理;
|
||||
消息接受者从分布式消息队列获取该消息后,即可进行后续处理,并不需要探寻这个消息从何而来。
|
||||
|
||||
|
||||
至于新增业务的问题,只要你对这类消息感兴趣,即可订阅该消息,对原有系统和业务没有任何影响,所以也就实现了业务的可扩展性设计。
|
||||
|
||||
讲了这么多概念层的东西,想必你迫不及待地想看具体代码了吧。接下来,我们来看一下 ZMQ 的实现。
|
||||
|
||||
ZMQ
|
||||
|
||||
先来看 ZMQ,这是一个非常轻量级的消息队列实现。
|
||||
|
||||
|
||||
作者 Pieter Hintjens 是一位大牛,他本人的经历也很传奇,2010年诊断出胆管癌,并成功做了手术切除。但2016年4月,却发现癌症大面积扩散到了肺部,已经无法治疗。他写的最后一篇通信模式是关于死亡协议的,之后在比利时选择接受安乐死。
|
||||
|
||||
|
||||
ZMQ 是一个简单好用的传输层,它有三种使用模式:
|
||||
|
||||
|
||||
Request - Reply 模式;
|
||||
Publish - Subscribe 模式;
|
||||
Parallel Pipeline 模式。
|
||||
|
||||
|
||||
第一种模式很简单,client 发消息给 server,server 处理后返回给 client,完成一次交互。这个场景你一定很熟悉吧,没错,和 HTTP 模式非常像,所以这里我就不重点介绍了。至于第三种模式,与今天内容无关,这里我也不做深入讲解。
|
||||
|
||||
我们需要详细来看的是第二种,即“PubSub”模式。下面是它的具体实现,代码很清晰,你应该很容易理解:
|
||||
|
||||
# 订阅者 1
|
||||
import zmq
|
||||
|
||||
|
||||
def run():
|
||||
context = zmq.Context()
|
||||
socket = context.socket(zmq.SUB)
|
||||
socket.connect('tcp://127.0.0.1:6666')
|
||||
socket.setsockopt_string(zmq.SUBSCRIBE, '')
|
||||
|
||||
print('client 1')
|
||||
while True:
|
||||
msg = socket.recv()
|
||||
print("msg: %s" % msg)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
run()
|
||||
|
||||
########## 输出 ##########
|
||||
|
||||
client 1
|
||||
msg: b'server cnt 1'
|
||||
msg: b'server cnt 2'
|
||||
msg: b'server cnt 3'
|
||||
msg: b'server cnt 4'
|
||||
msg: b'server cnt 5'
|
||||
|
||||
|
||||
# 订阅者 2
|
||||
import zmq
|
||||
|
||||
|
||||
def run():
|
||||
context = zmq.Context()
|
||||
socket = context.socket(zmq.SUB)
|
||||
socket.connect('tcp://127.0.0.1:6666')
|
||||
socket.setsockopt_string(zmq.SUBSCRIBE, '')
|
||||
|
||||
print('client 2')
|
||||
while True:
|
||||
msg = socket.recv()
|
||||
print("msg: %s" % msg)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
run()
|
||||
|
||||
########## 输出 ##########
|
||||
|
||||
client 2
|
||||
msg: b'server cnt 1'
|
||||
msg: b'server cnt 2'
|
||||
msg: b'server cnt 3'
|
||||
msg: b'server cnt 4'
|
||||
msg: b'server cnt 5'
|
||||
|
||||
|
||||
# 发布者
|
||||
import time
|
||||
import zmq
|
||||
|
||||
|
||||
def run():
|
||||
context = zmq.Context()
|
||||
socket = context.socket(zmq.PUB)
|
||||
socket.bind('tcp://*:6666')
|
||||
|
||||
cnt = 1
|
||||
|
||||
while True:
|
||||
time.sleep(1)
|
||||
socket.send_string('server cnt {}'.format(cnt))
|
||||
print('send {}'.format(cnt))
|
||||
cnt += 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
run()
|
||||
|
||||
########## 输出 ##########
|
||||
|
||||
send 1
|
||||
send 2
|
||||
send 3
|
||||
send 4
|
||||
send 5
|
||||
|
||||
|
||||
这里要注意的一点是,如果你想要运行代码,请先运行两个订阅者,然后再打开发布者。
|
||||
|
||||
接下来,我来简单讲解一下。
|
||||
|
||||
对于订阅者,我们要做的是创建一个 zmq Context,连接 socket 到指定端口。其中,setsockopt_string() 函数用来过滤特定的消息,而下面这行代码:
|
||||
|
||||
socket.setsockopt_string(zmq.SUBSCRIBE, '')
|
||||
|
||||
|
||||
则表示不过滤任何消息。最后,我们调用 socket.recv() 来接受消息就行了,这条语句会阻塞在这里,直到有新消息来临。
|
||||
|
||||
对于发布者,我们同样要创建一个 zmq Context,绑定到指定端口,不过请注意,这里用的是 bind 而不是 connect。因为在任何情况下,同一个地址端口 bind 只能有一个,但却可以有很多个 connect 链接到这个地方。初始化完成后,再调用 socket.send_string ,即可将我们想要发送的内容发送给 ZMQ。
|
||||
|
||||
当然,这里还有几个需要注意的地方。首先,有了 send_string,我们其实已经可以通过 JSON 序列化,来传递几乎我们想要的所有数据结构,这里的数据流结构就已经很清楚了。
|
||||
|
||||
另外,把发布者的 time.sleep(1) 放在 while 循环的最后,严格来说应该是不影响结果的。这里你可以尝试做个实验,看看会发生什么。
|
||||
|
||||
你还可以思考下另一个问题,如果这里是多个发布者,那么 ZMQ 应该怎么做呢?
|
||||
|
||||
Kafka
|
||||
|
||||
接着我们再来看一下 Kafka。
|
||||
|
||||
通过代码实现你也可以发现,ZMQ 的优点主要在轻量、开源和方便易用上,但在工业级别的应用中,大部分人还是会转向 Kafka 这样的有充足支持的轮子上。
|
||||
|
||||
相比而言,Kafka 提供了点对点网络和发布订阅模型的支持,这也是用途最广泛的两种消息队列模型。而且和 ZMQ 一样,Kafka 也是完全开源的,因此你也能得到开源社区的充分支持。
|
||||
|
||||
Kafka的代码实现,和ZMQ大同小异,这里我就不专门讲解了。关于Kafka的更多内容,极客时间平台也有对 Kafka 的专门详细的介绍,对此有兴趣的同学,可以在极客时间中搜索“Kafka核心技术与实战”,这个专栏里,胡夕老师用详实的篇幅,讲解了 Kafka 的实战和内核,你可以加以学习和使用。
|
||||
|
||||
|
||||
|
||||
来自极客时间专栏“Kafka核心技术与实战”
|
||||
|
||||
基于消息队列的 Orderbook 数据流
|
||||
|
||||
最后回到我们的量化交易系统上。
|
||||
|
||||
量化交易系统中,获取 orderbook 一般有两种用途:策略端获取实时数据,用来做决策;备份在文件或者数据库中,方便让策略和回测系统将来使用。
|
||||
|
||||
如果我们直接单机监听交易所的消息,风险将会变得很大,这在分布式系统中叫做 Single Point Failure。一旦这台机器出了故障,或者网络连接突然中断,我们的交易系统将立刻暴露于风险中。
|
||||
|
||||
于是,一个很自然的想法就是,我们可以在不同地区放置不同的机器,使用不同的网络同时连接到交易所,然后将这些机器收集到的信息汇总、去重,最后生成我们需要的准确数据。相应的拓扑图如下:
|
||||
|
||||
|
||||
|
||||
当然,这种做法也有很明显的缺点:因为要同时等待多个数据服务器的数据,再加上消息队列的潜在处理延迟和网络延迟,对策略服务器而言,可能要增加几十到数百毫秒的延迟。如果是一些高频或者滑点要求比较高的策略,这种做法需要谨慎考虑。
|
||||
|
||||
但是,对于低频策略、波段策略,这种延迟换来的整个系统的稳定性和架构的解耦性,还是非常值得的。不过,你仍然需要注意,这种情况下,消息队列服务器有可能成为瓶颈,也就是刚刚所说的Single Point Failure,一旦此处断开,依然会将系统置于风险之中。
|
||||
|
||||
事实上,我们可以使用一些很成熟的系统,例如阿里的消息队列,AWS 的 Simple Queue Service 等等,使用这些非常成熟的消息队列系统,风险也将会最小化。
|
||||
|
||||
总结
|
||||
|
||||
这节课,我们分析了现代化软件工程领域中的中间件系统,以及其中的主要应用——消息队列。我们讲解了最基础的消息队列的模式,包括点对点模型、发布者订阅者模型,和一些其他消息队列自己支持的模型。
|
||||
|
||||
在真实的项目设计中,我们要根据自己的产品需求,来选择使用不同的模型;同时也要在编程实践中,加深对不同技能点的了解,对系统复杂性进行解耦,这才是设计出高质量系统的必经之路。
|
||||
|
||||
思考题
|
||||
|
||||
今天的思考题,文中我也提到过,这里再专门列出强调一下。在ZMQ 那里,我提出了两个问题:
|
||||
|
||||
|
||||
如果你试着把发布者的 time.sleep(1) 放在 while 循环的最后,会发生什么?为什么?
|
||||
如果有多个发布者,ZMQ 应该怎么做呢?
|
||||
|
||||
|
||||
欢迎留言写下你的思考和疑惑,也欢迎你把这篇文章分享给更多的人一起学习。
|
||||
|
||||
|
||||
|
||||
|
@ -0,0 +1,81 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
43 Q&A:聊一聊职业发展和选择
|
||||
你好,我是景霄。
|
||||
|
||||
在前面几节课中,我分享了在FB工作的一些经验和感想,不少同学都提出了自己的困惑,也希望我能给出一些职业发展方面的建议。综合这些问题,我主要选取了下面三个主题,来说说职业发展、职业选择方面我的看法。
|
||||
|
||||
Q:程序员的岗位主要有哪些类型?我该如何选择?
|
||||
|
||||
A:无论是在求职阶段,还是正式进入公司工作后,你都会发现,工程师普遍按技术的不同,分为下面几个岗位。
|
||||
|
||||
|
||||
前端:包括移动(Android、iOS)以及Web前端(JavaScript、CSS)开发。
|
||||
后端(服务器端):主要是服务器端的开发,简单来说,就是输入为请求,输出为响应,发送给客户端。
|
||||
算法:主要涉及到的是机器学习,比如推荐系统如何更好地实现个性化推荐,搜索引擎返回的结果如何才能更符合地用户的需求等等。
|
||||
架构:涉及系统架构,偏底层,语言以C++为主。
|
||||
|
||||
|
||||
从薪酬的角度来看,普遍来说:算法 > 架构 > 后端 > 前端。当然,这主要是由市场的供需关系决定的。
|
||||
|
||||
就拿算法岗来说,国内市场普遍缺少算法人才,也是因为这个岗位的培养难度更大,需要投入更大的精力。在顶尖互联网公司,参与核心产品研发的算法工程师们,工作三年,年收入100-200W人民币是很常见的。
|
||||
|
||||
不过,我这里所说的算法人才,绝不是指类似在校生那种,看过几篇论文,写过一些MATLAB,在学校做过几个科研项目的程度。算法工作岗位需要的算法能力,是你必须身体力行,有某些产品线的实践经历。还需要你真正了解市场,比如今日头条的推荐算法是怎样的,Google搜索引擎是怎么工作的,头条里的广告排序又是怎么做的等等。
|
||||
|
||||
再来说说架构,这也是目前一个热门的方向。我一直认为这是一个很偏工程、很硬核的领域,发展前景也相当不错,可以说是一个产品的基石。就拿刚刚提到的推荐系统来说,广告的定位和排序系统背后,都需要强有力的架构支撑。因此,这一行也可以称得上是人才紧缺,是企业舍得花高薪聘请的对象之一。
|
||||
|
||||
与算法不同的是,这个领域不会涉及很深的数学知识,工程师的主要关注点,在于如何提高系统性能,包括如何使系统高扩展、减小系统的延迟和所需CPU的容量等等。架构师需要很强的编程能力,常用的语言是C++;当然,最重要的还是不断积累大型项目中获得的第一手经验,对常见的问题有最principle的处理方式。
|
||||
|
||||
最后说说后端和前端,这是绝大多数程序员从事的岗位,也是我刚进公司时的选择。也许比起前两个岗位,不少人会认为,后端、前端工程师的薪酬较低,没有什么发展前景。这其实大错特错了!从一个产品的角度出发,你可以没有算法工程师、没有架构师,但是你能缺少后端和前端的开发人员吗?显然是不可能的。
|
||||
|
||||
后端和前端,相当于是一个产品的框架。框架搭好了,才会有机器学习、算法等的锦上添花。诚然,这两年来看,后端和前端没有前两者那么热门(还是市场供需关系的问题),但这并不代表,这些岗位没有发展前景,或者你就可以小看其技术含量。
|
||||
|
||||
比起算法和架构,后端、前端确实门槛更低些,但是其工作依然存在很高的技术含量。比如对一个产品或者其中的某些部件来说,如何设计搭建前后端的开发框架结构,使系统更加合理、可维护性更高,就是很多资深的开发工程师正在做的事。
|
||||
|
||||
前面聊了这么多,最后回到最根本的问题上:到底如何选择呢?
|
||||
|
||||
这里我给出的建议是:首先以自己的兴趣为出发点,因为只有自己感兴趣的东西,你才能做到最好。比如,一些人就是对前端感兴趣,那么为啥偏要去趟机器学习这趟浑水呢?当然不少人可能没有明确的偏好,那么这种情况下,我建议你尽可能多地去尝试,这是了解自己兴趣最好的方法。
|
||||
|
||||
另外,从广义的角度来看,计算机这门技术存在着study deep和study broad这两个方向,你得想清楚你属于哪类。所谓的study deep,就意味着数十年专攻一个领域,励志成为某个领域的专家;而study broad,便是类似于全栈工程师,对一个产品、系统的end to end都有一个了解,能够随时胜任任意角色的工作,这一点在初创公司身上体现得最为明显。
|
||||
|
||||
Q:如何成为一个全栈工程师?
|
||||
|
||||
A:相信屏幕前的不少同学是在创业公司工作的,刚刚也提到了,创业公司里全栈工程师的需求尤为突出。那么,如何成为一个优秀的全栈工程师呢?
|
||||
|
||||
简单来说,最好的方法就是“尽可能地多接触、多实践不同领域的项目”。身体力行永远是学习新知识、提高能力的最好办法。
|
||||
|
||||
当然,在每个领域的初始阶段,你可能会感觉到异常艰难,比如从未接触过前端的人被要求写一个页面,一时间内显然会不知从何下手。这个时候,我建议你可以先从“依葫芦画瓢”开始,通过阅读别人相似的代码,并在此基础上加以修改,完成你要实现的功能。时间久了,你看的多了,用的多了,理解自然就越来越深,动起手来也就越来越熟练了。
|
||||
|
||||
有条件的同学,比如工作在类似于FB这种文化的公司,可以通过在公司内部换组的方式,去接触不同的项目。这自然是最好不过了,因为和特定领域的人合作,永远比一个人单干强得多,你能够迅速学到更多的东西。
|
||||
|
||||
不过,没这种条件的同学也不必绝望,你还可以利用业余时间“充电“,自己做一些项目来培养和加强别的领域的能力。毕竟,对于成年人来说,自学才是精进自己的主要方式。
|
||||
|
||||
这样,到了最后,你应该达到的结果便是,自己一个人能够扛起整条产品线的开发,也对系统的整个工作流程有一个全面而深入的理解。
|
||||
|
||||
Q:学完本专栏后,在Python领域我该如何继续进阶呢?
|
||||
|
||||
A:在我看来,这个专栏的主要目的,是带你掌握Python这门语言的常见基本和高阶用法。接下来的进阶,便是Python本身在各种不同方向的运用,拿后端开发这个方向来说,比如,如何搭建大型系统的后台便是你需要掌握的。一个好的后端,自然离不开:
|
||||
|
||||
|
||||
合理的系统、框架设计;
|
||||
简约高效的代码质量;
|
||||
稳健齐全的单元测试;
|
||||
出色的性能表现。
|
||||
|
||||
|
||||
具体来说,你搭建的系统后端是不是易于拓展呢?比如过半年后,有了新的产品需求,需要增加新的功能。那么,在你的框架下,是否可以尽可能少地改动来实现新的功能,而不需要把某部分推倒重来呢?
|
||||
|
||||
再比如,你搭建的系统是不是符合可维护性高、可靠性高、单元测试齐全的要求,从而不容易在线上发生bug呢?
|
||||
|
||||
总之,在某一领域到了进阶的阶段,你需要关注的,绝不仅仅只是某些功能的实现,更需要你考虑所写代码的性能、质量,甚至于整个系统的设计等等。
|
||||
|
||||
虽然讲了这么多东西,但最后我想说的是,三百六十行,行行出状元。对于计算机行业,乃至整个职场来说,每一个领域都没有优劣之分,每个领域你都可以做得很牛逼,前提是你不懈地学习、实践和思考。
|
||||
|
||||
那么,对于职业选择和发展,你又是如何看待和理解的呢?欢迎留言和我一起交流探讨,也希望屏幕前的一直不懈学习的你,能找到属于自己的方向,不断前进和创新,实现自己的人生理想。
|
||||
|
||||
|
||||
|
||||
|
@ -0,0 +1,228 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
27 zipfile压缩库:如何给数据压缩&加密备份?
|
||||
你好,我是尹会生。
|
||||
|
||||
你在日常工作中,肯定和压缩文件打过交道,它们能把文件夹制作成一个体积更小的压缩文件,不仅方便数据备份,还方便作为邮件附件来传输,或者与他人共享。
|
||||
|
||||
但是如果你需要每天都进行数据备份,或者把压缩包作为每天工作的日报发送给领导,你肯定希望它能自动化的压缩。面对这个需求,我们同样可以通过python来解决。我们可以用Python来自动压缩文件夹,并为压缩包设置密码,保证备份数据的安全。
|
||||
|
||||
在Python中,要想实现数据的压缩,一般可以采用基于标准库zipfile的方式来实现,也可以采用命令行方式来实现。
|
||||
|
||||
当我们希望能够用Python自动压缩一个无需密码保护的文件夹时,可以通过zipfile来实现,它的好处是使用简单,而且不用安装任何的软件包,就能制作出“zip”格式的压缩包。不过zipfile没法对压缩文件进行加密,因此当你需要对压缩文件加密时,还需要调用可执行命令。
|
||||
|
||||
这两种实现方式就是我们今天要学习的重点了,接下来我们分别看一下这两种方式的具体操作方法。
|
||||
|
||||
使用zipfile实现无密码压缩
|
||||
|
||||
如果我想要把“C:\data\”文件夹压缩为“当前日期.zip”文件,就可以使用目录遍历、按日期自动生成压缩包的文件名、把文件夹写入压缩文件这三个步骤来实现。
|
||||
|
||||
目录遍历
|
||||
|
||||
我们先来学习怎么实现目录遍历功能。我在第16讲已经为你讲解过它的技术细节了,这里我就继续使用os库来实现目录的遍历。
|
||||
|
||||
由于目录遍历的功能与其他功能之间的调用关系耦合得比较宽松,所以我就把目录遍历功能单独定义成一个getAllFiles()函数,并把要遍历的目录作为函数的参数,把该目录下的所有文件以及所在路径作为函数的返回值。
|
||||
|
||||
我把getAllFiles()函数的代码放在下方,供你参考。
|
||||
|
||||
import os
|
||||
|
||||
# 遍历目录,得到该目录下所有的子目录和文件
|
||||
def getAllFiles(dir):
|
||||
for root,dirs,files in os.walk(dir):
|
||||
for file in files:
|
||||
yield os.path.join(root, file)
|
||||
|
||||
|
||||
细心的你一定发现了,在函数getAllFiles()的返回语句中,我使用yield语句代替了之前学习过的return语句返回文件路径和名称。为什么我要使用yield语句呢?
|
||||
|
||||
原因就在于,*一个函数如果使用yield语句来返回的话,这个函数则被称作生成器*。yield的返回数据类型以及对类型的访问方式,都和return不同。我来为你解释一下yield和return的具体区别,以及使用yield的好处。
|
||||
|
||||
首先从返回类型来看,yield返回的数据类型叫做生成器类型,这一类型的好处是调用getAllFiles()一次,函数就会返回一个文件路径和文件名。而return返回的是一个列表类型,需要一次性把要备份目录下的所有文件都访问一次,一旦要备份的文件数量非常多,就会导致计算机出现程序不响应的问题。
|
||||
|
||||
除了返回类型,还有调用方式也和return不同。使用yield返回的对象被称作生成器对象,该对象没法像列表一样,一次性获得对象中的所有数据,你必须使用for循环迭代访问,才能依次获取数据。
|
||||
|
||||
此外,当所有的数据访问完成,还会引发一个“StopIteration”异常,告知当前程序,这个生成器对象的内容已经全部被取出来,那么这个生成器将会在最后一次访问完成被计算机回收,这样yield就能够知道对象是否已经全部被读取完。
|
||||
|
||||
从yield和return的行为对比,可以说,yield返回对象最大的好处是可以逐个处理,而不是一次性处理大量的磁盘读写操作,这样就有效减少了程序因等待磁盘IO而出现不响应的情况。这就意味着你不必在调用getAllFiles()函数时,因为需要备份的文件过多,而花费较长的时间等待它执行完成。
|
||||
|
||||
按日期自动生成压缩包的文件名
|
||||
|
||||
接下来我们来学习一下按日期自动生成压缩包的函数genZipfilename()。按日期生成文件名,在定时备份的场景中经常被用到,我们希望每天产生一个新的备份文件,及时保存计算机每天文件的变化。
|
||||
|
||||
这就要求今天的备份的文件名称不能和昨天的同名,避免覆盖上次备份的文件。
|
||||
|
||||
所以genZipfilename()函数就把程序执行的日期作为文件名来进行备份,例如当前的日期是2021年4月12日,那么备份文件会自动以“20210412.zip”作为文件名称。我把代码贴在下方,供你参考。
|
||||
|
||||
import datetime
|
||||
|
||||
# 以年月日作为zip文件名
|
||||
def genZipfilename():
|
||||
today = datetime.date.today()
|
||||
basename = today.strftime('%Y%m%d')
|
||||
extname = "zip"
|
||||
return f"{basename}.{extname}"
|
||||
|
||||
|
||||
在这段代码中,“datetime.date.today()”函数能够以元组格式取得今天的日期,不过它的返回格式是元组,且年、月、日默认采用了三个元素被存放在元组中,这种格式是没法直接作为文件名来使用的。因此你还需要通过strftime()函数把元组里的年、月、日三个元素转换为一个字符串,再把字符串作为文件的名称来使用。
|
||||
|
||||
把文件夹写入压缩文件
|
||||
|
||||
最后,准备工作都完成之后,你就可以使用zipfile库把要备份的目录写入到zip文件了。zipfile库是Python的标准库,所以不需要安装软件包,为了让这个完整脚本都不需要安装第三方软件包,我在实现文件遍历的时候同样采用os库代替pathlib库。
|
||||
|
||||
除了不需要安装之外,zipfile库在使用上也比较友好,它创建和写入zip文件的方式就是模仿普通文件的操作流程,使用with关键字打开zip文件,并使用write()函数把要备份的文件写入zip文件。
|
||||
|
||||
所以通过学习一般文件的操作,你会发现Python在对其他格式的文件操作上,都遵循着相同的操作逻辑,这也体现出Python语言相比其他语言更加优雅和简单。
|
||||
|
||||
那么我把使用zipfile库实现创建zip文件的功能写入zipWithoutPassword()函数中,你可以对照一般文件的写入逻辑来学习和理解这段代码,代码如下:
|
||||
|
||||
from zipfile import ZipFile
|
||||
|
||||
def zipWithoutPassword(files,backupFilename):
|
||||
with ZipFile(backupFilename, 'w') as zf:
|
||||
for f in files:
|
||||
zf.write(f)
|
||||
|
||||
|
||||
对比一般的文件写入操作,zip文件的打开使用了“ZipFile()函数”,而一般文件的打开使用了open函数。写入方法与一般文件相同,都是调用“write()”函数实现写入。
|
||||
|
||||
这三个函数,也就是函数getAllFiles()、genZipfilename()和zipWithoutPassword(),就是把备份目录到zip文件的核心函数了。我们以备份“C:\data”文件夹为“20210412.zip”压缩文件为例,依次调用三个函数就能实现自动备份目录了,我把调用的代码也写在下方供你参考。
|
||||
|
||||
if __name__ == '__main__':
|
||||
# 要备份的目录
|
||||
backupDir = r"C:\data"
|
||||
# 要备份的文件
|
||||
backupFiles = getAllFiles(backupDir)
|
||||
# zip文件的名字“年月日.zip”
|
||||
zipFilename = genZipfilename()
|
||||
# 自动将要备份的目录制作成zip文件
|
||||
zipWithoutPassword(backupFiles, zipFilename)
|
||||
|
||||
|
||||
在执行这段代码后,就会在代码运行的目录下产生“20210412.zip”文件,你通过计算机上的winrar等压缩软件查看,就会发现其中会有“C:\data”文件夹下的所有文件。由于文件名称是以当前日期自动产生的,所以每天执行一次备份脚本,就能实现按天备份指定的文件夹为压缩包了。
|
||||
|
||||
不过在备份时,除了要保证数据的可用性,你还有考虑数据的安全性,最好的办法就是在备份时为压缩包指定密码。接下来我就带你使用命令行调用实现有密码的文件压缩。
|
||||
|
||||
使用可执行命令实现有密码压缩
|
||||
|
||||
在制作有密码的压缩包时,我们必须使用命令代替zipfile来压缩文件,因为zipfile默认是不支持密码压缩功能的。当你需要对压缩数据有保密性的要求时,可以使用7zip、winrar这些知名压缩软件的命令行进加密压缩。
|
||||
|
||||
我在本讲中就以7zip压缩工具为例,带你学习一下怎么使用Python通过命令行方式调用7zip实现文件的加密压缩。
|
||||
|
||||
执行方式和执行参数
|
||||
|
||||
要想使用7zip实现压缩并被Python直接调用,你除了需要在Windows上安装7zip外,还需要知道它的执行方式和执行的参数。
|
||||
|
||||
我先来带你学习一下执行方式。7zip软件Windows安装成功后,它的命令行可执行程序叫做“7z.exe”。但是它想要在命令行运行的话,需要指定程序的完整路径。例如:“c:\path\to\installed\7zip\7z.exe”。如果你希望在命令行直接输入“7z.exe”运行,需要你把可执行程序放在命令搜索路径中。我在这里有必要为你解释一下命令搜索路径的概念,有助于你以后在各种操作系统上执行命令行工具。
|
||||
|
||||
一条命令要想运行,必须要使用路径+可执行文件的名称才可以。例如我Windows中,需要把Python的可执行命令“python.exe”安装到“C:\python3.8\scripts\python.exe”这一位置。
|
||||
|
||||
那么,一般情况下当你需要运行Python解释器时,必须输入很长的路径。这种做法在经常使用命令行参数时没法接受的,一个是你需要记住大量命令的所在路径,另一个是较长的路径也会降低你的执行效率。
|
||||
|
||||
因此在各种操作系统上,都有“命令搜索路径”的概念。在Windows中,命令搜索路径被保存在Path环境变量中,Path变量的参数是由分号分隔开的文件夹,即:当你在命令行输入“python.exe”并回车运行它时,操作系统会遍历Path变量参数中的每个文件夹。如果找到了“python.exe”文件,就可以直接运行它,如果没有找到,则会提示用户该命令不存在。这就避免你每次执行一条命令时都需要输入较长的路径。
|
||||
|
||||
再回到7zip的命令行执行文件“7z.exe”上,我把它安装在“C:\7zip\”文件夹下,如果你希望执行运行7z.exe,且不输入路径,那么根据上面的分析,现在有两种解决办法。
|
||||
|
||||
|
||||
把7z.exe放到现有的命令搜索路径中,例如“C:\python3.8\scripts\”文件夹。
|
||||
把7z.exe所在的文件夹“C:\7zip\”加入到命令搜索路径Path变量的参数中。加入的方法是在Windows的搜索栏搜索关键字“环境变量,然后在弹出的环境变量菜单,把路径加入到Path变量参数即可。
|
||||
|
||||
|
||||
设置完成环境变量后,7z.exe就不必在命令行中输入路径,直接运行即可。
|
||||
|
||||
在你掌握了执行方式后,我再来带你学习一下它的参数,要想使用支持密码加密方式的zip压缩包,你需要使用四个参数,它们分别是:
|
||||
|
||||
|
||||
a参数:7z.exe能够把文件夹压缩为压缩包,也能解压一个压缩包。a参数用来指定7z将要对一个目录进行的压缩操作。
|
||||
-t参数:用来指定7z.exe制作压缩包的类型和名称。为了制作一个zip压缩包,我将把该参数指定为-tzip,并在该参数后指定zip压缩包的名称。
|
||||
-p参数:用来指定制作的压缩包的密码。
|
||||
“目录”参数:用来指定要把哪个目录制作为压缩包。
|
||||
|
||||
|
||||
如果我希望把压缩包“20210412.zip”的密码制作为“password123”,可以把这四个压缩包的参数组合在一起,使用如下命令行:
|
||||
|
||||
7z.exe a -tzip 20210412.zip -ppassword123 C:\data
|
||||
|
||||
|
||||
|
||||
扩展zipfile
|
||||
|
||||
由于命令的参数较多,且记住它的顺序也比较复杂,所以我们可以利用Python的popen()函数,把“7z.exe”封装在Python代码中,会更容易使用。
|
||||
|
||||
因此我在无密码压缩的代码中,就可以再增加一个函数zipWithPassword(),用来处理要压缩的目录、压缩文件名和密码参数,并通过这个函数,再去调用popen()函数,封装命令行调用7z.exe的代码,从而实现有密码的压缩功能。代码如下:
|
||||
|
||||
import os
|
||||
def zipWithPassword(dir, backupFilename, password=None):
|
||||
cmd = f"7z.exe a -tzip {backupFilename} -p{password} {dir}"
|
||||
status = os.popen(cmd)
|
||||
return status
|
||||
|
||||
|
||||
|
||||
我来解释一下这段代码。在实现有密码压缩的函数中,为了调用函数更加方便,我把“压缩的文件夹、zip文件名称、密码”作为该函数的参数,这样当在你调用zipWithPassword()函数时,就能指定所有需要加密的文件和目录了。此外,在执行命令时,我还通过os.popen()函数产生了一个新的子进程(如果你不记得这个概念,可以参考第五讲)用来执行7z.exe,这样7z.exe会按照函数的参数,把文件夹压缩成zip文件并增加密码。
|
||||
|
||||
通过zipWithPassword()函数,你就能够实现zipfile的扩展,实现有密码文件压缩功能了。
|
||||
|
||||
小结
|
||||
|
||||
最后,我来为你总结一下今天这节课的主要内容。我通过zipfile库和7zip软件,分别实现了无密码压缩文件和有密码压缩文件。
|
||||
|
||||
无密码压缩文件更加简单方便,而有密码压缩文件更加安全,配合自动根据当前日期改变压缩文件名称,可以作为你进行每日数据自动化备份的主要工具。
|
||||
|
||||
除了备份功能的学习外,我还为你讲解了新的函数返回方式yield,和return不同的是,yield返回的是生成器对象,需要使用for迭代方式访问它的全部数据。yield语句除了可以和zipfile库一起实现数据备份外,还经常被应用于互联网上的图片批量下载压缩场景中。
|
||||
|
||||
以上内容就是怎么实现无密码和有密码压缩的全部内容了,我将完整代码贴在下方中,一起提供给你,你可以直接修改需要备份的目录,完成你自己文件夹的一键备份脚本。
|
||||
|
||||
from zipfile import ZipFile
|
||||
import os
|
||||
import datetime
|
||||
|
||||
# 以年月日作为zip文件名
|
||||
def genZipfilename():
|
||||
today = datetime.date.today()
|
||||
basename = today.strftime('%Y%m%d')
|
||||
extname = "zip"
|
||||
return f"{basename}.{extname}"
|
||||
|
||||
# 遍历目录,得到该目录下所有的子目录和文件
|
||||
def getAllFiles(dir):
|
||||
for root,dirs,files in os.walk(dir):
|
||||
for file in files:
|
||||
yield os.path.join(root, file)
|
||||
|
||||
# 无密码生成压缩文件
|
||||
def zipWithoutPassword(files,backupFilename):
|
||||
with ZipFile(backupFilename, 'w') as zf:
|
||||
for f in files:
|
||||
zf.write(f)
|
||||
|
||||
def zipWithPassword(dir, backupFilename, password=None):
|
||||
cmd = f"7z.exe a -tzip {backupFilename} -p{password} {dir}"
|
||||
status = os.popen(cmd)
|
||||
return status
|
||||
|
||||
if __name__ == '__main__':
|
||||
# 要备份的目录
|
||||
backupDir = "/data"
|
||||
# 要备份的文件
|
||||
backupFiles = getAllFiles(backupDir)
|
||||
# zip文件的名字“年月日.zip”
|
||||
zipFilename = genZipfilename()
|
||||
# 自动将要备份的目录制作成zip文件
|
||||
zipWithoutPassword(backupFiles, zipFilename)
|
||||
# 使用密码进行备份
|
||||
zipWithPassword(backupDir, zipFilename, "password123")
|
||||
|
||||
|
||||
思考题
|
||||
|
||||
按照惯例,我来为你留一道思考题,如果需要备份的是两个甚至更多的目录,你会怎么改造脚本呢?
|
||||
|
||||
欢迎把你的想法和思考分享在留言区,我们一起交流讨论。也欢迎你把课程分享给你的同事、朋友,我们一起做职场中的效率人。我们下节课再见!
|
||||
|
||||
|
||||
|
||||
|
@ -0,0 +1,43 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
10 自动化 CI&CD 与灰度发布
|
||||
环境管理和自动化部署
|
||||
|
||||
当我们从传统开发迁移到 Serverless 下,对于环境和部署的管理思路也会有所不同。当用户转到 Serverless ,可以轻松地提供更多的环境,而这个好处常被忽略。
|
||||
|
||||
|
||||
|
||||
当我们开发项目时,通常需要一个生产环境,然后需要预发环境,还有一些测试环境。但通常每个环境都需要消耗资源和成本,以保持服务在线。而大多数时候非生产环境上的访问量非常少,为此付出大量的成本很不划算。
|
||||
|
||||
但是,在 Serverless 架构中,我们可以为每位开发人员提供一个准生产环境。做 CI/CD 的时候,可以为每个功能分支创建独立的演示环境。
|
||||
|
||||
当团队成员在开发功能或者修复 bug 时,想要预览新功能,就可以立即部署,而不需要在自己机器上模拟或者找其他同事协调测试环境的使用时间。
|
||||
|
||||
这一切都受益于 Serverless,我们不需要为空闲资源付费。当我们去部署那些基本没有访问量的环境时,成本是极低的。
|
||||
|
||||
由于部署新环境变得很容易,对于自动化部署的要求就变高了。当然无论是否采用 Serverless 架构,自动化部署都很重要。能否自动化地构建、部署和创建整个环境是判断开发团队优秀与否的重要因素。在 serverless 场景,这种能力尤为重要,因为只有这样才能充分利用平台的优势。
|
||||
|
||||
后面的课程我们会了解到,借助于函数计算平台提供的 Funcraft 工具,开发人员可以用从前做不到的方式在准生产环境中轻松部署和测试代码。
|
||||
|
||||
灰度发布
|
||||
|
||||
由于 Serverless 提供的弹性机制,没有访问量的时候能自动缩容到零,极大地节约了部署的多环境的成本。然而在同一套环境内的多个不同的版本也可以受益于这套机制。
|
||||
|
||||
|
||||
|
||||
传统应用虽然也支持在一个环境中并存多个版本,但相比于 Serverless 更加困难。首先每个版本都需要相对独立的运行环境,会消耗更多的资源。其次需要解决多个版本之间流量的分配问题。
|
||||
|
||||
在 FaaS 上这些问题已经被版本和别名机制完美的解决。由于没有流量就不消耗计算资源,所以发布一个版本的成本极低,每次发布都可以形成一个版本。然后通过别名进行版本的切换和流量分配。
|
||||
|
||||
基于 FaaS 的这套抽象,让灰度发布和 A/B 测试变得非常的简单。不再需要像 K8s 那样复杂的基础设置,开发者也能轻松地享受到平滑升级和快速验证的高级特性。
|
||||
|
||||
结语
|
||||
|
||||
Serverless 让开发和部署都变得更加的简单。希望您能继续探索其他 Serverless 和函数计算的内容,更多相关的资料可以访问函数计算的产品页 https://www.aliyun.com/product/fc
|
||||
|
||||
|
||||
|
||||
|
@ -0,0 +1,39 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
24 Serverless 应用如何管理日志&持久化数据
|
||||
实时日志
|
||||
|
||||
|
||||
|
||||
首先,SAE 支持查看应用实例分组下各个 Pod 的实时日志。当应用出现异常情况时,可以通过查看 Pod 的实时日志定位问题。当应用运行时,可以在【控制台 - 日志管理菜单下 - 实时日志子菜单】方便地看到应用实例的实时日志。
|
||||
|
||||
文件日志
|
||||
|
||||
|
||||
|
||||
SAE 将业务文件日志(不包含 stdout 和 stderr 日志)收集并输入 SLS 中,实现无限制行数查看日志、自行聚合分析日志,方便业务日志对接,并按日志使用量计费。
|
||||
|
||||
您可以在部署应用时配置日志收集服务,填入需要采集的日志源,对于滚动日志的场景,可以填入通配符进行解决。
|
||||
|
||||
|
||||
|
||||
当配置完成后,可以在【控制台 - 日志管理菜单 - 文件日志子菜单】方便地看到采集的文件日志。
|
||||
|
||||
NAS 持久化存储
|
||||
|
||||
|
||||
|
||||
由于存储在容器中数据是非持久化的,SAE 支持了 NAS 存储功能,解决了应用实例数据持久化和实例间多读共享数据的问题。
|
||||
|
||||
您可以通过部署应用来配置持久化存储,选择创建好的 NAS,并填入容器中对应的挂载路径即可。
|
||||
|
||||
|
||||
|
||||
当配置完成后,可以通过 cat /proc/mount | grep nfs 命令查看是否挂载成功,或者可以准备 2 个应用实例,A 和 B,分别挂载 NAS。对 A 执行写入命令 echo “hello” > tmp.txt,对 B 执行读取命令 cat tmp.txt。如果 B 中能够读取到在 A 中写入的 hello,表示 NAS 挂载成功。
|
||||
|
||||
|
||||
|
||||
|
@ -0,0 +1,98 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
28 如何通过压测工具+ SAE 弹性能力轻松应对大促
|
||||
传统大促挑战
|
||||
|
||||
|
||||
|
||||
一次常见的大促活动,技术人员通常会从下面几个方面着手,进行准备工作:
|
||||
|
||||
|
||||
架构梳理:对参与大促的服务,进行系统性的架构梳理;
|
||||
容量规划:结合架构梳理,确定系统 SLA 指标,形成容量模型,帮助业务进行评估;
|
||||
性能测试:核心系统的单机容量评估,与核心链路全链路压测,可以验证容量模型,发现系统存在的问题;
|
||||
应用/数据库优化:对发现的系统问题,譬如热点、死锁或慢 SQL 等,进行优化,确保系统可以支撑大促;
|
||||
准备扩容方案:通过容量规划与性能测试,可以确定一套满足活动需求的扩容方案,既保障业务,又降低成本;
|
||||
应急预案准备:当遇到突发情况如何应对,譬如业务降级,砍掉非核心逻辑,或者限流降级,保障核心链路稳定;
|
||||
大促在线应急保障:专人专项,对问题进行响应,执行应急预案。
|
||||
|
||||
|
||||
要完成上述准备工作,经常会遇到如下痛点:
|
||||
|
||||
|
||||
系统核心全链路,缺少全局关系视角。需要花大量时间,整理依赖关系。
|
||||
链路上下游问题、定位问题比较耗时。压测与在线应急保障过程中,汇总链路上下游问题,定位问题比较耗时,缺少快速定位分析工具。
|
||||
业务开发迭代快,需要常态化压测支持。大量重复性人力投入,给大家造成很大负担。
|
||||
预留资源成本高,需要频繁扩缩容。需要产品化支持自动弹性伸缩,降低自建机房等高成本高闲置的固定投入。
|
||||
|
||||
|
||||
SAE 大促解决方案
|
||||
|
||||
|
||||
|
||||
首先,SAE 是一款面向应用的 Serverless PaaS 平台,在传统 PaaS 功能之外,提供了完备的全链路监控、微服务管理等能力,并借助 Serverless 能力,最大程度进行快速扩缩容、降低手工运维成本。
|
||||
|
||||
|
||||
|
||||
SAE 提供的解决方案,将从三方面入手:
|
||||
|
||||
|
||||
指标可视化:借助应用监控 ARMS 提供丰富的 JVM、全链路 Tracing 、慢 SQL 等功能,便捷地评估水位、定位问题;
|
||||
应用高可用:借助 AHAS 限流降级能力,流量激增时,保护核心服务,保障可用性不完全跌 0;
|
||||
性能压测:借助压测工具如 PTS,模拟单机压测或全链路压测,验证容量规划、发现应用问题。
|
||||
|
||||
|
||||
快速压测验证
|
||||
|
||||
那么如何通过 SAE ,进行一次快速的大促压测验证呢?下面将进行一次完整的展示:
|
||||
|
||||
第一步:观察应用监控指标,大致拟定弹性/压测/限流降级
|
||||
|
||||
|
||||
|
||||
通过观察应用监控,对日常业务的监控指标,有一个大致的概念。以一个典型的电商类应用为例。
|
||||
|
||||
从监控情况看:
|
||||
|
||||
|
||||
该应用为 HTTP 微服务应用;
|
||||
应用依赖大量 HTTP 微服务调用,少量使用 Redis / MySQL 服务,适合使用单机 + 分布式压测工具,分别进行压测;
|
||||
QPS 指标,相比 CPU、MEM 和 RT 指标,对业务更敏感,更适合作为弹性策略指标。
|
||||
|
||||
|
||||
第二步:选择合适的压测工具
|
||||
|
||||
|
||||
|
||||
根据业务诉求,可以选择快速使用的工具,或功能完整的压测工具。
|
||||
|
||||
|
||||
譬如单机 HTTP 压测工具 ab、wrk,可以提供简单快速的压测方式,但只支持单机、不支持上下文。
|
||||
如果我们需要支持 WebSocket 、常态化压测,云产品 PTS 可以提供较为完整的服务,相比自建成本更低。
|
||||
|
||||
|
||||
第三步:配置 SAE 弹性伸缩策略 + AHAS 限流降级策略
|
||||
|
||||
|
||||
|
||||
无需精准设置,选择一些合适的指标,配置 SAE 弹性伸缩策略,或额外配置 AHAS 限流策略 / ARMS 告警。
|
||||
|
||||
|
||||
对 API 类型,可通过对 API QPS、SQL QPS 等指标进行限流,保障超过系统水位的请求,快速 failover,降低对容量内业务的 SLA;并选择应用监控指标 QPS、RT,配置弹性规则,让系统进行弹性伸缩;
|
||||
对于计算型应用,则可选择更敏感的指标,如 CPU、Memory 对应用进行扩缩容。
|
||||
|
||||
|
||||
第四步:执行压测 – 观察结果 – 优化代码 – 调整策略配置
|
||||
|
||||
|
||||
|
||||
1)根据压测与监控结果,看是否有必要优化代码,或调整 SAE 弹性伸缩策略、AHAS 限流策略。 2)执行压测,查看压测结果,发现存在失败请求。 3)查看监控异常,发现存在 GC 异常。通过 SAE 控制台,优化 JVM 参数解决。 4)再次压测,验证问题是否解决。 5)如此重复一两轮,解决其中发现的主要问题,可以更从容地面对大促。
|
||||
|
||||
详细演示过程请点击【视频课链接】进行观看。
|
||||
|
||||
|
||||
|
||||
|
@ -0,0 +1,256 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
06 数据分片:如何实现分库、分表、分库+分表以及强制路由?(上)
|
||||
通过前面几个课时的介绍,相信你对 ShardingSphere 已经有了初步了解。从今天开始,我将带领你通过案例分析逐步掌握 ShardingSphere 的各项核心功能,首当其冲的就是分库分表机制。
|
||||
|
||||
单库单表系统
|
||||
|
||||
我们先从单库单表系统说起。在整个课程中,如果没有特殊强调,我们将默认使用 Spring Boot 集成和 ShardingSphere 框架,同时基于 Mybatis 实现对数据库的访问。
|
||||
|
||||
导入开发框架
|
||||
|
||||
系统开发的第一步是导入所需的开发框架。在下面这段代码中,我们新建了一个 Spring Boot 代码工程,在 pom 文件中需要添加对 sharding-jdbc-spring-boot-starter 和 mybatis-spring-boot-starter 这两个 starter 的引用:
|
||||
|
||||
<dependency>
|
||||
<groupId>org.apache.shardingsphere</groupId>
|
||||
<artifactId>sharding-jdbc-spring-boot-starter</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.mybatis.spring.boot</groupId>
|
||||
<artifactId>mybatis-spring-boot-starter</artifactId>
|
||||
</dependency>
|
||||
|
||||
|
||||
开发环境初始化要做的工作也就是这些,下面我们来介绍案例的业务场景。
|
||||
|
||||
梳理业务场景
|
||||
|
||||
我们考虑一个在医疗健康领域中比较常见的业务场景。在这类场景中,每个用户(User)都有一份健康记录(HealthRecord),存储着代表用户当前健康状况的健康等级(HealthLevel),以及一系列健康任务(HealthTask)。通常,医生通过用户当前的健康记录创建不同的健康任务,然后用户可以通过完成医生所指定的任务来获取一定的健康积分,而这个积分决定了用户的健康等级,并最终影响到整个健康记录。健康任务做得越多,健康等级就越高,用户的健康记录也就越完善,反过来健康任务也就可以越做越少,从而形成一个正向的业务闭环。这里,我们无意对整个业务闭环做过多的阐述,而是关注这一业务场景下几个核心业务对象的存储和访问方式。
|
||||
|
||||
在这个场景下,我们关注 User、HealthRecord、HealthLevel 和 HealthTask 这四个业务对象。在下面这张图中,对每个业务对象给出最基础的字段定义,以及这四个对象之间的关联关系:
|
||||
|
||||
|
||||
|
||||
完成基础功能
|
||||
|
||||
既然采用 Mybatis 作为 ORM 框架,那么就需要遵循 Mybatis 的开发流程。首先,我们需要完成各个业务实体的定义:
|
||||
|
||||
|
||||
业务实体的类定义
|
||||
|
||||
基于这些业务实体,我们需要完成对应的 Mapper 文件编写,我把这些 Mapper 文件放在代码工程的 resources 目录下:
|
||||
|
||||
|
||||
Mybatis Mapper 文件定义
|
||||
|
||||
下一步是数据源信息的配置,我们把这些信息放在一个单独的 application-traditional.properties 配置文件中。
|
||||
|
||||
spring.datasource.driverClassName = com.mysql.jdbc.Driver
|
||||
spring.datasource.url = jdbc:mysql://localhost:3306/ds
|
||||
spring.datasource.username = root
|
||||
spring.datasource.password = root
|
||||
|
||||
|
||||
按照 Spring Boot 的配置约定,我们在 application.properties 配置文件中把上述配置文件设置为启动 profile。通过使用不同的 profile,我们可以完成不同配置体系之间的切换。
|
||||
|
||||
spring.profiles.active=traditional
|
||||
|
||||
|
||||
接下来要做的事情就是创建 Repository 层组件:
|
||||
|
||||
|
||||
Repository 层接口定义
|
||||
|
||||
最后,我们设计并实现了相关的三个服务类,分别是 UserService、HealthLevelService 和 HealthRecordService。
|
||||
|
||||
|
||||
Service 层接口和实现类定义
|
||||
|
||||
通过 UserService,我们会插入一批用户数据用于完成用户信息的初始化。然后,我们有一个 HealthLevelService,专门用来初始化健康等级信息。请注意,与其他业务对象不同,健康等级信息是系统中的一种典型字典信息,我们假定系统中存在 5 种健康等级。
|
||||
|
||||
第三个,也是最重要的服务就是 HealthRecordService,我们用它来完成 HealthRecord 以及 HealthTask 数据的存储和访问。这里以 HealthRecordService 服务为例,下面这段代码给出了它的实现过程:
|
||||
|
||||
@Service
|
||||
public class HealthRecordServiceImpl implements HealthRecordService {
|
||||
@Autowired
|
||||
private HealthRecordRepository healthRecordRepository;
|
||||
@Autowired
|
||||
private HealthTaskRepository healthTaskRepository;
|
||||
@Override
|
||||
public void processHealthRecords() throws SQLException{
|
||||
insertHealthRecords();
|
||||
}
|
||||
|
||||
private List<Integer> insertHealthRecords() throws SQLException {
|
||||
List<Integer> result = new ArrayList<>(10);
|
||||
for (int i = 1; i <= 10; i++) {
|
||||
HealthRecord healthRecord = insertHealthRecord(i);
|
||||
insertHealthTask(i, healthRecord);
|
||||
result.add(healthRecord.getRecordId());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private HealthRecord insertHealthRecord(final int i) throws SQLException {
|
||||
HealthRecord healthRecord = new HealthRecord();
|
||||
healthRecord.setUserId(i);
|
||||
healthRecord.setLevelId(i % 5);
|
||||
healthRecord.setRemark("Remark" + i);
|
||||
healthRecordRepository.addEntity(healthRecord);
|
||||
return healthRecord;
|
||||
}
|
||||
|
||||
private void insertHealthTask(final int i, final HealthRecord healthRecord) throws SQLException {
|
||||
HealthTask healthTask = new HealthTask();
|
||||
healthTask.setRecordId(healthRecord.getRecordId());
|
||||
healthTask.setUserId(i);
|
||||
healthTask.setTaskName("TaskName" + i);
|
||||
healthTaskRepository.addEntity(healthTask);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
现在,我们已经从零开始实现了一个完整业务场景所需要的 DAO 层和 Service 层组件。这些组件在业务逻辑上都非常简单,而在技术上也是完全采用了 Mybatis 的经典开发过程。最后,我们可以通过一组简单的单元测试来验证这些组件是否能够正常运行。下面这段代码以 UserServiceTest 类为例给出它的实现,涉及 @RunWith、@SpringBootTest 等常见单元测试注解的使用:
|
||||
|
||||
@RunWith(SpringRunner.class)
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK)
|
||||
public class UserServiceTest {
|
||||
@Autowired
|
||||
private UserService userService;
|
||||
|
||||
@Test
|
||||
public void testProcessUsers() throws Exception {
|
||||
userService.processUsers();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
运行这个单元测试,我们可以看到测试通过,并且在数据库的 User 表中也看到了插入的数据。至此,一个单库单表的系统已经构建完成。接下来,我们将对这个系统做分库分表改造。
|
||||
|
||||
在传统单库单表的数据架构上进行分库分表的改造,开发人员只需要做一件事情,那就是基于上一课时介绍的 ShardingSphere 配置体系完成针对具体场景的配置工作即可,所有已经存在的业务代码都不需要做任何的变动,这就是 ShardingSphere 的强大之处。让我们一起开始吧。
|
||||
|
||||
系统改造:如何实现分库?
|
||||
|
||||
作为系统改造的第一步,我们首先来看看如何基于配置体系实现数据的分库访问。
|
||||
|
||||
初始化数据源
|
||||
|
||||
针对分库场景,我们设计了两个数据库,分别叫 ds0 和 ds1。显然,针对两个数据源,我们就需要初始化两个 DataSource 对象,这两个 DataSource 对象将组成一个 Map 并传递给 ShardingDataSourceFactory 工厂类:
|
||||
|
||||
spring.shardingsphere.datasource.names=ds0,ds1
|
||||
spring.shardingsphere.datasource.ds0.type=com.alibaba.druid.pool.DruidDataSource
|
||||
spring.shardingsphere.datasource.ds0.driver-class-name=com.mysql.jdbc.Driver
|
||||
spring.shardingsphere.datasource.ds0.url=jdbc:mysql://localhost:3306/ds0
|
||||
spring.shardingsphere.datasource.ds0.username=root
|
||||
spring.shardingsphere.datasource.ds0.password=root
|
||||
spring.shardingsphere.datasource.ds1.type=com.alibaba.druid.pool.DruidDataSource
|
||||
spring.shardingsphere.datasource.ds1.driver-class-name=com.mysql.jdbc.Driver
|
||||
spring.shardingsphere.datasource.ds1.url=jdbc:mysql://localhost:3306/ds1
|
||||
spring.shardingsphere.datasource.ds1.username=root
|
||||
spring.shardingsphere.datasource.ds1.password=root
|
||||
|
||||
|
||||
设置分片策略
|
||||
|
||||
明确了数据源之后,我们需要设置针对分库的分片策略:
|
||||
|
||||
spring.shardingsphere.sharding.default-database-strategy.inline.sharding-column=user_id
|
||||
spring.shardingsphere.sharding.default-database-strategy.inline.algorithm-expression=ds$->{user_id % 2}
|
||||
|
||||
|
||||
我们知道,在 ShardingSphere 中存在一组 ShardingStrategyConfiguration,这里使用的是基于行表达式的 InlineShardingStrategyConfiguration。
|
||||
InlineShardingStrategyConfiguration 包含两个需要设置的参数,一个是指定分片列名称的 shardingColumn,另一个是指定分片算法行表达式的 algorithmExpression。在我们的配置方案中,将基于 user_id 列对 2 的取模值来确定数据应该存储在哪一个数据库中。同时,注意到这里配置的是“default-database-strategy”项。结合上一课时的内容,设置这个配置项相当于是在 ShardingRuleConfiguration 中指定了默认的分库 ShardingStrategy。
|
||||
|
||||
设置绑定表和广播表
|
||||
|
||||
接下来我们需要设置绑定表。绑定表(BindingTable)是 ShardingSphere 中提出的一个新概念,我来给你解释一下。
|
||||
|
||||
所谓绑定表,是指与分片规则一致的一组主表和子表。例如,在我们的业务场景中,health_record 表和 health_task 表中都存在一个 record_id 字段。如果我们在应用过程中按照这个 record_id 字段进行分片,那么这两张表就可以构成互为绑定表关系。
|
||||
|
||||
引入绑定表概念的根本原因在于,互为绑定表关系的多表关联查询不会出现笛卡尔积,因此关联查询效率将大大提升。举例说明,如果所执行的为下面这条 SQL:
|
||||
|
||||
SELECT record.remark_name FROM health_record record JOIN health_task task ON record.record_id=task.record_id WHERE record.record_id in (1, 2);
|
||||
|
||||
|
||||
如果我们不显式配置绑定表关系,假设分片键 record_id 将值 1 路由至第 1 片,将数值 2 路由至第 0 片,那么路由后的 SQL 应该为 4 条,它们呈现为笛卡尔积:
|
||||
|
||||
SELECT record.remark_name FROM health_record0 record JOIN health_task0 task ON record.record_id=task.record_id WHERE record.record_id in (1, 2);
|
||||
SELECT record.remark_name FROM health_record0 record JOIN health_task1 task ON record.record_id=task.record_id WHERE record.record_id in (1, 2);
|
||||
SELECT record.remark_name FROM health_record1 record JOIN health_task0 task ON record.record_id=task.record_id WHERE record.record_id in (1, 2);
|
||||
SELECT record.remark_name FROM health_record1 record JOIN health_task1 task ON record.record_id=task.record_id WHERE record.record_id in (1, 2);
|
||||
|
||||
|
||||
然后,在配置绑定表关系后,路由的 SQL 就会减少到 2 条:
|
||||
|
||||
SELECT record.remark_name FROM health_record0 record JOIN health_task0 task ON record.record_id=task.record_id WHERE record.record_id in (1, 2);
|
||||
SELECT record.remark_name FROM health_record1 record JOIN health_task1 task ON record.record_id=task.record_id WHERE record.record_id in (1, 2);
|
||||
|
||||
|
||||
请注意,如果想要达到这种效果,互为绑定表的各个表的分片键要完全相同。在上面的这些 SQL 语句中,我们不难看出,这个需要完全相同的分片键就是 record_id。
|
||||
|
||||
让我们回到案例中的场景,显然,health_record 和 health_task 应该互为绑定表关系。所以,我们可以在配置文件中添加对这种关系的配置:
|
||||
|
||||
spring.shardingsphere.sharding.binding-tables=health_record, health_task
|
||||
|
||||
|
||||
介绍完绑定表,再来看广播表的概念。所谓广播表(BroadCastTable),是指所有分片数据源中都存在的表,也就是说,这种表的表结构和表中的数据在每个数据库中都是完全一样的。广播表的适用场景比较明确,通常针对数据量不大且需要与海量数据表进行关联查询的应用场景,典型的例子就是每个分片数据库中都应该存在的字典表。
|
||||
|
||||
同样回到我们的场景,对于 health_level 表而言,由于它保存着有限的健康等级信息,可以认为它就是这样的一种字典表。所以,我们也在配置文件中添加了对广播表的定义,在下面这段代码中你可以看到:
|
||||
|
||||
spring.shardingsphere.sharding.broadcast-tables=health_level
|
||||
|
||||
|
||||
设置表分片规则
|
||||
|
||||
通过前面的这些配置项,我们根据需求完成了 ShardingRuleConfiguration 中与分库操作相关的配置信息设置。我们知道 ShardingRuleConfiguration 中的 TableRuleConfiguration 是必填项。所以,我们来看一下这个场景下应该如何对表分片进行设置。
|
||||
|
||||
TableRuleConfiguration 是表分片规则配置,包含了用于设置真实数据节点的 actualDataNodes;用于设置分库策略的 databaseShardingStrategyConfig;以及用于设置分布式环境下的自增列生成器的 keyGeneratorConfig。前面已经在 ShardingRuleConfiguration 中设置了默认的 databaseShardingStrategyConfig,现在我们需要完成剩下的 actualDataNodes 和 keyGeneratorConfig 的设置。
|
||||
|
||||
对于 health_record 表而言,由于存在两个数据源,所以,它所属于的 actual-data-nodes 可以用行表达式 ds$->{0..1}.health_record 来进行表示,代表在 ds0 和 ds1 中都存在表 health_record。而对于 keyGeneratorConfig 而言,通常建议你使用雪花算法。明确了这些信息之后,health_record 表对应的 TableRuleConfiguration 配置也就顺理成章了:
|
||||
|
||||
spring.shardingsphere.sharding.tables.health_record.actual-data-nodes=ds$->{0..1}.health_record
|
||||
spring.shardingsphere.sharding.tables.health_record.key-generator.column=record_id
|
||||
spring.shardingsphere.sharding.tables.health_record.key-generator.type=SNOWFLAKE
|
||||
spring.shardingsphere.sharding.tables.health_record.key-generator.props.worker.id=33
|
||||
|
||||
|
||||
同样的,health_task 表的配置也完全类似,这里需要根据实际情况调整 key-generator.column 的具体数据列:
|
||||
|
||||
spring.shardingsphere.sharding.tables.health_task.actual-data-nodes=ds$->{0..1}.health_task
|
||||
spring.shardingsphere.sharding.tables.health_task.key-generator.column=task_id
|
||||
spring.shardingsphere.sharding.tables.health_task.key-generator.type=SNOWFLAKE
|
||||
spring.shardingsphere.sharding.tables.health_task.key-generator.props.worker.id=33
|
||||
|
||||
|
||||
让我们重新执行 HealthRecordTest 单元测试,并检查数据库中的数据。下面这张图是 ds0 中的 health_record 和 health_task 表:
|
||||
|
||||
|
||||
ds0 中 health_record 表数据
|
||||
|
||||
|
||||
ds0 中 health_task 表数据
|
||||
|
||||
而这张图是 ds1 中的 health_record 和 health_task 表:
|
||||
|
||||
|
||||
ds1 中 health_record 表数据
|
||||
|
||||
|
||||
ds1 中 health_task 表数据
|
||||
|
||||
显然,这两张表的数据已经正确进行了分库。
|
||||
|
||||
小结
|
||||
|
||||
从本课时开始,我们正式进入到 ShardingSphere 核心功能的讲解。为了介绍这些功能特性,我们将从单库单表架构讲起,基于一个典型的业务场景梳理数据操作的需求,并给出整个代码工程的框架,以及基于测试用例验证数据操作结果的实现过程。今天的内容关注于如何实现分库操作,我们通过引入 ShardingSphere 中强大的配置体系实现了分库效果。
|
||||
|
||||
这里给你留一道思考题:如何理解绑定表和广播表的含义和作用?
|
||||
|
||||
分库是 ShardingSphere 中分片引擎的核心功能之一,也可以说是最简单的功能之一。在下一课时中,我们将继续介绍分表、分库+分表以及强制路由等分片机制。
|
||||
|
||||
|
||||
|
||||
|
@ -0,0 +1,380 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
07 数据分片:如何实现分库、分表、分库+分表以及强制路由?(下)
|
||||
在上一课时中,我们基于业务场景介绍了如何将单库单表架构改造成分库架构。今天我们继续后续的改造工作,主要涉及如何实现分表、分库+分表以及如何实现强制路由。
|
||||
|
||||
系统改造:如何实现分表?
|
||||
|
||||
相比分库,分表操作是在同一个数据库中,完成对一张表的拆分工作。所以从数据源上讲,我们只需要定义一个 DataSource 对象即可,这里把这个新的 DataSource 命名为 ds2:
|
||||
|
||||
spring.shardingsphere.datasource.names=ds2
|
||||
spring.shardingsphere.datasource.ds2.type=com.alibaba.druid.pool.DruidDataSource
|
||||
spring.shardingsphere.datasource.ds2.driver-class-name=com.mysql.jdbc.Driver
|
||||
spring.shardingsphere.datasource.ds2.url=jdbc:mysql://localhost:3306/ds2
|
||||
spring.shardingsphere.datasource.ds2.username=root
|
||||
spring.shardingsphere.datasource.ds2.password=root
|
||||
|
||||
|
||||
同样,为了提高访问性能,我们设置了绑定表和广播表:
|
||||
|
||||
spring.shardingsphere.sharding.binding-tables=health_record, health_task
|
||||
spring.shardingsphere.sharding.broadcast-tables=health_level
|
||||
|
||||
|
||||
现在,让我们再次回想起 TableRuleConfiguration 配置,该配置中的 tableShardingStrategyConfig 代表分表策略。与用于分库策略的 databaseShardingStrategyConfig 一样,设置分表策略的方式也是指定一个用于分表的分片键以及分片表达式:
|
||||
|
||||
spring.shardingsphere.sharding.tables.health_record.table-strategy.inline.sharding-column=record_id
|
||||
spring.shardingsphere.sharding.tables.health_record.table-strategy.inline.algorithm-expression=health_record$->{record_id % 2}
|
||||
|
||||
|
||||
在代码中可以看到,对于 health_record 表而言,我们设置它用于分表的分片键为 record_id,以及它的分片行表达式为 health_record$->{record_id % 2}。也就是说,我们会根据 record_id 将 health_record 单表拆分成 health_record0 和 health_record1 这两张分表。
|
||||
基于分表策略,再加上 actualDataNodes 和 keyGeneratorConfig 配置项,我们就可以完成对 health_record 表的完整分表配置:
|
||||
|
||||
spring.shardingsphere.sharding.tables.health_record.actual-data-nodes=ds2.health_record$->{0..1}
|
||||
spring.shardingsphere.sharding.tables.health_record.table-strategy.inline.sharding-column=record_id
|
||||
spring.shardingsphere.sharding.tables.health_record.table-strategy.inline.algorithm-expression=health_record$->{record_id % 2}
|
||||
spring.shardingsphere.sharding.tables.health_record.key-generator.column=record_id
|
||||
spring.shardingsphere.sharding.tables.health_record.key-generator.type=SNOWFLAKE
|
||||
spring.shardingsphere.sharding.tables.health_record.key-generator.props.worker.id=33
|
||||
|
||||
|
||||
对于 health_task 表而言,可以采用同样的配置方法完成分表操作:
|
||||
|
||||
spring.shardingsphere.sharding.tables.health_task.actual-data-nodes=ds2.health_task$->{0..1}
|
||||
spring.shardingsphere.sharding.tables.health_task.table-strategy.inline.sharding-column=record_id
|
||||
spring.shardingsphere.sharding.tables.health_task.table-strategy.inline.algorithm-expression=health_task$->{record_id % 2}
|
||||
spring.shardingsphere.sharding.tables.health_task.key-generator.column=task_id
|
||||
spring.shardingsphere.sharding.tables.health_task.key-generator.type=SNOWFLAKE
|
||||
spring.shardingsphere.sharding.tables.health_task.key-generator.props.worker.id=33
|
||||
|
||||
|
||||
可以看到,由于 health_task 与 health_record 互为绑定表,所以在 health_task 的配置中,我们同样基于 record_id 列进行分片,也就是说,我们会根据 record_id 将 health_task 单表拆分成 health_task0 和 health_task1 两张分表。当然,自增键的生成列还是需要设置成 health_task 表中的 task_id 字段。
|
||||
|
||||
这样,完整的分表配置就完成了。现在,让我们重新执行 HealthRecordTest 单元测试,会发现数据已经进行了正确的分表。下图是分表之后的 health_record0 和 health_record1 表:
|
||||
|
||||
|
||||
分表后的 health_record0 表数据
|
||||
|
||||
|
||||
分表后的 health_record1 表数据
|
||||
|
||||
而这是分表之后的 health_task0 和 health_task1 表:
|
||||
|
||||
|
||||
分表后的 health_task0 表数据
|
||||
|
||||
|
||||
分表后的 health_task1表数据
|
||||
|
||||
系统改造:如何实现分库+分表?
|
||||
|
||||
在完成独立的分库和分表操作之后,系统改造的第三步是尝试把分库和分表结合起来。这个过程听起来比较复杂,但事实上,基于 ShardingSphere 提供的强大配置体系,开发人员要做的只是将分表针对分库和分表的配置项整合在一起就可以了。这里我们重新创建 3 个新的数据源,分别为 ds3、ds4 和 ds5:
|
||||
|
||||
spring.shardingsphere.datasource.names=ds3,ds4,ds5
|
||||
spring.shardingsphere.datasource.ds3.type=com.alibaba.druid.pool.DruidDataSource
|
||||
spring.shardingsphere.datasource.ds3.driver-class-name=com.mysql.jdbc.Driver
|
||||
spring.shardingsphere.datasource.ds3.url=jdbc:mysql://localhost:3306/ds3
|
||||
spring.shardingsphere.datasource.ds3.username=root
|
||||
spring.shardingsphere.datasource.ds3.password=root
|
||||
spring.shardingsphere.datasource.ds4.type=com.alibaba.druid.pool.DruidDataSource
|
||||
spring.shardingsphere.datasource.ds4.driver-class-name=com.mysql.jdbc.Driver
|
||||
spring.shardingsphere.datasource.ds4.url=jdbc:mysql://localhost:3306/ds4
|
||||
spring.shardingsphere.datasource.ds4.username=root
|
||||
spring.shardingsphere.datasource.ds4.password=root
|
||||
spring.shardingsphere.datasource.ds5.type=com.alibaba.druid.pool.DruidDataSource
|
||||
spring.shardingsphere.datasource.ds5.driver-class-name=com.mysql.jdbc.Driver
|
||||
spring.shardingsphere.datasource.ds5.url=jdbc:mysql://localhost:3306/ds5
|
||||
spring.shardingsphere.datasource.ds5.username=root
|
||||
spring.shardingsphere.datasource.ds5.password=root
|
||||
|
||||
|
||||
注意,到现在有 3 个数据源,而且命名分别是 ds3、ds4 和 ds5。所以,为了根据 user_id 来将数据分别分片到对应的数据源,我们需要调整行表达式,这时候的行表达式应该是 ds$->{user_id % 3 + 3}:
|
||||
|
||||
spring.shardingsphere.sharding.default-database-strategy.inline.sharding-column=user_id
|
||||
spring.shardingsphere.sharding.default-database-strategy.inline.algorithm-expression=ds$->{user_id % 3 + 3}
|
||||
spring.shardingsphere.sharding.binding-tables=health_record,health_task
|
||||
spring.shardingsphere.sharding.broadcast-tables=health_level
|
||||
|
||||
|
||||
对于 health_record 和 health_task 表而言,同样需要调整对应的行表达式,我们将 actual-data-nodes 设置为 ds\(->{3..5}.health_record\)->{0..2},也就是说每张原始表将被拆分成 3 张分表:
|
||||
|
||||
spring.shardingsphere.sharding.tables.health_record.actual-data-nodes=ds$->{3..5}.health_record$->{0..2}
|
||||
spring.shardingsphere.sharding.tables.health_record.table-strategy.inline.sharding-column=record_id
|
||||
spring.shardingsphere.sharding.tables.health_record.table-strategy.inline.algorithm-expression=health_record$->{record_id % 3}
|
||||
spring.shardingsphere.sharding.tables.health_record.key-generator.column=record_id
|
||||
spring.shardingsphere.sharding.tables.health_record.key-generator.type=SNOWFLAKE
|
||||
spring.shardingsphere.sharding.tables.health_record.key-generator.props.worker.id=33
|
||||
spring.shardingsphere.sharding.tables.health_task.actual-data-nodes=ds$->{3..5}.health_task$->{0..2}
|
||||
spring.shardingsphere.sharding.tables.health_task.table-strategy.inline.sharding-column=record_id
|
||||
spring.shardingsphere.sharding.tables.health_task.table-strategy.inline.algorithm-expression=health_task$->{record_id % 3}
|
||||
spring.shardingsphere.sharding.tables.health_task.key-generator.column=task_id
|
||||
spring.shardingsphere.sharding.tables.health_task.key-generator.type=SNOWFLAKE
|
||||
spring.shardingsphere.sharding.tables.health_task.key-generator.props.worker.id=33
|
||||
|
||||
|
||||
这样,整合分库+分表的配置方案就介绍完毕了,可以看到,这里并没有引入任何新的配置项让我们重新执行单元测试,从而确认数据是否已经正确地进行了分库分表。这是 ds3 中的 health_record0、health_record1 和 health_record2 表:
|
||||
|
||||
|
||||
ds3 中的 health_record0 表数据
|
||||
|
||||
|
||||
ds3 中的 health_record1 表数据
|
||||
|
||||
|
||||
ds3 中的 health_record2 表数据
|
||||
|
||||
这是 ds4 中的 health_record0、health_record1 和 health_record2 表:
|
||||
|
||||
|
||||
ds4 中的 health_record0 表数据
|
||||
|
||||
|
||||
ds4 中的 health_record1 表数据
|
||||
|
||||
|
||||
ds4 中的 health_record2 表数据
|
||||
|
||||
而下面是 ds5 中的 health_record0、health_record1 和 health_record2 表:
|
||||
|
||||
|
||||
ds5 中的 health_record0 表数据
|
||||
|
||||
|
||||
ds5 中的 health_record1 表数据
|
||||
|
||||
|
||||
ds5 中的 health_record2 表数据
|
||||
|
||||
对于 health_task 表而言,我们得到的也是类似的分库分表效果。
|
||||
|
||||
系统改造:如何实现强制路由?
|
||||
|
||||
从 SQL 执行效果而言,分库分表可以看作是一种路由机制,也就是说把 SQL 语句路由到目标数据库或数据表中并获取数据。在实现了分库分表的基础之上,我们将要引入一种不同的路由方法,即强制路由。
|
||||
|
||||
什么是强制路由?
|
||||
|
||||
强制路由与一般的分库分表路由不同,它并没有使用任何的分片键和分片策略。我们知道通过解析 SQL 语句提取分片键,并设置分片策略进行分片是 ShardingSphere 对重写 JDBC 规范的实现方式。但是,如果我们没有分片键,是否就只能访问所有的数据库和数据表进行全路由呢?显然,这种处理方式也不大合适。有时候,我们需要为 SQL 执行开一个“后门”,允许在没有分片键的情况下,同样可以在外部设置目标数据库和表,这就是强制路由的设计理念。
|
||||
|
||||
在 ShardingSphere 中,通过 Hint 机制实现强制路由。我们在这里对 Hint 这一概念再做进一步的阐述。在关系型数据库中,Hint 作为一种 SQL 补充语法扮演着非常重要的角色。它允许用户通过相关的语法影响 SQL 的执行方式,改变 SQL 的执行计划,从而对 SQL 进行特殊的优化。很多数据库工具也提供了特殊的 Hint 语法。以 MySQL 为例,比较典型的 Hint 使用方式之一就是对所有索引的强制执行和忽略机制。
|
||||
|
||||
MySQL 中的强制索引能够确保所需要执行的 SQL 语句只作用于所指定的索引上,我们可以通过 FORCE INDEX 这一 Hint 语法实现这一目标:
|
||||
|
||||
SELECT * FROM TABLE1 FORCE INDEX (FIELD1)
|
||||
|
||||
|
||||
类似的,IGNORE INDEX 这一 Hint 语法使得原本设置在具体字段上的索引不被使用:
|
||||
|
||||
SELECT * FROM TABLE1 IGNORE INDEX (FIELD1, FIELD2)
|
||||
|
||||
|
||||
对于分片字段非 SQL 决定、而由其他外置条件决定的场景,可使用 SQL Hint 灵活地注入分片字段。
|
||||
|
||||
如何设计和开发强制路由?
|
||||
|
||||
基于 Hint 进行强制路由的设计和开发过程需要遵循一定的约定,同时,ShardingSphere 也提供了专门的 HintManager 来简化强制路由的开发过程。
|
||||
|
||||
|
||||
HintManager
|
||||
|
||||
|
||||
HintManager 类的使用方式比较固化,我们可以通过查看源码中的类定义以及核心变量来理解它所包含的操作内容:
|
||||
|
||||
public final class HintManager implements AutoCloseable {
|
||||
|
||||
//基于ThreadLocal存储HintManager实例
|
||||
private static final ThreadLocal<HintManager> HINT_MANAGER_HOLDER = new ThreadLocal<>();
|
||||
//数据库分片值
|
||||
private final Multimap<String, Comparable<?>> databaseShardingValues = HashMultimap.create();
|
||||
//数据表分片值
|
||||
private final Multimap<String, Comparable<?>> tableShardingValues = HashMultimap.create();
|
||||
//是否只有数据库分片
|
||||
private boolean databaseShardingOnly;
|
||||
//是否只路由主库
|
||||
private boolean masterRouteOnly;
|
||||
…
|
||||
}
|
||||
|
||||
|
||||
在变量定义上,我们注意到 HintManager 使用了 ThreadLocal 来保存 HintManager 实例。显然,基于这种处理方式,所有分片信息的作用范围就是当前线程。我们也看到了用于分别存储数据库分片值和数据表分片值的两个 Multimap 对象,以及分别用于指定是否只有数据库分片,以及是否只路由主库的标志位。可以想象,HintManager 基于这些变量开放了一组 get/set 方法供开发人员根据具体业务场景进行分片键的设置。
|
||||
|
||||
同时,在类的定义上,我们也注意到 HintManager 实现了 AutoCloseable 接口,这个接口是在 JDK7 中引入的一个新接口,用于自动释放资源。AutoCloseable 接口只有一个 close 方法,我们可以实现这个方法来释放自定义的各种资源。
|
||||
|
||||
public interface AutoCloseable {
|
||||
void close() throws Exception;
|
||||
}
|
||||
|
||||
|
||||
在 JDK1.7 之前,我们需要手动通过 try/catch/finally 中的 finally 语句来释放资源,而使用 AutoCloseable 接口,在 try 语句结束的时候,不需要实现 finally 语句就会自动将这些资源关闭,JDK 会通过回调的方式,调用 close 方法来做到这一点。这种机制被称为 try with resource。AutoCloseable 还提供了语法糖,在 try 语句中可以同时使用多个实现这个接口的资源,并通过使用分号进行分隔。
|
||||
|
||||
HintManager 中通过实现 AutoCloseable 接口支持资源的自动释放,事实上,JDBC 中的 Connection 和 Statement 接口的实现类同样也实现了这个 AutoCloseable 接口。
|
||||
|
||||
对于 HintManager 而言,所谓的资源实际上就是 ThreadLocal 中所保存的 HintManager 实例。下面这段代码实现了 AutoCloseable 接口的 close 方法,进行资源的释放:
|
||||
|
||||
public static void clear() {
|
||||
HINT_MANAGER_HOLDER.remove();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
HintManager.clear();
|
||||
}
|
||||
|
||||
|
||||
HintManager 的创建过程使用了典型的单例设计模式,下面这段代码展现了通过一个静态的 getInstance 方法,从 ThreadLocal 中获取或设置针对当前线程的 HintManager 实例。
|
||||
|
||||
public static HintManager getInstance() {
|
||||
Preconditions.checkState(null == HINT_MANAGER_HOLDER.get(), "Hint has previous value, please clear first.");
|
||||
HintManager result = new HintManager();
|
||||
HINT_MANAGER_HOLDER.set(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
在理解了 HintManager 的基本结构之后,在应用程序中获取 HintManager 的过程就显得非常简单了,这里给出推荐的使用方式:
|
||||
|
||||
try (HintManager hintManager = HintManager.getInstance();
|
||||
Connection connection = dataSource.getConnection();
|
||||
Statement statement = connection.createStatement()) {
|
||||
…
|
||||
}
|
||||
|
||||
|
||||
可以看到,我们在 try 语句中获取了 HintManager、Connection 和 Statement 实例,然后就可以基于这些实例来完成具体的 SQL 执行。
|
||||
|
||||
|
||||
实现并配置强制路由分片算法
|
||||
|
||||
|
||||
开发基于 Hint 的强制路由的基础还是配置。在介绍与 Hint 相关的配置项之前,让我们回想在 05 课时:“ShardingSphere 中的配置体系是如何设计的?”中介绍的 TableRuleConfiguration。我们知道 TableRuleConfiguration 中包含两个 ShardingStrategyConfiguration,分别用于设置分库策略和分表策略。而 ShardingSphere 专门提供了 HintShardingStrategyConfiguration 用于完成 Hint 的分片策略配置,如下面这段代码所示:
|
||||
|
||||
public final class HintShardingStrategyConfiguration implements ShardingStrategyConfiguration {
|
||||
|
||||
private final HintShardingAlgorithm shardingAlgorithm;
|
||||
|
||||
public HintShardingStrategyConfiguration(final HintShardingAlgorithm shardingAlgorithm) {
|
||||
Preconditions.checkNotNull(shardingAlgorithm, "ShardingAlgorithm is required.");
|
||||
this.shardingAlgorithm = shardingAlgorithm;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
可以看到,HintShardingStrategyConfiguration 中需要设置一个 HintShardingAlgorithm。HintShardingAlgorithm 是一个接口,我们需要提供它的实现类来根据 Hint 信息执行分片。
|
||||
|
||||
public interface HintShardingAlgorithm<T extends Comparable<?>> extends ShardingAlgorithm {
|
||||
//根据Hint信息执行分片
|
||||
Collection<String> doSharding(Collection<String> availableTargetNames, HintShardingValue<T> shardingValue);
|
||||
}
|
||||
|
||||
|
||||
在 ShardingSphere 中内置了一个 HintShardingAlgorithm 的实现类 DefaultHintShardingAlgorithm,但这个实现类并没有执行任何的分片逻辑,只是将传入的所有 availableTargetNames 直接进行返回而已,如下面这段代码所示:
|
||||
|
||||
public final class DefaultHintShardingAlgorithm implements HintShardingAlgorithm<Integer> {
|
||||
|
||||
@Override
|
||||
public Collection<String> doSharding(final Collection<String> availableTargetNames, final HintShardingValue<Integer> shardingValue) {
|
||||
return availableTargetNames;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
我们可以根据需要提供自己的 HintShardingAlgorithm 实现类并集成到 HintShardingStrategyConfiguration 中。例如,我们可以对比所有可用的分库分表键值,然后与传入的强制分片键进行精准匹配,从而确定目标的库表信息:
|
||||
|
||||
public final class MatchHintShardingAlgorithm implements HintShardingAlgorithm<Long> {
|
||||
|
||||
@Override
|
||||
public Collection<String> doSharding(final Collection<String> availableTargetNames, final HintShardingValue<Long> shardingValue) {
|
||||
Collection<String> result = new ArrayList<>();
|
||||
for (String each : availableTargetNames) {
|
||||
for (Long value : shardingValue.getValues()) {
|
||||
if (each.endsWith(String.valueOf(value))) {
|
||||
result.add(each);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
一旦提供了自定的 HintShardingAlgorithm 实现类,就需要将它添加到配置体系中。在这里,我们基于 Yaml 配置风格来完成这一操作:
|
||||
|
||||
defaultDatabaseStrategy:
|
||||
hint:
|
||||
algorithmClassName: com.tianyilan.shardingsphere.demo.hint.MatchHintShardingAlgorithm
|
||||
|
||||
|
||||
ShardingSphere 在进行路由时,如果发现 TableRuleConfiguration 中设置了 Hint 的分片算法,就会从 HintManager 中获取分片值并进行路由操作。
|
||||
|
||||
如何基于强制路由访问目标库表?
|
||||
|
||||
在理解了强制路由的概念和开发过程之后,让我们回到案例。这里以针对数据库的强制路由为例,给出具体的实现过程。为了更好地组织代码结构,我们先来构建两个 Helper 类,一个是用于获取 DataSource 的 DataSourceHelper。在这个 Helper 类中,我们通过加载 .yaml 配置文件来完成 DataSource 的构建:
|
||||
|
||||
public class DataSourceHelper {
|
||||
static DataSource getDataSourceForShardingDatabases() throws IOException, SQLException {
|
||||
return YamlShardingDataSourceFactory.createDataSource(getFile("/META-INF/hint-databases.yaml"));
|
||||
}
|
||||
|
||||
private static File getFile(final String configFile) {
|
||||
return new File(Thread.currentThread().getClass().getResource(configFile).getFile());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
这里用到了 YamlShardingDataSourceFactory 工厂类,针对 Yaml 配置的实现方案你可以回顾 05 课时中的内容。
|
||||
|
||||
另一个 Helper 类是包装 HintManager 的 HintManagerHelper。在这个帮助类中,我们通过使用 HintManager 开放的 setDatabaseShardingValue 来完成数据库分片值的设置。在这个示例中,我们只想从第一个库中获取目标数据。HintManager 还提供了 addDatabaseShardingValue 和 addTableShardingValue 等方法设置强制路由的分片值。
|
||||
|
||||
public class HintManagerHelper {
|
||||
static void initializeHintManagerForShardingDatabases(final HintManager hintManager) {
|
||||
hintManager.setDatabaseShardingValue(1L);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
最后,我们构建一个 HintService 来完成整个强制路由流程的封装:
|
||||
|
||||
public class HintService {
|
||||
private static void processWithHintValueForShardingDatabases() throws SQLException, IOException {
|
||||
DataSource dataSource = DataSourceHelper.getDataSourceForShardingDatabases();
|
||||
try (HintManager hintManager = HintManager.getInstance();
|
||||
Connection connection = dataSource.getConnection();
|
||||
Statement statement = connection.createStatement()) {
|
||||
HintManagerHelper.initializeHintManagerForShardingDatabases(hintManager);
|
||||
ResultSet result = statement.executeQuery("select * from health_record");
|
||||
|
||||
while (result.next()) {
|
||||
System.out.println(result.getInt(0) + result.getString(1));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
可以看到,在这个 processWithHintValueForShardingDatabases 方法中,我们首先通过 DataSourceHelper 获取目标 DataSource。然后使用 try with resource 机制在 try 语句中获取了 HintManager、Connection 和 Statement 实例,并通过 HintManagerHelper 帮助类设置强制路由的分片值。最后,通过 Statement 来执行一个全表查询,并打印查询结果:
|
||||
|
||||
2020-05-25 21:58:13.932 INFO 20024 --- [ main] ShardingSphere-SQL : Logic SQL: select user_id, user_name from user
|
||||
…
|
||||
2020-05-25 21:58:13.932 INFO 20024 --- [ main] ShardingSphere-SQL : Actual SQL: ds1 ::: select user_id, user_name from user
|
||||
6: user_6
|
||||
7: user_7
|
||||
8: user_8
|
||||
9: user_9
|
||||
10: user_10
|
||||
|
||||
|
||||
我们获取执行过程中的日志信息,可以看到原始的逻辑 SQL 是 select user_id, user_name from user,而真正执行的真实 SQL 则是 ds1 ::: select user_id, user_name from user。显然,强制路由发生了效果,我们获取的只是 ds1 中的所有 User 信息。
|
||||
|
||||
小结
|
||||
|
||||
承接上一课时的内容,今天我们继续在对单库单表架构进行分库操作的基础上,讲解如何实现分表、分库+分表以及强制路由的具体细节。有了分库的实践经验,要完成分表以及分库分表是比较容易的,所做的工作只是调整和设置对应的配置项。而强制路由是一种新的路由机制,我们通过较大的篇幅来对它的概念和实现方法进行了展开,并结合业务场景给出了案例分析。
|
||||
|
||||
这里给你留一道思考题:ShardingSphere 如何基于 Hint 机制实现分库分表场景下的强制路由?
|
||||
|
||||
从路由的角度讲,基于数据库主从架构的读写分离机制也可以被认为是一种路由。在下一课时的内容中,我们将对 ShardingSphere 提供的读写分离机制进行讲解,并同样给出读写分离与分库分表、强制路由进行整合的具体方法。
|
||||
|
||||
|
||||
|
||||
|
@ -0,0 +1,189 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
08 读写分离:如何集成分库分表+数据库主从架构?
|
||||
为了应对高并发场景下的数据库访问需求,读写分离架构是现代数据库架构的一个重要组成部分。今天,我就和你一起来学习 ShardingSphere 中所提供的读写分离机制,以及这一机制如何与前面介绍的分库分表和强制路由整合在一起使用。
|
||||
|
||||
ShardingSphere 中的读写分离
|
||||
|
||||
为了应对数据库读写分离,ShardingSphere 所提供的解决方案还是依赖于强大的配置体系。为了更好地理解这些读写分离相关的配置,我们有必要对读写分离与主从架构有一定的了解。
|
||||
|
||||
读写分离与主从架构
|
||||
|
||||
目前,大部分的主流关系型数据库都提供了主从架构的实现方案,通过配置两台或多台数据库的主从关系,可以将一台数据库服务器的数据更新自动同步到另一台服务器上。而应用程序可以利用数据库的这一功能,实现数据的读写分离,从而改善数据库的负载压力。
|
||||
|
||||
|
||||
|
||||
可以看到,所谓的读写分离,实际上就是将写操作路由到主数据库,而将读操作路由到从数据库。对于互联网应用而言,读取数据的需求远远大于写入数据的需求,所以从数据库一般都是多台。当然,对于复杂度较高的系统架构而言,主库的数量同样也可以是多台。
|
||||
|
||||
读写分离与 ShardingSphere
|
||||
|
||||
就 ShardingSphere 而言,支持主从架构下的读写分离是一项核心功能。目前 ShardingSphere 支持单主库、多从库的主从架构来完成分片环境下的读写分离,暂时不支持多主库的应用场景。
|
||||
|
||||
在数据库主从架构中,因为从库一般会有多台,所以当执行一条面向从库的 SQL 语句时,我们需要实现一套负载均衡机制来完成对目标从库的路由。ShardingSphere 默认提供了随机(Random)和轮询(RoundRobin)这两种负载均衡算法来完成这一目标。
|
||||
|
||||
另一方面,由于主库和从库之间存在一定的同步时延和数据不一致情况,所以在有些场景下,我们可能更希望从主库中获取最新数据。ShardingSphere 同样考虑到了这方面需求,开发人员可以通过 Hint 机制来实现对主库的强制路由。
|
||||
|
||||
配置读写分离
|
||||
|
||||
实现读写分离要做的还是配置工作。通过配置,我们的目标是获取支持读写分离的 MasterSlaveDataSource,而 MasterSlaveDataSource 的创建依赖于 MasterSlaveDataSourceFactory 工厂类:
|
||||
|
||||
public final class MasterSlaveDataSourceFactory {
|
||||
|
||||
public static DataSource createDataSource(final Map<String, DataSource> dataSourceMap, final MasterSlaveRuleConfiguration masterSlaveRuleConfig, final Properties props) throws SQLException {
|
||||
return new MasterSlaveDataSource(dataSourceMap, new MasterSlaveRule(masterSlaveRuleConfig), props);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
在上面这段代码中,我们可以看到 createDataSource 方法中传入了三个参数,除了熟悉的 dataSourceMap 和 props 之外,还有一个 MasterSlaveRuleConfiguration,而这个 MasterSlaveRuleConfiguration 包含了所有我们需要配置的读写分离信息:
|
||||
|
||||
public class MasterSlaveRuleConfiguration implements RuleConfiguration {
|
||||
//读写分离数据源名称
|
||||
private final String name;
|
||||
//主库数据源名称
|
||||
private final String masterDataSourceName;
|
||||
//从库数据源名称列表
|
||||
private final List<String> slaveDataSourceNames;
|
||||
//从库负载均衡算法
|
||||
private final LoadBalanceStrategyConfiguration loadBalanceStrategyConfiguration;
|
||||
…
|
||||
}
|
||||
|
||||
|
||||
从 MasterSlaveRuleConfiguration 类所定义的变量中不难看出,我们需要配置读写分离数据源名称、主库数据源名称、从库数据源名称列表以及从库负载均衡算法这四个配置项,仅此而已。
|
||||
|
||||
系统改造:如何实现读写分离?
|
||||
|
||||
在掌握了读写分离的基本概念以及相关配置项之后,我们回到案例,看如何在单库单表架构中引入读写分离机制。
|
||||
|
||||
第一步,仍然是设置用于实现读写分离的数据源。为了演示一主多从架构,我们初始化了一个主数据源 dsmaster 以及两个从数据源 dsslave0 和 dsslave1:
|
||||
|
||||
spring.shardingsphere.datasource.names=dsmaster,dsslave0,dsslave1
|
||||
spring.shardingsphere.datasource.dsmaster.type=com.alibaba.druid.pool.DruidDataSource
|
||||
spring.shardingsphere.datasource.dsmaster.driver-class-name=com.mysql.jdbc.Driver
|
||||
spring.shardingsphere.datasource.dsmaster.url=jdbc:mysql://localhost:3306/dsmaster
|
||||
spring.shardingsphere.datasource.dsmaster.username=root
|
||||
spring.shardingsphere.datasource.dsmaster.password=root
|
||||
spring.shardingsphere.datasource.dsslave0.type=com.alibaba.druid.pool.DruidDataSource
|
||||
spring.shardingsphere.datasource.dsslave0.driver-class-name=com.mysql.jdbc.Driver
|
||||
spring.shardingsphere.datasource.dsslave0.url=jdbc:mysql://localhost:3306/dsslave0
|
||||
spring.shardingsphere.datasource.dsslave0.username=root
|
||||
spring.shardingsphere.datasource.dsslave0.password=root
|
||||
spring.shardingsphere.datasource.dsslave1.type=com.alibaba.druid.pool.DruidDataSource
|
||||
spring.shardingsphere.datasource.dsslave1.driver-class-name=com.mysql.jdbc.Driver
|
||||
spring.shardingsphere.datasource.dsslave1.url=jdbc:mysql://localhost:3306/dsslave1?serverTimezone=UTC&useSSL=false&useUnicode=true&characterEncoding=UTF-8
|
||||
spring.shardingsphere.datasource.dsslave1.username=root
|
||||
spring.shardingsphere.datasource.dsslave1.password=root
|
||||
|
||||
|
||||
有了数据源之后,我们需要设置 MasterSlaveRuleConfiguration 类中所指定的 4 个配置项,这里负载均衡算法设置的是 random,也就是使用的随机算法:
|
||||
|
||||
spring.shardingsphere.masterslave.name=health_ms
|
||||
spring.shardingsphere.masterslave.master-data-source-name=dsmaster
|
||||
spring.shardingsphere.masterslave.slave-data-source-names=dsslave0,dsslave1
|
||||
spring.shardingsphere.masterslave.load-balance-algorithm-type=random
|
||||
|
||||
|
||||
现在我们来插入 User 对象,从控制台的日志中可以看到,ShardingSphere 执行的路由类型是 master-slave ,而具体 SQL 的执行是发生在 dsmaster 主库中:
|
||||
|
||||
2020-05-25 19:58:08.721 INFO 4392 --- [ main] ShardingSphere-SQL : Rule Type: master-slave
|
||||
2020-05-25 19:58:08.721 INFO 4392 --- [ main] ShardingSphere-SQL : SQL: INSERT INTO user (user_id, user_name) VALUES (?, ?) ::: DataSources: dsmaster
|
||||
Insert User:1
|
||||
2020-05-25 19:58:08.721 INFO 4392 --- [ main] ShardingSphere-SQL : Rule Type: master-slave
|
||||
2020-05-25 19:58:08.721 INFO 4392 --- [ main] ShardingSphere-SQL : SQL: INSERT INTO user (user_id, user_name) VALUES (?, ?) ::: DataSources: dsmaster
|
||||
Insert User:2
|
||||
…
|
||||
|
||||
|
||||
然后,我们再对 User 对象执行查询操作并获取 SQL 执行日志:
|
||||
|
||||
2020-05-25 20:00:33.066 INFO 3364 --- [main] ShardingSphere-SQL : Rule Type: master-slave
|
||||
2020-05-25 20:00:33.066 INFO 3364 --- [main] ShardingSphere-SQL : SQL : SELECT * FROM user; ::: DataSources: dsslave0
|
||||
|
||||
|
||||
可以看到,这里用到的 DataSource 是 dsslave0,也就是说查询操作发生在 dsslave0 从库中。由于设置的是随机负载均衡策略,当我们多次执行查询操作时,目标 DataSource 会在 dsslave0 和 dsslave1 之间交替出现。
|
||||
|
||||
系统改造:如何实现读写分离+分库分表?
|
||||
|
||||
我们同样可以在分库分表的基础上添加读写分离功能。这时候,我们需要设置两个主数据源 dsmaster0 和 dsmaster1,然后针对每个主数据源分别设置两个从数据源:
|
||||
|
||||
spring.shardingsphere.datasource.names=dsmaster0,dsmaster1,dsmaster0-slave0,dsmaster0-slave1,dsmaster1-slave0,dsmaster1-slave1
|
||||
|
||||
|
||||
这时候的库分片策略 default-database-strategy 同样分别指向 dsmaster0 和 dsmaster1 这两个主数据源:
|
||||
|
||||
spring.shardingsphere.sharding.default-database-strategy.inline.sharding-column=user_id
|
||||
spring.shardingsphere.sharding.default-database-strategy.inline.algorithm-expression=dsmaster$->{user_id % 2}
|
||||
|
||||
|
||||
而对于表分片策略而言,我们还是使用在 07 课时中介绍的分片方式进行设置:
|
||||
|
||||
spring.shardingsphere.sharding.tables.health_record.actual-data-nodes=dsmaster$->{0..1}.health_record$->{0..1}
|
||||
spring.shardingsphere.sharding.tables.health_record.table-strategy.inline.sharding-column=record_id
|
||||
spring.shardingsphere.sharding.tables.health_record.table-strategy.inline.algorithm-expression=health_record$->{record_id % 2}
|
||||
|
||||
|
||||
完成这些设置之后,同样需要设置两个主数据源对应的配置项:
|
||||
|
||||
spring.shardingsphere.sharding.master-slave-rules.dsmaster0.master-data-source-name=dsmaster0
|
||||
spring.shardingsphere.sharding.master-slave-rules.dsmaster0.slave-data-source-names=dsmaster0-slave0, dsmaster0-slave1
|
||||
spring.shardingsphere.sharding.master-slave-rules.dsmaster1.master-data-source-name=dsmaster1
|
||||
spring.shardingsphere.sharding.master-slave-rules.dsmaster1.slave-data-source-names=dsmaster1-slave0, dsmaster1-slave1
|
||||
|
||||
|
||||
这样,我们就在分库分表的基础上添加了对读写分离的支持。ShardingSphere 所提供的强大配置体系使得开发人员可以在原有配置的基础上添加新的配置项,而不需要对原有配置做过多调整。
|
||||
|
||||
系统改造:如何实现读写分离下的强制路由?
|
||||
|
||||
在上个课时中我们介绍了强制路由,在这个基础上,我将给出如何基于 Hint,完成读写分离场景下的主库强制路由方案。
|
||||
|
||||
要想实现主库强制路由,我们还是要使用 HintManager。HintManager 专门提供了一个 setMasterRouteOnly 方法,用于将 SQL 强制路由到主库中。我们把这个方法也封装在 HintManagerHelper 帮助类中:
|
||||
|
||||
public class HintManagerHelper {
|
||||
static void initializeHintManagerForMaster(final HintManager hintManager) {
|
||||
hintManager.setMasterRouteOnly();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
现在,我们在业务代码中加入主库强制路由的功能,下面这段代码演示了这个过程:
|
||||
|
||||
@Override
|
||||
public void processWithHintValueMaster() throws SQLException, IOException {
|
||||
DataSource dataSource = DataSourceHelper.getDataSourceForMaster();
|
||||
try (HintManager hintManager = HintManager.getInstance();
|
||||
Connection connection = dataSource.getConnection();
|
||||
Statement statement = connection.createStatement()) {
|
||||
HintManagerHelper.initializeHintManagerForMaster(hintManager);
|
||||
ResultSet result = statement.executeQuery("select user_id, user_name from user");
|
||||
|
||||
while (result.next()) {
|
||||
System.out.println(result.getLong(1) + ": " + result.getString(2));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
执行这段代码,可以在控制台日志中获取执行的结果:
|
||||
|
||||
2020-05-25 22:06:17.166 INFO 16680 --- [ main] ShardingSphere-SQL : Rule Type: master-slave
|
||||
2020-05-25 22:06:17.166 INFO 16680 --- [ main] ShardingSphere-SQL : SQL: select user_id, user_name from user ::: DataSources: dsmaster
|
||||
1: user_1
|
||||
2: user_2
|
||||
…
|
||||
|
||||
|
||||
显然,这里的路由类型是 master-slave,而执行 SQL 的 DataSource 只有 dsmaster,也就是说,我们完成了针对主库的强制路由。
|
||||
|
||||
小结
|
||||
|
||||
继续承接上一课时的内容,今天我们讲解 ShardingSphere 中的读写分离机制。在日常开发过程中,读写分离是应对高并发数据访问的一种有效技术手段。而在ShardingSphere中,读写分离既可以单独使用,也可以和分库组合在一起使用。ShardingSphere的另一个强大之处还在于提供了针对主库的强制路由机制,这在需要确保获取主库最新数据的场景下非常有用。
|
||||
|
||||
这里给你留一道思考题:如果我们想要在主从架构中只访问主库中的数据,在 ShardingSphere 中有什么方法可以做到这一点?
|
||||
|
||||
|
||||
|
||||
|
@ -0,0 +1,395 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
34 系统集成:如何完成 ShardingSphere 内核与 Spring+SpringBoot 的无缝整合?
|
||||
今天,我们将进入整个课程中最后一个模块——系统集成模块的介绍。这里所谓的系统集成,指的就是 ShardingSphere 和 Spring 框架的集成。
|
||||
|
||||
到目前为止,ShardingSphere 实现了两种系统集成机制:一种是命名空间(namespace)机制,即通过扩展 Spring Schema 来实现与 Spring 框架的集成;而另一种则是通过编写自定义的 starter 组件来完成与 Spring Boot 的集成。本课时我将分别讲解这两种系统集成机制。
|
||||
|
||||
基于系统集成模块,无论开发人员采用哪一种 Spring 框架,对于使用 ShardingSphere 而言都是零学习成本。
|
||||
|
||||
基于命名空间集成 Spring
|
||||
|
||||
从扩展性的角度讲,基于 XML Schema 的扩展机制也是非常常见和实用的一种方法。在 Spring 中,允许我们自己定义 XML 的结构,并且可以用自己的 Bean 解析器进行解析。通过对 Spring Schema 的扩展,ShardingSphere 可以完成与 Spring 框架的有效集成。
|
||||
|
||||
1.基于命名空间集成 Spring 的通用开发流程
|
||||
|
||||
基于命名空间机制实现与 Spring 的整合,开发上通常采用的是固定的一个流程,包括如下所示的五大步骤:
|
||||
|
||||
|
||||
|
||||
这些步骤包括:编写业务对象、编写 XSD 文件、编写 BeanDefinitionParser 实现类、编写 NamespaceHandler 实现类,以及编写 spring.handlers 和 spring.schemas 配置文件,我们来看看 ShardingSphere 中实现这些步骤的具体做法。
|
||||
|
||||
2.ShardingSphere 集成 Spring
|
||||
|
||||
ShardingSphere 中存在两个以“spring-namespace”结尾的代码工程,即 sharding-jdbc-spring-namespace 和 sharding-jdbc-orchestration-spring-namespace,显然后者关注的是编排治理相关功能的集成,相对比较简单。再因为命名空间机制的实现过程也基本一致,因此,我们以 sharding-jdbc-spring-namespace 工程为例展开讨论。
|
||||
|
||||
而在 sharding-jdbc-spring-namespace 工程中,又包含了对普通分片、读写分离和数据脱敏这三块核心功能的集成内容,它们的实现也都是采用了类似的方式,因此我们也不会重复进行说明,这里就以普通分片为例进行介绍。
|
||||
|
||||
首先,我们发现了一个专门用于与 Spring 进行集成的 SpringShardingDataSource 类,这个类就是业务对象类,如下所示:
|
||||
|
||||
public class SpringShardingDataSource extends ShardingDataSource {
|
||||
|
||||
public SpringShardingDataSource(final Map<String, DataSource> dataSourceMap, final ShardingRuleConfiguration shardingRuleConfiguration, final Properties props) throws SQLException {
|
||||
|
||||
super(dataSourceMap, new ShardingRule(shardingRuleConfiguration, dataSourceMap.keySet()), props);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
可以看到这个 SpringShardingDataSource 类实际上只是对 ShardingDataSource 的一种简单封装,没有包含任何实际操作。
|
||||
|
||||
然后,我们来看配置项标签的定义类,这种类是一种简单的工具类,其作用就是定义标签的名称。在命名上,ShardingSphere 中的这些类都以“BeanDefinitionParserTag”结尾,例如如下所示的 ShardingDataSourceBeanDefinitionParserTag:
|
||||
|
||||
public final class ShardingDataSourceBeanDefinitionParserTag {
|
||||
|
||||
public static final String ROOT_TAG = "data-source";
|
||||
|
||||
public static final String SHARDING_RULE_CONFIG_TAG = sharding-rule";
|
||||
|
||||
public static final String PROPS_TAG = "props";
|
||||
|
||||
public static final String DATA_SOURCE_NAMES_TAG = "data-source-names";
|
||||
|
||||
public static final String DEFAULT_DATA_SOURCE_NAME_TAG = "default-data-source-name";
|
||||
|
||||
public static final String TABLE_RULES_TAG = "table-rules";
|
||||
|
||||
…
|
||||
|
||||
}
|
||||
|
||||
|
||||
这里定义了一批 Tag 和一批 Attribute,我们不做 一 一 展开。可以对照如下所示的基于 XML 的配置示例来对这些定义的配置项进行理解:
|
||||
|
||||
<sharding:data-source id="shardingDataSource">
|
||||
|
||||
<sharding:sharding-rule data-source-names="ds0,ds1">
|
||||
|
||||
<sharding:table-rules>
|
||||
|
||||
<sharding:table-rule …/>
|
||||
|
||||
<sharding:table-rule …/>
|
||||
|
||||
…
|
||||
|
||||
</sharding:table-rules>
|
||||
|
||||
…
|
||||
|
||||
</sharding:sharding-rule>
|
||||
|
||||
</sharding:data-source>
|
||||
|
||||
|
||||
然后,我们在 sharding-jdbc-spring-namespace 代码工程的 META-INF/namespace 文件夹下找到了对应的 sharding.xsd 文件,其基本结构如下所示:
|
||||
|
||||
<xsd:schema xmlns="http://shardingsphere.apache.org/schema/shardingsphere/sharding"
|
||||
|
||||
xmlns:xsd="http://www.w3.org/2001/XMLSchema"
|
||||
|
||||
xmlns:beans="http://www.springframework.org/schema/beans"
|
||||
|
||||
xmlns:encrypt="http://shardingsphere.apache.org/schema/shardingsphere/encrypt"
|
||||
|
||||
targetNamespace="http://shardingsphere.apache.org/schema/shardingsphere/sharding"
|
||||
|
||||
elementFormDefault="qualified" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
|
||||
xsi:schemaLocation="http://shardingsphere.apache.org/schema/shardingsphere/encrypt http://shardingsphere.apache.org/schema/shardingsphere/encrypt/encrypt.xsd">
|
||||
|
||||
<xsd:import namespace="http://www.springframework.org/schema/beans" schemaLocation="http://www.springframework.org/schema/beans/spring-beans.xsd" />
|
||||
|
||||
<xsd:import namespace="http://shardingsphere.apache.org/schema/shardingsphere/encrypt" schemaLocation="http://shardingsphere.apache.org/schema/shardingsphere/encrypt/encrypt.xsd"/>
|
||||
|
||||
<xsd:element name="data-source">
|
||||
|
||||
<xsd:complexType>
|
||||
|
||||
<xsd:all>
|
||||
|
||||
<xsd:element ref="sharding-rule" />
|
||||
|
||||
<xsd:element ref="props" minOccurs="0" />
|
||||
|
||||
</xsd:all>
|
||||
|
||||
<xsd:attribute name="id" type="xsd:string" use="required" />
|
||||
|
||||
</xsd:complexType>
|
||||
|
||||
</xsd:element>
|
||||
|
||||
…
|
||||
|
||||
</xsd:schema>
|
||||
|
||||
|
||||
可以看到对于“data-source”这个 element 而言,包含了“sharding-rule”和“props”这两个子 element,其中“props”不是必需的。同时,“data-source”还可以包含一个“id”属性,而这个属性则是必填的,我们在前面的配置示例中已经看到了这一点。而对于“sharding-rule”而言,则可以有很多内嵌的属性,sharding.xsd 文件中对这些属性都做了定义。
|
||||
|
||||
同时,我们应该注意到的是,sharding.xsd 中通过使用 xsd:import 标签还引入了两个 namespace,一个是 Spring 中的http://www.springframework.org/schema/beans,另一个则是 ShardingSphere 自身的http://shardingsphere.apache.org/schema/shardingsphere/encrypt,这个命名空间的定义位于与 sharding.xsd 同目录下的 encrypt.xsd文件中。
|
||||
|
||||
有了业务对象类,以及 XSD 文件的定义,接下来我们就来看看 NamespaceHandler 实现类 ShardingNamespaceHandler,如下所示:
|
||||
|
||||
public final class ShardingNamespaceHandler extends NamespaceHandlerSupport {
|
||||
|
||||
@Override
|
||||
|
||||
public void init() {
|
||||
|
||||
registerBeanDefinitionParser(ShardingDataSourceBeanDefinitionParserTag.ROOT_TAG, new ShardingDataSourceBeanDefinitionParser());
|
||||
|
||||
registerBeanDefinitionParser(ShardingStrategyBeanDefinitionParserTag.STANDARD_STRATEGY_ROOT_TAG, new ShardingStrategyBeanDefinitionParser());
|
||||
|
||||
…
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
可以看到这里也是直接使用了 registerBeanDefinitionParser 方法来完成标签项与具体的 BeanDefinitionParser 类之间的对应关系。我们来看这里的 ShardingDataSourceBeanDefinitionParser,其核心的 parseInternal 方法如下所示:
|
||||
|
||||
@Override
|
||||
|
||||
protected AbstractBeanDefinition parseInternal(final Element element, final ParserContext parserContext) {
|
||||
|
||||
//构建针对 SpringShardingDataSource 的 BeanDefinitionBuilder
|
||||
|
||||
BeanDefinitionBuilder factory = BeanDefinitionBuilder.rootBeanDefinition(SpringShardingDataSource.class);
|
||||
|
||||
//解析构造函数中的 DataSource 参数
|
||||
|
||||
factory.addConstructorArgValue(parseDataSources(element));
|
||||
|
||||
//解析构造函数中 ShardingRuleConfiguration 参数 factory.addConstructorArgValue(parseShardingRuleConfiguration(element));
|
||||
|
||||
//解析构造函数中 Properties 参数
|
||||
|
||||
factory.addConstructorArgValue(parseProperties(element, parserContext));
|
||||
|
||||
factory.setDestroyMethodName("close");
|
||||
|
||||
return factory.getBeanDefinition();
|
||||
|
||||
}
|
||||
|
||||
|
||||
这里,我们自己定义了一个 BeanDefinitionBuilder 并将其绑定到前面定义的业务对象类 SpringShardingDataSource。然后,我们通过三个 addConstructorArgValue 方法的调用,分别为 SpringShardingDataSource 构造函数中所需的 dataSourceMap、shardingRuleConfiguration 以及 props 参数进行赋值。
|
||||
|
||||
我们再来进一步看一下上述方法中的 parseDataSources 方法,如下所示:
|
||||
|
||||
private Map<String, RuntimeBeanReference> parseDataSources(final Element element) {
|
||||
|
||||
Element shardingRuleElement = DomUtils.getChildElementByTagName(element, ShardingDataSourceBeanDefinitionParserTag.SHARDING_RULE_CONFIG_TAG);
|
||||
|
||||
List<String> dataSources = Splitter.on(",").trimResults().splitToList(shardingRuleElement.getAttribute(ShardingDataSourceBeanDefinitionParserTag.DATA_SOURCE_NAMES_TAG));
|
||||
|
||||
Map<String, RuntimeBeanReference> result = new ManagedMap<>(dataSources.size());
|
||||
|
||||
for (String each : dataSources) {
|
||||
|
||||
result.put(each, new RuntimeBeanReference(each));
|
||||
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
}
|
||||
|
||||
|
||||
基于前面介绍的配置示例,我们理解这段代码的作用是获取所配置的“ds0,ds1”字符串,并对其进行拆分,然后基于每个代表具体 DataSource 的名称构建 RuntimeBeanReference 对象并进行返回,这样就可以把在 Spring 容器中定义的其他 Bean 加载到 BeanDefinitionBuilder 中。
|
||||
|
||||
关于 ShardingDataSourceBeanDefinitionParser 中其他 parse 方法的使用,大家可以通过阅读对应的代码进行理解,处理方式都是非常类似的,就不再重复展开。
|
||||
|
||||
最后,我们需要在 META-INF 目录下提供spring.schemas 文件,如下所示:
|
||||
|
||||
http\://shardingsphere.apache.org/schema/shardingsphere/sharding/sharding.xsd=META-INF/namespace/sharding.xsd
|
||||
|
||||
http\://shardingsphere.apache.org/schema/shardingsphere/masterslave/master-slave.xsd=META-INF/namespace/master-slave.xsd
|
||||
|
||||
http\://shardingsphere.apache.org/schema/shardingsphere/encrypt/encrypt.xsd=META-INF/namespace/encrypt.xsd
|
||||
|
||||
|
||||
同样,spring.handlers 的内容如下所示:
|
||||
|
||||
http\://shardingsphere.apache.org/schema/shardingsphere/sharding=org.apache.shardingsphere.shardingjdbc.spring.namespace.handler.ShardingNamespaceHandler
|
||||
|
||||
http\://shardingsphere.apache.org/schema/shardingsphere/masterslave=org.apache.shardingsphere.shardingjdbc.spring.namespace.handler.MasterSlaveNamespaceHandler
|
||||
|
||||
http\://shardingsphere.apache.org/schema/shardingsphere/encrypt=org.apache.shardingsphere.shardingjdbc.spring.namespace.handler.EncryptNamespaceHandler
|
||||
|
||||
|
||||
至此,我们对 ShardingSphere 中基于命名空间机制与 Spring 进行系统集成的实现过程介绍完毕。
|
||||
|
||||
接下来,我们来看 ShardingSphere 中实现一个自定义 spring-boot-starter 的过程。
|
||||
|
||||
基于自定义 starter 集成 Spring Boot
|
||||
|
||||
与基于命名空间的实现方式一样,ShardingSphere 提供了 sharding-jdbc-spring-boot-starter 和 sharding-jdbc-orchestration-spring-boot-starter 这两个 starter 工程。篇幅关系,我们同样只关注于 sharding-jdbc-spring-boot-starter 工程。
|
||||
|
||||
对于 Spring Boot 工程,我们首先来关注 META-INF 文件夹下的 spring.factories 文件。Spring Boot 中提供了一个 SpringFactoriesLoader 类,该类的运行机制类似于 “13 | 微内核架构:ShardingSphere如何实现系统的扩展性?” 中所介绍的 SPI 机制,只不过以服务接口命名的文件是放在 META-INF/spring.factories 文件夹下,对应的 Key 为 EnableAutoConfiguration。SpringFactoriesLoader 会查找所有 META-INF/spring.factories 目录下的配置文件,并把 Key 为 EnableAutoConfiguration 所对应的配置项通过反射实例化为配置类并加载到容器。在 sharding-jdbc-spring-boot-starter 工程中,该文件内容如下所示:
|
||||
|
||||
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
|
||||
|
||||
org.apache.shardingsphere.shardingjdbc.spring.boot.SpringBootConfiguration
|
||||
|
||||
|
||||
现在这里的 EnableAutoConfiguration 配置项指向了 SpringBootConfiguration 类。也就是说,这个类在 Spring Boot 启动过程中都能够通过 SpringFactoriesLoader 被加载到运行时环境中。
|
||||
|
||||
1.SpringBootConfiguration 中的注解
|
||||
|
||||
接下来,我们就来到这个 SpringBootConfiguration,首先关注于加在该类上的各种注解,如下所示:
|
||||
|
||||
@Configuration
|
||||
|
||||
@ComponentScan("org.apache.shardingsphere.spring.boot.converter")
|
||||
|
||||
@EnableConfigurationProperties({
|
||||
|
||||
SpringBootShardingRuleConfigurationProperties.class,
|
||||
|
||||
SpringBootMasterSlaveRuleConfigurationProperties.class, SpringBootEncryptRuleConfigurationProperties.class, SpringBootPropertiesConfigurationProperties.class})
|
||||
|
||||
@ConditionalOnProperty(prefix = "spring.shardingsphere", name = "enabled", havingValue = "true", matchIfMissing = true)
|
||||
|
||||
@AutoConfigureBefore(DataSourceAutoConfiguration.class)
|
||||
|
||||
@RequiredArgsConstructor
|
||||
|
||||
public class SpringBootConfiguration implements EnvironmentAware
|
||||
|
||||
|
||||
首先,我们看到了一个 @Configuration 注解。这个注解不是 Spring Boot 引入的新注解,而是属于 Spring 容器管理的内容。该注解表明这个类是一个配置类,可以启动组件扫描,用来将带有 @Bean 注解的实体进行实例化 bean。
|
||||
|
||||
然后,我们又看到了一个同样属于 Spring 容器管理范畴的老注解,即 @ComponentScan 注解。@ComponentScan 注解就是扫描基于 @Component 等注解所标注的类所在包下的所有需要注入的类,并把相关 Bean 定义批量加载到IoC容器中。
|
||||
|
||||
显然,Spring Boot 应用程序中同样需要这个功能。注意到,这里需要进行扫描的包路径位于另一个代码工程 sharding-spring-boot-util 的 org.apache.shardingsphere.spring.boot.converter 包中。
|
||||
|
||||
然后,我们看到了一个 @EnableConfigurationProperties 注解,该注解的作用就是使添加了 @ConfigurationProperties 注解的类生效。在 Spring Boot 中,如果一个类只使用了 @ConfigurationProperties 注解,然后该类没有在扫描路径下或者没有使用 @Component 等注解,就会导致无法被扫描为 bean,那么就必须在配置类上使用 @EnableConfigurationProperties 注解去指定这个类,才能使 @ConfigurationProperties 生效,并作为一个 bean 添加进 spring 容器中。这里的 @EnableConfigurationProperties 注解包含了四个具体的 ConfigurationProperties。以 SpringBootShardingRuleConfigurationProperties 为例,该类的定义如下所示,可以看到,这里直接继承了 sharding-core-common 代码工程中的 YamlShardingRuleConfiguration:
|
||||
|
||||
@ConfigurationProperties(prefix = "spring.shardingsphere.sharding")
|
||||
|
||||
public class SpringBootShardingRuleConfigurationProperties extends YamlShardingRuleConfiguration {
|
||||
|
||||
}
|
||||
|
||||
|
||||
SpringBootConfiguration 上的下一个注解是 @ConditionalOnProperty,该注解的作用在于只有当所提供的属性属于 true 时才会实例化 Bean。
|
||||
|
||||
最后一个与自动加载相关的注解是 @AutoConfigureBefore,如果该注解用在类名上,其作用是标识在加载当前类之前需要加载注解中所设置的配置类。基于这一点,我们明确在加载 SpringBootConfiguration 类之前,Spring Boot 会先加载 DataSourceAutoConfiguration。这一步的作用与我们后面要看到的创建各种 DataSource 相关。
|
||||
|
||||
2.SpringBootConfiguration 中的功能
|
||||
|
||||
介绍完这些注解之后,我们来看一下 SpringBootConfiguration 类所提供的功能。
|
||||
|
||||
我们知道对于 ShardingSphere 而言,其对外的入口实际上就是各种 DataSource,因此 SpringBootConfiguration 中提供了一批创建不同 DataSource 的入口方法,例如如下所示的 shardingDataSource 方法:
|
||||
|
||||
@Bean
|
||||
|
||||
@Conditional(ShardingRuleCondition.class)
|
||||
|
||||
public DataSource shardingDataSource() throws SQLException {
|
||||
|
||||
return ShardingDataSourceFactory.createDataSource(dataSourceMap, new ShardingRuleConfigurationYamlSwapper().swap(shardingRule), props.getProps());
|
||||
|
||||
}
|
||||
|
||||
|
||||
该方法上添加了两个注解,一个是常见的 @Bean,另一个则是 @Conditional 注解,该注解的作用是只有满足指定条件的情况下才能加载这个 Bean。我们看到 @Conditional 注解中设置了一个 ShardingRuleCondition,该类如下所示:
|
||||
|
||||
public final class ShardingRuleCondition extends SpringBootCondition {
|
||||
|
||||
@Override
|
||||
|
||||
public ConditionOutcome getMatchOutcome(final ConditionContext conditionContext, final AnnotatedTypeMetadata annotatedTypeMetadata) {
|
||||
|
||||
boolean isMasterSlaveRule = new MasterSlaveRuleCondition().getMatchOutcome(conditionContext, annotatedTypeMetadata).isMatch();
|
||||
|
||||
boolean isEncryptRule = new EncryptRuleCondition().getMatchOutcome(conditionContext, annotatedTypeMetadata).isMatch();
|
||||
|
||||
return isMasterSlaveRule || isEncryptRule ? ConditionOutcome.noMatch("Have found master-slave or encrypt rule in environment") : ConditionOutcome.match();
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
可以看到 ShardingRuleCondition 是一个标准的 SpringBootCondition,实现了 getMatchOutcome 抽象方法。我们知道 SpringBootCondition 的作用就是代表一种用于注册类或加载 Bean 的条件。ShardingRuleCondition 类的实现上分别调用了 MasterSlaveRuleCondition 和 EncryptRuleCondition 来判断是否满足这两个 SpringBootCondition。显然,对于 ShardingRuleCondition 而言,只有在两个条件都不满足的情况下才应该被加载。对于 masterSlaveDataSource 和 encryptDataSource 这两个方法而言,处理逻辑也类似,不做赘述。
|
||||
|
||||
最后,我们注意到 SpringBootConfiguration 还实现了 Spring 的 EnvironmentAware 接口。在 Spring Boot 中,当一个类实现了 EnvironmentAware 接口并重写了其中的 setEnvironment 方法之后,在代码工程启动时就可以获得 application.properties 配置文件中各个配置项的属性值。SpringBootConfiguration 中所重写的 setEnvironment 方法如下所示:
|
||||
|
||||
@Override
|
||||
|
||||
public final void setEnvironment(final Environment environment) {
|
||||
|
||||
String prefix = "spring.shardingsphere.datasource.";
|
||||
|
||||
for (String each : getDataSourceNames(environment, prefix)) {
|
||||
|
||||
try {
|
||||
|
||||
dataSourceMap.put(each, getDataSource(environment, prefix, each));
|
||||
|
||||
} catch (final ReflectiveOperationException ex) {
|
||||
|
||||
throw new ShardingException("Can't find datasource type!", ex);
|
||||
|
||||
} catch (final NamingException namingEx) {
|
||||
|
||||
throw new ShardingException("Can't find JNDI datasource!", namingEx);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
这里的代码逻辑是获取“spring.shardingsphere.datasource.name”或“spring.shardingsphere.datasource.names”配置项,然后根据该配置项中所指定的 DataSource 信息构建新的 DataSource 并加载到 dataSourceMap 这个 LinkedHashMap。这点我们可以结合课程案例中的配置项来加深理解:
|
||||
|
||||
spring.shardingsphere.datasource.names=ds0,ds1
|
||||
|
||||
spring.shardingsphere.datasource.ds0.type=com.alibaba.druid.pool.DruidDataSource
|
||||
|
||||
spring.shardingsphere.datasource.ds0.driver-class-name=com.mysql.jdbc.Driver
|
||||
|
||||
spring.shardingsphere.datasource.ds0.url=jdbc:mysql://localhost/ds0
|
||||
|
||||
spring.shardingsphere.datasource.ds0.username=root
|
||||
|
||||
spring.shardingsphere.datasource.ds0.password=root
|
||||
|
||||
spring.shardingsphere.datasource.ds1.type=com.alibaba.druid.pool.DruidDataSource
|
||||
|
||||
spring.shardingsphere.datasource.ds1.driver-class-name=com.mysql.jdbc.Driver
|
||||
|
||||
spring.shardingsphere.datasource.ds1.url=jdbc:mysql://localhost/ds1
|
||||
|
||||
spring.shardingsphere.datasource.ds1.username=root
|
||||
|
||||
spring.shardingsphere.datasource.ds1.password=root
|
||||
|
||||
|
||||
至此,整个 SpringBootConfiguration 的实现过程介绍完毕。
|
||||
|
||||
从源码解析到日常开发
|
||||
|
||||
今天所介绍的关于 ShardingSphere 集成 Spring 的实现方法可以直接导入到日常开发过程中。如果我们需要实现一个自定义的框架或工具类,从面向开发人员的角度讲,最好能与 Spring 等主流的开发框架进行集成,以便提供最低的学习和维护成本。与 Spring 框架的集成过程都有固定的开发步骤,我们按照今天课时中所介绍的内容,就可以模仿 ShardingSphere 中的做法自己实现这些步骤。
|
||||
|
||||
小结与预告
|
||||
|
||||
本课时是 ShardingSphere 源码解析的最后一部分内容,我们围绕如何集成 Spring 框架这一主题对 ShardingSphere 的具体实现方法做了展开。ShardingSphere 在这方面提供了一种可以直接进行参考的模版式的实现方法,包括基于命名空间的 Spring 集成以及基于 starter的Spring Boot 集成方法。
|
||||
|
||||
这里给你留一道思考题:在 ShardingSphere 集成 Spring Boot 时,SpringBootConfiguration 类上的注解有哪些,分别起到了什么作用?
|
||||
|
||||
讲完 ShardingSphere 源码解析部分内容之后,下一课时是整个课程的最后一讲,我们将对 ShardingSphere 进行总结,并对它的后续发展进行展望。
|
||||
|
||||
|
||||
|
||||
|
@ -0,0 +1,93 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
26 互联网产品 + 大数据产品 = 大数据平台
|
||||
从今天开始,我们进入专栏的“大数据平台与系统集成”模块。
|
||||
|
||||
前面我讲了各种大数据技术的原理与架构,大数据计算通过将可执行的代码分发到大规模的服务器集群上进行分布式计算,以处理大规模的数据,即所谓的移动计算比移动数据更划算。但是在分布式系统中分发执行代码并启动执行,这样的计算方式必然不会很快,即使在一个规模不太大的数据集上进行一次简单计算,MapReduce也可能需要几分钟,Spark快一点,也至少需要数秒的时间。
|
||||
|
||||
而互联网产品处理用户请求,需要毫秒级的响应,也就是说,要在1秒内完成计算,因此大数据计算必然不能实现这样的响应要求。但是互联网应用又需要使用大数据,实现统计分析、数据挖掘、关联推荐、用户画像等一系列功能。
|
||||
|
||||
那么如何才能弥补这互联网和大数据系统之间的差异呢?解决方案就是将面向用户的互联网产品和后台的大数据系统整合起来,也就是今天我要讲的构建一个大数据平台。
|
||||
|
||||
大数据平台,顾名思义就是整合网站应用和大数据系统之间的差异,将应用程序产生的数据导入到大数据系统,经过处理计算后再导出给应用程序使用。
|
||||
|
||||
下图是一个典型的互联网大数据平台的架构。
|
||||
|
||||
|
||||
|
||||
在这张架构图中,大数据平台里面向用户的在线业务处理组件用褐色标示出来,这部分是属于互联网在线应用的部分,其他蓝色的部分属于大数据相关组件,使用开源大数据产品或者自己开发相关大数据组件。
|
||||
|
||||
你可以看到,大数据平台由上到下,可分为三个部分:数据采集、数据处理、数据输出与展示。
|
||||
|
||||
数据采集
|
||||
|
||||
将应用程序产生的数据和日志等同步到大数据系统中,由于数据源不同,这里的数据同步系统实际上是多个相关系统的组合。数据库同步通常用Sqoop,日志同步可以选择Flume,打点采集的数据经过格式化转换后通过Kafka等消息队列进行传递。
|
||||
|
||||
不同的数据源产生的数据质量可能差别很大,数据库中的数据也许可以直接导入大数据系统就可以使用了,而日志和爬虫产生的数据就需要进行大量的清洗、转化处理才能有效使用。
|
||||
|
||||
数据处理
|
||||
|
||||
这部分是大数据存储与计算的核心,数据同步系统导入的数据存储在HDFS。MapReduce、Hive、Spark等计算任务读取HDFS上的数据进行计算,再将计算结果写入HDFS。
|
||||
|
||||
MapReduce、Hive、Spark等进行的计算处理被称作是离线计算,HDFS存储的数据被称为离线数据。在大数据系统上进行的离线计算通常针对(某一方面的)全体数据,比如针对历史上所有订单进行商品的关联性挖掘,这时候数据规模非常大,需要较长的运行时间,这类计算就是离线计算。
|
||||
|
||||
除了离线计算,还有一些场景,数据规模也比较大,但是要求处理的时间却比较短。比如淘宝要统计每秒产生的订单数,以便进行监控和宣传。这种场景被称为大数据流式计算,通常用Storm、Spark Steaming等流式大数据引擎来完成,可以在秒级甚至毫秒级时间内完成计算。
|
||||
|
||||
数据输出与展示
|
||||
|
||||
前面我说过,大数据计算产生的数据还是写入到HDFS中,但应用程序不可能到HDFS中读取数据,所以必须要将HDFS中的数据导出到数据库中。数据同步导出相对比较容易,计算产生的数据都比较规范,稍作处理就可以用Sqoop之类的系统导出到数据库。
|
||||
|
||||
这时,应用程序就可以直接访问数据库中的数据,实时展示给用户,比如展示给用户关联推荐的商品。淘宝卖家的量子魔方之类的产品,其数据都来自大数据计算产生。
|
||||
|
||||
除了给用户访问提供数据,大数据还需要给运营和决策层提供各种统计报告,这些数据也写入数据库,被相应的后台系统访问。很多运营和管理人员,每天一上班,就是登录后台数据系统,查看前一天的数据报表,看业务是否正常。如果数据正常甚至上升,就可以稍微轻松一点;如果数据下跌,焦躁而忙碌的一天马上就要开始了。
|
||||
|
||||
将上面三个部分整合起来的是任务调度管理系统,不同的数据何时开始同步,各种MapReduce、Spark任务如何合理调度才能使资源利用最合理、等待的时间又不至于太久,同时临时的重要任务还能够尽快执行,这些都需要任务调度管理系统来完成。
|
||||
|
||||
有时候,对分析师和工程师开放的作业提交、进度跟踪、数据查看等功能也集成在这个任务调度管理系统中。
|
||||
|
||||
简单的大数据平台任务调度管理系统其实就是一个类似Crontab的定时任务系统,按预设时间启动不同的大数据作业脚本。复杂的大数据平台任务调度还要考虑不同作业之间的依赖关系,根据依赖关系的DAG图进行作业调度,形成一种类似工作流的调度方式。
|
||||
|
||||
对于每个公司的大数据团队,最核心开发、维护的也就是这个系统,大数据平台上的其他系统一般都有成熟的开源软件可以选择,但是作业调度管理会涉及很多个性化的需求,通常需要团队自己开发。开源的大数据调度系统有Oozie,也可以在此基础进行扩展。
|
||||
|
||||
上面我讲的这种大数据平台架构也叫Lambda架构,是构建大数据平台的一种常规架构原型方案。Lambda架构原型请看下面的图。
|
||||
|
||||
|
||||
|
||||
1.数据(new data)同时写入到批处理大数据层(batch layer)和流处理大数据层(speed layer)。
|
||||
|
||||
2.批处理大数据层是数据主要存储与计算的地方,所有的数据最终都会存储到批处理大数据层,并在这里被定期计算处理。
|
||||
|
||||
3.批处理大数据层的计算结果输出到服务层(serving layer),供应用使用者查询访问。
|
||||
|
||||
4.由于批处理的计算速度比较慢,数据只能被定期处理计算(比如每天),因此延迟也比较长(只能查询到截止前一天的数据,即数据输出需要T+1)。所以对于实时性要求比较高的查询,会交给流处理大数据层(speed layer),在这里进行即时计算,快速得到结果。
|
||||
|
||||
5.流处理计算速度快,但是得到的只是最近一段时间的数据计算结果(比如当天的);批处理会有延迟,但是有全部的数据计算结果。所以查询访问会将批处理计算的结果和流处理计算的结果合并起来,作为最终的数据视图呈现。
|
||||
|
||||
小结
|
||||
|
||||
我们看下一个典型的互联网企业的数据流转。用户通过App等互联网产品使用企业提供的服务,这些请求实时不停地产生数据,由系统进行实时在线计算,并把结果数据实时返回用户,这个过程被称作在线业务处理,涉及的数据主要是用户自己一次请求产生和计算得到的数据。单个用户产生的数据规模非常小,通常内存中一个线程上下文就可以处理。但是大量用户并发同时请求系统,对系统而言产生的数据量就非常可观了,比如天猫“双十一”,开始的时候一分钟就有数千万用户同时访问天猫的系统。
|
||||
|
||||
在线数据完成和用户的交互后,会以数据库或日志的方式存储在系统的后端存储设备中,大量的用户日积月累产生的数据量非常庞大,同时这些数据中蕴藏着大量有价值的信息需要计算。但是我们没有办法直接在数据库以及磁盘日志中对这些数据进行计算,前面我们也一再讨论过大规模数据计算的挑战,所以需要将这些数据同步到大数据存储和计算系统中进行处理。
|
||||
|
||||
但是这些数据并不会立即被数据同步系统导入到大数据系统,而是需要隔一段时间再同步,通常是隔天,比如每天零点后开始同步昨天24小时在线产生的数据到大数据平台。因为数据已经距其产生间隔了一段时间,所以这些数据被称作离线数据。
|
||||
|
||||
离线数据被存储到HDFS,进一步由Spark、Hive这些离线大数据处理系统计算后,再写入到HDFS中,由数据同步系统同步到在线业务的数据库中,这样用户请求就可以实时使用这些由大数据平台计算得到的数据了。
|
||||
|
||||
离线计算可以处理的数据规模非常庞大,可以对全量历史数据进行计算,但是对于一些重要的数据,需要实时就能够进行查看和计算,而不是等一天,所以又会用到大数据流式计算,对于当天的数据实时进行计算,这样全量历史数据和实时数据就都被处理了。
|
||||
|
||||
我的专栏前面三个模块都是关于大数据产品的,但是在绝大多数情况下,我们都不需要自己开发大数据产品,我们仅仅需要用好这些大数据产品,也就是如何将大数据产品应用到自己的企业中,将大数据产品和企业当前的系统集成起来。
|
||||
|
||||
大数据平台听起来高大上,事实上它充当的是一个粘合剂的作用,将互联网线上产生的数据和大数据产品打通,它的主要组成就是数据导入、作业调度、数据导出三个部分,因此开发一个大数据平台的技术难度并不高。前面也有同学提问说,怎样可以转型做大数据相关业务,我觉得转型去做大数据平台开发也许是一个不错的机会。
|
||||
|
||||
思考题
|
||||
|
||||
如果你所在的公司安排你去领导开发公司的大数据平台,你该如何开展工作?建议从资源申请、团队组织、跨部门协调、架构设计、开发进度、推广实施多个维度思考。
|
||||
|
||||
欢迎你点击“请朋友读”,把今天的文章分享给好友。也欢迎你写下自己的思考或疑问,与我和其他同学一起讨论。
|
||||
|
||||
|
||||
|
||||
|
@ -0,0 +1,141 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
Q&A加餐丨关于代码质量,你关心的那些事儿
|
||||
专栏上线后,有一些同学对于代码质量有关的问题还不是很清楚,有很多疑问,所以我特意做了一期Q&A,来回答一下这些问题。
|
||||
|
||||
|
||||
有没有什么技巧可以不费力地查看源代码?
|
||||
———————–
|
||||
|
||||
|
||||
这是一个好问题。但遗憾的是,我们费力的程度,主要取决于代码的作者,而不是我们自己。我想了好久,也没有找到不费力气查看源代码的技巧。
|
||||
|
||||
通常我自己用的办法,有时候就像剥洋葱,从外朝里看;有时候也像挖井,找到地表的一小块地儿,朝下一直挖,直到我理解了代码的逻辑关系。
|
||||
|
||||
如果你刚开始接触,我建议你先不要看代码,先去看README,再去看用户指南。先把软件干什么、怎么用搞清楚。然后再去看开发者指南,搞清楚模块之间的关系、功能,理解代码中的示例。最后,再去看代码。
|
||||
|
||||
看代码的时候,找一个顺手的IDE。IDE丰富的检索功能,可以帮助我们找到一个方法,在什么地方定义的,有哪些地方使用了。
|
||||
|
||||
如果你还不知道要看哪一个源代码,先找一个例子开始。不管这个例子是开发指南里的,还是测试代码里的。先找出一个例子,把它读懂,然后阅读例子中调用的源代码。
|
||||
|
||||
比如,你要是看到示例代码调用了Collections.unmodifiableList()方法,如果想了解它,就查看它的规范文档或者源代码。从例子开始剥每一个你关心的方法,一层一层地深入下去。
|
||||
|
||||
OpenJDK的代码评审,很多时候代码量很大。代码评审的时候,很多文档还没有出来。我一般是分层看的。先看用户接口设计的这部分代码,这一部分的代码量一般比较少。看完用户接口的设计,才能明白作者的思路和目标。这样,自己就有了一个思路,对代码的方向有了一个大致的了解。然后再看接口实现的代码,看看实现和接口是不是吻合的。这个过程中,我一般会记录下类和方法之间的依赖关系,也会顺着依赖关系来理解代码的逻辑关系。
|
||||
|
||||
好的代码,有清晰的分割和层次,逻辑清晰,代码的行文一般也是简单直观,读起来比较容易。不好的代码,阅读起来就费力得多了。
|
||||
|
||||
|
||||
代码质量和工作效率的矛盾如何取舍?
|
||||
———————
|
||||
|
||||
|
||||
这个问题有一个隐含的假设,就是代码质量和工作效率不可兼得。这本身是个常见的误区。这个误区也给了我们一个看似成立的借口:要么牺牲代码质量,要么牺牲工作效率。
|
||||
|
||||
代码质量和工作效率,是不是矛盾的呢?这取决于我们观察的时间、地点以及维度,甚至我们是怎么定义效率的。
|
||||
|
||||
如果给我们一个小时的时间,看看谁写的代码多。不顾及代码质量的也许会胜出(只是也许,我们后面再说为什么只是也许);认真设计、认真对待每一行代码的也许会败北(也只是也许)。
|
||||
|
||||
短期内代码写得多与否,我们可以把这个比喻成“走得慢,还是走得快”的问题。
|
||||
|
||||
如果给我们半年的时间,那些质量差的代码,编写效率也许可以和质量好的代码保持在同一水准,特别是软件还没有见到用户的时候。
|
||||
|
||||
如果给我们一年的时间,软件已经见到了用户,那么质量差的代码的编写效率,应该大幅度落后于优质代码了。甚至生产这些代码的团队,都被市场无情淘汰了。
|
||||
|
||||
看谁的代码能够长期赢得竞争,我们可以把这个比喻成“到得慢,还是到得快”问题。
|
||||
|
||||
为什么会这样呢? 一小时内,什么都不管,什么都不顾,怎么能不多产呢!
|
||||
|
||||
可是,不管不顾,并不意味真的可以高枕无忧。需求满足不了就会返工,程序出了问题也会返工,测试通不过还会返工······每一次的返工,都要你重新阅读代码,梳理逻辑,修改代码。
|
||||
|
||||
有很多时候,你会发现,这代码真是垃圾,没法改了,只有推倒重来。
|
||||
|
||||
这个时候再回过头看看这种代码编写的背景,你能说这是一种高效率的行为吗?
|
||||
|
||||
这就相当于,一个马拉松比赛,前1000米你在前头,后来你就要往回跑。1000米这个槛,有人跑一次就够了,你要是跑七八次,还谈什么效率呢。这种绝望的事情看似荒唐,其实每天都会发生。
|
||||
|
||||
为什么会这样呢? 因为在软件开发的过程中,遗留的问题需要弥补,这就类似于往回跑。所以,走得快,不一定到得快。
|
||||
|
||||
你不妨记录一下三个月以来,你的工作时间,看看有多少时间是花在了修修补补上,有多少时间是花在了新的用户需求上。这样,对这个问题可能有不一样的感受。
|
||||
|
||||
另外,是不是关注代码质量,就一定走得慢呢?
|
||||
|
||||
其实也不是这样的。比如说,如果一个定义清晰,承载功能单一的接口,我们就容易理解,编码思路也清晰,写代码就又快又好。可是,简单直观的接口怎么来?我们需要花费大量的时间,去设计接口,才能获得这样的效果。
|
||||
|
||||
为什么有的人一天可以写几千行代码,有的人一天就只能写几十行代码呢?这背后最重要的一个区别就是心里有没有谱,思路是不是清晰。几千行的代码质量就比几十行的差吗? 也不一定。
|
||||
|
||||
你有没有遇到这样的例子,一个同学软件已经实现一半了,写了十万行代码。另一个熊孩子还在吭哧吭哧地设计接口,各种画图。当这个同学写了十五万行代码的时候,完成一大半工作的时候,那个熊孩子已经五万行代码搞定了所有的事情。你想想,效率到底该怎么定义呢?
|
||||
|
||||
那个熊孩子是不是没有能力写二十万行代码呢?不是的,只要他愿意,他也许可以写得更快。只是,既然目标实现了,为什么不去聊聊天,喝喝咖啡呢?搞这么多代码干啥!你想想,效率能用代码数量定义吗?
|
||||
|
||||
就单个的程序员而言,代码质量其实是一个意识和技能的问题。当我们有了相关的意识和技能以后,编写高质量的代码甚至还会节省时间。如果我们没有代码质量的意识,就很难积累相关的技能,编写代码就是一件苦差事,修修补补累死人。
|
||||
|
||||
有很多公司不愿意做代码评审,效率也是其中一个重要的考量。大家太注重一小时内的效率,而不太关切一年内的效率。如果我们将目光放得更长远,就会发现很多不一样的东西。
|
||||
|
||||
比如说代码评审,就可以减少错误,减少往回跑的频率,从而节省时间。代码评审也可以帮助年轻的程序员快速地成长,降低团队出错的机率,提高团队的效率。
|
||||
|
||||
有一些公司,定了编写规范,定了安全规范,定了很多规范,就是执行不下去,为什么呢? 没有人愿意记住那么多生硬的规范,这个时候,代码评审就是一个很好的方法,有很多眼睛看着代码,有反馈,有讨论,有争议,有建议,团队能够更快地形成共识,找出问题,形成习惯,共同进步。看似慢,其实快。
|
||||
|
||||
英文里,有一句经典的话 “Run slowly, and you will get there faster”。汉语的说法更简洁,“因为慢,所以快”。
|
||||
|
||||
一般情况下,通常意义上的软件开发,如果我们从产品的角度看,我认为高质量的代码,会提升工作的效率,而不是降低工作效率。
|
||||
|
||||
当然,也有特殊情况。比如我们对质量有着偏执般的追求,这时候,效率就不是我们的首选项。也有情况需要我们在五秒以内眨眼之间就给出代码,这时候,质量也不是我们的首选项。
|
||||
|
||||
代码的质量该怎么取舍呢?这取决于具体的环境,和你的真实目标。
|
||||
|
||||
|
||||
你加入了Java SE团队,经历了从JDK 1.5.0到JDK 12的整个迭代过程,这个阶段中,Java开发的流程都经历了哪些迭代?
|
||||
———————————————————————-
|
||||
|
||||
|
||||
在十多年间,Java开发的流程有很多大大小小的调整。影响最大的,我觉得主要有三个。
|
||||
|
||||
第一个变化是更加开放了。Java开源以后,不仅仅是把代码开放出来,开发流程也开放了出来。OpenJDK有详细的开发人员手册,告诉大家怎么参与到OpenJDK社区中来。
|
||||
|
||||
OpenJDK开放了Java改进的流程,这就是JEP(JDK Enhancement-Proposal & Roadmap Process)。每一个Java的改进,从雏形开始,一直到改进完成,都要经过OpenJDK社区的讨论、评审。什么都要经过OpenJDK讨论,这效率不是变慢了吗?其实,这种开放反而加快了Java的演进。
|
||||
|
||||
创新性的想法第一时间就送到用户面前,接受用户的审视。
|
||||
|
||||
一个项目是好还是坏?做还是不做?该怎么做?这都在用户可以“挑剔”的范围内。Java的演进,也从少数的专家委员会模式,变更为小步快走的大集市模式。
|
||||
|
||||
OpenJDK也开放了Java代码评审的流程。现在,几乎所有的变更,都是通过OpenJDK进行的。为什么要变更?变更的是什么?变更带来的影响有哪些,都要描述得清清楚楚。而且,任何人都可参与评审,都可以发表意见。如果有兼容性的影响,用户会在第一时间发现,而不是等到系统出了问题再来修补。透明化带来的好处就是,有更多的眼睛盯着Java的变更,代码的质量会更好,潜在的问题也会更少。
|
||||
|
||||
第二个变化是研发节奏更快了。Java的版本演进,从传统的好几年一个版本,变更为半年一个版本。两三年一个版本的演进模式,使得Java的任何改进,都要两三年以后见。即使这些改进已经成熟了,也得在代码库里躺着,到不了用户的场景里。没有用户反馈,产品的质量也就没有经过真实的检验了,没有改进的真实建议。这其实是一种浪费,效率会变低。
|
||||
|
||||
第三个变化是自动化程度提高了。现在,OpenJDK提交的每一个变更,都会自动运行很多的测试。如果这个变更引起了测试错误,测试系统会给参与者发邮件,指明出错的测试,以及潜在的怀疑对象。变更提交前,我们也可以发出指令,运行这些测试。这些自动化的测试,可以提高代码的质量,减轻工程师的压力,提高工作的效率。
|
||||
|
||||
|
||||
您是JDK 11 TLS 1.3项目的leader,在这个项目中,你对代码安全又是怎么理解的呢?
|
||||
—————————————————-
|
||||
|
||||
|
||||
代码的安全,我一直以为是一个见识问题。一个安全问题,你见识到了,认识到了,你就会在代码里解决掉。没有认识到安全问题,可能想破脑袋,也不知道问题出在哪。
|
||||
|
||||
比如说,TLS 1.3废弃掉了密码学的很多经典算法,包括RSA密钥交换、链式加密算法等。如果去你去查看经典的密码学教材,你会发现这些算法都被看做牢不可破的算法,全世界的每一粒沙子都变成CPU,也破解不了它们。
|
||||
|
||||
可是,站在2019年再来看这些算法,各有各的问题,有的破解也就是几分钟的事情。那我们还应该使用这些算法吗?当然要想办法升级。可现实是,你根本不知道这些算法已经有问题了。当然,也想不到去升级使用这些算法的应用程序。这就是我们说的见识。知道了,你才能想到去解决。
|
||||
|
||||
见识是一个坏东西,需要我们看得多、见得多,才能够拥有。甚至,需要付出代价,比如遭受到黑客攻击。
|
||||
|
||||
见识也是一个好东西,见得越多,看得越多,你构筑起来的竞争优势就越明显。随着阅历的增长,见识也会增强,竞争力就提高了。
|
||||
|
||||
如果一个东西,每个人三秒就可以掌握,那当然是好的。但同时,它就算不上你的优势了。即使有优势,也只是三秒钟的差距。
|
||||
|
||||
另一个常见的问题,就是认为安全的代码牺牲效率。
|
||||
|
||||
编写安全的代码,会不会牺牲工作的效率呢?一般情况下,这对效率的影响几乎可以忽略不计。 比如说,一个公开接口,我们不应该信任用户输入的数据,应该检查用户输入数据的边界和有效性。做这样的检查,增加不了多少代码量,反而会让我们的思路变得清晰。再编写具体的业务逻辑的时候,编码的效率就变高了,甚至还会减少代码量。
|
||||
|
||||
就拿TLS 1.3来说,当废弃掉一些经典的算法时,一幅全新的画面就出现在我们面前。TLS协议的设计更加简单,更有效,效率也会翻倍地提升。
|
||||
|
||||
代码质量、工作效率、软件性能、代码安全,这些东西可以作为基准,但是不适用拿来对比。如果非要单纯地从概念上对比,看看有没有冲突,没有一点儿现实意义。安全的代码会牺牲软件性能吗? 不一定。重视代码质量,就会牺牲工作效率吗?也不一定。
|
||||
|
||||
今天挑了几个同学的问题来回答。其实关注代码质量这种事情,就像爬山一样,每个人都会,但不是所有人都能爬到最后。会当凌绝顶,一览众山小。当自己在山峰上爬得越来越高的时候,再回过头,你会发现自己和身边的人已经不一样了。
|
||||
|
||||
如果你觉得这篇文章对你有所启发,欢迎你点击“请朋友读”,把它分享给你的朋友或者同事。
|
||||
|
||||
|
||||
|
||||
|
@ -0,0 +1,173 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
02 万丈高楼平地起- 物理层 + 数据链路层
|
||||
物理层
|
||||
|
||||
本来是想要一层层的来介绍。但是物理层确实没有太多你需要知道的内容。你可以理解为就是物理连接,(如果面试问你网线怎么做的话,转身就跑,这是要让你去干苦力呀)。还好TCP/IP模型也为我开了这个后门,把这两层放到了一起叫做数据链路层,所以我就可以冠冕堂皇的说我是按照TCP/IP来讲解的。
|
||||
|
||||
即使没有太多要讲的,还是要讲,存在即合理。说明物理层肯定是做了一些有作用的工作(只是作为工程师的你没有必要知道这些细节)。不然也就没有存在的必要了对不对,我们来试想一下,每一层是不是都有它的作用,物理层也必然如此。它的主要功能是什么呢?有以下几点。
|
||||
|
||||
|
||||
硬件规格的定义:你能随便拿一根线就插在你的电脑上和网络连接吗?电脑和电脑之间连接用什么线呢?这些是不是都需要有规定,你能想到的常见硬件设备有什么?电缆,连接器,无线电收发器,网络接口卡和其他硬件设备的操作详细信息通常是物理层的功能。
|
||||
编码和信号处理。其实我的本科主要学的就是信号处理(何为处理,就是数学计算,我那个时候高等数学玩的贼溜,甚至选了一门课就是去证明高等数学里面教的这些公式是不是对的。郭德纲说过,雅到极致就没饭吃了,这个东西你算的再好,不是做研究你觉得有必要吗?我当年要不就是读博士,要不就是读个硕士就工作了(别问我为什么不是本科就工作,因为我有追求呀,其实是本科毕业的我只会算高等数学)。其实我读博的话是去研究5G,但是我太俗气了,想赶紧工作。哈哈,有点扯远了,我就是为了告诉你们,这个东西知道就好了,你如果不是博士毕业的话,或者是做硬件的信号处理,这辈子基本不太会碰到这玩意。
|
||||
数据收发,数据说白了是什么,就是一个个的信号。主要作用是传输比特流,(比特流就是0,1转化为电流,然后到了目的地时候在转换回来,但是错了怎么办呢,不好意思,物理层解决不了,它只能传输)
|
||||
拓扑和物理网络设计:物理层也被认为是许多与硬件相关的网络设计问题的领域,例如LAN和WAN拓扑。
|
||||
|
||||
|
||||
既然是物理层,就会涉及到实体,比如说双绞线呀,铜线呀,光纤呀,肯定不同的设备和材料传输的速度就是不同,但是这个专栏是针对程序员的,不是针对纯网络工程师,在这里就不多加赘述了。
|
||||
|
||||
数据链路层
|
||||
|
||||
我们上面提到的数据收发,只能传输,那出错了怎么办?谁来处理?正是因为可能出现的传输错误,数据接收者可能需要调节数据到达的速率, 处理同步以及接口交接的问题。所以需要一层来处理比如错误检测,流量控制,错误控制等,而这一层也就是我们要讲的数据链路层。
|
||||
|
||||
链路层还包括两小层(逻辑连接控制层(LLC)和媒体访问控制层也就是我们常说的MAC)。
|
||||
|
||||
|
||||
|
||||
MAC层在下面,我们先说。
|
||||
|
||||
媒体访问控制(MAC)子层提供用于访问传输介质的控制。它负责通过共享的传输介质将数据包从一个网络接口卡(NIC)移动到另一个。物理寻址在MAC子层进行。 MAC也在这一层进行处理。这是指用于为计算机分配网络访问权限并防止它们同时传输从而导致数据冲突的方法。常见的MAC方法包括以太网网络使用的载波侦听多路访问/冲突检测(CSMA / CD),AppleTalk网络使用的载波侦听多路访问/冲突避免(CSMA / CA)和令牌环和光纤使用的令牌传递分布式数据接口(FDDI)网络。这里就不展开讲解了。
|
||||
|
||||
然后在向上就是逻辑连接控制层。如IEEE-802 LAN规范所述,LLC子层的作用是控制各种应用程序和服务之间的数据流,并提供确认和错误通知机制。然后,LLC子层可以与许多IEEE 802 MAC子层进行对话。
|
||||
|
||||
下面我们来看一下这些功能
|
||||
|
||||
Flow Control(流量控制)
|
||||
|
||||
Flow Control是为了确保数据流的传输不会使接收方不堪重负。接收方通常会分配一些最大传输长度的数据缓冲区,当数据收到了,接收方必须要在传给上一层之前对一定的数据进行处理。 我们先来看一下正常的传输和有错误的是什么样子的。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
那Flow Control是怎么来保证他的工作的?有什么算法来解决这些问题呢?
|
||||
|
||||
首先第一个叫做 Stop-and-Wait Flow Control(这些简单的英文我就不翻译了).
|
||||
|
||||
这是一个最简单的算法。从起点发送一个Frame(帧)到终点,如果收到了就返回一个Ack。起点会等待收到上一个Frame的ack才会去发送下一个Frame。当然如果这个Frame比较大的话,一般会分成多个小Frame。仔细想一下,你能想到是什么原因吗?
|
||||
|
||||
|
||||
缓冲的大小是有限制的。
|
||||
越大的文件越容易出错,越小的文件可以越早发现错误,当然也更容易重新传送。
|
||||
还有一个就是在共用的媒介上,比如LAN, 你如果发送一个越大的文件,是不是占用的资源时间就越长,你好意思自己霸占着不放吗,当然会有一个限时,时间太长,就会被踢走。那是不是不管重试多少次都没用。
|
||||
|
||||
|
||||
第二个算法是Sliding-Window Flow Control.
|
||||
|
||||
第一种算法一次只能传送一个Frame,很明显效率很低,是不是一次性多传送几个会快很多呢,就好像多线程一样(记住知识都是相通的)。
|
||||
|
||||
|
||||
|
||||
好像上图所示,比如一次性发送4个作为一个window。收到了两个,可以ack两个,然后起点再发两个,始终保持一个窗口有四个Frame在发送中。这个算法我们后面还会用到,而且面试中也会考Sliding Window的算法题(感兴趣的同学自己试一下)。
|
||||
|
||||
Error- Control(错误控制)
|
||||
|
||||
我们当然希望一切都是那么的美好,可以按照设定好的来走,但是发生错误是再正常不过的事情了。那错误发生了应该怎么办?重新发送就好了,就好像你点外卖,美团小哥给你弄撒了,那咋办,从做一份然后再送给你不就好了。这种方法叫做ARQ(automatic repeat request) 自动重发。
|
||||
|
||||
Stop-and-wait ARQ
|
||||
|
||||
这个就是基于Stop-and-wait Flow Control的算法。起点直到收到ack才会发下一个Frame. 当然终点收到Frame的时候会去检测出没出错误。通常会有两种错误。一种是到达终点的Frame已经损坏了。接收方通过某些方法知道出错了,别问我怎么知道,我就是知道。还是比如你点外卖,我收到外卖后不和外卖小哥交流。也不告诉外卖小哥外卖坏了,我就是傲娇,我直接把外卖扔了。那卖家怎么知道出错了呢。他有一个时间表,当到时间了,发现还没有买家留评价说收到,那就意识到出现问题了,卖家会重新发送一份外卖。
|
||||
|
||||
那第二种的错误可能是什么,仔细想一下。买家收到了外卖,也完好无损,但是当留评价的时候,美团当机了,或者买家自己的网断了,发不出去评价了。那卖家那边的timer到时间了,没有收到评价,卖家怎么办,它就要再发一份,那买家收到第二份之后怎么告诉卖家呢,他会留评价叫做ack0和ack1. 当ack1收到的时候,是不是就可以发送下一份外卖了。(这种情况只有Sliding-window flow control需要,自己想一下为什么)。 当然这种算法最大的优点就是简单,缺点是什么,还是没有效率。
|
||||
|
||||
Go-Back-N ARQ
|
||||
|
||||
这是基于滑动窗口流量控制的最常用的算法。当没有错误的时候,接收方会回应RR=receive ready。如果接收方发现Frame里面有错误比如FrameA,会回复一个消极的ack。也就是REJ=reject。 接收方会扔掉这个坏掉的Frame以及在那之后所有的Frame直到收到正确的FrameA。所以发送方,当他收到一个REJ的时候,必须要立即重新发送FrameA。
|
||||
|
||||
根据我们第一种算法,我们知道会有两种情况,一种是Damaged Frame(出错的帧),另一种是出错的RR。试想一下这个场景Frame (i - 1) 以及之前的所有Frame都没有问题,现在开始传送Frame i了,但是发生了错误。甚至这个错误可能导致接收方B 都没有感觉自己收到了这个Frame。
|
||||
|
||||
|
||||
对于Damaged Frame a. 如果没有超时的问题,接收方首先收到了Frame i+1,发送回REJ给发送方,发送方必须要重新发送i以及所有i之后的Frame。
|
||||
|
||||
|
||||
b. 超时发生了,那就是B什么都没有收到,所以既没有发送RR也没有发送REJ. 那双方僵持的时间长了,是不是就会发生超时,这时候发送方就像暖男一样,发送一个诚意满满的“道歉包”RR (包括玫瑰花也就是P bit 也就是1). B也不是小气的人,对吧,会回复一个RR 暗示A(我们和好了)你可以发送Frame i了。当A收到了,就会重新发送Frame i了。
|
||||
|
||||
|
||||
对于Damaged RR
|
||||
|
||||
|
||||
a. B 收到了Frame i,发送回RR(i + 1), 但是发生了错误(比如RR 4的话,说明所有的帧直到4都已经收到了),A很有可能会收到下一个不同的RR,可能是RR 5 可能是RR1,然后根据不同的RR来进行不同的处理 b. 如果A的timer超时了。效仿a2的情况。
|
||||
|
||||
|
||||
对于Damaged REJ, 如果REJ丢了,情况和a2相同。
|
||||
|
||||
|
||||
Seletive-Reject ARQ
|
||||
|
||||
这种算法中,唯一需要重新传的Frame是收到了一个SREJ的回复或者是超时,这种算法比上一种要更高效,因为减少了需要重传的量。具体可以看一下下面的图。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
希望到这里,你没有看睡着呀,细心的朋友是不是发现我漏了什么,我讲了发生了错误怎么办?但是却没有讲怎么发现错误,对不对?哈哈,好,那让我们来看一下错误是怎么被发现的。首先我们要知道什么是错误,如果没有一个标准来定义对与错,那怎么去找呢。我们知道物理层传输的是比特也就是0和1,那么我要传送0,但是你收到了1,或者相反传送1,你收到了0,这就是错误。
|
||||
|
||||
常见的错误检测方式分为
|
||||
|
||||
|
||||
奇偶校验(Parity Check): 偶校验用于同步传输,奇校验用于异步传输
|
||||
循环冗余校验(Cyclic Redundancy check):这是最常用也是最有效的方式,利用的是XOR,也就是异或运算。这里我先卖个关子,在第二大章的二进制计算里面,会帮你们彻底弄明白这个运算。(算法面试也会考到哦)
|
||||
|
||||
|
||||
Frame 帧
|
||||
|
||||
我们这一章从头到尾都在使用一个名字那就是Frame(帧),你肯定会想知道Frame的格式是什么。
|
||||
|
||||
记住一点(这很重要),你试想一下,在网络中传输的数据都是0和1,那怎么来区分哪个是哪个呢?这就是每一个packet或者说Frame,或者说IP等等都会有的一个东东,那就是header(头),这些header里包含着我是谁,我要去哪等等重要的信息。
|
||||
|
||||
|
||||
|
||||
Flag Filed
|
||||
|
||||
好我们来看一下Frame的头。从图上可以看到左右头和尾都有一个Flag的区域,这个Flag使用的是01111110来作为唯一的模式。在用户网络接口的两侧,接收器不断寻找Flag的序列来进行帧的同步。在接收帧的同时,会继续搜寻该序列以确定帧的结尾(这个结尾就是上面提到的那个标识01111110)。因为该协议允许存在任意位模式,你不能保证01111110这个模式不会被用到别的地方,也就是说在Information区域,可能会出现01111110的信息。(这里害怕你迷糊,来多说两句,这个01111110没有什么特殊的,只是选它作为标识,你可以选择00111100作为标识或者是二狗子作为标识。这只是一个定义,所以你不能限制这个标识不出现在其他的地方)。但问题是,如果这个01111110出现在别的地方,是不是就破坏了帧的同步,因为接收方只知道寻找01111110作为头和尾。那这个问题怎么解决呢?这里使用的解决方法叫做bit suffing
|
||||
|
||||
bit suffing 比特填充的首尾标志法
|
||||
|
||||
|
||||
|
||||
对于开始标志和结束标志之间的所有位,发送器在帧中每出现5个1后插入一个额外的0位。当出现五个1的模式时,第六位被检查。如果该位为0,则将其删除。如果第六位和第七位均为1,则发送方指示中止条件。通过使用位修饰,可以将任意位模式插入帧的数据字段。此属性称为数据透明属性。看一下下面这个例子。
|
||||
|
||||
Address Filed 地址字段标识
|
||||
|
||||
|
||||
|
||||
好看完了Flag位,下面是Address位。地址字段标识作为已发送或将要接收帧的辅助位。点对点链接不需要此字段,但为了统一起见,始终包含此字段。地址字段通常为8位长。但可以使用扩展格式(如图),其中实际地址长度为7位的倍数。每个八位位组的最左位是1或0,这取决于它是否是地址字段的最后一个八位位组。每个八位位组的其余7位构成地址的一部分。 1111111的单字节地址被解释为基本格式和扩展格式的全站地址。它用于允许主要节点广播帧以供所有辅助节点接收。
|
||||
|
||||
Control Filed 控制位标识
|
||||
|
||||
HDLC(High-level Data Link Control)定义了三种类型的Frame。每一种Frame都有自己独有的控制位标识。
|
||||
|
||||
|
||||
信息帧 (Information Frames):携带要为用户传输的数据。另外,流和错误控制数据(根据arq机制)被附加在信息帧上。
|
||||
|
||||
管理帧 (Supervisory Frames) : 不使用搭载时提供arq机制。
|
||||
|
||||
无编号帧 (Unnumbered Frames):提供补充的链接控制功能。
|
||||
|
||||
|
||||
|
||||
|
||||
Inforamtion Field 消息标识
|
||||
|
||||
这个消息标识只在信息帧和一些无编号帧上存在。该字段可以包含任何位序列,但必须包含整数个八位位组。信息字段的长度是可变的,可以大到系统的最大值。
|
||||
|
||||
Frame Check Sequence Field 帧检查序列字段
|
||||
|
||||
帧检查序列字段是从帧的其余位(不包括标志)计算出的错误检测代码。
|
||||
|
||||
总结一下。数据链路层的主要功能就是以下几点。
|
||||
|
||||
|
||||
处理比特传输发生的错误。
|
||||
它确保数据流的传输速度不会使发送和接收设备不堪重负。
|
||||
它允许将数据传输到网络层的第3层,并在其中进行寻址和路由。
|
||||
|
||||
|
||||
我个人是希望你们可以把这些原理理解的很透彻,但是如果不能,也不用强求,面试的时候,可以和面试官把几种算法讲出来,哪种好,好在哪里就可以了,因为你的面试官也不一定会。好,我们下一节再见。
|
||||
|
||||
|
||||
|
||||
|
@ -0,0 +1,202 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
07 1+1 = 2吗? - 二进制的计算
|
||||
Hello, 大家好,希望上一节课的基础,大家都已经掌握了。那我们就来继续这一小节的学习,来看一下1+1=2这一道永远的难题。
|
||||
|
||||
二进制的运算 - 加法和减法
|
||||
|
||||
加法
|
||||
|
||||
加法从基础来说,和普通数字的加法也没有什么太大的区别。加法都是从右向左,一次一个数。在每一个点上,都会生成一个和的数字以及一个要进位的数字。当然我们这里不是每到9才产生一个进位,而是每到2就会产生一个进位。那我们现在来做一道小小的加法11 + 3 = ?你肯定知道是14吧。哈哈。那让我们来看一下在二进制里是怎么做呢?
|
||||
|
||||
11 的二进制表达式 -> 01011
|
||||
3 的二进制表达式 -> 00011
|
||||
相加的结果 -> 01110
|
||||
|
||||
复制
|
||||
|
||||
|
||||
01110 = 什么呢? 1 * 8 + 1 * 4 + 1 * 2 = 14。我们来验证一下上面的加法,从右向左,1+1是不是要进位所以最右是一个0,然后第二位有三个1,那就留下一个1,在进位1个1,就成为了10,然后1+0等于1 - > 110,之后又是一个1 + 0 = 1,所以结果就是1110了,是不是很简单。
|
||||
|
||||
|
||||
|
||||
减法
|
||||
|
||||
下面我们再来看一下减法,减法实际上就是加法的变种,只不过就是A + (-B)。好,我们来看一下这个例子14 - 9。在这里我们要插播一下补码和反码的小知识(要不然你完全无法理解-9是怎么用二进制来表示)。
|
||||
|
||||
小知识课堂
|
||||
|
||||
|
||||
|
||||
原码
|
||||
|
||||
什么叫原码?可能提到原码你能想到的是底层源码,比如java的源码是什么?spring的源码是什么?但是因为我们中文的博大精深,所以会造成这个误解,但是你看字的话,很明显有不同是不是。那这里的原码是指什么呢?话语千遍不如一个实例。
|
||||
|
||||
对于正数来说,原码就是自己,比如
|
||||
我们来用9来作为实例。00000000 00000000 00000000 00001001是9的原码
|
||||
那-9的原码呢?其实就是在最高位加一个1。
|
||||
这里1表示负数,0表示正数 100000000 00000000 00000000 00001001是-9的原码
|
||||
|
||||
复制
|
||||
|
||||
|
||||
这里的最高位是你自己来选择是作为数值还是作为符号,比如一个byte类型的话,有8个字节。0000 0000,如果不使用符号位的话,数值就是从0到255,0就是0000 0000,255就是1111 1111。如果使用符号位的话范围就是-127 到 127。1111 1111 因为第一位是符号位,所以表示负数,然后后面的7个1表示127,所以值是-127。然后0111 1111也就是正数的最大位,等于127。所以范围就是-127到127。
|
||||
|
||||
我相信聪明的你这时候会有一个疑问,为什么负数不能直接用原码,而有什么之后要讨论的反码,补码?
|
||||
|
||||
那是因为原码有它的弱点
|
||||
|
||||
|
||||
首先0是两个,那就是会有两个0,也就是+0和-0(00000000和10000000)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
当要进行异号相加或同号相减时,方法比较笨拙- 先要判断2个数的绝对值大小,然后进行加减操作,最后运算结果的符号还要与大的符号相同。于是,反码产生了。(每一个概念的出现都是为了解决一个问题,对不对)
|
||||
|
||||
|
||||
反码
|
||||
|
||||
反码也是属于数值存储的一种,多应用于系统环境设置,如linux平台的目录和文件的默认权限的设置umask,就是使用反码原理。在计算机内,定点数有3种表示法:原码、反码和补码。
|
||||
|
||||
对于正数来说,反码与原码相同(正数是多么善良和正义的存在)。
|
||||
负数的反码是对该数的原码各位取反(符号位除外)。比如
|
||||
100000000 00000000 00000000 00001001 的反码
|
||||
111111111 11111111 11111111 11110110
|
||||
|
||||
复制
|
||||
|
||||
|
||||
因为反码 还是有+0和-0这个问题。但是不修改的话,就会被时代所淘汰,反码就成为了过滤产物,也就是, 后来补码出现了。
|
||||
|
||||
补码
|
||||
|
||||
首先是谁也不怎么认真读的概念:补码表示统一的符号位和数值位,使得符号位可以和数值位一起直接参与运算,这也为后面设计乘法器除法器等运算器件提供了极大的方便。
|
||||
|
||||
对于正数来说,补码与原码相同(正数才是正道的光)。
|
||||
负数的补码是对该数的原码各位取反(符号位除外)。然后在最后一位加1。比如
|
||||
100000000 00000000 00000000 00001001 的补码加一之前是
|
||||
111111111 11111111 11111111 11110110 然后再加上一
|
||||
111111111 11111111 11111111 11110111
|
||||
|
||||
复制
|
||||
|
||||
|
||||
好,现在让我们再回归到上面讲的那个减法。我们的例子是14 - 9。好,现在让我们来分析以下。
|
||||
|
||||
14是一个正数,所以基本上不太会有任何的trick。
|
||||
00000000 00001110
|
||||
下面让我们来分析一下-9。
|
||||
首先是9 -> 00000000 00001001
|
||||
-9 10000000 00001001
|
||||
反码 11111111 11110110
|
||||
补码 11111111 11110111
|
||||
+00000000 00001110 (这个是之前的14)
|
||||
00000000 00000101 (这个是最后的结果)。不用我说,你也能算出来结果是5吧。
|
||||
|
||||
复制
|
||||
|
||||
|
||||
所以我们做减法的顺序是
|
||||
|
||||
|
||||
把要减的数的正数算出来
|
||||
把第一个最高位变成符号位1
|
||||
把这个数的反码写出来
|
||||
把这个数的补码写出来
|
||||
把这个补码和之前的正数进行相加。
|
||||
最后的结果就是相减的结果
|
||||
|
||||
|
||||
希望你读到这里,头还没有晕
|
||||
|
||||
|
||||
|
||||
二进制的运算 - 逻辑计算
|
||||
|
||||
AND运算
|
||||
|
||||
AND是二进制的逻辑运算法,这意味着它需要两个输入的数值。也可以认为AND需要两个来源。它的运算很简单。基本的原则是
|
||||
|
||||
A B AND
|
||||
0 0 0
|
||||
0 1 0
|
||||
1 0 0
|
||||
1 1 1
|
||||
|
||||
复制
|
||||
|
||||
|
||||
从上图你可以看出,只有A和B同时都是1的时候,AND的结果才是1。你可以把它想象成一个串行电路。一前一后两个电路。只有两个电路同时通的时候,整体才会通电,有任何一个不能通电的话,那就不成功。虽然很简单还是给你们一个例子吧。我要做一个严谨的人。
|
||||
|
||||
00111010 01101001 A
|
||||
01011001 00100001 B
|
||||
00011000 00100001 And之后的结果
|
||||
|
||||
复制
|
||||
|
||||
|
||||
OR运算
|
||||
|
||||
OR是二进制的另一个重要的逻辑运算法,它的需求和AND一样需要两个输入的数值。它的运算也很简单。基本的原则是
|
||||
|
||||
A B OR
|
||||
0 0 0
|
||||
0 1 1
|
||||
1 0 1
|
||||
1 1 1
|
||||
|
||||
复制
|
||||
|
||||
|
||||
从上图你可以看出,只要A和B任意一个是1的时候,OR的结果就是是1。你可以把它想象成一个并行电路。上下两个电路。只要有一个电路通的时候,整体就会通电,只有两个都不通电的时候,才会不成功。同样是一个小例子
|
||||
|
||||
00111010 01101001 A
|
||||
01011001 00100001 B
|
||||
01111011 01101001 OR之后的结果
|
||||
|
||||
复制
|
||||
|
||||
|
||||
NOT运算
|
||||
|
||||
OR是二进制的另一个重要的逻辑运算法,它只需要一个输入就可
|
||||
|
||||
A NOT
|
||||
0 1
|
||||
1 0
|
||||
|
||||
复制
|
||||
|
||||
|
||||
Exclusive-OR运算
|
||||
|
||||
Exclusive-OR通常也简写成XOR。当然它也是一个逻辑运算符。同样需要两个输入。当然它的结果可能是有一点和你平时的计算不同。只有当两个输入不同的时候才会是1。相同的话就会是0。
|
||||
|
||||
A B XOR
|
||||
0 0 0
|
||||
0 1 1
|
||||
1 0 1
|
||||
1 1 0
|
||||
|
||||
复制
|
||||
|
||||
|
||||
这几种逻辑运算符中最最复杂的可能就是这个。我们还是看一个小案例
|
||||
|
||||
00111010 01101001 A
|
||||
01011001 00100001 B
|
||||
01100011 01001000 XOR之后的结果
|
||||
|
||||
复制
|
||||
|
||||
|
||||
其实这一小节还是属于基础,只不过不是二进制的基础,而是二进制计算的基础。我们讲了原码,反码和补码,加法减法以及逻辑运算。小朋友,你是不是有很多问号?你的小脑瓜一定在想这些有什么用?我可以负责任的告诉你。IP的计算会用到。电路的计算会用到。因为二进制归根到底就是电流。那个就有点扯远了。还有一个最重要的就是算法的考试和面试也会问到。而往往二进制的算法题不长,但是不练习或者不知道基础的你往往想不到。我们下一节来看一下16进制,然后我会给你们举一些面试的时候会问到问题。
|
||||
|
||||
|
||||
|
||||
|
@ -0,0 +1,46 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
13 分布式事务考点梳理 + 高频面试题
|
||||
本课时我将和你一起梳理一下面试中分布式事务的高频考点,做到温故知新。
|
||||
|
||||
如何考察分布式事务
|
||||
|
||||
数据一致性和分布式事务是互联网分布式系统设计中必须要考虑的,所以对分布式事务的考察是中高级工程师面试必须跨过的一道门槛。
|
||||
|
||||
面试官通常会通过一个实际的系统设计题来展开提问,以考察候选人对分布式基础理论的理解、对各种数据一致性模型的掌握,以及对分布式下事务实现的原理、机制和各种实现手段的熟悉程度。
|
||||
|
||||
下面我模拟一个实际的面试场景,面试官可能会对你提出以下一连串的问题,你可以检测一下自己在学习中的掌握程度:
|
||||
|
||||
|
||||
请说说你对分布式系统 CAP 理论的理解,CAP 分别代表什么含义?
|
||||
为什么分布式系统的一致性和可用性不能同时满足?
|
||||
你是如何理解数据一致性的?数据一致性有哪几种模型?
|
||||
你在做系统设计时,如何选择实现强一致性还是弱一致性?
|
||||
在你的项目里,是如何设计分布式事务,实现最终一致性的?
|
||||
你了解数据库的 binlog 和 redolog 吗?是如何实现一致性的呢?
|
||||
|
||||
|
||||
需要说明的是,面试并不是应试考试,很多问题并没有标准答案,不过这里的问题,很多都可以在“模块二:分布式事务”中找到思路。
|
||||
|
||||
分布式事务高频考点
|
||||
|
||||
在分布式事务的面试中,主要会围绕分布式理论、一致性算法、分布式事务及其应用来展开提问。下面我进行了简单梳理,这里有一张分布式事务的知识点思维导图,你可以对照这张图片,查漏补缺进行分析。
|
||||
|
||||
|
||||
|
||||
分布式理论部分的主要内容包括 CAP 理论、Base 理论、各种数据一致性模型的应用等。在工作中应用比较多的是 ZooKeeper,需要了解 ZooKeeper 的原理和实现、应用场景等。
|
||||
|
||||
一致性算法部分,希望你能够对经典的数据一致性算法,比如 Paxos 算法等有自己的理解,并不是要做到对算法细节倒背如流,而是要能够通过自己的描述,把算法的整体流程讲清楚。
|
||||
|
||||
分布式事务的应用是日常开发中打交道最多的部分,如果你在工作中实践过分布式事务的实现是最好的,若没有,可以去了解一些开源的分布式事务中间件。比如我在专栏中多次介绍过的 Alibaba Seata 等组件,通过学习开源组件设计思路,你也可以对这一部分内容有个整体的把握。
|
||||
|
||||
在专栏的第 [10]、[11]课时我们一起讨论了分布式锁的应用场景和实现细节,你可以回顾一下,使用 Redis 实现分布式锁,需要注意哪些细节呢?不同的实现方式,又存在哪些缺陷呢?
|
||||
|
||||
另外,除了专栏的内容,我推荐你结合一些经典的公开课程去学习,以加深印象,建议关注拉勾教育直播课哦,有许多分布式相关的主题分享。
|
||||
|
||||
|
||||
|
||||
|
@ -0,0 +1,54 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
22 分布式服务考点梳理 + 高频面试题
|
||||
本课时我将和你回顾一下该模块的核心内容,并且一起梳理一下面试中分布式服务的高频考点。
|
||||
|
||||
如何考察分布式服务
|
||||
|
||||
在整个分布式课程中,分布式服务是大部分工程师实际开发中应用最多的,也是面试中经常出现的一个热点。
|
||||
|
||||
在分布式服务部分的面试中,面试官通常会围绕“服务治理”的各个场景进行提问,考察候选人对微服务和服务治理各个环节的掌握程度。分布式服务这部分内容涉及的比较广,有非常丰富的内涵和外延知识。本课程只是带你描述了一些核心领域的知识点,剩下的内容,还需要你在平时的工作和学习中多多积累。
|
||||
|
||||
我们在课程中提到了 Spring Cloud 和 Dubbo 两个技术栈,这两大技术栈是目前大部分公司进行服务治理的选择。当然,一些公司使用的是 Thrift 和 gRPC 等服务框架,但是应用比例要小很多,在实际的面试中,通常会选择一个服务治理的技术栈来展开提问,对候选人进行考察。
|
||||
|
||||
下面我以 Dubbo 技术栈为例,整理了一些分布式服务相关的问题,来模拟实际的面试场景。这些问题都是比较基础的,你可以作为对照,检测一下掌握程度:
|
||||
|
||||
|
||||
为什么需要 Dubbo?
|
||||
Dubbo 的主要应用场景?
|
||||
Dubbo 的核心功能?
|
||||
Dubbo 服务注册与发现的流程?
|
||||
Dubbo 的服务调用流程?
|
||||
Dubbo 支持哪些协议,每种协议的应用场景、优缺点?
|
||||
Dubbo 有些哪些注册中心?
|
||||
Dubbo 如何实现服务治理?
|
||||
Dubbo 的注册中心集群挂掉,如何正常消费?
|
||||
Dubbo 集群提供了哪些负载均衡策略?
|
||||
Dubbo 的集群容错方案有哪些?
|
||||
Dubbo 支持哪些序列化方式?
|
||||
|
||||
|
||||
需要你注意的是,即使开发框架不同,但是在服务治理中关注的功能是一致的,如果你应用的是另外的分布式服务框架,可以把关键词做一些替换,比如 Spring Cloud 的主要应用场景、Spring Cloud 的核心功能,同样可以用来考察自己对整体技术栈的掌握程度。
|
||||
|
||||
微服务技术栈梳理
|
||||
|
||||
下面我分别展开 Dubbo 和 Spring Cloud 这两大微服务技术栈,并且简单描绘了一张知识点思维导图,你可以对照这张图片,查漏补缺进行针对性的学习。
|
||||
|
||||
|
||||
|
||||
对 Spring Cloud 和 Dubbo 两大技术栈的掌握,重在深入而不是只能泛泛而谈。举个例子,Dubbo 在不同业务场景时,如何选择集群容错策略和不同的线程模型,又如何配置不同的失败重试机制呢?
|
||||
|
||||
Dubbo 为什么选择通过 SPI 来实现服务扩展,又对 Java 原生的 SPI 机制做了哪些调整呢?这些应用细节都要针对性地了解,才能在系统设计时避免各种问题。
|
||||
|
||||
除了上层的技术组件之外,微服务底层的技术支撑也要去了解一下,比如 Docker 容器化相关知识,容器内隔离是如何实现的,JVM 对容器资源限制的理解,以及可能产生的问题,还有容器如何调度等。
|
||||
|
||||
继续扩展,你可以思考一下,为什么现在很多企业选择 Golang 作为主要的开发语言,其中一个原因,就和 Go 语言部署和构建快速,占用容器资源小有关系。
|
||||
|
||||
在技术之外,微服务设计常用的 DDD(领域驱动设计)思路,开发中的设计模式,也要有一定的理解和掌握。
|
||||
|
||||
|
||||
|
||||
|
@ -0,0 +1,59 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
29 分布式存储考点梳理 + 高频面试题
|
||||
你好,欢迎来到分布式存储模块的加餐环节,本课时我将和你一起梳理面试中分布式系统的数据库的高频考点,做到温故知新。
|
||||
|
||||
面试中如何考察分布式存储
|
||||
|
||||
广义的分布式存储根据不同的应用领域,划分为以下的类别:
|
||||
|
||||
|
||||
分布式协同系统
|
||||
分布式文件系统
|
||||
分布式任务调度框架
|
||||
分布式 NoSQL 存储
|
||||
分布式关系数据库
|
||||
各种消息队列 MQ
|
||||
流式计算框架
|
||||
|
||||
|
||||
当然,这只是一种划分方式,你也可以根据存储数据的特点,将分布式存储系统划分为块存储、对象存储等不同的分类。
|
||||
|
||||
可以看到,分布式存储技术的范围非常大,技术覆盖的广度和深度都很有料,比如分布式协同系统或者各种流计算框架,都可以单独作为一个专栏来进行展开讲解。
|
||||
|
||||
由于篇幅有限,我在分布式存储这个模块里,主要围绕分布式系统下的关系型数据库这一主题,选择了与大部分开发者直接相关的热点内容,包括数据库的读写分离、分库分表存储拆分后的唯一主见问题,以及典型的 NoSQL 数据库应用。另外,简单介绍了 ElasticSearch 技术、倒排索引的实现等。
|
||||
|
||||
和之前一样,我在这里选择了一些热点技术问题,你可以考察一下自己的掌握程度。以分布式场景下的数据库拆分为例,面试官会对你进行下面的考察:
|
||||
|
||||
|
||||
当高并发系统设计时,为什么要分库分表?
|
||||
用过哪些分库分表中间件?
|
||||
不同的分库分表中间件都有什么优点和缺点?
|
||||
如何对数据库进行垂直拆分或水平拆分?
|
||||
如果要设计一个可以动态扩容缩容的分库分表方案,应该如何做?
|
||||
数据库分库分表以后,如何处理设计主键生成器?
|
||||
不同的主键生成方式有什么区别?
|
||||
|
||||
|
||||
上面的问题,都可以在“分布式存储”模块的内容中找到思路,你可以对照本模块学过的知识,整理自己的答案。
|
||||
|
||||
分布式存储有哪些高频考点
|
||||
|
||||
上面我提到过,分布式存储包含了非常丰富的技术栈,本模块的内容虽然在实际开发中有着高频应用,但只是分布式存储技术领域中非常小的一部分。在下面这张思维导图中,除了分布式下的关系型数据库之外的内容,我还补充了一些经典分布式存储技术的部分,你可以对照这张思维导图,进行针对性的扩展。
|
||||
|
||||
|
||||
|
||||
以分布式文件系统为例,常见的分布式文件系统有 Google 的 GFS、Hadoop 实现的分布式文件系统 HDFS、Sun 公司推出的 Lustre、淘宝的 TFS、FastDFS 等,这几种存储组件都有各自的应用场景。
|
||||
|
||||
比如淘宝的 TFS 适合用于图片等小文件、大规模存储的应用场景,是淘宝专门为了支持电商场景下数以千万的商品图片而开发的;FastDFS 类似 GFS,是一款开源的分布式文件系统,适合各类规模较小的图片和视频网站。
|
||||
|
||||
比如流式计算框架,有著名的流式计算三剑客,Storm、Spark 和 Flink,这三个框架基本上覆盖了绝大多数的流式计算业务,适用于不同的大数据处理场景。
|
||||
|
||||
今天的内容就到这里了,也欢迎你留言分享自己的面试经验,和大家一起讨论。
|
||||
|
||||
|
||||
|
||||
|
@ -0,0 +1,71 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
37 消息队列考点梳理 + 高频面试题
|
||||
你好,欢迎来到分布式消息队列模块的加餐环节,本课时我将和你一起梳理面试中消息队列的高频考点,做到温故知新。
|
||||
|
||||
面试中如何考察消息队列
|
||||
|
||||
消息队列作为日常开发中应用最高频的基础组件之一,相关的问题自然也是面试中的常客。
|
||||
|
||||
在面试中对消息队列的考察方式,主要包括两种形式,一种是针对消息队列的相关理论,比如消息队列重复消费、消费幂等性、消息队列的可靠传输等;另一种考察方式是针对某个具体的消息队列中间件,考察组件应用的原理,实现方案和应用细节,比如常见的 Kafka、RabbitMQ、RocketMQ 等消息队列组件。
|
||||
|
||||
下面我梳理了一些面试中的高频问题,你可以对照这些问题,检测自己是否掌握了问题考察的内容,针对自己薄弱的环节,进行针对性地提高。
|
||||
|
||||
消息队列理论高频问题
|
||||
|
||||
对消息队列应用相关理论和设计的考察,面试官可以提出下面一系列的问题:
|
||||
|
||||
|
||||
如何保证消息队列的高可用?
|
||||
如何保证消息不被重复消费?
|
||||
如何保证消费的时候是幂等?
|
||||
如何保证消息的可靠性传输?
|
||||
传输过程出现消息丢失了怎么办?
|
||||
如何保证消息的顺序性?
|
||||
如何解决消息队列的延时问题?
|
||||
如何解决消息队列的过期失效问题?
|
||||
消息队列满了以后该怎么处理?
|
||||
有几百万消息持续积压几小时,应该怎么解决?
|
||||
如果让你写一个消息队列,该如何进行架构设计?
|
||||
|
||||
|
||||
可以看到,这方面的问题非常重视考察候选人对实际问题处理的经验,不过没有固定的答案。我在专栏里多次强调,授人以鱼不如授人以渔,关于分布式的方法论是最重要的。如果让你从零到一设计一个消息队列,该如何展开呢?你可以从分布式的基础理论出发,从数据存储的一致性,集群扩展结合我在分布式消息队列模块所讲解的内容,同时融入自己对系统架构的理解,最后形成自己的观点。
|
||||
|
||||
消息队列应用高频问题
|
||||
|
||||
面试中对具体某一种消息组件的考察,一般是候选人有过该组件的应用经验,重点是考察候选人对基础组件掌握的深度,出现问题后的解决办法等。
|
||||
|
||||
以 Kafka 为例,可以提出以下的问题:
|
||||
|
||||
|
||||
描述一下 Kafka 的设计架构?
|
||||
Kafka、ActiveMQ、RabbitMQ、RocketMQ 之间都有什么区别?
|
||||
Kafka 消费端是否可能出现重复消费问题?
|
||||
Kafka 为什么会分区?
|
||||
Kafka 如何保证数据一致性?
|
||||
Kafka 中 ISR、OSR、AR 是什么?
|
||||
Kafka 在什么情况下会出现消息丢失?
|
||||
Kafka 消息是采用 Pull 模式,还是 Push 模式?
|
||||
Kafka 如何和 ZooKeeper 进行交互?
|
||||
Kafka 是如何实现高吞吐率的?
|
||||
|
||||
|
||||
如果是 RocketMQ,很多问题都是类似的,可以从以下的问题出发进行考察:
|
||||
|
||||
|
||||
RocketMQ 和 ActiveMQ 有哪些区别?
|
||||
为什么 RocketMQ 不会丢失消息?
|
||||
RocketMQ 的事务消息都有哪些应用?
|
||||
RocketMQ 是怎么保证系统高可用的?
|
||||
|
||||
|
||||
这些问题中一部分可以在专栏中找到思路,但大部分的问题还要靠你在平时多积累与思考,比如消息队列的高可用,你可以多机器部署,防止单点故障;主从结构复制,通过消息冗余防止消息丢失;消息持久化,磁盘写入的 ACK 等角度进行分析。
|
||||
|
||||
今天的内容就到这里了,也欢迎你留言分享自己的面试经验,和大家一起讨论。
|
||||
|
||||
|
||||
|
||||
|
@ -0,0 +1,68 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
44 分布式缓存考点梳理 + 高频面试题
|
||||
你好,欢迎来到分布式缓存模块的加餐环节,本课时我将和你一起梳理面试中分布式缓存的高频考点,做到温故知新。
|
||||
|
||||
分布式缓存在面试中如何考察
|
||||
|
||||
对缓存和数据库的考察,一直都是业务开发同学在面试中的核心问题,特别是缓存部分,随着大部分公司业务规模的增加,缓存的应用越来越重要。我偶尔会和身边的同事调侃:如何应对高并发?答案是加一层缓存,如果不够,就再加一层缓存。
|
||||
|
||||
缓存在分布式场景下的应用,比单机情况下更加复杂,除了常见的缓存雪崩、缓存穿透的预防,还要额外考虑缓存数据之间的一致性,缓存节点的负载均衡,缓存的监控和优化等。在面试中,对分布式缓存的考察一般有两种方式:
|
||||
|
||||
|
||||
通过实际场景来考察对缓存设计和应用的理解;
|
||||
直接考察常用的缓存组件,比如 Redis、Memcached、Tair。
|
||||
|
||||
|
||||
面试官通常会通过一个实际场景,结合常用的缓存组件,进行 System Design 相关方面的考察。下面我梳理了部分分布式缓存的高频考点,希望可以帮助你提纲挈领,体系化地去学习相关知识。
|
||||
|
||||
缓存如何应用
|
||||
|
||||
|
||||
缓存雪崩、缓存穿透如何理解?
|
||||
如何在业务中避免相关问题?
|
||||
如何保证数据库与缓存的一致性?
|
||||
如何进行缓存预热?
|
||||
|
||||
|
||||
缓存的高可用
|
||||
|
||||
|
||||
缓存集群如何失效?
|
||||
一致性哈希有哪些应用?
|
||||
缓存如何监控和优化热点 key?
|
||||
|
||||
|
||||
Redis 应用
|
||||
|
||||
|
||||
Redis 有哪些数据结构?
|
||||
Redis 和 Memcached 有哪些区别?
|
||||
单线程的 Redis 如何实现高性能读写?
|
||||
Redis 支持事务吗?
|
||||
Redis 的管道如何实现?
|
||||
Redis 有哪些失效策略?
|
||||
Redis 的主从复制如何实现?
|
||||
Redis 的 Sentinel 有哪些应用?
|
||||
Redis 集群有哪几种方式?
|
||||
Redis 和 memcached 什么区别?
|
||||
Redis 的集群模式如何实现?
|
||||
Redis 的 key 是如何寻址的?
|
||||
Redis 的持久化底层如何实现?
|
||||
Redis 过期策略都有哪些?
|
||||
缓存与数据库不一致怎么办?
|
||||
Redis 常见的性能问题和解决方案?
|
||||
使用 Redis 如何实现异步队列?
|
||||
Redis 如何实现延时队列?
|
||||
|
||||
|
||||
以上的这些问题,都是面试中非常高频的,你可以进行一个模拟面试,考察自己对这部分知识的掌握程度,有一部分问题在专栏中已经介绍过了,比如缓存集群、缓存一致性、缓存负载均衡等,专栏没有涉及的,可以作为一份索引,帮助你有针对性地学习。
|
||||
|
||||
今天的内容就到这里了,也欢迎你留言分享自己的面试经验,和大家一起讨论。
|
||||
|
||||
|
||||
|
||||
|
@ -0,0 +1,190 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
02 基本概念:指标+日志+链路追踪=可观测性?
|
||||
你好,我是翁一磊。
|
||||
|
||||
上节课,我们介绍了计算机系统监控的发展历史,这节课我们来具体聊一聊可观测性,以及大家对于可观测性的一些误解。
|
||||
|
||||
什么是可观测性?
|
||||
|
||||
就像我们在开篇词中说的,可观测性强调的是可以从系统向外部输出的信息来推断出系统内部状态的好坏。
|
||||
|
||||
当我们把“可观测性”这个概念挪到软件系统时,其实强调的也是一种度量能力,一个软件应用程序具有可观测性,意味着它能够让我们通过各种维度和各种角度去分析和理解这个系统当前所处的任何状态,无论这种状态有多奇怪、无论我们之前有没有遇到过,都不需要预先定义或预测。如果能够在不发布新代码(如增加一个用于调试的日志)的情况下理解任何奇怪或不确定性的状态,那么我们的系统就具备可观测性。
|
||||
|
||||
因此,可观测性是描述人们如何与他们的复杂系统互动,以及如何理解这些复杂系统的概念。如果你接受这个定义,那么看看接下来这些问题:
|
||||
|
||||
|
||||
如何收集数据并将它们组合起来进行分析?
|
||||
处理这些数据的技术要求是什么?
|
||||
要从这些数据中获益,团队需要具备哪些能力?
|
||||
|
||||
|
||||
这些问题,我们都会在专栏中一一解答。不过别着急,这节课我们还是要先把可观测性的概念和内涵理清楚。
|
||||
|
||||
指标+日志+链路追踪=可观测性?
|
||||
|
||||
既然选择学习这门课程,你八成听过可观测性的“三大支柱”:指标(metrics),日志(logs)和链路追踪(Tracing)。但是,指标、日志再加上链路追踪,真的就是可观测性吗?让我们先来看一下这三类数据的含义。
|
||||
|
||||
指标:是在⼀段时间内测量的数值。它包括特定属性,例如时间戳、名称、键和值。和⽇志不同,指标在默认情况下是结构化的,这会让查询和优化存储变得更加容易。
|
||||
|
||||
例如:2022/05/20 12:48:22,CPU usage user,23.87%,它就表示 CPU 运行在用户态的时间占比在这一刻为 23.87%。
|
||||
|
||||
日志:是对特定时间发⽣的事件的⽂本记录。日志一般是非结构化字符串,会在程序执行期间被写入磁盘。每个请求会产生一行或者多行的日志,每个日志行可能包含 1-5 个维度的有用数据(例如客户端 IP,时间戳,调用方法,响应码等等)。当系统出现问题时,⽇志通常也是工程师⾸先查看的地⽅。常见的日志格式是下面的样子。
|
||||
|
||||
127.0.0.1 - - [24/Mar/2021:13:54:19 +0800] "GET /basic_status HTTP/1.1" 200 97 "-" "Mozilla/5.0 (Macintosh; Intel Mac OS X 11_1_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.72 Safari/537.36"
|
||||
|
||||
|
||||
链路追踪:有时候也被称为分布式追踪(Distributed Tracing),表示请求通过分布式系统的端到端的路径。当请求通过主机系统时, 它执⾏的每个操作被称为“跨度”(Span)。
|
||||
|
||||
举个分布式调用的例子:客户端发起请求,请求首先到达负载均衡器,经过认证服务、系统服务,然后请求资源,最终返回结果;那这里面的操作就包括请求网关、身份认证、请求资源、以及返回结果等。
|
||||
|
||||
链路追踪一般会通过一个可视化的瀑布图展现出来。瀑布图展示了用于调试的请求的每个阶段,以及每个部分的开始时间和持续时长。
|
||||
|
||||
比方说,在下图这个例子里,瀑布图由 Span 组成。特定的链路追踪中的 Span 可能是根 Span(也就是最顶层的 Span),也可能是根 Span 以下的 Span。Span还可能包含 Span,这种常被称为父子关系。比如,如果服务 B 调用服务 B-1,服务 B-1 调用 B-2,那么在这条链路中,Span B 是 Span B-1 的父亲 Span,Span B-1 是 Span B-2 的父亲 Span。
|
||||
|
||||
|
||||
|
||||
然而,仅仅是收集这些数据类并不能保证系统的可观测性,尤其是当你彼此独⽴地使⽤它们时。从根本上来说,指标、日志和链路追踪只是数据类型,与可观测性无关。
|
||||
|
||||
另一方面,这三种数据类型也有着局限性。
|
||||
|
||||
|
||||
指标
|
||||
|
||||
|
||||
由于指标最大的特点是聚合性,它生成的数值反映了预定义时间段内系统状态的汇总报告,在此期间处于活动状态的所有请求的行为都会汇总为一个数值,因此缺乏细颗粒度。同时这些指标很可能都是彼此不相关的,没有关联性。
|
||||
|
||||
例如:page_load_time 指标可能会检查在最后 5 秒间加载所有活动页面所花费的平均时间;requests_per_second 指标可能会检查任何给定服务在最后一秒内打开的 HTTP 连接数。这就导致能够挖掘的信息的颗粒度是比较粗的,如果在 5 秒内发生了一千个离散事件,从 page_load_time 指标中根本无法获取某一事件的具体情况。
|
||||
|
||||
当然,这并不是说指标完全没用,指标对于静态仪表板的构建、随时间变化的趋势分析、或监控维度是否保持在定义的阈值内很有用,但这些并不是可观测性,因为这些信息的颗粒度在做故障排查或根因分析时是远远不够的。
|
||||
|
||||
|
||||
日志
|
||||
|
||||
|
||||
日志文件本质上是分散的事件,是一大块非结构化文本,旨在方便人类阅读,但要达到这个目的,日志通常要将一个事件的所有细节分成多行文本。这样在生产环境中,日志通常散布在数以百万计的文本行中,通过使用某种类型的日志文件解析器才可以完成对它们的搜索。解析器将日志数据拆分为信息块,并尝试以有意义的方式对它们进行分组。但是,对于非结构化数据,解析变得复杂,因为不同类型的日志文件存在不同的格式化规则里(或根本没有规则)。
|
||||
|
||||
针对这一点的解决方案是创建结构化日志数据,例如将上面的日志解析成下面这样。
|
||||
|
||||
结构化日志是机器可解析的,如果它们被重新设计为类似于结构化事件的话,可以帮助我们实现可观测的目标。关于结构化事件,后面还会做进一步介绍。
|
||||
|
||||
"fields": {
|
||||
"agent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 11_1_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.72 Safari/537.36",
|
||||
"browser":"Chrome",
|
||||
"browserVer":"89.0.4389.72",
|
||||
"bytes":97,
|
||||
"client_ip":"127.0.0.1",
|
||||
"engine":"AppleWebKit",
|
||||
"engineVer":"537.36",
|
||||
"http_method":"GET",
|
||||
"http_url":"/basic_status",
|
||||
"http_version":"1.1",
|
||||
"isBot":false,
|
||||
"isMobile":false,
|
||||
"message":"127.0.0.1 - - [24/Mar/2021:13:54:19 +0800] "GET /basic_status HTTP/1.1" 200 97 "-" "Mozilla/5.0 (Macintosh; Intel Mac OS X 11_1_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.72 Safari/537.36"",
|
||||
"os":"Intel Mac OS X 11_1_0",
|
||||
"referrer":"-",
|
||||
"status":"OK",
|
||||
"status_code":200,
|
||||
"ua":"Macintosh"
|
||||
},
|
||||
|
||||
|
||||
|
||||
链路追踪
|
||||
|
||||
|
||||
链路追踪检测的主要问题是,如果仅靠开发人员“插桩”(英文 Instrument,有些地方也翻译成埋点,是指将有关系统状态的数据发送到监测系统)他们的代码是不够的。大量应用程序是使用可能需要额外工具的开源框架或库构建的。这在多语言架构的地方变得更加具有挑战性,因为要考虑到每种语言、框架和协议的不同。
|
||||
|
||||
同时,增加插桩的成本也是比较高的,很难真正做到全面覆盖。这样的方式只适用于具体的业务场景,如果其他地方有类似的需要,就需要再次插桩。而且随着产品的不断迭代,我们很难一次性把需要插桩的地方都考虑周全,这就会带来反复的工作,也可能会涉及多次上线,增加了工作量的同时也降低了系统的可靠性。另一方面,大量的插桩也会占用比较高的计算资源。
|
||||
|
||||
总之,指标、日志和链路追踪只是数据的类型,本身并不代表可观测性。可观测性也不是供应商提供的一种技术,而是你构建的系统的属性,就像可用性、高可用性和稳定性这些一样。
|
||||
|
||||
设计和构建可观测系统的目标是确保系统运行时,操作员可以检测到服务停机、错误和响应缓慢等不良行为,并可以通过足够的信息来确定问题的根本原因。
|
||||
|
||||
可观测性的特性
|
||||
|
||||
就像我在前面介绍的,我们对软件系统的“可观测性”的定义是一种度量能力,能够帮助你更好地理解和解释系统当前所处的任何状态,无论这种状态或者问题是否在之前出现过。而结构化的事件(Structured Events)就是可观测性的基础。
|
||||
|
||||
事件指的是特定请求与服务交互时所有信息的记录,通过事件能了解生产环境中服务所受到的影响。
|
||||
|
||||
那什么是结构化的事件呢?
|
||||
|
||||
在请求第一次进入服务时,会有一个空的地图(Map)被初始化出来。在该请求的生命周期内发生的任何细节(包括唯一的 ID、变量值、标头、请求传递的每个参数、执行时间、对远程服务的任何调用、这些远程调用的执行时间),或任何可能在之后的调试中有价值的上下文,都会附加到这个地图中。然后,当请求即将退出或出错时,刚刚所发生的事情都被丰富地记录了下来。写入该地图的数据被组织和格式化为键值对,以便于搜索。换句话说,这些数据就是结构化的事件。
|
||||
|
||||
这样做的好处是什么呢?
|
||||
|
||||
当你调试服务中的问题时,可以相互比较结构化事件,及时发现异常。当某些事件的行为与其他事件明显不同时,你可以尝试确定这些异常值的共同点。探索这些异常值,需要分析可能与你的调查相关的事件,按照这些事件中所包含的不同维度(甚至是不同维度的组合)进行过滤和分组。另一方面,对你有帮助的信息可能包含不特定于任何给定请求的运行时信息(例如容器信息或版本信息),也包含有关通过服务的每个请求的信息(例如购物车 ID、用户 ID 或会话令牌等等)。这两种类型的数据都对调试很有用。
|
||||
|
||||
所有这些数据都可以用于调试并存储在你的事件中。它们是任意“宽度”的事件,因为你需要的调试数据可能包含大量字段,或是来自任意维度,而不应该有实际限制。如果要分析一个异常的状态,具有可观测性的调试方式就是尽量保留每一个请求的上下文,这样你就可以针对这个上下文分析定位修复这个Bug或者调整相关的环境配置了。
|
||||
|
||||
所以我们说,数据的高基数和高维度,这将成为能够发现隐藏在复杂系统架构中的其他隐藏问题的关键组成部分。我们分开来看一下。
|
||||
|
||||
基数的作用
|
||||
|
||||
在数据库的概念中,基数是指包含在一个集合中的唯一值的数量。低基数意味着这一列在其集合中有很多重复的值;高基数意味着该列包含很大比例的完全唯一的值。
|
||||
|
||||
举例来说,在一个包含1亿条用户记录的集合中,任何通用唯一标识符(UUID)都是高基数的,另外用户名也具有很高的基数(当然会低于UUID,因为有些名称可能是重复的)。另一方面,像性别这样的领域的基数就会很低。再举个例子,假设所有用户都是人类,像物种这样的字段可能具有最低的基数。
|
||||
|
||||
基数对于可观测性很重要,因为高基数信息在调试或理解系统的数据时是最有用的。如果能够按照这些字段,例如 userid、cartid、requestid 或任何其他 ID (host、container_name、hostname、version、span 等),根据其中的唯一 ID 来查询数据,是在“大海”中精确定位每一滴“水滴”的最佳方法。你总是可以通过聚合采样高基数的值获得较低基数的值(例如,通过首字母存储姓氏),但没法反过来。
|
||||
|
||||
维度的作用
|
||||
|
||||
基数指的是数据中值的唯一性,维度指的则是数据中键(key)的数量。在可观测系统中,遥测数据被生成为任意“宽度”的结构化事件,它们可以而且应该包含数百甚至数千个键值对(即维度)。事件范围越广,事件发生时获取的上下文就越丰富,在以后调试时,就越容易定义问题的原因。
|
||||
|
||||
假设你有一个事件模式,每个事件定义了六个高基数维度:时间、应用、主机、用户、端点以及状态。通过这六个维度,你可以创建查询,分析任何维度组合,以发现可能导致异常的相关模式。例如,你可以检索:“过去半小时内,发生在主机 host001 上的所有的502错误请求”,或是“由用户 vipuser001 在做数据导出时产生的所有403错误请求”。
|
||||
|
||||
也就是说,只需六个基本维度,你就可以通过一组有用的条件,来确定你的应用程序系统中可能发生的情况。但是在现代系统中,可能发生的故障的排列方式是无限的,只在传统监控数据中捕捉几个基本维度是不够的。现在想象一下,除了六个维度之外,你还可以关注数百乃至数千个包含无数细节、值、计数器或字符串的维度,这些维度在将来的某个时候可能对你的调试有帮助。例如,你可以包含像这样的维度:
|
||||
|
||||
create_time
|
||||
component
|
||||
date_ns
|
||||
duration
|
||||
endpoint
|
||||
env
|
||||
http.route
|
||||
host
|
||||
operation
|
||||
parent_id
|
||||
pid
|
||||
resource
|
||||
service
|
||||
servlet.path
|
||||
source
|
||||
source_type
|
||||
start
|
||||
span_id
|
||||
span_type
|
||||
status
|
||||
trace.id
|
||||
thread.id
|
||||
thread_name
|
||||
version
|
||||
|
||||
|
||||
有了更多可用的维度,你就可以检测各种事件,在任何一组服务请求之间建立高度复杂的关联了。数据的维度越高,就越有可能发现应用程序行为中隐藏的、难以捉摸的模式。在后面的章节,我们还会更详细地讲解这部分内容。
|
||||
|
||||
小结
|
||||
|
||||
好了,这节课就讲到这里,我来小结一下。
|
||||
|
||||
尽管“可观测性”这个专有名词已经出现几十年了,但在软件系统中它还是一个新事物,它带来了一些新的考虑和特性。可观测性的出现,其实也刚好符合计算机领域现阶段的需求,由于现代系统引入了额外的复杂性,系统的故障比以往任何时候都更难预测、检测和修复。
|
||||
|
||||
为了减轻这种复杂性,工程团队现在必须能够以灵活的方式不断收集遥测数据,及时调试问题,而不需要首先预知故障可能如何发生。可观测性让工程师能够以灵活的方式分析遥测数据,快速找到未知问题的根源。
|
||||
|
||||
可观测性通常被错误地描述为包含指标、日志和追踪的“三个支柱”,但其实这些只是遥测数据类型。如果我们必须拥有可观测性的三个支柱,那么它们应该是支持高基数、高维度和可探索性工具。下节课,我们会探讨可观测性与传统系统监控方法的不同之处。
|
||||
|
||||
课后题
|
||||
|
||||
在这节课的最后,留给你一道思考题。
|
||||
|
||||
你在使用监控工具对系统和应用进行监控的时候,遇到过哪些难以依靠单纯的监控来解决的问题?后来是如何找到问题原因的?
|
||||
|
||||
欢迎你在留言区和我交流讨论,我们下节课见!
|
||||
|
||||
|
||||
|
||||
|
@ -0,0 +1,278 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
不定期加餐5 借助实例,探究C++编译器的内部机制
|
||||
你好,我是宫文学。欢迎来到编译原理实战课的加餐环节,今天我们来探讨一下C++的编译器。
|
||||
|
||||
在前面的课程中,我们已经一起解析了很多语言的编译器了,但一直没有讨论C和C++的编译器。并不是因为它们不重要,而是因为C语言家族的编译器实现起来要更复杂一些,阅读代码的难度也更高一些,会对初学者造成比较大的挑战。
|
||||
|
||||
不过,没有解析C和C++语言的特性及其编译器的实现,其实在我心里也多多少少有点遗憾,因为C和C++是很经典的语言。至今为止,我们仍然有一些编程任务是很难用其他语言来代替的,比如,针对响应时间和内存访问量,需要做精确控制的高性能的服务端程序,以及一些系统级的编程任务,等等。
|
||||
|
||||
C和C++有很多个编译器,今天我们要研究的是Clang编译器。其实它只是前端编译器,而后端用的是LLVM。之所以选择Clang,是因为它的模块划分更清晰,更便于理解,并且还可以跟课程里介绍过的LLVM后端工具串联起来学习。
|
||||
|
||||
另外,因为C++语言的特性比较多,编译器实现起来也比较复杂一些,下手阅读编译器的源代码会让人觉得有点挑战。所以今天这一讲,我的主要目的,就是给你展示如何借助调试工具,深入到Clang的内部,去理解它的运行机制。
|
||||
|
||||
我们会具体探究哪个特性呢?我选择了C++的模板技术。这个技术是很多人学习C++时感觉有困难的一个技术点。通过探究它在编译器中的实现过程,你不仅会加深了解编译器是如何支持元编程的,也能够加深对C++模板技术本身的了解。
|
||||
|
||||
那么下面,我们就先来认识一下Clang这个前端。
|
||||
|
||||
认识Clang
|
||||
|
||||
Clang是LLVM的一个子项目,它是C、C++和Objective-C的前端。在llvm.org的官方网站上,你可以下载Clang+LLVM的源代码,这次我用的是10.0.1版本。为了省事,你可以下载带有全部子项目的代码,这样就同时包含了LLVM和Clang。然后你可以参考官网的文档,用Cmake编译一下。
|
||||
|
||||
我使用的命令如下,你可以参考:
|
||||
|
||||
cd llvm-project-10.0.1
|
||||
|
||||
#创建用于编译的目录
|
||||
mkdir build
|
||||
cd build
|
||||
|
||||
#生成用于编译的文件
|
||||
cmake -DCMAKE_BUILD_TYPE=Debug -DLLVM_TARGETS_TO_BUILD="X86" -DLLVM_BUILD_EXAMPLES=ON ../llvm
|
||||
|
||||
#调用底层的build工具去执行具体的build
|
||||
cmake --build .
|
||||
|
||||
|
||||
这里你要注意的地方,是我为Cmake提供的一些变量的值。我让Cmake只为x86架构生成代码,这样可以大大降低编译工作量,也减少了对磁盘空间的占用;并且我是编译成了debug的版本,这样的话,我就可以用LLDB或其他调试工具,来跟踪Clang编译C++代码的过程。
|
||||
|
||||
编译完毕以后,你要把llvm-project-10.0.1 /build/bin目录加到PATH中,以便在命令行使用Clang和LLVM的各种工具。你可以写一个简单的C++程序,比如说foo.cpp,然后就可以用“clang++ foo.cpp”来编译这个程序。
|
||||
|
||||
|
||||
补充:如果你像我一样,是在macOS上编译C++程序,并且使用了像iostream这样的标准库,那么可能编译器会报找不到头文件的错误。这是我们经常会遇到的一个问题。-
|
||||
|
||||
这个时候,你需要安装Xcode的命令行工具。甚至还要像我一样,在.zshrc文件中设置两个环境变量:
|
||||
|
||||
|
||||
export CPLUS_INCLUDE_PATH="/Library/Developer/CommandLineTools/usr/include/c++/v1:$CPLUS_INCLUDE_PATH"
|
||||
export SDKROOT="/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk"
|
||||
|
||||
|
||||
好,到目前为止,你就把Clang的环境配置好了。那回过头来,你可以先去看看Clang的源代码结构。
|
||||
|
||||
你会看到,Clang的源代码主要分为两个部分:头文件(.h文件)全部放在include目录下,而.cpp文件则都放在了lib目录下。这两个目录下的子目录结构是一致的,每个子目录代表了一个模块,模块的划分还是很清晰的。比如:
|
||||
|
||||
|
||||
AST目录:包含了AST的数据结构,以及对AST进行遍历处理的功能。
|
||||
Lex目录:词法分析功能。
|
||||
Parse目录:语法分析功能。
|
||||
Sema目录:语义分析功能(Sema是Sematic Analysis的缩写)。
|
||||
|
||||
|
||||
接下来,你可以进入这些目录,去寻找一下词法分析、语法分析、语义分析等功能的实现。由于Clang的代码组织很清晰,你可以很轻松地根据源代码的名称猜到它的功能,从而找到语法分析等功能的具体实现。
|
||||
|
||||
现在,你可以先猜测一下,Clang的词法分析和语法分析都是如何实现的呢?
|
||||
|
||||
如果你已经学过了第二个模块中几个编译器的实现,可能就会猜测得非常准确,因为你已经在Java编译器、Go的编译器、V8的编译器中多次见到了这种实现思路:
|
||||
|
||||
|
||||
词法分析:手写的词法分析器,也就是用手工的方法构造有限自动机。
|
||||
语法分析:总体上,采用了手写的递归下降解析器;在表达式解析部分,采用的是运算符优先级解析器。
|
||||
|
||||
|
||||
所以,针对词法分析和语法分析的内容,我们就不多展开了。
|
||||
|
||||
那么,Clang的语义分析有什么特点呢?
|
||||
|
||||
通过前面课程的学习,现在你已经知道,语义分析首先要做的是建立符号表,并做引用消解。C和C++在这方面的实现比较简单。简单在哪里呢?因为它要求必须声明在前,使用在后,这就让引用消解变得很简单。
|
||||
|
||||
而更现代一些的语言,在声明和使用的顺序上可以更加自由,比如Java类中,方法中可以引用类成员变量和其他方法,而被引用的成员变量和方法可以在该方法之后声明。这种情况,对引用消解算法的要求就要更高一些。
|
||||
|
||||
然后,现在你也知道,在语义分析阶段,编译器还要做类型检查、类型推导和其他很多的语义检查。这些方面Clang实现得也很清晰,你可以去看它的StaticAnalysis模块。
|
||||
|
||||
最后,在语义分析阶段,Clang还会做一些更加复杂的工作,比如C++的模板元编程机制。
|
||||
|
||||
我在探究元编程的那一讲中,介绍过C++的模板机制,它能有效地提高代码的复用能力。比如,你可以实现一个树的容器类,用来保存整型、浮点型等各种不同类型的数据,并且它不会像Java的泛型那样浪费额外的存储空间。因为C++的模板机制,会根据不同的模板类型生成不同的代码。
|
||||
|
||||
那么,C++具体是如何实现这一套机制的呢?接下来我就带你一起去深入了解一下,从而让你对模板元编程技术的理解也更加深入。
|
||||
|
||||
揭秘模板的实现机制
|
||||
|
||||
首先,我们通过一个示例程序,来观察一下Clang是如何编译模板程序的。假设,你写了一个简单的函数min,用来比较两个参数的大小,并返回比较小的那个参数。
|
||||
|
||||
int min(float a, float b){
|
||||
return a<b ? a : b;
|
||||
}
|
||||
|
||||
|
||||
你可以用clang++命令带上“-ast-dump”参数来编译这个示例程序,并显示编译后产生的AST。
|
||||
|
||||
clang++ -Xclang -ast-dump min.cpp
|
||||
|
||||
|
||||
下图中展示的是min函数对应的AST。你能发现AST节点的命名都很直观,一下子就能看明白每个节点的含义。其中,函数声明的节点是FunctionDecl,也就是Function Declaration的缩写。
|
||||
|
||||
|
||||
|
||||
min函数是一个普通的函数,只适用于参数为浮点型的情况。那么我们再增加一个使用模板的版本,并且函数名称一样,这样就可以支持用多种数据类型来比较大小,比如整型、双精度型等。
|
||||
|
||||
template <typename T> T min(T a, T b){
|
||||
return a<b ? a : b;
|
||||
}
|
||||
|
||||
|
||||
这时,顶层的AST节点是FunctionTemplateDecl,也就是函数模板声明。它有两个子节点,一个是模板类型参数声明(TemplateTypeParmDecl),也就是尖括号里面的部分;第二个子节点其实是一个普通的函数声明节点,其AST的结构几乎跟普通的min函数版本是一样的。
|
||||
|
||||
|
||||
|
||||
这样,通过查看AST,你就能了解函数模板和普通函数的联系和区别了。接下来就要进入重点了:函数模板是如何变成一个具体的函数的?
|
||||
|
||||
为此,我们在main函数里调用一下min函数,并传入两个整型的参数min(2,3):
|
||||
|
||||
int main(){
|
||||
min(2,3);
|
||||
}
|
||||
|
||||
|
||||
这个时候,我们再看一下它生成的AST,就会发现函数模板声明之下,增加了一个新的函数声明。这个函数的名称仍然是min,但是参数类型具体化了,是整型。
|
||||
|
||||
|
||||
|
||||
这说明,当编译器发现有一个min(2,3)这样的函数调用的时候,就会根据参数的类型,在函数模板的基础上生成一个参数类型确定的函数,然后编译成目标代码。这个过程叫做特化(Specialization),也就是从一般到具体的过程。函数模板可以支持各种类型,而特化后的版本只针对某个具体的数据类型。
|
||||
|
||||
那么,特化过程是怎样发生的呢?我们目前只看到了AST,AST反映了编译的结果,但它并没有揭示编译的过程。而只有搞清楚这个过程,我们才能真正理解模板函数的编译机制。
|
||||
|
||||
要揭示编译过程,最快的方法是用调试器来跟踪程序的执行过程。最常用的调试器就是LLDB和GDB。这里我使用的是LLDB,你可以参考我给出的命令来设置断点、调试程序。
|
||||
|
||||
|
||||
|
||||
|
||||
小提示:如果你像我一样,是在macOS中运行LLDB,可能会遇到报错信息,即操作系统不让LLDB附加到被调试的程序上。这是出于安全上的考虑。你需要重启macOS,并在启动时按住command-R键进入系统恢复界面,然后在命令行窗口里输入“csrutil disable”来关闭这个安全选项。
|
||||
|
||||
|
||||
不过,在跟踪clang++执行的时候,你会发现,clang++只是一个壳,真正的编译工作不是在这个可执行文件里完成的。实际上,clang++启动了一个子进程来完成编译工作,这个子进程执行的是clang-10。所以,你需要另外启动一个LLDB,来调试新启动的进程。
|
||||
|
||||
|
||||
|
||||
在使用LLDB的时候,你会发现,确定好在什么位置上设置断点是特别重要的,这能大大节省单步跟踪所花费的时间。
|
||||
|
||||
那么现在,我们想要探究函数模板是什么时候被特化的,应该在哪里设置断点呢?
|
||||
|
||||
在研究前面示例程序的AST的时候,我们发现编译器会在函数特化的时候,创建一棵新的函数声明的子树,这就需要建立一个新的FunctionDecl节点。因此,我们可以监控FunctionDecl的构建函数都是什么时候被调用的,就可以快速得到整个调用过程。
|
||||
|
||||
那怎么查看调用过程呢?当clang-10在FunctionDecl断点停下以后,你可以用“bt”命令打印出调用栈。我把这个调用栈整理了一下,并加了注释,你可以很容易看清楚编译器的运行过程:
|
||||
|
||||
|
||||
|
||||
接着,分析这个调用栈,你会发现其主要的处理过程是这样的:
|
||||
|
||||
|
||||
第一,语法分析器在解析表达式“min(2,3)”的时候,会去做引用消解,弄清楚这个min()函数是在哪里定义的。在这里,你又一次看到语法分析和语义分析交错起来的情况。在这个点上,编译器并没有做完所有的语法分析工作,但是语义分析的功能会被按需调用。
|
||||
第二,由于函数允许重载,所以编译器会在所有可能的重载函数中,去匹配参数类型正确的那个。
|
||||
第三,编译器没有找到与参数类型相匹配的普通函数,于是就去函数模板中找,结果找到了以T作为类型参数的函数模板。
|
||||
第四,根据min(2,3)中参数的类型,对函数模板的类型参数进行推导,结果推导出T应该是整型。这里你要注意,min(2,3)的第一个参数和第二个参数的类型需要是一样的,这样才能推导出正确的模板参数。如果一个是整型,一个是浮点型,那么类型推导就会失败。
|
||||
最后,把推导出来的类型,也就是整型,去替换函数模板中的类型参数,就得到了一个新的函数定义。不过在这里,编译器只生成了函数声明的节点,缺少函数体,是个空壳子。
|
||||
|
||||
|
||||
注意,这里最后一句的说法只是目前我自己的判断,所以我们要来验证一下。
|
||||
|
||||
Clang在重要的数据结构中都有dump()函数,AST节点也有这个函数。因此,你可以在LLDB中调用dump()函数,来显示一棵AST子树的信息。
|
||||
|
||||
(lldb) expr Function->dump()
|
||||
|
||||
|
||||
这个时候,在父进程的LLDB窗口中会显示出被dump出的信息,输出格式跟我们在编译的时候使用-ast-dump参数显示的AST是一样的。从输出的信息中,你会看到当前的函数声明是缺少函数体的。
|
||||
|
||||
|
||||
|
||||
那么,函数体是什么时候被添加进来的呢?这个也不难,你仍然可以用调试器来找到答案。
|
||||
|
||||
从前面函数模板的AST中你已经知道,函数体中包含了一个ConditionalOperator节点。所以,我们可以故技重施,在ConditionalOperator()上设置断点来等着。因为编译器要实例化函数体,就一定会新创建一个ConditionalOperator节点。
|
||||
|
||||
事实证明,这个策略是成功的。程序会按照你的预期在这个断点停下,然后你会得到下面的调用栈:
|
||||
|
||||
|
||||
|
||||
研究这个调用栈,你会得到两个信息:
|
||||
|
||||
|
||||
从函数模板实例化出具体的函数,是被延后执行的,程序是在即将解析完毕AST之后才去执行这项任务的。
|
||||
Clang使用了TreeTransform这样的工具类,自顶向下地遍历一棵子树,来完成对AST的变换。
|
||||
|
||||
|
||||
这样,经过上述处理以后,函数的特化才算最终完成。这个时候你再dump一下这个函数声明节点的信息,就会发现它已经是一个完整的函数声明了。
|
||||
|
||||
好了,到此为止,你就知道了Clang对函数模板的处理过程。我再给你强调一下其中的关键步骤,你需要好好掌握:
|
||||
|
||||
|
||||
在处理函数调用时,要去消解函数的引用,找到这个函数的定义;
|
||||
如果有多个重载的函数,需要找到参数类型匹配的那个;
|
||||
如果找不到符合条件的普通函数,那就去找函数模板;
|
||||
找到函数模板后,推导出模板参数,也就是正确的数据类型;
|
||||
之后,根据推导出的模板参数来生成一个具体的函数声明。
|
||||
|
||||
|
||||
其中的关键点,是特化的过程。编译器总是要把模板做特化处理,然后才能被程序的其他部分使用。
|
||||
|
||||
抓住了这个关键点,你还可以进一步在大脑中推演一下编译器是如何处理类模板的。然后你可以通过打印AST和跟踪执行这两个技术手段,来验证你的想法。
|
||||
|
||||
不过,模板技术可不仅仅能够支持函数模板和类模板,它还有很多其他的能力。比如,在第36讲我介绍元编程的时候,曾经举过一个计算阶乘的例子。在那个例子中,模板参数不是类型,而是一个整数,这样程序就可以在编译期实现对阶乘值的计算。
|
||||
|
||||
好了,现在你已经知道,对于类型参数,编译器的主要工作是进行类型推导和特化。
|
||||
|
||||
那么针对非类型参数,编译器是如何处理的呢?如何完成编译期的计算功能的呢?接下来,我们就一起来分析一下。
|
||||
|
||||
使用非类型模板参数
|
||||
|
||||
首先,你可以看看我新提供的这个示例程序,这个程序同样使用了模板技术,来计算阶乘值。
|
||||
|
||||
template<int n>
|
||||
struct Fact {
|
||||
static const int value = n*Fact<n-1>::value; //递归计算
|
||||
};
|
||||
|
||||
template<>
|
||||
struct Fact<1> {
|
||||
static const int value =1; //当参数为1时,阶乘值是1
|
||||
};
|
||||
|
||||
int main(){
|
||||
int a = Fact<3>::value; //在编译期就计算出阶乘值
|
||||
}
|
||||
|
||||
|
||||
在Fact这个结构体中,value是一个静态的常量。在运行时,你可以用Fact::value这样的表达式,直接使用一个阶乘值,不需要进行计算。而这个值,其实是在编译期计算出来的。
|
||||
|
||||
那编译期具体的计算过程是怎样的呢?你可以像我们在前面研究函数模板那样如法炮制,马上就能探究清楚。
|
||||
|
||||
比如,你可以先看一下示例程序在编译过程中形成的AST,我在其中做了一些标注,方便你理解:
|
||||
|
||||
|
||||
|
||||
可以看到,在AST中,首先声明了一个结构体的模板,其AST节点的类型是ClassTemplateDecl。
|
||||
|
||||
接着,是针对这个模板做的特化。由于在main函数中引用了Fact::value,所以编译器必须把Fact特化。特化的结果,是生成了一棵ClassTemplateSpecializationDecl子树,此时模板参数为3。而这个特化版本又引用了Fact::value。
|
||||
|
||||
那么,编译器需要再把Fact特化。进一步,这个特化版本又引用了Fact::value。
|
||||
|
||||
而Fact这个特化版本,在程序中就已经提供了,它的value字段的值是常数1。
|
||||
|
||||
那么,经过这个分析过程,Fact的值就可以递归地计算出来了。如果Fact<n>中,n的值更大,那计算过程也是一样的。
|
||||
|
||||
Fact<3>::value = 3 * Fact<2>::value
|
||||
= 3 * 2 * Fact<1>::value
|
||||
= 3 * 2 * 1
|
||||
|
||||
|
||||
另外,你还可以用这节课中学到的debug方法,跟踪一下上述过程,验证一下你的想法。在这个过程中,你仍然要注意设置最合适的断点。
|
||||
|
||||
课程小结
|
||||
|
||||
今天我们一起探讨了C++的模板机制的部分功能,并借此了解了Clang编译C++程序的机制。通过这节课,你会发现编译器是通过特化的机制,来生成新的AST子树,也就是生成新的程序,从而支持模板机制的。另外你还要明确,特化的过程是递归的,直到不再有特化任务为止。
|
||||
|
||||
模板功能是一个比较复杂的功能。而你发现,当你有能力进到编译器的内部时,你会更快、更深刻地掌握模板功能的实质。这也是编译原理知识对于学习编程的帮助。
|
||||
|
||||
探究C++的编译器是一项有点挑战的工作。所以在这节课里,我更关注的是如何带你突破障碍,掌握探究Clang编译器的方法。这节课我只带你涉及了Clang编译器一个方面的功能,你可以用这节课教给你的方法,继续去探究你关心的其他特性是如何实现的,可能会有很多惊喜的发现呢!
|
||||
|
||||
一课一思
|
||||
|
||||
在计算阶乘的示例程序中,当n是正整数时,都是能够正常编译的。而当n是0或者负数时,是不能正常编译的。你能否探究一下,编译器是如何发现和处理这种类型的编译错误的呢?
|
||||
|
||||
欢迎在留言区分享你的发现。如果你使用这节课的方法探究了C++编译器的其他特性,也欢迎你分享出来。
|
||||
|
||||
|
||||
|
||||
|
@ -0,0 +1,166 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
01 CISC & RISC:从何而来,何至于此
|
||||
你好,我是LMOS。
|
||||
|
||||
这个专栏我会带你学习计算机基础。什么是基础?
|
||||
|
||||
基础就是根,从哪里来,到哪里去。而学习计算机基础,首先就要把握它的历史,这样才能了解计算机是怎么一步步发展到今天这个样子的,再根据今天的状况推导出未来的发展方向。
|
||||
|
||||
正所谓读历史方知进退,明兴衰。人类比其它动物高级的原因,就是人类能使用和发现工具。从石器时代到青铜器时代,再到铁器时代,都是工具种类和材料的发展,推动了文明升级。
|
||||
|
||||
让我们先从最古老的算盘开始聊起,接着了解一下机械计算机、图灵机和电子计算机。最后我会带你一起看看芯片的发展,尤其是它的两种设计结构——CISC与RISC。
|
||||
|
||||
从算盘到机械计算机
|
||||
|
||||
算盘就是一种辅助计算的工具,由中国古代劳动人民发明,迄今已有两千多年的历史,一直沿用至今。我准备了算盘的平面草图,你可以感受一下:
|
||||
|
||||
|
||||
|
||||
上图中周围一圈蓝色的是框架,一串一串的是算椽和算珠,一根算椽上有七颗算珠,可以上下拨动,从右至左有个、十、百……亿等计数位。有了算盘,计算的准确性和速度得到提高,我们从中可以感受到先辈的智慧。
|
||||
|
||||
与其说算盘是计算机,还不如说它是个数据寄存器。“程序”的执行需要人工实现,按口诀拨动算珠。过了两千多年,人们开始思考,能不能有一种机器,不需要人实时操作就能自动完成一些计算呢?
|
||||
|
||||
16世纪,苏格兰人John Napier发表了论文,提到他发明了一种精巧设备,可以进行四则运算和解决方根运算。之后到了18世纪,英国人Babbage设计了一台通用分析机。这期间还出现了计算尺等机械计算设备,主要是利用轴、杠杆、齿轮等机械部件来做计算。
|
||||
|
||||
尤其是Babbage设计的分析机,设计理论非常超前,既有保存1000个50位数的“齿轮式储存室”,用于运算的“运算室”,还有发送和读取数据的部件以及负责在“存储室”、“运算室”运算运输数据的部件。具体的构思细节,你有兴趣可以自行搜索资料探索。
|
||||
|
||||
一个多世纪之后,现代电脑的结构几乎是Babbage分析机的翻版,无非是主要部件替换成了大规模集成电路。仅此一点,Babbage作为计算机系统设计的“开山鼻祖”,就当之无愧。
|
||||
|
||||
值得一提的是,Babbage设计分析机的过程里,遇到了一位得力女助手——Ada。虽说两人的故事无从考证,但Ada的功劳值得铭记,她是为分析机编写程序(计算三角函数的程序、伯努利函数程序等)的第一人,也是公认的世界上第一位软件工程师。
|
||||
|
||||
又过了一个世纪,据说美国国防部花了十年光阴,才把开发军事产品所需的全部软件功能,都归纳整理到了一种计算机语言上,期待它成为军方千种计算机的标准。1981年,这种语言被正式命名为ADA语言。
|
||||
|
||||
可惜的是,这种分析机需要非常高的机械工程制造技术,后来政府停止了对他们的支持。尽管二人后来贫困潦倒,Ada也在36岁就英年早逝,但这两个人的思想和为计算机发展作出的贡献,足以彪炳史册,流芳百世。
|
||||
|
||||
图灵机
|
||||
|
||||
机械计算机有很多缺点,比如难于制造,难于维护,计算速度太慢,理论不成熟等。这些难题导致用机械计算机做通用计算的话,并不可取。
|
||||
|
||||
而真正奠定现代通用计算机理论的人,在20世纪初横空出世,他就是图灵,图灵奖就是用他名字命名的。
|
||||
|
||||
图灵在计算可行性和人工智能领域贡献卓越,最重要的就是提出了图灵机。
|
||||
|
||||
图灵机的概念是怎么来的呢?图灵在他的《论可计算数及其在判定问题中的应用》一文中,全面分析了人的计算过程。他把计算提炼成最简单、基本、确定的动作,然后提出了一种简单的方法,用来描述机械性的计算程序,让任何程序都能对应上这些动作。
|
||||
|
||||
该方法以一个抽象自动机概念为基础,不但定义了什么“计算”,还首次将计算和自动机联系起来。这对后世影响巨大,而这种“自动机”后来就被我们称为“图灵机”。
|
||||
|
||||
图灵机是一个抽象的自动机数学模型,它是这样运转的:有一条无限长的纸带,纸带上有无限个小格子,小格子中写有相关的信息。纸带上有一个读头,读头能根据纸带小格子里的信息做相关的操作,并且能来回移动。
|
||||
|
||||
如果你感觉文字叙述还不够形象,我再来画一幅示意图:
|
||||
|
||||
|
||||
|
||||
我们不妨想象一下,把自己写的一条条代码,放入上图纸带的格子中,随着读头的读取代码做相应的动作。读头移动到哪一个,就会读取哪一格的代码,然后执行相应的顺序、跳转、循环动作,完成相应计算工作。
|
||||
|
||||
如果我们把读头及读头的运行规则理解为CPU,把纸带解释为内存,把纸带上信息理解为程序和数据,那这个模型就非常接近现代计算机了。在我看来,以最简单的方法抽象出统一的计算模型,这就是图灵的伟大之处。
|
||||
|
||||
电子计算机
|
||||
|
||||
图灵机这种美好的抽象模型,如果没有好的实施方案,是做不出实际产品的,这将是一个巨大的遗憾。为此,人类进行了多次探索,可惜都没有结果。最后还是要感谢弗莱明和福雷斯特,尽管他们一个是英国人,一个是美国人。
|
||||
|
||||
这两个人本来没什么交集,不过后来福雷斯特在弗莱明的真空二极管里,加上了一个电极(一种栅栏式的金属网,形成电子管的第三个极),就获得了可以放大电流的新器件,他把这个新器件命名为三极管,也叫真空三极管。这个三极管提高了弗莱明的真空二极管的检波灵敏度。
|
||||
|
||||
不过,一个三极管虽然做不了计算机,但是许多个三极管组合起来形成的数字电路,就能够实现布尔代数中的逻辑运算,电子计算机的大门自此打开。
|
||||
|
||||
1946年,ENIAC成功研制,它诞生于美国宾夕法尼亚大学,是世界上第一台真正意义上的电子计算机。
|
||||
|
||||
ENIAC占地面积约170平方米,估计你在城里的房子也放不下这台机器。它有多达30个操作台,重达30吨,耗电量150千瓦。
|
||||
|
||||
别说屋子里放不下,电费咱们也花不起。这台机器包含了17468根电子管和7200根晶体二极管,1500个继电器,6000多个开关等许多其它电子元件,计算速度是每秒5000次加法或者400次乘法,大约是人工计算速度的20万倍。
|
||||
|
||||
但是三极管也不是完美的,因为三极管的内部封装在一个抽成真空的玻璃管中,这种方案在当时是非常高级的,但是仍然不可靠,用不了多久就会坏掉了。电子计算机一般用一万多根三极管,坏了其中一根,查找和维护都极为困难。
|
||||
|
||||
直到1947年12月,美国贝尔实验室的肖克利、巴丁和布拉顿组成的研究小组,研制出了晶体管,问题才得以解决。现在我们常说的晶体管通常指的是晶体三极管。
|
||||
|
||||
晶体三极管跟真空三极管功能一样,不过制造材料是半导体。它的特点在于响应速度快,准确性高,稳定性好,不易损坏。关键它可以做得非常小,一块集成电路即可容纳十几亿到几十亿个晶体管。
|
||||
|
||||
这样的器件用来做计算机就是天生的好材料。可以说,晶体管是后来几十年电子计算机飞速发展的基础。没有晶体管,我们简直不敢想像,计算机能做成今天这个样子。具体是如何做的呢?我们接着往下看。
|
||||
|
||||
芯片
|
||||
|
||||
让我们加点速,迈入芯片时代。我们不要一提到芯片,就只想到CPU。
|
||||
|
||||
CPU确实也是芯片中的一种,但芯片是所有半导体元器件的统称,它是把一定数量的常用电子元件(如电阻、电容、晶体管等),以及这些元件之间的连线,通过半导体工艺集成在一起的、具有特定功能的电路。你也可以把芯片想成集成电路。
|
||||
|
||||
那芯片是如何实现集成功能的呢?
|
||||
|
||||
20世纪60年代,人们把硅提纯,切成硅片。想实现具备一定功能的电路,离不开晶体管、电阻、电容等元件及它们之间的连接导线,把这些集成到硅片上,再经过测试、封装,就成了最终的产品——芯片。相关的制造工艺(氧化、光刻、粒子注入等)极其复杂,是人类的制造极限。
|
||||
|
||||
正因为出现了集成电路,原先占地广、重量大的庞然大物才能集成于“方寸之间”。而且性能高出数万倍,功耗缩小数千倍。随着制造工艺的升级,现在指甲大小的晶片上集成数十亿个晶体管,甚至在一块晶片上集成了CPU、GPU、NPU和内部总线等,每秒钟可进行上10万亿次操作。在集成电路发展初期,这样的这样的性能是不可想像的。
|
||||
|
||||
下面我们看看芯片中的特例——CPU,它里面包括了控制部件和运算部件,即中央处理器。1971年,Intel将运算器和控制器集成在一个芯片上,称为4004微处理器,这标志着CPU的诞生。到了1978年,开发的8086处理器奠定了X86指令集架构。此后,8086系列处理器被广泛应用于个人计算机以及高性能服务器中。
|
||||
|
||||
那CPU是怎样运行的呢?CPU的工作流程分为以下 5 个阶段:取指令、指令译码、执行指令、访存读取数据和结果写回。指令和数据统一存储在内存中,数据与指令需要从统一的存储空间中存取,经由共同的总线传输,无法并行读取数据和指令。这就是大名鼎鼎的冯诺依曼体系结构。
|
||||
|
||||
CPU运行程序会循环执行上述五个阶段,它既是程序指令的执行者,又被程序中相关的指令所驱动,最后实现了相关的计算功能。这些功能再组合成相应算法,然后由多种算法共同实现功能强大的软件。
|
||||
|
||||
既然CPU的工作离不开指令,指令集架构就显得尤其重要了。
|
||||
|
||||
CISC
|
||||
|
||||
从前面的内容中,我们已经得知CPU就是不断地执行指令,来实现程序的执行,最后实现相应的功能。但是一颗CPU能实现多少条指令,每条指令完成多少功能,却是值得细细考量的问题。
|
||||
|
||||
显然,CPU的指令集越丰富、每个指令完成的功能越多,为该CPU编写程序就越容易,因为每一项简单或复杂的任务都有一条对应的指令,不需要软件开发人员写大量的指令。这就是复杂指令集计算机体系结构——CISC。
|
||||
|
||||
CISC的典型代表就是x86体系架构,x86 CPU中包含大量复杂指令集,比如串操作指令、循环控制指令、进程任务切换指令等,还有一些数据传输指令和数据运算指令,它们包含了丰富的内存寻址操作。
|
||||
|
||||
有了这些指令,工程师们编写汇编程序的工作量大大降低。CISC的优势在于,用少量的指令就能实现非常多的功能,程序自身大小也会下降,减少内存空间的占用。但凡事有利就有弊,这些复杂指令集,包含的指令数量多而且功能复杂。
|
||||
|
||||
而想实现这些复杂指令,离不开CPU运算单元和控制单元的电路,硬件工程师要想设计制造这样的电路,难度非常高。
|
||||
|
||||
到了20世纪80年代,各种高级编程语言的出现,大大简化了程序的开发难度。
|
||||
|
||||
高级语言编写的代码所对应的语言编译器,很容易就能编译生成对应的CPU指令,而且它们生成的多条简单指令,跟原先CISC里复杂指令完成的功能等价。因此,那些功能多样的复杂指令光环逐渐黯淡。
|
||||
|
||||
说到这里,你应该也发现了,在CPU发展初期,CISC体系设计是合理的,设计大量功能复杂的指令是为了降低程序员的开发难度。因为那个时代,开发软件只能用汇编或者机器语言,这等同于用硬件电路设计帮了软件工程师的忙。
|
||||
|
||||
随着软硬件技术的进步,CISC的局限越来越突出,因此开始出现了与CISC相反的设计。是什么设计呢?我们继续往下看。
|
||||
|
||||
RISC
|
||||
|
||||
每个时代都有每个时代的产物。
|
||||
|
||||
20世纪80年代,编译器技术的发展,导致各种高级编程语言盛行。这些高级语言编译器生成的低级代码,比程序员手写的低级代码高效得多,使用的也是常用的几十条指令。
|
||||
|
||||
前面我说过,文明的发展离不开工具的种类与材料升级。指令集的发展,我们也可以照这个思路推演。芯片生产工艺升级之后,人们在CPU上可以实现高速缓存、指令预取、分支预测、指令流水线等部件。
|
||||
|
||||
不过,这些部件的加入引发了新问题,那些一次完成多个功能的复杂指令,执行的时候就变得捉襟见肘,困难重重。
|
||||
|
||||
比如,一些串操作指令同时依赖多个寄存器和内存寻址,这导致分支预测和指令流水线无法工作。另外,当时在IBM工作的John Cocke也发现,计算机80%的工作由大约20%的CPU指令来完成,这代表CISC里剩下的80%的指令都没有发挥应有的作用。
|
||||
|
||||
这些最终导致人们开始向CISC的反方向思考,由此产生了RISC——精简指令集计算机体系结构。
|
||||
|
||||
正如它的名字一样,RISC设计方案非常简约,通常有20多条指令的简化指令集。每条指令长度固定,由专用的加载和储存指令用于访问内存,减少了内存寻址方式,大多数运算指令只能访问操作寄存器。
|
||||
|
||||
而CPU中配有大量的寄存器,这些指令选取的都是工程中使用频率最高的指令。由于指令长度一致,功能单一,操作依赖于寄存器,这些特性使得CPU指令预取、分支预测、指令流水线等部件的效能大大发挥,几乎一个时钟周期能执行多条指令。
|
||||
|
||||
这对CPU架构的设计和功能部件的实现也很友好。虽然完成某个功能要编写更多的指令,程序的大小也会适当增加,更占用内存。但是有了高级编程语言,再加上内存容量的扩充,这些已经不是问题。
|
||||
|
||||
RISC的代表产品是ARM和RISC-V。其实到了现在,RISC与CISC早已没有明显界限,开始互相融合了,比如ARM中加入越来越多的指令,x86 CPU通过译码器把一条指令翻译成多条内部微码,相当于精简指令。x86这种外CISC内RISC的选择,正好说明了这一点。
|
||||
|
||||
历史的车轮滚滚向前,留下的都是经典,历史也因此多彩而厚重,今天的课程就到这里了,我们要相信,即便自己不能改写历史,也能在历史上留下点什么。我们下一节课见,下次,我想继续跟你聊聊芯片行业的新贵RISC-V。
|
||||
|
||||
重点回顾
|
||||
|
||||
今天我们一起完成了一次“穿越之旅”,从最早的算盘、机械计算机,现代计算机雏形的图灵机,一路讲到芯片和CPU的两种指令架构集。
|
||||
|
||||
其实仅仅一节课的时间,很难把计算机的历史一一道来,所以我选择了那些对计算机产生和演进最关键的事件或者技术,讲给你听。我把今天的重点内容为你梳理了一张思维导图。
|
||||
|
||||
-
|
||||
有了这些线索,你就能在脑海里大致勾勒出,计算机是如何一步步变成今天的样子。技术发展的“接力棒”现在传到了我们这代人手里,我对未来的发展充满了期待。
|
||||
|
||||
就拿CPU的发展来说,我觉得未来的CPU可能是多种不同指令集的整合,一个CPU指令能执行多类型的指令,分别完成不同的功能。不同类型的指令由不同的CPU功能组件来执行,有的功能组件执行数字信号分析指令,有的功能组件执行图形加速指令,有的功能组件执行神经网络推算指令……
|
||||
|
||||
思考题
|
||||
|
||||
为什么RISC的CPU能同时执行多条指令?
|
||||
|
||||
欢迎你在留言区跟我交流互动,如果觉得这节课讲得不错,也推荐你分享给身边的朋友。
|
||||
|
||||
|
||||
|
||||
|
@ -0,0 +1,194 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
25 堆&栈:堆与栈的区别和应用
|
||||
你好,我是LMOS。
|
||||
|
||||
在上一课中,我们讲了虚拟内存和物理内存,明白了虚拟内存是一个假想的地址空间,想要真正工作运行起来,就必须要经过MMU把虚拟地址转换成物理地址,寻址索引到真正的DRAM。
|
||||
|
||||
今天,我们继续深入到应用程序的虚拟内存地址空间中,弄清楚一个常规应用程序的虚拟内存地址空间中都有哪些东西。首先,我们看看里面的整体布局,然后看看里面的堆与栈,最后我还会重点带你了解一下堆与栈的区别和应用场景。
|
||||
|
||||
课程的配套代码你可以从这里下载。
|
||||
|
||||
应用程序的虚拟内存布局
|
||||
|
||||
你可以把应用程序的虚拟内存,想成一个房子。房子自然要有个合理的布局,有卧室、客厅、厨房这些不同的房间。同样地,应用程序的虚拟内存,承载着应用程序的指令、数据、资源等各种信息。
|
||||
|
||||
既然我们想要观察应用程序的虚拟内存布局,首先得有一个应用程序。当然,你也可以观察系统正在运行的应用程序,但是这些应用往往是很复杂的。
|
||||
|
||||
为了找到一个足够简单、又能说明问题的观察对象,我们还是自己动手写一个应用,代码如下所示:
|
||||
|
||||
#include "stdio.h"
|
||||
#include "stdlib.h"
|
||||
#include "unistd.h"
|
||||
//下面变量来自于链接器
|
||||
extern int __executable_start,etext, edata, __bss_start, end;
|
||||
int main()
|
||||
{
|
||||
char c;
|
||||
printf("Text段,程序运行时指令数据开始:%p,结束:%p\n", &__executable_start, &etext);
|
||||
printf("Data段,程序运行时初始化全局变量和静态变量的数据开始:%p,结束:%p\n", &etext, &edata);
|
||||
printf("Bss段,程序运行时未初始化全局变量和静态变量的数据开始:%p,结束:%p\n", &__bss_start, &end);
|
||||
while(1)
|
||||
{
|
||||
printf("(pid:%d)应用程序正在运行,请输入:c,退出\n", getpid());
|
||||
printf("请输入:");
|
||||
c = getchar();
|
||||
if(c == 'c')
|
||||
{
|
||||
printf("应用程序退出\n");
|
||||
return 0;
|
||||
}
|
||||
printf("%c\n", c);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
我来给你解释一下这个简单的应用程序,开始的三个printf函数会输出该应用程序自身的三大段,即Text段、Data段、Bss段的开始、结束地址,这些地址由链接器产生,都在应用程序的虚拟内存空间中。Text段、Data段、Bss段中包含了什么,在代码里我已经做了说明,只是Bss段并不在应用程序文件中占有空间,而是操作系统加载应用程序时,动态分配空间并将其初始化为0。
|
||||
|
||||
由于我们要观察应用程序在运行中的虚拟内存布局,这就需要人为地控制应用程序退出,而不是不直接运行完就退出,导致我们没办法观察。所以,我们要在一个死循环中,输出应用程序对应进程的id和提示信息,然后等待我们下一步的输入。如果输入c则退出,否则输出信息,继续循环。
|
||||
|
||||
你现在需要把这个应用程序编译并运行起来。其实这个工作并不复杂,只需要进入对应的工程目录,make一下,再make run就可以把程序运行起来了。
|
||||
|
||||
要如何才能观察到应用程序的虚拟内存布局呢?这在Windows下非常困难,但是Linux对开发人员很友好,它提供了一个proc文件系统,这个目录下有所有应用程序进程的相关信息,每个进程一个文件夹,文件夹的名称就是进程的id,这就是上述代码中要打印进程的pid的原因。
|
||||
|
||||
每个进程目录下,包括一个maps和smaps文件,后者更为详细,我们只要用后面的命令读取它们就行了。
|
||||
|
||||
sudo cat /proc/59916/maps > main.map
|
||||
#或者
|
||||
sudo cat /proc/59916/smaps > main.map
|
||||
|
||||
|
||||
上述命令是我机器上的情况,59916是我运行程序后给出的pid,上述命令就是把/proc/59916/maps 或者 smaps 读取输出到main.map文件中,我们打开main.map文件,看到的情况如下图所示:
|
||||
|
||||
|
||||
|
||||
对照截图我们可以看到,每一行都表示一个应用进程虚拟内存中的一个区段。第一列表示该区间的起始、结束虚拟地址。第二列是该区段的属性,r代表读、w代表写、x代表执行、p代表私有。最后一列是该区段的内容属于哪个文件。
|
||||
|
||||
我们发现,一个应用程序运行之后,它的虚拟内存中不仅仅有它自身的指令和数据,main.elf一共有5个区段,包含了text、data、bss,还有其它的文件内容,比如共享动态链接库。共享动态链接库也是一种程序,可以通过应用调用其功能接口。
|
||||
|
||||
同时,我们也注意到了后面要详细探索的堆、栈,我为你画幅图总结一下,如下所示:
|
||||
|
||||
|
||||
|
||||
应用程序自身的段,取决于编译器和链接器的操作,堆段、内存映射段、栈段、环境变量和命令行参数段,这取决于操作系统的定义。需要注意的是,堆段和栈段的大小都是动态增加和减少的、且增长方向相反。堆是向高地址方向增长,栈是向低地址方向增长。这就是一个应用程序被操作系统加载运行后的虚拟内存布局。
|
||||
|
||||
堆
|
||||
|
||||
下面我们将重点关注堆和栈。我们经常把堆栈作为一个名词,连在一起说,但这其实并不准确。因为堆是堆而栈是栈,这是两个不同的概念,不可以混为一谈。
|
||||
|
||||
在计算机学科里,堆(heap)是一类特殊的数据结构的统称,我们通常把堆看作一棵树的数组对象。堆具备这样两个性质:一是堆中某个结点的值总是不大于或不小于其父结点的值;二是堆总是一棵完全二叉树。
|
||||
|
||||
不过,我们今天要关注的重点,是操作系统为应用程序建立的堆。所以这节课要探讨的“堆”,不具有数据结构中对堆定义的完整特性,你可以只把它看作一个可以动态增加和减少大小的数组对象。
|
||||
|
||||
简单点说,堆就是应用程序在运行时刻调用malloc函数时,动态分配的一块儿内存区域,有了它,就能满足应用程序在运行时动态分配内存空间,从而存放数据的需求了。
|
||||
|
||||
你可以结合后面的示意图来理解。
|
||||
|
||||
|
||||
|
||||
由上图可以看出,堆其实是虚拟内存空间中一个段,由堆的开始地址和结束地址控制其大小,有一个堆指针指向未分配的第一个字节。所以,堆在本质上是指应用程序在运行过程中进行动态分配的内存区域,堆的管理通常在库函数中完成。
|
||||
|
||||
之所以叫做堆,是因为通常会使用堆这种数据结构来管理分配出来的这块内存,但也可以使用更简单的方法来管理,下面让我们看看Linux是如何对堆区进行操作的。
|
||||
|
||||
|
||||
|
||||
关于如何得到上图右边的map文件,可以参考前面应用程序虚拟内存布局的那部分内容。
|
||||
|
||||
上图代码中的sbrk函数是库函数,它会调用Linux内核中的brk系统调用。这个brk系统调用,用于增加或者减少进程的mm_struct中的堆区指针brk。
|
||||
|
||||
由于堆区指针始终指向未分配的堆区空间,brk系统调用会首先保存当前的brk到临时的tmpbrk,然后让当前brk加上传进来的大小,赋给brk,最后返回tmpbrk,这样就实现了堆区内存的分配。你可以看到图中三次调用sbrk函数返回的地址,确实落在应用程序的堆区内。
|
||||
|
||||
分配的地址也是从低到高,这也验证了我们之前所说的堆的增长方向。你也可以自行阅读Linux内核中,brk系统调用函数的代码进行考证,尽管内核代码中的细节很多,但核心逻辑和我们这里描述的相差无几。
|
||||
|
||||
堆也有界限,虽然可以调整,但却不能无限增加其大小。堆到底可以“占多大面积”,这取决于虚拟地址空间的大小和操作系统的定义。
|
||||
|
||||
在堆区分配内存速度很快,为什么呢?根据前面的信息可知,在堆区分配内存,只需要增加堆指针就行了,因此分配速度很快。由于实现分配的大小与请求分配大小是相同的、且地址也是连续的,所以它不会有内存碎片的情况。
|
||||
|
||||
但这个分配方式有一个致命的缺点,释放堆区中的内存不会立即见效。比如上述代码中,分配了alloc2之后,释放alloc,虽然这时currheap与alloc2之间有空闲内存,这时也是不能分配的,由此产生了内存空洞,只有等alloc2也释放了,内存空洞才会消失。
|
||||
|
||||
现在我们已经知道了操作系统为应用程序建立的堆,不同于数据结构中的堆。应用程序的堆区,不过是一个动态增加或减少的内存空间,用于应用程序动态分配内存,它的分配性能很好,但会产生内存空洞。
|
||||
|
||||
好,堆就说到这里,我们接下来去研究栈。
|
||||
|
||||
栈
|
||||
|
||||
说到栈,你应该想到存储货物的仓库或者供旅客歇脚住宿的客栈,那么引入到计算机领域里,就是指数据暂时存储的地方,所以才有了后面的压栈、出栈的说法。
|
||||
|
||||
虽然应用程序的堆区和数据结构中的堆不是一回事儿,但应用程序的栈区确实就是数据结构的那个栈。栈是支持程序运行的基本数据结构,它跟计算机硬件,比如CPU的栈指针寄存器、操作系统息息相关,还跟编译器关系密切。
|
||||
|
||||
我们先来看看栈的本质是什么,再分析它怎么用。
|
||||
|
||||
栈作为一种数据结构,相当于只能在一端进行插入和删除操作的特殊线性表。它按照后进先出的原则存储数据,先进入的数据被压入栈底,最后进入的数据在栈顶,需要读数据的时候从栈顶开始弹出数据,最后进入的数据会被首先读出来。
|
||||
|
||||
你可以把栈想象成一个桶,你往桶里压入东西就是压栈,你从桶里拿出东西就是出栈。但是要记住,你只能从桶的最上面开始拿,这就是栈。如下图所示:
|
||||
|
||||
|
||||
|
||||
由于CPU的硬件特性,导致栈是从内存高地址向内存低地址增长的,所以实际应用程序中的栈更像是一个倒立的桶,栈其实也像一个反过来的堆。
|
||||
|
||||
栈有两个基本的操作:压栈和出栈,有时也称为压入和弹出。压入操作就是栈指针减去一个栈中对象的大小,然后将对象写入栈指针对应的内存空间中;而弹出是将栈指针指向的对象读出,然后将栈指针加上一个栈中对象的大小,从而指向栈中的前一个对象。
|
||||
|
||||
前面我们说过栈是和计算机硬件相关的,那是因为CPU很多指令都依赖于栈,例如x86 CPU的 call、ret、push、pop等指令,push和pop是栈的压入和弹出指令,call是函数调用指令,它把下一条指令的地址压入栈中,而ret指令则将call指令压入栈中的地址弹出,实现函数返回。
|
||||
|
||||
栈还和编译器,特别跟C语言编译器有关,这是因为我们在函数中定义的局部变量,就是放在栈中的。C语言编译器会生成额外的代码,来为局部变量在栈中分配和释放空间,自动处理各个变量的生命周期,不需要程序员手动维护,更不用担心局部变量导致内存泄漏,因为C函数返回时会自己从栈中弹出变量。栈的先进后出的特性,能保证被调用函数可以使用调用者函数的数据,反过来就不行了。
|
||||
|
||||
另一个重点是函数的调用和返回,也是依赖于栈,所以C语言想要正常工作,必须要有栈才行。下面我们写代码验证一下。
|
||||
|
||||
我们来写两个函数,主要就是打印自身的三个局部变量的地址,stacktest2函数被stacktest1函数调用,而stacktest1函数最终会被main函数所调用。打印这些局部变量的地址,是为了方便我们查看这些变量放在了内存的什么地方。代码如下:
|
||||
|
||||
void stacktest2()
|
||||
{
|
||||
long val1 = 1;
|
||||
long val2 = 2;
|
||||
long val3 = 3;
|
||||
printf("stacktest2运行时val1地址:%p val2地址:%p val3地址:%p\n", &val1, &val2, &val3);
|
||||
return;
|
||||
}
|
||||
void stacktest1()
|
||||
{
|
||||
long val1 = 1;
|
||||
long val2 = 2;
|
||||
long val3 = 3;
|
||||
printf("stacktest1运行时val1地址:%p val2地址:%p val3地址:%p\n", &val1, &val2, &val3);
|
||||
stacktest2();
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
按照前面的描述,C函数的局部变量是放在栈中的,现在我们运行这个程序看一看,运行截图如下所示:
|
||||
|
||||
|
||||
|
||||
由上图可以看出,两个函数的三个变量都落在了应用程序的栈区,我们可以用课程开头的命令得到图中的map文件,就可以看到应用程序栈区的地址区间的范围了。
|
||||
|
||||
再结合前面说的栈区空间是从高地址向低地址方向增长继续分析。我们首先看到的是stacktest1函数的三个变量,其地址从高到低每次会下降8个字节,这就是因为long类型在64位系统上占用8字节的空间。然后是stacktest2函数的三个变量,它们的地址要远低于stacktest1函数的三个变量的地址,这是因为stacktest2函数是被stacktest1函数调用的。
|
||||
|
||||
现在我们已经知道了,栈是现代计算机运行不可缺少的基础数据结构。本质上,栈就是动态增长的内存空间,它遵守先进后出的原则,在此基础上就定义了两个操作:压入和弹出。
|
||||
|
||||
重点回顾
|
||||
|
||||
今天我们学习了应用虚拟内存布局。需要区分清楚的是,堆是堆、栈是栈,它们之间区别很大。理解了这节课,相信你也能清晰地把堆和栈的本质讲给身边的同学了。
|
||||
|
||||
现在我们来回顾一下这节课的重点内容。首先,我们从应用程序的虚拟内存空间布局出发,了解了应用程序虚拟内存空间中都有什么。除了程序自身的指令和数据,虚拟内存空间里包括有堆区、内存映射区、栈区、环境变量与命令行参数区。
|
||||
|
||||
然后,我们重点研究了堆,发现应用程序虚拟内存空间的堆区,跟数据结构里的堆并不是一回事儿,它只是一个可以从低地址向高地址动态增长的内存空间,用于应用程序动态分配内存。
|
||||
|
||||
最后,我们探讨了栈。硬件、应用程序、高级语言编译器,都需要栈。它是一种地址由高向低动态增长的内存空间,并且定义了压栈、出栈两个操作,遵守先进后出的原则。C语言的运行环境必须要有栈,栈是现代计算机运行的基础数据结构。
|
||||
|
||||
这节课的导图如下,供你参考回顾。
|
||||
|
||||
|
||||
|
||||
思考题
|
||||
|
||||
你觉得堆、栈空间是虚拟内存空间吗?如果是,请问是在什么时候分配的物理内存呢?
|
||||
|
||||
期待你在留言区记录自己的思考或疑问,积极参与是提升学习效果的秘诀。如果觉得这节课不错,别忘了分享给更多朋友。
|
||||
|
||||
|
||||
|
||||
|
@ -0,0 +1,171 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
加餐02 学习攻略(一):大数据&云计算,究竟怎么学?
|
||||
你好,我是LMOS。
|
||||
|
||||
上节课,我带你了解了云计算中IAAS层的技术。结合云计算的分层架构,下面一层就是PaaS,PaaS与IaaS相似,区别在于云服务提供商还提供了操作系统和数据库。
|
||||
|
||||
这节课,我们就一起了解一下云计算PaaS层的大数据体系吧。什么是大数据呢?其实这是早在1980年出版的图书《第三次浪潮》里就预见到的一种场景,而具体到工程落地层面,就不得不提到Google的“三驾马车”。
|
||||
|
||||
今天这节课,我想从需求角度,和你讨论一下在工程上为什么要这样设计。
|
||||
|
||||
GFS的核心问题
|
||||
|
||||
我们先从谷歌文件系统GFS开始说起。
|
||||
|
||||
顾名思义,这个系统是用来储存文件的。你可能觉得,存文件听起来好像不难呀?
|
||||
|
||||
我们可以仔细思考一下,存文件会有什么难度呢?先让我们停下手头的工作,看看自己电脑上的硬盘空间还有多大,500G还是1TB、5TB?
|
||||
|
||||
没错,空间容量就是我们遇到的第一个门槛,单台电脑的存储空间确实不是无限大的。
|
||||
|
||||
接下来,我们找出一份大一点的文件,把它复制到另一个目录,看看复制速度如何?这里就碰到了第二个问题——文件写入速度。一般来说,机械盘硬盘的最高写入速度是200MB/s左右,而固态硬盘的写入速度是3000MB/s左右。
|
||||
|
||||
试想一下,如果我们有1TB的数据写入硬盘(就算真的有一块1TB空间的固态硬盘可以使用)那我们也至少需要4天时间,数据才能完全写入完毕。
|
||||
|
||||
还有一个生活中常见的问题,你遇到过电脑故障、死机或者硬盘坏掉的情况么?是的,在普通PC机器运行的过程中,故障其实是常态。你平常用家里的网络打网游时遇到过丢包、掉线、卡顿之类的情况么?没错,网络故障确实也是我们要考虑的问题。
|
||||
|
||||
那么到底怎样才能设计一套文件系统,同时满足以下条件呢:
|
||||
|
||||
|
||||
容量“无限”大;
|
||||
对大容量的数据读写性能高;
|
||||
遇到软硬件问题时,系统可靠性也很高。
|
||||
|
||||
|
||||
这里就要用到问题切分和并行化的思想了,这些我们在[第四十节课]也讲过。
|
||||
|
||||
比如想要解决文件比较大的问题,就我们可以考虑把它切分成很多份。切分完了之后,我们还得想到鸡蛋(文件)放在一个篮子里,遇到故障“全军覆没”的风险。为此,咱们就得多搞几台机器,多存几份呗。
|
||||
|
||||
还担心存的比较慢?那我们就把多个文件并行存储到不同的硬盘上,这样就不会受到磁盘写入速度的限制了。
|
||||
|
||||
说到这里,你现在是不是已经跃跃欲试,想要开始实现一套分布式文件系统啦?别着急,让我们先把刚刚讨论到的设计思路梳理一下:
|
||||
|
||||
首先,为了不给使用者应用程序增加太多负担,我们还是希望用户能像以前单机读写文件一样通过简单的API就能完成文件读写。这时候,我们就需要抽象出一套统一的客户端client,提供给用户使用。
|
||||
|
||||
其次,是切分成很多份文件。GFS会把每一份文件叫做一个chunk,这个chunk大小的默认值是64MB,这比操作系统上的文件系统要大一些,这么做为了减少GFS client和GFS master的交互次数、提升文件读取性能。同时,为了保障可靠性,GFS还会为每个chunk保留三个副本。
|
||||
|
||||
但是这里还有个问题没解决,文件都切成很多份存到很多机器上了,我们怎么知道哪一个chunk存到哪里去了呢?这时候,我们就需要把这种chunk分片文件映射到存储位置、原始文件名、权限之类的关联关系抽象出来,我们把这类用来找数据的数据叫做元数据信息。
|
||||
|
||||
那么元数据存在哪里好一点?
|
||||
|
||||
聪明的你可能已经想到了,我们可以给这些服务器分一下类,让老大master带着小弟chunkserver来干活儿,元数据比较重要,所以咱们就交给老大来保管。有了这些思路,相信你再看 GFS论文中的架构图时,就会感觉清晰很多。
|
||||
|
||||
|
||||
|
||||
MapReduce的分分合合
|
||||
|
||||
接下来,我们再说说MapReduce。
|
||||
|
||||
我们首先要搞清楚MapReduce是什么,当看到MapReduce时,你可能感觉它是一个概念,但其实不然,MapReduce应该是Map、Reduce,是两个概念,即映射和归约。
|
||||
|
||||
用软件实现这两个概念,就会形成Map、Reduce两个操作,落实到代码中可能是两个接口函数、或者库,又或者是进程。我们可以把这些东西,理解成一套编程模型。
|
||||
|
||||
那么什么是Map呢?Map字面意思为映射,但本质是拆分。
|
||||
|
||||
接下来,我们以汽车为例,看一下我们把一辆完好的汽车执行Map操作之后的状态,如下图所示:
|
||||
|
||||
|
||||
|
||||
从上图可以看出,执行map操作时,汽车首先作为输入,然后标记出汽车的各种零部件,最后汽车被拆分成各种零件。
|
||||
|
||||
现在。让我们切换一下视角,把这辆汽车转换成用户的大规模数据,于是就变成了对一个大数据进行标记,然后拆分成许多小数据的过程,这就是MapReduce中的Map操作。
|
||||
|
||||
什么又是Reduce呢?Reduce的字面意思为归约,是Map操作是逆向操作,其本质是合并。同样地,我们以汽车为例,看看一辆被Map操作的汽车,在Reduce的操作下,会变成什么样子。如下图所示:
|
||||
|
||||
|
||||
|
||||
我们可以看到,执行Reduce操作时,是之前把Map汽车产生的各个零件作为输入,然后进行各种零部件的组装,最后合并生成汽车,或者是更高级的类汽车产品。
|
||||
|
||||
同样地,把这辆汽车各种零部件换成用户Map后的各种小数据,就相当于合并许多个小数据,然后生成原来的大数据或者对数据进行更高级的处理,这就是MapReduce中Reduce操作的作用。
|
||||
|
||||
我们刚刚把一台车子进行了一大波MapReduce操作,这台车子就变成了变形金刚了,哈哈。 举个例子,理解了MapReduce的原理之后,我们再来看一下它的六大步骤。
|
||||
|
||||
如果你是家大型汽车生产厂家, 你拥有许多不同类型的汽车设计方案(Input),还拥有许多汽车零件供应商,不同的汽车零件供应商会主动挑选不同的汽车零件(Split),挑选好之后你就把汽车生产方案进行拆解(Map)。
|
||||
|
||||
之后,再把不同的零件下发到不同供应商的生产车间生产(Shuffle),最后要能根据不同的顾客需求,取用不同的零件拼装成最终的汽车,这就是Reduce。拼装好汽车之后,会放到售卖部那边等待客户取货(Ticket),这个过程是Finalize。
|
||||
|
||||
所以MapReduce是六大过程,简单来说,就是 Input、Split、Map、Shuffle、Reduce和 Finalize。那么这六大步骤又是怎样被一套框架管理起来的呢?答案其实还是老大(Master)带着小弟(Worker)干活。
|
||||
|
||||
下面,我们结合MapReduce的架构图,分析一下它的工作原理。
|
||||
|
||||
|
||||
|
||||
我们的用户程序要想使用MapReduce,必须要链接MapReduce库。有了MapReduce库就可以进行Map、Reduce操作了。
|
||||
|
||||
用户程序运行后先声明数据有多少,然后需要将它们拆分成一些Mapper和Reducer来执行。假如把数据分成n份,那就要找n个Mapper来处理。这时会产生许多Worker,这些Worker有的是执行Map操作的,有的 Worker是执行Reduce操作的。
|
||||
|
||||
最重要的是还会产生一个 Master Worker,它与其他Worker的等级是相同的,它会调度其它Worker运行,并作为用户的代理来协调整个过程,让用户可以做其他事情。
|
||||
|
||||
Master Worker会让一个Worker去处理0号数据,另一个Worker负责处理1号数据等等,这就是分配数据的过程。每个Worker都会在本地处理数据,并把结果写入缓存或硬盘。当执行Map操作的 Worker完成任务后,Master Worker会让执行Reduce操作的Worker去获取数据。
|
||||
|
||||
他们会从各个Worker那里获取需要的数据,并在本地完成Reduce操作,最后将结果写入最终的文件中,这就是Finalize。这个过程其实就是前面说过的六个步骤。
|
||||
|
||||
BigTable
|
||||
|
||||
最后一驾马车就是BigTable。在说它之前,我们先聊聊表。
|
||||
|
||||
请和我一起思考一下,什么是表呢?为了更好理解,我们可以抄出Excel这个神器,来仔细认识一下表的基本构成:
|
||||
|
||||
|
||||
|
||||
不难发现,表是由一个又一个的格子构成的,而每一个格子里的内容,又能通过行和列的坐标定位到。
|
||||
|
||||
这时候我们不妨联想一下,是不是我们只需要存储足够多的格子,就可以存储各种各样的表啦。那么光有行、列和格子里内容就足够了么?
|
||||
|
||||
并非如此,别忘了格子里的内容还有可能会修改。比如上图中的B1单元格里的Linux版本需要从1.0.5更新到1.0.6,因此还需要记录格子的时间。
|
||||
|
||||
没错,BigTable其实也是这样的思路,BigTable把每个格子的数据都抽象成了Key Value的键值对的格式。其中,key是由行(row:string)、列(column:string)、时间戳(time:int64)这三部分构成的,而Value则是用string来存储的。
|
||||
|
||||
这样的Key Value数据结构有没有让你联想到什么?其实它就类似于我们数据结构中常用的HashMap。但这个HashMap有点特殊,因为它还要支持后面这几种功能:
|
||||
|
||||
|
||||
给定几个key,能够快速返回小于或者等于某个key的那个数据。
|
||||
给定key1和key2,可以返回key3值中最高的数据。
|
||||
key也可以只给前缀格式prefix,返回所有符合前缀的值。
|
||||
这个“HashMap”在读、写性能上,都要相对比较好。
|
||||
这个“HashMap”要能持久化,因为数据不能丢。
|
||||
|
||||
|
||||
有了上述功能的约束,你是不是感觉一时半会儿还真没想出来,要怎么设计这个数据结构?
|
||||
|
||||
其实Google已经把这个数据结构设计好了,这个数据结构叫做SSTable,具体实现确实有些复杂,但好在有官方开源的单机实现——LevelDB。后面还有基于LevelDB演进升级的RocksDB,也是一个不错的项目,感兴趣的话可以自行了解。
|
||||
|
||||
现在,我们有了把表化简成小格子,再把每个格子使用Key Value结构存储到了单机的“HashMap”数据结构上。接下来,我们还得想清楚,如何让单机的“HashMap”数据结构变成可以分布式运算的。
|
||||
|
||||
这时候,我们就可以把前面这个思路做进一步抽象,你可以结合后面的示意图看一下,具体是抽象成了三层:
|
||||
|
||||
|
||||
|
||||
首先,对于每个表,我们都需要保存这个表的元数据。
|
||||
|
||||
其次如果随着数据增长,表变得比较大了,我们需要具备自动切分这张表的能力。切分表的最小单位我们叫做Tablet,也就是说,一张表会对应一个或多个Tablet。
|
||||
|
||||
具体到每一个Tablet,我们是基于一个或多个单机的“HashMap”数据结构,也就是SSTable来实现的;而每一个SSTable中存储的,又是一堆用Key Value格式表示的单元格。
|
||||
|
||||
对应到服务上,我们又可以套用前面讲的老大带小弟干活(主从架构)的思路,把一个或者多个Tablet交给Tablet Server这一类小弟(服务)来干活儿。而老大(Master)主要负责为Tablet服务器分配Tablets、检测新加入的或者过期失效的Tablet服务器、对Tablet服务器进行负载均衡、对保存在GFS上的文件做垃圾收集、处理和模式相关的修改操作(比如建立表和列族)。
|
||||
|
||||
理清了思路,你再来看看后面这张架构图,是不是就很容易理解了呢?
|
||||
|
||||
|
||||
|
||||
小结
|
||||
|
||||
今天,我们主要了解了现代云计算PAAS层中,大数据体系的由来。其中最核心的就是谷歌的三驾马车,即谷歌文件系统GFS、面向大型集群的简化数据处理MapReduce、BigtTable结构化数据的分布式存储系统。
|
||||
|
||||
GFS(Google文件系统)是一种分布式文件系统,它为Google的大型数据处理应用提供了数据存储和访问功能;MapReduce是一种编程模型,它允许开发人员更方便地处理大量数据;而BigTable是一种高性能的分布式存储系统,它可以处理海量的结构化数据。
|
||||
|
||||
如果学过今天内容,你还觉得意犹未尽,想要更深入地学习这三种技术,建议阅读谷歌相关的论文和文档,并尝试去做一下mit 6.824分布式系统课程提供的课后练习。
|
||||
|
||||
思考题
|
||||
|
||||
推荐你在课后能搜索GFS、MapReduce、BigTable这三篇原始论文阅读一下,结合今天学到的设计过程的思路,进一步思考这么设计的优点和缺点分别是什么,还有什么改进空间?
|
||||
|
||||
欢迎你在评论区和我交流讨论,如果觉得这节课内容还不错,也可以转发给你的朋友,一起学习进步。
|
||||
|
||||
|
||||
|
||||
|
@ -0,0 +1,192 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
加餐03 学习攻略(二):大数据&云计算,究竟怎么学?
|
||||
你好,我是LMOS。
|
||||
|
||||
上节课我们从谷歌的三驾马车开始,学习了大数据三件套的设计思路,可惜谷歌三驾马车作为商用软件,只开放了论文来讲解原理,却并没有开放出对应的源代码。
|
||||
|
||||
为了帮你更好地理解这些核心技术是怎么落地的,这节课我会简单介绍一下另外三个基础组件的设计原理,它们也是开源大数据生态中的重要角色。
|
||||
|
||||
HDFS设计原理
|
||||
|
||||
首先我们来说说HDFS,它的全称是Hadoop Distributed File System,你可以理解为一个可以由低成本的普通PC机组成的大规模分布式文件系统。
|
||||
|
||||
HDFS的架构图如下所示:
|
||||
|
||||
|
||||
|
||||
其实,HDFS的核心架构和[上节课]讲过的GFS,架构思路是一脉相承的。
|
||||
|
||||
HDFS基于主/从架构设计,其集群的核心是由NameNode(充当主服务器)、DataNode(充当从服务器)、Client这三部分构成的。各部分的含义和功能,你可以参考后面这张表:-
|
||||
|
||||
|
||||
通过这几个组件的配合,我们就拥有了一个可靠的分布式文件系统。
|
||||
|
||||
那么HDFS有哪些优势呢?主要是后面这四点:
|
||||
|
||||
|
||||
容错性:可以在集群中的任意节点发生故障时继续运行,这能保证数据的安全性。
|
||||
大数据处理能力:HDFS可以存储海量的数据,并支持大规模并行计算。
|
||||
高可靠性:HDFS将文件分割成多个块存储,并在集群中多次复制,可以保证数据的高可靠性。
|
||||
简单易用:HDFS提供了简单易用的文件存储和访问接口,与其他系统集成很方便。
|
||||
|
||||
|
||||
但是,HDFS也有一些不足,具体包括:
|
||||
|
||||
|
||||
性能相对较低:不适合低延迟的数据访问。
|
||||
不支持随机写入:不支持随机写入,只能进行顺序写入。
|
||||
对小文件不友好:不能很好地存储小文件,因为它需要将小文件分割成大块存储,而这会导致存储和计算效率低下。
|
||||
|
||||
|
||||
总之,HDFS能够高效地存储海量数据,并支持大规模并行计算。但是,HDFS 不适合用于低延迟的数据访问,也不适合用于存储小文件。
|
||||
|
||||
说到这,我们就不难推测HDFS的适用场景了——它适合用在海量数据存储和大规模数据分析的场景中,例如搜索引擎、广告系统、推荐系统等。
|
||||
|
||||
YARN设计原理
|
||||
|
||||
其实早期Hadoop也按照Google Mapreduce的架构,实现了一套Mapreduce的资源管理器,用于管理和调度MapReduce任务所需要的资源。但是JobTracker存在单点故障,它承受的访问压力也比较大,这影响了系统的可扩展性。另外,早期设计还不支持MapReduce之外的计算框架(比如Spark、Flink)。
|
||||
|
||||
正是因为上述问题,Hadoop才做出了YARN这个新的Hadoop资源管理器。YARN的全称是Yet Another Resource Negotiator,让我们结合架构图了解一下它的工作原理。-
|
||||
|
||||
|
||||
根据架构图可见,YARN由ResourceManager、NodeManager、JobHistoryServer、Containers、Application Master、job、Task、Client组成。
|
||||
|
||||
YARN的架构图中的各个模块的功能,你可以参考后面这张表格:-
|
||||
|
||||
|
||||
了解了每个模块大致的功能之后,我们再看看YARN运行的基本流程吧!-
|
||||
|
||||
|
||||
到YARN运行主要是包括后面表格里的八个步骤。-
|
||||
|
||||
|
||||
其实我们计算的每一个MapReduce的作业,也都是通过这几步,被YARN资源管理器调度到不同的机器上运行的。弄懂了YARN的工作原理,对“Hadoop大数据生态下如何调度计算作业到不同容器做计算”这个问题,你会更容易理解。
|
||||
|
||||
然而,解决了存储和计算问题还不够。因为大数据生态下需要的组件非常多,各种组件里还有很多需要同步、订阅或通知的状态信息。如果这些信息没有一个统一组件处理,那整个分布式系统的运行都会失控,这就不得不提到一个重要的协调组件——ZooKeeper了。
|
||||
|
||||
ZooKeeper设计原理
|
||||
|
||||
ZooKeeper集群中包含Leader、Follower以及Observer三个角色。
|
||||
|
||||
Leader负责进行投票的发起和决议,更新系统状态,Leader是由选举产生的。Follower用于接受客户端请求并向客户端返回结果,在选主过程中会参与投票。
|
||||
|
||||
Observer的目的是扩展系统,提高读取速度。Observer会从客户端接收请求,并将结果返回给客户端。Observer可以接受客户端连接,也可以接收读写请求,并将写请求转发给Leader。但是,Observer不参与投票过程,只同步Leader的状态。
|
||||
|
||||
后面是ZooKeeper的架构图:
|
||||
|
||||
-
|
||||
在其核心,Zookeeper使用原子广播来保持服务器同步。实现这种机制的协议称为Zab协议,它包括恢复模式(用于主选择)和广播模式(用于同步)。
|
||||
|
||||
当服务启动或leader崩溃后,Zab协议进入恢复模式。恢复模式结束时,leader已经当选,大多数服务器已经同步完成leader的状态。这种状态同步可以确保leader和Server的系统状态相同。
|
||||
|
||||
为了保证事务序列的一致性,ZooKeeper使用递增的事务ID(zxid)来标识事务。所有提案提交时都会附上zxid。Zxid为64位整数,高32位表示领导人关系是否发生变化(每选出一个领导者,就会创建一个新的epoch,表示当前领导人所属的统治时期),低32位用于增量计数。
|
||||
|
||||
在工作期间,每个服务器都有三种状态:
|
||||
|
||||
|
||||
LOOKING:表示当前服务器不知道该领导者,正在寻找他。
|
||||
LEADING:表示当前Server为已当选的leader。
|
||||
FOLLOWING:表示该leader已经当选,当前Server正在与该leader同步。
|
||||
|
||||
|
||||
通过这样一套可靠的一致性协议和架构设计,Zookeeper把用户改变数据状态的操作,抽象成了类似于对文件目录树的操作。这样就简化了分布式系统中数据状态协调的难度,提高了分布式系统运行的稳定性和可靠性。
|
||||
|
||||
综合应用与环境搭建
|
||||
|
||||
学了这么多基础概念,我们来挑战一个综合性问题。假设在一个大型Hadoop集群中,你作为系统管理员需要解决这样一个问题——如何保证数据的安全性?
|
||||
|
||||
你会如何解决呢,使用哪些HDFS、YARN、ZooKeeper中的哪些功能,为什么这样选择呢?你可以自己先思考一下,再听听我的想法。
|
||||
|
||||
为了保证数据的安全性,我们可以使用HDFS的多副本机制来保存数据。在HDFS中,我们可以将文件分成若干块存储在集群中的多个节点上,并设置每个块的副本数量。这样,即使某个节点出现故障,也可以通过其他节点上的副本来恢复数据。
|
||||
|
||||
此外,还可以利用YARN的资源管理功能来控制集群中节点的使用情况,以避免资源过度使用导致的数据丢失。
|
||||
|
||||
最后,我们还可以利用ZooKeeper的分布式锁功能,来保证集群中只有一个节点可以访问某个文件。这样多个节点同时写入同一个文件造成的数据冲突,也能够避免。
|
||||
|
||||
总的来说,综合使用HDFS的多副本机制、YARN的资源管理功能以及Zookeeper的分布式锁功能,可以帮我们有效保证数据的安全性。
|
||||
|
||||
接下来就让我们动手搭建一套大数据开发环境吧。大数据开发环境搭建一般环节比较多,所以比较费时。为了节约部署时间,提高开发效率,我比较推荐使用Docker部署。
|
||||
|
||||
首先,我们先安装好Docker和docker-compose。
|
||||
|
||||
要安装Docker,一共要执行六步操作。第一步,在终端中更新软件包列表:
|
||||
|
||||
sudo apt update
|
||||
|
||||
|
||||
第二步,安装依赖包:
|
||||
|
||||
sudo apt install apt-transport-https ca-certificates curl software-properties-common
|
||||
|
||||
|
||||
第三步,添加Docker的官方GPG密钥:
|
||||
|
||||
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add -
|
||||
|
||||
|
||||
第四步,在系统中添加Docker的存储库:
|
||||
|
||||
sudo add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable"
|
||||
|
||||
|
||||
第五步,更新软件包列表并安装Docker:
|
||||
|
||||
sudo apt update
|
||||
sudo apt install docker-ce
|
||||
|
||||
|
||||
第六步,启动Docker服务并将其设置为开机启动:
|
||||
|
||||
sudo systemctl start docker
|
||||
sudo systemctl enable docker
|
||||
|
||||
|
||||
安装完Docker,接下来我们来还需要执行两个步骤,来安装 Docker Compose。首先我们要下载Docker Compose可执行文件,代码如下:
|
||||
|
||||
sudo curl -L "https://github.com/docker/compose/releases/download/1.27.4/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
|
||||
|
||||
|
||||
第二步,为Docker Compose可执行文件设置执行权限:
|
||||
|
||||
sudo chmod +x /usr/local/bin/docker-compose
|
||||
|
||||
|
||||
现在,Docker和Docker Compose都安好了。为了确认安装是否成功,可以使用后面的命令验证:
|
||||
|
||||
docker --version
|
||||
docker-compose --version
|
||||
|
||||
|
||||
接下来,我们就可以启动大数据项目了。首先需要使用命令克隆仓库:
|
||||
|
||||
git clone https://github.com/spancer/bigdata-docker-compose.git
|
||||
|
||||
|
||||
然后,我们打开项目目录运行下面的命令:
|
||||
|
||||
docker-compose up -d
|
||||
|
||||
|
||||
等待项目启动成功,我们就可以使用Hadoop生态的各个组件,做更多的探索实验啦。
|
||||
|
||||
总结
|
||||
|
||||
这节课我们学到了开源大数据生态中的三个重要角色,它们是Hadoop大数据平台的基础,负责了文件存储、资源管理和分布式协调。
|
||||
|
||||
HDFS是Hadoop的分布式文件系统,它可以将海量数据分布在集群中的多个节点上进行存储,采用多副本机制保证数据安全。
|
||||
|
||||
YARN是Hadoop的资源管理系统,负责调度任务并管理资源。
|
||||
|
||||
ZooKeeper是分布式协调服务,提供分布式锁、队列、通知等功能,常用于分布式系统的配置管理、分布式协调和集群管理。
|
||||
|
||||
了解了这些组件的原理之后,我们还一起分析了一道综合应用题帮你加深理解。最后,动手环节也必不可少,利用Docker,可以帮我们快速搭建一套大数据开发环境,课后你有兴趣的话也推荐自己上手试试看。
|
||||
|
||||
欢迎你在留言区和我交流讨论,我们下节课见。
|
||||
|
||||
|
||||
|
||||
|
@ -0,0 +1,137 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
13 效率为王:脚本与数据的解耦 + Page Object模型
|
||||
在上一篇文章中,我用Selenium 2.0实现了我们的第一个GUI自动化测试用例,在你感觉神奇的同时,是否也隐隐感到一丝丝的担忧呢?比如,测试脚本中既有测试数据又有测试操作,所有操作都集中在一个脚本中等等。
|
||||
|
||||
那么,今天我就通过介绍GUI测试中两个非常重要的概念:测试脚本和数据的解耦,以及页面对象(Page Object)模型,带你看看如何优化这个测试用例。
|
||||
|
||||
测试脚本和数据的解耦
|
||||
|
||||
我在前面的文章中,和你分享过GUI自动化测试适用的场景,它尤其适用于需要回归测试页面功能的场景。那么,你现在已经掌握了一些基本的GUI自动化测试用例的实现方法,是不是正摩拳擦掌准备批量开发GUI自动化脚本,把自己从简单、重复的GUI界面操作中解放出来呢?
|
||||
|
||||
但是,你很快就会发现,如果在测试脚本中硬编码(hardcode)测试数据的话,测试脚本灵活性会非常低。而且,对于那些具有相同页面操作,而只是测试输入数据不同的用例来说,就会存在大量重复的代码。
|
||||
|
||||
举个最简单的例子,上一篇文章中实现的百度搜索的测试用例,当时用例中搜索的关键词是“极客时间”,假设我们还需要测试搜索关键词是“极客邦”和“InfoQ”的场景,如果不做任何处理,那我们就可能需要将之前的代码复制3份,每份代码的主体完全一致,只是其中的搜索关键词和断言(Assert)的预期结果不同。
|
||||
|
||||
显然,这样的做法是低效的。
|
||||
|
||||
更糟糕的是,界面有任何的变更需要修改自动化脚本时,你之前复制出来的三个脚本都需要做相应的修改。比如,搜索输入框的名字发生了变化,你就需要修改所有脚本中findElement方法的by.name属性。
|
||||
|
||||
而这里只有三个脚本还好,如果有30个或者更多的脚本呢,你会发现脚本的维护成本实在是太高了。那么,这种情况应该怎么处理呢?
|
||||
|
||||
相信你现在已经想到了,把测试数据和测试脚本分离。也就是说测试脚本只有一份,其中需要输入数据的地方会用变量来代替,然后把测试输入数据单独放在一个文件中。这个存放测试输入数据的文件,通常是表格的形式,也就是最常见的CSV文件。
|
||||
|
||||
然后,在测试脚本中通过data provider去CSV文件中读取一行数据,赋值给相应的变量,执行测试用例。接着再去CSV文件中读取下一行数据,读取完所有的数据后,测试结束。CSV文件中有几行数据,测试用例就会被执行几次。具体流程如图1所示。
|
||||
|
||||
|
||||
|
||||
图1 数据驱动测试的基本概念
|
||||
|
||||
这也就是典型的数据驱动(Data-driven)测试了。
|
||||
|
||||
|
||||
数据驱动很好地解决了大量重复脚本的问题,实现了“测试脚本和数据的解耦”。 目前几乎所有成熟的自动化测试工具和框架,都支持数据驱动的测试,而且除了支持CSV这种最常见的数据源外,还支持xls文件、JSON文件,YAML文件,甚至还有直接以数据库中的表作为数据源的,比如QTP就支持以数据库中的表作为数据驱动的数据源。
|
||||
|
||||
数据驱动测试的数据文件中不仅可以包含测试输入数据,还可以包含测试验证结果数据,甚至可以包含测试逻辑分支的控制变量。 图1中的“Result_LoginSuccess_Flag”变量其实就是用户分支控制变量。
|
||||
|
||||
数据驱动测试的思想不仅适用于GUI测试,还可以用于API测试、接口测试、单元测试等。 所以,很多API测试工具(比如SoapUI),以及单元测试框架都支持数据驱动测试,它们往往都是通过Test Data Provider模块将外部测试数据源逐条“喂”给测试脚本。
|
||||
|
||||
|
||||
页面对象(Page Object)模型
|
||||
|
||||
为了让你了解“页面对象(Page Object)模型”这个概念的来龙去脉,并能够深入理解这个概念的核心思想,我会先从早期的GUI自动化测试开始讲起。
|
||||
|
||||
早期的GUI自动化测试脚本,无论是用开源的Selenium开发,还是用商用的QTP(Quick Test Professional,现在已经改名为Unified Functional Testing)开发,脚本通常是由一系列的页面控件的顺序操作组成的,如图2所示的伪代码展示了一个典型的早期GUI测试脚本的结构。
|
||||
|
||||
|
||||
|
||||
图2 早期的GUI测试脚本伪代码示例
|
||||
|
||||
我先来简单介绍一下这个脚本实现的功能。
|
||||
|
||||
|
||||
第1-4行,输入用户名和密码并点击“登录”按钮,登录完成后页面将跳转至新页面;
|
||||
第5行,在新页面找到“图书”链接,然后点击链接跳转至图书的页面;
|
||||
第7-10行,在图书搜索框输入需要查找的书名,点击“搜索”按钮,然后通过assert验证搜索结果;
|
||||
第11-12行,用户登出。
|
||||
|
||||
|
||||
看完这段伪代码,你是不是觉得脚本有点像操作级别的“流水账”,而且可读性也比较差,这主要体现在以下几个方面:
|
||||
|
||||
|
||||
脚本逻辑层次不够清晰,属于All-in-one的风格,既有页面元素的定位查找,又有对元素的操作。
|
||||
脚本的可读性差。 为了方便你理解,示例中的代码用了比较直观的findElementByName,你可以很方便地从name的取值,比如“username”和“password”,猜出脚本所执行的操作。-
|
||||
但在实际代码中,很多元素的定位都会采用Xpath、ID等方法,此时你就很难从代码中直观看出到底脚本在操作哪个控件了。也就是说代码的可读性会更差,带来的直接后果就是后期脚本的维护难度增大。-
|
||||
有些公司自动化测试脚本的开发和维护是两拨人,脚本开发并调试完以后,开发人员就会把脚本移交给自动化测试执行团队使用并维护,这种情况下脚本的可读性就至关重要了。但即使是同一拨人维护,一段时间后,当时的开发人员也会遗忘某些甚至是大部分的开发步骤。
|
||||
由于脚本的每一行都直接描述各个页面上的元素操作,你很难一眼看出脚本更高层的业务测试流程。 比如图2的业务测试流程其实就三大步:用户登录、搜索书籍和用户登出,但是通过阅读代码很难一下看出来。
|
||||
通用步骤会在大量测试脚本中重复出现。 脚本中的某些操作集合在业务上是属于通用步骤,比如上面伪代码的第1-4行完成的是用户登录操作,第11-12行完成的是用户的登出操作。
|
||||
|
||||
|
||||
这些通用的操作,会在其他测试用例的脚本中被多次重复。无论操作发生变动,还是页面控件的定位发生变化时,都需要同时修改大量的脚本。
|
||||
|
||||
其实,我上面说到的这四点正是早期GUI自动化测试的主要问题,这也是我一直说“开发几个GUI自动化测试玩玩会觉得很高效,但是当你开发成百上千个GUI自动化测试的时候,你会很痛苦”的本质含义。
|
||||
|
||||
那怎么解决这个问题呢?你可能已经想到了软件设计中模块化设计的思想。
|
||||
|
||||
没错,就是利用模块化思想,把一些通用的操作集合打包成一个个名字有意义的函数,然后GUI自动化脚本直接去调用这些操作函数来构成整个测试用例,这样GUI自动化测试脚本就从原本的“流水账”过渡到了“可重用脚本片段”。
|
||||
|
||||
如图3所示,就是利用了模块化思想的伪代码。
|
||||
|
||||
|
||||
|
||||
图3 基于模块化的GUI测试用例伪代码示例
|
||||
|
||||
第1-6行就是测试用例,非常简单直接,一眼就可以看出测试用例具体在执行什么操作,而各个操作函数的具体内部实现还是之前那些“流水账”。当然这里对于测试输入数据完全可以采用测试驱动方法,这里为了直观我就直接硬编码了测试示例数据。
|
||||
|
||||
实际工程应用中,第1-6行的测试用例和第8-30行的操作函数通常不会放在一个文件中,因为操作函数往往会被很多测试用例共享。这种模块化的设计思想,带来的好处包括:
|
||||
|
||||
|
||||
解决了脚本可读性差的问题,脚本的逻辑层次也更清晰了;
|
||||
|
||||
解决了通用步骤会在大量测试脚本中重复出现的问题, 现在操作函数可以被多个测试用例共享,当某个步骤的操作或者界面控件发生变化时,只要一次性修改相关的操作函数就可以了,而不需要去每个测试用例中逐个修改。
|
||||
|
||||
|
||||
但是,这样的设计并没有完全解决早期GUI自动化测试的主要问题,比如每个操作函数内部的脚本可读性问题依然存在,而且还引入了新的问题,即如何把控操作函数的粒度,以及如何衔接两个操作函数之间的页面。
|
||||
|
||||
关于这两个新引入的问题,我会在后面的文章中为你详细阐述。我先来跟你聊聊,怎么解决早期GUI自动化测试的“可读性差、难以维护”问题。
|
||||
|
||||
现在,操作函数的内部实现还只是停留在“既有页面元素的定位查找,又有对元素的操作”的阶段,当业务操作本身比较复杂或者需要跨多个页面时,“可读性差、难以维护”的问题就会暴露得更加明显了。
|
||||
|
||||
那么,有什么更好的办法来解决这个问题吗?答案就是,我要分享的GUI自动化测试的第二个概念:页面对象(Page Object)模型。
|
||||
|
||||
页面对象模型的核心理念是,以页面(Web Page 或者Native App Page)为单位来封装页面上的控件以及控件的部分操作。而测试用例,更确切地说是操作函数,基于页面封装对象来完成具体的界面操作,最典型的模式是“XXXPage.YYYComponent.ZZZOperation”。
|
||||
|
||||
基于这个思想,上述用例的伪代码可以进化成如图4所示的结构。这里,我只给出了login函数的伪代码,建议你按照这种思路,自己去实现一下search和logout的代码,这样可以帮你更好的体会页面对象模型带来的变化。
|
||||
|
||||
|
||||
|
||||
图4 基于页面对象模型的伪代码示例
|
||||
|
||||
通过这样的代码结构,你可以清楚地看到是在什么页面执行什么操作,代码的可读性以及可维护性大幅度提高,也可以更容易地将具体的测试步骤转换成测试脚本。
|
||||
|
||||
总结
|
||||
|
||||
今天我给你讲了什么是数据驱动的测试,让你明白了“测试脚本和数据解耦”的实现方式以及应用场景。接着从GUI自动化测试历史发展演变的角度引出了GUI测试中的“页面对象模型”的概念。
|
||||
|
||||
“测试脚本和数据解耦”的本质是实现了数据驱动的测试,让操作相同但是数据不同的测试可以通过同一套自动化测试脚本来实现,只是在每次测试执行时提供不同的测试输入数据。
|
||||
|
||||
“页面对象模型”的核心理念是,以页面为单位来封装页面上的控件以及控件的部分操作。而测试用例使用页面对象来完成具体的界面操作。
|
||||
|
||||
希望这篇文章,可以让你更清楚地认识GUI自动化测试用例的逻辑以及结构。同时,你可能已经发现,这篇文章的内容并不是局限在某个GUI自动化测试框架上,你可以把这些设计思想灵活地运用其他GUI自动化测试项目中,这也是我希望达到的“授人以鱼,不如授人以渔”。
|
||||
|
||||
思考题
|
||||
|
||||
我在文中有这样一段描述:页面对象模型的核心理念是,以页面为单位来封装页面上的控件以及控件的部分操作。但是,现在业界对“是否应该在页面对象模型中封装控件的操作”一直有不同的看法。
|
||||
|
||||
有些观点认为,可以在页面对象模型中封装页面控件的操作;而有些观点则认为,页面对象模型只封装控件,而操作应该再做一层额外的封装。
|
||||
|
||||
你更认同哪种观点呢,说说你的理由吧。
|
||||
|
||||
欢迎你给我留言。
|
||||
|
||||
|
||||
|
||||
|
@ -0,0 +1,116 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
16 脑洞大开:GUI测试还能这么玩(Page Code Gen + Data Gen + Headless)?
|
||||
在前面的几篇文章中,我介绍了GUI自动化测试的数据驱动测试、页面对象(Page Object)模型、业务流程封装,以及测试数据相关的内容。
|
||||
|
||||
今天这篇文章,我将从页面对象自动生成、GUI测试数据自动生成、无头浏览器三个方面展开,这也是GUI测试中三个比较有意思的知识点。
|
||||
|
||||
页面对象自动生成
|
||||
|
||||
在前面的文章中,我已经介绍过页面对象(Page Object)模型的概念。页面对象模型,是以Web页面为单位来封装页面上的控件以及控件的部分操作,而测试用例基于页面对象完成具体操作。最典型的模式就是:XXXPage.YYYComponent.ZZZOperation。
|
||||
|
||||
基于页面对象模型的伪代码示例,如图1所示。
|
||||
|
||||
|
||||
|
||||
图1 基于页面对象模型的伪代码示例
|
||||
|
||||
如果你在实际项目中已经用过页面对象模型,你会发现开发和维护页面对象的类(Page Class),是一件很耗费时间和体力的事儿。
|
||||
|
||||
|
||||
你需要打开页面,识别出可以唯一确定某元素的属性或者属性集合,然后把它们写到Page Class里,比如图1的第2行代码username_input=findElementByName(“username”),就是通过控件的名字(username)来定位元素的。
|
||||
更糟糕的是,GUI的页面会经常变动,如果开发人员开发前端代码时没有严格遵循可测试性的要求,Page Class的维护成本就会更高。
|
||||
|
||||
|
||||
那么,什么方法能够解决这个问题呢?答案就是,页面对象自动生成技术,它非常适用于需要维护大量页面对象的中大型GUI自动化测试项目。
|
||||
|
||||
页面对象自动生成技术,属于典型的“自动化你的自动化”的应用场景。它的基本思路是,你不用再手工维护Page Class了,只需要提供Web的URL,它就会自动帮你生成这个页面上所有控件的定位信息,并自动生成Page Class。
|
||||
|
||||
但是,需要注意的是,那些依赖于数据的动态页面对象也会被包含在自动生成的Page Class里,而这种动态页面对象通常不应该包含在Page Class里,所以,往往需要以手工的方式删除。
|
||||
|
||||
目前,很多商用自动化工具,比如UFT,已经支持页面对象自动生成功能了,同时还能够对Page Class进行版本管理。
|
||||
|
||||
但是,开源的自动化方案,页面对象自动生成功能一般需要自己开发,并且需要与你所用的自动化测试框架深度绑定。目前,中小企业很少有自己去实现这一功能的。
|
||||
|
||||
不过,有个好消息是,目前国内应用还不算多、免费的Katalon Studio,已经提供了类似的页面对象库管理功能,如果感兴趣的话,你可以去试用一下。
|
||||
|
||||
GUI测试数据自动生成
|
||||
|
||||
GUI测试数据自动生成,指的由机器自动生成测试用例的输入数据。
|
||||
|
||||
乍一听上去是不是感觉有点玄乎?机器不可能理解你的业务逻辑,怎么可能自动生成测试数据呢?
|
||||
|
||||
你的这个想法完全合理,并且也是完全正确的。所以,我在这里说的“测试数据自动生成”,仅仅局限于以下两种情况:
|
||||
|
||||
|
||||
根据GUI输入数据类型,以及对应的自定义规则库自动生成测试输入数据。 比如,GUI界面上有一个“书名”输入框,它的数据类型是string。-
|
||||
那么,基于数据类型就可以自动生成诸如 Null、SQL注入、超长字符串、非英语字符等测试数据。-
|
||||
同时,根据自定义规则库,还可以根据具体规则生成各种测试数据。这个自定义规则库里面的规则,往往反映了具体的业务逻辑。比如,对于“书名”,就会有书名不能大于多少个字符、一些典型的书名(比如,英文书名、中文书名等)等等业务方面的要求,那么就可以根据这些业务要求来生成测试数据。-
|
||||
根据自定义规则生成测试数据的核心思想,与安全扫描软件AppScan基于攻击规则库自动生成和执行安全测试的方式,有异曲同工之处。
|
||||
|
||||
对于需要组合多个测试输入数据的场景,测试数据自动生成可以自动完成多个测试数据的笛卡尔积组合,然后再以人工的方式剔除掉非法的数据组合。-
|
||||
但是,这种方式并不一定是最高效的。对于输入参数比较多,且数据之间合法组合比较少或者难以明确的情况,先自动化生成笛卡尔积组合,再删除非法组合,效率往往还不如人为组合来得高。所以,在这个场景下是否要用测试数据自动生成方法,还需要具体问题具体分析。-
|
||||
更常见的用法是,先手动选择部分输入数据进行笛卡尔积,并删除不合法的部分;然后,在此基础上,再人为添加更多业务上有意义的输入数据组合。-
|
||||
比如,输入数据有A、B、C、D、E、F六个参数,你可以先选取最典型的几个参数生成笛卡尔积,假设这里选取A、B和C;然后,在生成的笛卡尔积中删除业务上不合法的组合;最后,再结合D、E和F的一些典型取值,构成更多的测试输入数据组合。
|
||||
|
||||
|
||||
无头浏览器
|
||||
|
||||
无头浏览器,即Headless Browser,是一种没有界面的浏览器。
|
||||
|
||||
什么?浏览器没有界面,还叫什么浏览器啊?别急,我将为你一一道来。
|
||||
|
||||
无头浏览器,其实是一个特殊的浏览器,你可以把它简单地想象成是运行在内存中的浏览器。它拥有完整的浏览器内核,包括JavaScript解析引擎、渲染引擎等。
|
||||
|
||||
与普通浏览器最大的不同是,无头浏览器执行过程中看不到运行的界面,但是你依然可以用GUI测试框架的截图功能截取它执行中的页面。
|
||||
|
||||
无头浏览器的主要应用场景,包括GUI自动化测试、页面监控以及网络爬虫这三种。在GUI测试过程中,使用无头浏览器的好处主要体现在四个方面:
|
||||
|
||||
|
||||
测试执行速度更快。 相对于普通浏览器来说,无头浏览器无需加载CSS以及渲染页面,在测试用例的执行速度上有很大的优势。
|
||||
|
||||
减少对测试执行的干扰。 可以减少操作系统以及其他软件(比如杀毒软件等)不可预期的弹出框,对浏览器测试的干扰。
|
||||
|
||||
简化测试执行环境的搭建。 对于大量测试用例的执行而言,可以减少对大规模Selenium Grid集群的依赖,GUI测试可以直接运行在无界面的服务器上。
|
||||
|
||||
在单机环境实现测试的并发执行。 可以在单机上很方便地运行多个无头浏览器,实现测试用例的并发执行。
|
||||
|
||||
|
||||
但是,无头浏览器并不完美,它最大的缺点是,不能完全模拟真实的用户行为,而且由于没有实际完成页面的渲染,所以不太适用于需要对于页面布局进行验证的场景。同时,业界也一直缺乏理想的无头浏览器方案。
|
||||
|
||||
在Google发布Headless Chrome之前,PhantomJS是业界主流的无头浏览器解决方案。但是,这个项目的维护一直以来做得都不够好,已知未解决的缺陷数量多达1800多个,虽然支持主流的Webkit浏览器内核,但是依赖的Chrome版本太低。所以,无头浏览器一直难以在GUI自动化测试中大规模应用。
|
||||
|
||||
但好消息是,2017年Google发布了Headless Chrome,以及与之配套的Puppeteer框架,Puppeteer不仅支持最新版本的Chrome,而且得到Google官方的支持,这使得无头浏览器可以在实际项目中得到更好的应用。
|
||||
|
||||
也正是这个原因,PhantomJS的创建者Ariya Hidayat停止了它的后续维护,Headless Chrome成了无头浏览器的首选方案。
|
||||
|
||||
那什么是Puppeteer呢?Puppeteer是一个Node库,提供了高级别的API封装,这些API会通过Chrome DevTools Protocol与Headless Chrome的交互达到自动化操作的目的。
|
||||
|
||||
Puppeteer也是由Google开发的,所以它可以很好地支持Headless Chrome以及后续Chrome的版本更新。
|
||||
|
||||
如果你也迫不及待地想要尝试把Headless Chrome应用到自己的GUI测试中,那还等什么,赶紧下载并开始吧。
|
||||
|
||||
总结
|
||||
|
||||
我分别介绍了无头浏览器、页面对象自动生成,以及GUI测试数据自动生成,这三个GUI测试中比较有意思的知识点,包括它们的概念、应用场景等内容。
|
||||
|
||||
|
||||
对于页面对象自动生成,商用测试软件已经实现了这个功能。但是,如果你选择开源测试框架,就需要自己实现这个功能了。
|
||||
|
||||
GUI测试数据自动生成,主要是基于测试输入数据的类型以及对应的自定义规则库实现的,并且对于多个测试输入数据,可以基于笛卡尔积来自动组合出完整的测试用例集合。
|
||||
|
||||
对于无头浏览器,你可以把它简单地想象成运行在内存中的浏览器,它拥有完整的浏览器内核。与普通浏览器最大的不同是,它在执行过程中看不到运行的界面。目前,Headless Chrome结合Puppeteer是最先进的无头浏览器方案,如果感兴趣,你可以下载试用。
|
||||
|
||||
|
||||
思考题
|
||||
|
||||
在你的工作中,还有哪些好的方法和实践可以提高GUI自动化测试的效率吗?
|
||||
|
||||
欢迎你给我留言。
|
||||
|
||||
|
||||
|
||||
|
@ -0,0 +1,194 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
31 数据库文件系统实例:MySQL 中 B 树和 B+ 树有什么区别?
|
||||
这一讲给你带来的关联面试题是:MySQL 中 B 树和 B+ 树的区别?
|
||||
|
||||
B 树和 B+ 树是两种数据结构(关于它们的名字为什么以 B 开头,因为众说纷纭,本讲我就不介绍了),构建了磁盘中的高速索引结构,因此不仅 MySQL 在用,MongoDB、Oracle 等也在用,基本属于数据库的标配常规操作。
|
||||
|
||||
数据库要经常和磁盘与内存打交道,为了提升性能,通常需要自己去构建类似文件系统的结构。这一讲的内容有限,我只是先带你入一个门,如果你感兴趣后续可以自己深入学习。下面我们一起来探讨数据库如何利用磁盘空间设计索引。
|
||||
|
||||
行存储和列存储
|
||||
|
||||
在学习构建磁盘数据的索引结构前,我们先通过行存储、列存储的学习来了解一些基本的存储概念,帮助你建立一个基本的认知。
|
||||
|
||||
目前数据库存储一张表格主要是行存储(Row Storage)和列存储(Column Storage)两种存储方式。行存储将表格看作一个个记录,每个记录是一行。以包含订单号、金额、下单时间 3 项的表为例,行存储如下图所示:
|
||||
|
||||
|
||||
|
||||
如上图所示,在计算机中没有真正的行的概念。行存储本质就是数据一个接着一个排列,一行数据后面马上跟着另一行数据。如果订单表很大,一个磁盘块(Block)存不下,那么实际上就是每个块存储一定的行数。 类似下图这样的结构:
|
||||
|
||||
|
||||
|
||||
行存储更新一行的操作,往往可以在一个块(Block)中进行。而查询数据,聚合数据(比如求 4 月份的订单数),往往需要跨块(Block)。因此,行存储优点很明显,更新快、单条记录的数据集中,适合事务。但缺点也很明显,查询慢。
|
||||
|
||||
还有一种表格的存储方式是列存储(Column Storage),列存储中数据是一列一列存的。还以订单表为例,如下图所示:
|
||||
|
||||
|
||||
|
||||
你可以看到订单号在一起、姓名在一起、时间在一起、金额也在一起——每个列的数据都聚集在一起。乍一看这样的结构很低效,比如说你想取出第一条订单,需要取第 1 列的第 1 个数据1001,然后取第 2 列的第 1 个数据小明,以此类推,需要 4 次磁盘读取。特别是更新某一条记录的时候,需要更新多处,速度很慢。那么列存储优势在哪里呢?优势其实是在查询和聚合运算。
|
||||
|
||||
在列存储中同一列数据总是存放在一起,比如要查找某个时间段,很有可能在一个块中就可以找到,因为时间是集中存储的。假设磁盘块的大小是 4KB,一条记录是 100 字节, 那么 4KB 可以存 40 条记录;但是存储时间戳只需要一个 32 位整数,4KB 可以存储 1000 个时间。更关键的是,我们可以把一片连续的硬盘空间通过 DMA 技术直接映射到内存,这样就大大减少了搜索需要的时间。所以有时候在行存储需要几分钟的搜索操作,在列存储中只需几秒钟就可以完成。
|
||||
|
||||
总结一下,行存储、列存储,最终都需要把数据存到磁盘块。行存储记录一个接着一个,列存储一列接着一列。前面我们提到行存储适合更新及事务处理,更新好理解,因为一个订单可以在相同的 Block 中更新,那么为什么适合事务呢?
|
||||
|
||||
其实适合不适合是相对的,说行存储适合是因为列存储非常不适合事务。试想一下,你更新一个表的若干个数据,如果要在不同块中更新,就可能产生多次更新操作。更新次数越多,保证一致性越麻烦。在单机环境我们可以上锁,可以用阻塞队列,可以用屏障……但是分布式场景中保证一致性(特别是强一致性)开销很大。因此我们说行存储适合事务,而列存储不适合。
|
||||
|
||||
索引
|
||||
|
||||
接下来,我们在行存储、列存储的基础上,讨论如何创建一些更高效的查询结构,这种结构通常称为索引。我们经常会遇到根据一个订单编号查订单的情况,比如说select * from order where id=1000000,这个时候就需要用到索引。而下面我将试图通过二分查找的场景,和你一起讨论索引是什么。
|
||||
|
||||
在亿级的订单 ID 中查找某个编号,很容易想到二分查找。要理解二分查找,最需要关心的是算法的进步机制。这个算法每进行一次查找,都会让问题的规模减半。当然,也有场景限制,二分查找只能应用在排序好的数据上。
|
||||
|
||||
比如我们要在下面排序好的数组中查找 3:
|
||||
|
||||
1,3,5,8,11,12,15,19,21,25
|
||||
|
||||
数组中一共有 10 个元素,因此我们第一次查找从数组正中间的元素找起。如果数组正中间有两个元素,就取左边的那个——对于这个例子是 11。我们比较 11 和 3 的值,因为 11 大于 3,因此可以排除包括 11 在内的所有 11 右边的元素。相当于我们通过一次运算将数据的规模减半。假设我们有 240 (1T 数据)个元素需要查询(规模已经相当大了,万亿级别),用二分查找只需要 40 次运算。
|
||||
|
||||
所以按照这个思路,我们需要做的是将数据按照订单 ID 排好序,查询的时候就可以应用二分查找了。而且按照二分查找的思路,也可以进行范围查找。比如要查找 [a,b] 之间的数据,可以先通过二分查找找到 a 的序号,再二分找到 b 的序号,两个序号之间的数据就是目标结果。
|
||||
|
||||
但是直接在原始数据上排序,我们可能会把数据弄乱,常规做法是设计冗余数据描述这种排序关系——这就是索引。下面我通过一个简单的例子告诉你为什么不能在原始数据上直接排序。
|
||||
|
||||
假设我们有一个订单表,里面有订单 ID 和金额。使用列存储做演示如下:
|
||||
|
||||
订单 ID 列:
|
||||
|
||||
10005 10001 ……
|
||||
|
||||
订单金额列:
|
||||
|
||||
99.00 100.00 ……
|
||||
|
||||
可以看到,订单(10001)是第 2 个订单。但是进行排序后,订单(10001)会到第 1 个位置。这样会弄乱订单 ID(10001)和 金额(100.00)对应的关系。
|
||||
|
||||
因此我们必须用空间换时间,额外将订单列拷贝一份排序:
|
||||
|
||||
10001,2,10005, 1
|
||||
|
||||
以上这种专门用来进行数据查询的额外数据,就是索引。索引中的一个数据,也称作索引条目。上面的索引条目一个接着一个,每个索引条目是 <订单 ID, 序号> 的二元组。
|
||||
|
||||
如果你考虑是行存储(比如 MySQL),那么依然可以生成上面的索引,订单 ID 和序号(行号)关联。如果有多个索引,就需要创造多个上面的数据结构。如果有复合索引,比如 <订单状态、日期、序号> 作为一个索引条目,其实就是先按照订单状态,再按照日期排序的索引。
|
||||
|
||||
所以复合索引,无非就是多消耗一些空间,排序维度多一些。而且你可以看出复合索引和单列索引完全是独立关系,所以我们可以认为每创造一组索引,就创造了一份冗余的数据。也创造了一种特别的查询方式。关于索引还有很多有趣的知识,我们先介绍这些,如果感兴趣可以自己查资料深挖。
|
||||
|
||||
接下来,请分析一个非常核心的问题:上面的索引是一个连续的、从小到大的索引,那么应不应该使用这种从小到大排序的索引呢?例如,我们需要查询订单,就事先创建另一个根据订单 ID 从小到大排序的索引,当用户查找某个订单的时候,无论是行存储、还是列存储,我们就用二分查找查询对应的索引条目。这种方式,我们姑且称为线性排序索引——看似很不错的一个方式,但是并不是非常好的一种做法,请看我接下来的讨论。
|
||||
|
||||
二叉搜索树
|
||||
|
||||
线性排序的数据虽然好用,但是插入新元素的时候性能太低。如果是内存操作,插入一个元素,需要将这个元素之后的所有元素后移一位。但如果这个操作发生在磁盘中呢?这必然是灾难性的。因为磁盘的速度比内存慢至少 10-1000 倍,如果是机械硬盘可能慢几十万到百万倍。
|
||||
|
||||
所以我们不能用一种线性结构将磁盘排序。那么树呢? 比如二叉搜索树(Binary Serach Tree)行不行呢?利用磁盘的空间形成一个二叉搜索树,例如将订单 ID 作为二叉搜索树的 Key。
|
||||
|
||||
如下图所示,二叉搜索树的特点是一个节点的左子树的所有节点都小于这个节点,右子树的所有节点都大于这个节点。而且,因为索引条目较少,确实可以考虑在查询的时候,先将足够大的树导入内存,然后再进行搜索。搜索的算法是递归的,与二分查找非常类似,每次计算可以将问题规模减半。当然,具体有多少数据可以导入内存,受实际可以使用的内存数量的限制。
|
||||
|
||||
|
||||
|
||||
在上面的二叉搜索树中,每个节点的数据分成 Key 和 Value。Key 就是索引值,比如订单 ID 创建索引,那么 Key 就是订单 ID。值中至少需要序号(对行存储也就是行号)。这样,如果们想找 18 对应的行,就可以先通过二叉搜索树找到对应的行号,然后再去对应的行读取数据。
|
||||
|
||||
|
||||
|
||||
二叉搜索树是一个天生的二分查找结构,每次查找都可以减少一半的问题规模。而且二叉搜索树解决了插入新节点的问题,因为二叉搜索树是一个跳跃结构,不必在内存中连续排列。这样在插入的时候,新节点可以放在任何位置,不会像线性结构那样插入一个元素,所有元素都需要向后排列。
|
||||
|
||||
那么回到本质问题,在使用磁盘的时候,二叉搜索树是不是一种合理的查询结构?
|
||||
|
||||
当然还不算,因此还需要继续优化我们的算法。二叉搜索树,在内存中是一个高效的数据结构。这是因为内存速度快,不仅可以随机存取,还可以高频操作。注意 CPU 缓存的穿透率只有 5% 左右,也就是 95% 的操作是在更快的 CPU 缓存中执行的。而且即便穿透,内存操作也是在纳秒级别可以完成。
|
||||
|
||||
但是,这个逻辑在磁盘中是不存在的,磁盘的速度慢太多了。我们可以尝试把尽可能多的二叉搜索树读入磁盘,但是如果数据量大,只能读入一部分呢?因此我们还需要继续改进算法。
|
||||
|
||||
B 树和 B+ 树
|
||||
|
||||
二叉搜索树解决了连续结构插入新元素开销很大的问题,同时又保持着天然的二分结构。但是,当需要索引的数据量很大,无法在一个磁盘 Block 中存下整棵二叉搜索树的时候。每一次递归向下的搜索,实际都是读取不同的磁盘块。这个时候二叉搜索树的开销很大。
|
||||
|
||||
试想一个一万亿条订单的表,进行 40 次查找找到答案,在内存中不是问题,要考虑到 CPU 缓存有 90% 以上的命中率(当然前提是内存足够大)。通常情况下我们没有这么大的内存空间,如果 40 次查找发生在磁盘上,也是非常耗时的。那么有没有更好的方案呢?
|
||||
|
||||
一个更好的方案,就是继续沿用树状结构,利用好磁盘的分块让每个节点多一些数据,并且允许子节点也多一些,树就会更矮。因为树的高度决定了搜索的次数。
|
||||
|
||||
|
||||
|
||||
上图中我们构造的树被称为 B 树(B-Tree),开头说过,B 这个字母具体是哪个单词或者人名的缩写,至今有争议,具体你可以查查资料。
|
||||
|
||||
B-Tree 是一种递归的搜索结构,与二叉搜索树非常类似。不同的是,B 树中的父节点中的数据会对子树进行区段分割。比如上图中节点 1 有 3 个子节点,并用数字 9,30 对子树的区间进行了划分。
|
||||
|
||||
上图中的 B 树是一个 3-4 B 树,3 指的是每个非叶子节点允许最大 3 个索引,4 指的是每个节点最多允许 4 个子节点,4 也指每个叶子节点可以存 4 个索引。上面只是一个例子,在实际的操作中,子节点有几十个、甚至上百个索引也很常见,因为我们希望树变矮,好减少磁盘操作。
|
||||
|
||||
B 树的每个节点是一个索引条目(例如:一个 <订单 ID,序号> 的组合),如果是行数据库可以索引到一条存储在磁盘上的记录。
|
||||
|
||||
继承 B 树:B+ 树
|
||||
|
||||
为了达到最高的效率,实战中我们往往使用的是一种继承于 B 树设计的结构,称为 B+ 树。B+ 树只有叶子节点才映射数据,下图中是对 B 树设计的一种改进,节点 1 为冗余节点,它不存储数据,只划定子树数据的范围。你可以看到节点 1 的索引 Key:12 和 30,在节点 3 和 4 中也有一份。
|
||||
|
||||
|
||||
|
||||
树的形成:插入
|
||||
|
||||
下面我以一棵 2-3 B+ 树来演示 B+ 树的插入过程。2 指的是 B+ 树每个非叶子节点允许 2 个数据,叶子节点最多允许 3 个索引,每个节点允许最多 3 个子节点。我们要在 2-3 B+ 树中依次插入 3,6,9,12,19,15,26,8,30。下图是演示:
|
||||
|
||||
|
||||
插入 3,6,9 过程很简单,都写入一个节点即可,因为叶子节点最多允许每个 3 个索引。接下来我们插入 12,会发生一次过载,然后节点就需要拆分,这个时候按照 B+ 树的设计会产生冗余节点。
|
||||
|
||||
|
||||
|
||||
然后插入 15 非常简单,直接加入即可:
|
||||
|
||||
|
||||
|
||||
接下来插入 19, 这个时候下图中红色部分发生过载:
|
||||
|
||||
|
||||
|
||||
因此需要拆分节点数据,我们从中间把红色的节点拆开,15 作为冗余的索引写入父节点,就形成下图的情况:
|
||||
|
||||
|
||||
|
||||
接着插入 26, 写入到对应位置即可。
|
||||
|
||||
|
||||
|
||||
接下来,插入 8 到对应位置即可。
|
||||
|
||||
|
||||
|
||||
然后我们插入 30,此时右边节点发生过载:
|
||||
|
||||
|
||||
|
||||
解决完一次过载问题之后,因为 26 会浮上去,根节点又发生了过载:
|
||||
|
||||
|
||||
|
||||
再次解决过载,拆分红色部分,得到最后结果:
|
||||
|
||||
|
||||
|
||||
在上述过程中,B+ 树始终可以保持平衡状态,而且所有叶子节点都在同一层级。更复杂的数学证明,我就不在这里讲解了。不过建议对算法感兴趣对同学,可以学习《算法导论》中关于树的部分。
|
||||
|
||||
插入和删除效率
|
||||
|
||||
B+ 树有大量的冗余节点,比如删除一个节点的时候,可以直接从叶子节点中删除,甚至可以不动非叶子节点。这样删除非常快。B 树则不同,B 树没有冗余节点,删除节点的时候非常复杂。比如删除根节点中的数据,可能涉及复杂的树的变形。
|
||||
|
||||
B+ 树的插入也是一样,有冗余节点,插入可能存在节点的拆分(如果节点饱和),但是最多只涉及树的一条路径。而且 B+ 树会自动平衡,不需要更多复杂的算法,类似红黑树的旋转操作等。
|
||||
|
||||
因此,B+ 树的插入和删除效率更高。
|
||||
|
||||
搜索:链表的作用
|
||||
|
||||
B 树和 B+ 树搜索原理基本一致。先从根节点查找,然后对比目标数据的范围,最后递归的进入子节点查找。
|
||||
|
||||
你可能会注意到,B+ 树所有叶子节点间还有一个链表进行连接。这种设计对范围查找非常有帮助,比如说我们想知道 1 月 20 日和 1 月 22 日之间的订单,这个时候可以先查找到 1 月 20 日所在的叶子节点,然后利用链表向右遍历,直到找到 1 月22 日的节点。这样我们就进一步节省搜索需要的时间。
|
||||
|
||||
总结
|
||||
|
||||
这一讲我们学习了在数据库中如何利用文件系统造索引。无论是行存储还是列存储,构造索引的过程都是类似的。索引有很多做法,除了 B+ 树,还有 HashTable、倒排表等。如果是存储海量数据的数据库,我们的思考点需要放在 I/O 的效率上。如果把今天的知识放到分布式数据库上,那除了需要节省磁盘读写还需要节省网络 I/O。
|
||||
|
||||
那么通过这一讲的学习,你现在可以尝试来回答本讲关联的面试题目:MySQL 中的 B 树和 B+ 树有什么区别?
|
||||
|
||||
【解析】B+ 树继承于 B 树,都限定了节点中数据数目和子节点的数目。B 树所有节点都可以映射数据,B+ 树只有叶子节点可以映射数据。
|
||||
|
||||
单独看这部分设计,看不出 B+ 树的优势。为了只有叶子节点可以映射数据,B+ 树创造了很多冗余的索引(所有非叶子节点都是冗余索引),这些冗余索引让 B+ 树在插入、删除的效率都更高,而且可以自动平衡,因此 B+ 树的所有叶子节点总是在一个层级上。所以 B+ 树可以用一条链表串联所有的叶子节点,也就是索引数据,这让 B+ 树的范围查找和聚合运算更快。
|
||||
|
||||
|
||||
|
||||
|
@ -0,0 +1,368 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
16 数据结构:Vec_T_、&[T]、Box_[T]_ ,你真的了解集合容器么?
|
||||
16|数据结构:Vec、&[T]、Box<[T]> ,你真的了解集合容器么?
|
||||
|
||||
你好,我是陈天。今天来学集合容器。
|
||||
|
||||
现在我们接触到了越来越多的数据结构,我把 Rust 中主要的数据结构从原生类型、容器类型和系统相关类型几个维度整理一下,你可以数数自己掌握了哪些。-
|
||||
-
|
||||
可以看到,容器占据了数据结构的半壁江山。
|
||||
|
||||
提到容器,很可能你首先会想到的就是数组、列表这些可以遍历的容器,但其实只要把某种特定的数据封装在某个数据结构中,这个数据结构就是一个容器。比如 Option,它是一个包裹了 T 存在或不存在的容器,而Cow 是一个封装了内部数据 B 或被借用或拥有所有权的容器。
|
||||
|
||||
对于容器的两小类,到目前为止,像 Cow 这样,为特定目的而产生的容器我们已经介绍了不少,包括 Box、Rc、Arc、RefCell、还没讲到的 Option 和 Result 等。
|
||||
|
||||
今天我们来详细讲讲另一类,集合容器。
|
||||
|
||||
集合容器
|
||||
|
||||
集合容器,顾名思义,就是把一系列拥有相同类型的数据放在一起,统一处理,比如:
|
||||
|
||||
|
||||
我们熟悉的字符串 String、数组 [T; n]、列表 Vec和哈希表 HashMap等;
|
||||
虽然到处在使用,但还并不熟悉的切片 slice;
|
||||
在其他语言中使用过,但在 Rust 中还没有用过的循环缓冲区 VecDeque、双向列表 LinkedList 等。
|
||||
|
||||
|
||||
这些集合容器有很多共性,比如可以被遍历、可以进行 map-reduce 操作、可以从一种类型转换成另一种类型等等。
|
||||
|
||||
我们会选取两类典型的集合容器:切片和哈希表,深入解读,理解了这两类容器,其它的集合容器设计思路都差不多,并不难学习。今天先介绍切片以及和切片相关的容器,下一讲我们学习哈希表。
|
||||
|
||||
切片究竟是什么?
|
||||
|
||||
在 Rust 里,切片是描述一组属于同一类型、长度不确定的、在内存中连续存放的数据结构,用 [T] 来表述。因为长度不确定,所以切片是个 DST(Dynamically Sized Type)。
|
||||
|
||||
切片一般只出现在数据结构的定义中,不能直接访问,在使用中主要用以下形式:
|
||||
|
||||
|
||||
&[T]:表示一个只读的切片引用。
|
||||
&mut [T]:表示一个可写的切片引用。
|
||||
Box<[T]>:一个在堆上分配的切片。
|
||||
|
||||
|
||||
怎么理解切片呢?我打个比方,切片之于具体的数据结构,就像数据库中的视图之于表。你可以把它看成一种工具,让我们可以统一访问行为相同、结构类似但有些许差异的类型。
|
||||
|
||||
来看下面的代码,辅助理解:
|
||||
|
||||
fn main() {
|
||||
let arr = [1, 2, 3, 4, 5];
|
||||
let vec = vec![1, 2, 3, 4, 5];
|
||||
let s1 = &arr[..2];
|
||||
let s2 = &vec[..2];
|
||||
println!("s1: {:?}, s2: {:?}", s1, s2);
|
||||
|
||||
// &[T] 和 &[T] 是否相等取决于长度和内容是否相等
|
||||
assert_eq!(s1, s2);
|
||||
// &[T] 可以和 Vec<T>/[T;n] 比较,也会看长度和内容
|
||||
assert_eq!(&arr[..], vec);
|
||||
assert_eq!(&vec[..], arr);
|
||||
}
|
||||
|
||||
|
||||
对于 array 和 vector,虽然是不同的数据结构,一个放在栈上,一个放在堆上,但它们的切片是类似的;而且对于相同内容数据的相同切片,比如 &arr[1…3] 和 &vec[1…3],这两者是等价的。除此之外,切片和对应的数据结构也可以直接比较,这是因为它们之间实现了 PartialEq trait(源码参考资料)。
|
||||
|
||||
下图比较清晰地呈现了切片和数据之间的关系:
|
||||
|
||||
另外在 Rust 下,切片日常中都是使用引用 &[T],所以很多同学容易搞不清楚 &[T] 和 &Vec 的区别。我画了张图,帮助你更好地理解它们的关系:
|
||||
|
||||
在使用的时候,支持切片的具体数据类型,你可以根据需要,解引用转换成切片类型。比如 Vec 和 [T; n] 会转化成为 &[T],这是因为 Vec 实现了 Deref trait,而 array 内建了到 &[T] 的解引用。我们可以写一段代码验证这一行为(代码):
|
||||
|
||||
use std::fmt;
|
||||
fn main() {
|
||||
let v = vec![1, 2, 3, 4];
|
||||
|
||||
// Vec 实现了 Deref,&Vec<T> 会被自动解引用为 &[T],符合接口定义
|
||||
print_slice(&v);
|
||||
// 直接是 &[T],符合接口定义
|
||||
print_slice(&v[..]);
|
||||
|
||||
// &Vec<T> 支持 AsRef<[T]>
|
||||
print_slice1(&v);
|
||||
// &[T] 支持 AsRef<[T]>
|
||||
print_slice1(&v[..]);
|
||||
// Vec<T> 也支持 AsRef<[T]>
|
||||
print_slice1(v);
|
||||
|
||||
let arr = [1, 2, 3, 4];
|
||||
// 数组虽没有实现 Deref,但它的解引用就是 &[T]
|
||||
print_slice(&arr);
|
||||
print_slice(&arr[..]);
|
||||
print_slice1(&arr);
|
||||
print_slice1(&arr[..]);
|
||||
print_slice1(arr);
|
||||
}
|
||||
|
||||
// 注意下面的泛型函数的使用
|
||||
fn print_slice<T: fmt::Debug>(s: &[T]) {
|
||||
println!("{:?}", s);
|
||||
}
|
||||
|
||||
fn print_slice1<T, U>(s: T)
|
||||
where
|
||||
T: AsRef<[U]>,
|
||||
U: fmt::Debug,
|
||||
{
|
||||
println!("{:?}", s.as_ref());
|
||||
}
|
||||
|
||||
|
||||
这也就意味着,通过解引用,这几个和切片有关的数据结构都会获得切片的所有能力,包括:binary_search、chunks、concat、contains、start_with、end_with、group_by、iter、join、sort、split、swap 等一系列丰富的功能,感兴趣的同学可以看切片的文档。
|
||||
|
||||
切片和迭代器 Iterator
|
||||
|
||||
迭代器可以说是切片的孪生兄弟。切片是集合数据的视图,而迭代器定义了对集合数据的各种各样的访问操作。
|
||||
|
||||
通过切片的 iter() 方法,我们可以生成一个迭代器,对切片进行迭代。
|
||||
|
||||
在[第12讲]Rust类型推导已经见过了 iterator trait(用 collect 方法把过滤出来的数据形成新列表)。iterator trait 有大量的方法,但绝大多数情况下,我们只需要定义它的关联类型 Item 和 next() 方法。
|
||||
|
||||
|
||||
Item 定义了每次我们从迭代器中取出的数据类型;
|
||||
|
||||
next() 是从迭代器里取下一个值的方法。当一个迭代器的 next() 方法返回 None 时,表明迭代器中没有数据了。
|
||||
|
||||
#[must_use = “iterators are lazy and do nothing unless consumed”]
|
||||
pub trait Iterator {
|
||||
|
||||
type Item;
|
||||
fn next(&mut self) -> Option<Self::Item>;
|
||||
// 大量缺省的方法,包括 size_hint, count, chain, zip, map,
|
||||
// filter, for_each, skip, take_while, flat_map, flatten
|
||||
// collect, partition 等
|
||||
...
|
||||
|
||||
}
|
||||
|
||||
|
||||
看一个例子,对 Vec 使用 iter() 方法,并进行各种 map/filter/take 操作。在函数式编程语言中,这样的写法很常见,代码的可读性很强。Rust 也支持这种写法(代码):
|
||||
|
||||
fn main() {
|
||||
// 这里 Vec<T> 在调用 iter() 时被解引用成 &[T],所以可以访问 iter()
|
||||
let result = vec![1, 2, 3, 4]
|
||||
.iter()
|
||||
.map(|v| v * v)
|
||||
.filter(|v| *v < 16)
|
||||
.take(1)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
println!("{:?}", result);
|
||||
}
|
||||
|
||||
|
||||
需要注意的是 Rust 下的迭代器是个懒接口(lazy interface),也就是说这段代码直到运行到 collect 时才真正开始执行,之前的部分不过是在不断地生成新的结构,来累积处理逻辑而已。你可能好奇,这是怎么做到的呢?
|
||||
|
||||
在 VS Code 里,如果你使用了 rust-analyzer 插件,就可以发现这一奥秘:-
|
||||
|
||||
|
||||
原来,Iterator 大部分方法都返回一个实现了 Iterator 的数据结构,所以可以这样一路链式下去,在 Rust 标准库中,这些数据结构被称为 Iterator Adapter。比如上面的 map 方法,它返回 Map 结构,而 Map 结构实现了 Iterator(源码)。
|
||||
|
||||
整个过程是这样的(链接均为源码资料):
|
||||
|
||||
|
||||
在 collect() 执行的时候,它实际试图使用 FromIterator 从迭代器中构建一个集合类型,这会不断调用 next() 获取下一个数据;
|
||||
此时的 Iterator 是 Take,Take 调自己的 next(),也就是它会调用 Filter 的 next();
|
||||
Filter 的 next() 实际上调用自己内部的 iter 的 find(),此时内部的 iter 是 Map,find() 会使用 try_fold(),它会继续调用 next(),也就是 Map 的 next();
|
||||
Map 的 next() 会调用其内部的 iter 取 next() 然后执行 map 函数。而此时内部的 iter 来自 Vec。
|
||||
|
||||
|
||||
所以,只有在 collect() 时,才触发代码一层层调用下去,并且调用会根据需要随时结束。这段代码中我们使用了 take(1),整个调用链循环一次,就能满足 take(1) 以及所有中间过程的要求,所以它只会循环一次。
|
||||
|
||||
你可能会有疑惑:这种函数式编程的写法,代码是漂亮了,然而这么多无谓的函数调用,性能肯定很差吧?毕竟,函数式编程语言的一大恶名就是性能差。
|
||||
|
||||
这个你完全不用担心, Rust 大量使用了 inline 等优化技巧,这样非常清晰友好的表达方式,性能和 C 语言的 for 循环差别不大。如果你对性能对比感兴趣,可以去最后的参考资料区看看。
|
||||
|
||||
介绍完是什么,按惯例我们就要上代码实际使用一下了。不过迭代器是非常重要的一个功能,基本上每种语言都有对迭代器的完整支持,所以只要你之前用过,对此应该并不陌生,大部分的方法,你一看就能明白是在做什么。所以这里就不再额外展示,等你遇到具体需求时,可以翻 Iterator 的文档查阅。
|
||||
|
||||
如果标准库中的功能还不能满足你的需求,你可以看看 itertools,它是和 Python 下 itertools 同名且功能类似的工具,提供了大量额外的 adapter。可以看一个简单的例子(代码):
|
||||
|
||||
use itertools::Itertools;
|
||||
|
||||
fn main() {
|
||||
let err_str = "bad happened";
|
||||
let input = vec![Ok(21), Err(err_str), Ok(7)];
|
||||
let it = input
|
||||
.into_iter()
|
||||
.filter_map_ok(|i| if i > 10 { Some(i * 2) } else { None });
|
||||
// 结果应该是:vec![Ok(42), Err(err_str)]
|
||||
println!("{:?}", it.collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
|
||||
在实际开发中,我们可能从一组 Future 中汇聚出一组结果,里面有成功执行的结果,也有失败的错误信息。如果想对成功的结果进一步做 filter/map,那么标准库就无法帮忙了,就需要用 itertools 里的 filter_map_ok()。
|
||||
|
||||
特殊的切片:&str
|
||||
|
||||
好,学完了普通的切片 &[T],我们来看一种特殊的切片:&str。之前讲过,String 是一个特殊的 Vec,所以在 String 上做切片,也是一个特殊的结构 &str。
|
||||
|
||||
对于 String、&String、&str,很多人也经常分不清它们的区别,我们在之前的一篇加餐中简单聊了这个问题,在上一讲智能指针中,也对比过String和&str。对于&String 和 &str,如果你理解了上文中 &Vec 和 &[T] 的区别,那么它们也是一样的:
|
||||
|
||||
String 在解引用时,会转换成 &str。可以用下面的代码验证(代码):
|
||||
|
||||
use std::fmt;
|
||||
fn main() {
|
||||
let s = String::from("hello");
|
||||
// &String 会被解引用成 &str
|
||||
print_slice(&s);
|
||||
// &s[..] 和 s.as_str() 一样,都会得到 &str
|
||||
print_slice(&s[..]);
|
||||
|
||||
// String 支持 AsRef<str>
|
||||
print_slice1(&s);
|
||||
print_slice1(&s[..]);
|
||||
print_slice1(s.clone());
|
||||
|
||||
// String 也实现了 AsRef<[u8]>,所以下面的代码成立
|
||||
// 打印出来是 [104, 101, 108, 108, 111]
|
||||
print_slice2(&s);
|
||||
print_slice2(&s[..]);
|
||||
print_slice2(s);
|
||||
}
|
||||
|
||||
fn print_slice(s: &str) {
|
||||
println!("{:?}", s);
|
||||
}
|
||||
|
||||
fn print_slice1<T: AsRef<str>>(s: T) {
|
||||
println!("{:?}", s.as_ref());
|
||||
}
|
||||
|
||||
fn print_slice2<T, U>(s: T)
|
||||
where
|
||||
T: AsRef<[U]>,
|
||||
U: fmt::Debug,
|
||||
{
|
||||
println!("{:?}", s.as_ref());
|
||||
}
|
||||
|
||||
|
||||
有同学会有疑问:那么字符的列表和字符串有什么关系和区别?我们直接写一段代码来看看:
|
||||
|
||||
use std::iter::FromIterator;
|
||||
|
||||
fn main() {
|
||||
let arr = ['h', 'e', 'l', 'l', 'o'];
|
||||
let vec = vec!['h', 'e', 'l', 'l', 'o'];
|
||||
let s = String::from("hello");
|
||||
let s1 = &arr[1..3];
|
||||
let s2 = &vec[1..3];
|
||||
// &str 本身就是一个特殊的 slice
|
||||
let s3 = &s[1..3];
|
||||
println!("s1: {:?}, s2: {:?}, s3: {:?}", s1, s2, s3);
|
||||
|
||||
// &[char] 和 &[char] 是否相等取决于长度和内容是否相等
|
||||
assert_eq!(s1, s2);
|
||||
// &[char] 和 &str 不能直接对比,我们把 s3 变成 Vec<char>
|
||||
assert_eq!(s2, s3.chars().collect::<Vec<_>>());
|
||||
// &[char] 可以通过迭代器转换成 String,String 和 &str 可以直接对比
|
||||
assert_eq!(String::from_iter(s2), s3);
|
||||
}
|
||||
|
||||
|
||||
可以看到,字符列表可以通过迭代器转换成 String,String 也可以通过 chars() 函数转换成字符列表,如果不转换,二者不能比较。
|
||||
|
||||
下图我把数组、列表、字符串以及它们的切片放在一起比较,可以帮你更好地理解它们的区别:
|
||||
|
||||
切片的引用和堆上的切片,它们是一回事么?
|
||||
|
||||
开头我们讲过,切片主要有三种使用方式:切片的只读引用 &[T]、切片的可变引用 &mut [T] 以及 Box<[T]>。刚才已经详细学习了只读切片 &[T],也和其他各种数据结构进行了对比帮助理解,可变切片 &mut [T] 和它类似,不必介绍。
|
||||
|
||||
现在我们来看看 Box<[T]>。
|
||||
|
||||
Box<[T]> 是一个比较有意思的存在,它和 Vec 有一点点差别:Vec 有额外的 capacity,可以增长;而 Box<[T]> 一旦生成就固定下来,没有 capacity,也无法增长。
|
||||
|
||||
Box<[T]>和切片的引用&[T] 也很类似:它们都是在栈上有一个包含长度的胖指针,指向存储数据的内存位置。区别是:Box<[T]> 只会指向堆,&[T] 指向的位置可以是栈也可以是堆;此外,Box<[T]> 对数据具有所有权,而 &[T] 只是一个借用。
|
||||
|
||||
那么如何产生 Box<[T]> 呢?目前可用的接口就只有一个:从已有的 Vec 中转换。我们看代码:
|
||||
|
||||
use std::ops::Deref;
|
||||
|
||||
fn main() {
|
||||
let mut v1 = vec![1, 2, 3, 4];
|
||||
v1.push(5);
|
||||
println!("cap should be 8: {}", v1.capacity());
|
||||
|
||||
// 从 Vec<T> 转换成 Box<[T]>,此时会丢弃多余的 capacity
|
||||
let b1 = v1.into_boxed_slice();
|
||||
let mut b2 = b1.clone();
|
||||
|
||||
let v2 = b1.into_vec();
|
||||
println!("cap should be exactly 5: {}", v2.capacity());
|
||||
|
||||
assert!(b2.deref() == v2);
|
||||
|
||||
// Box<[T]> 可以更改其内部数据,但无法 push
|
||||
b2[0] = 2;
|
||||
// b2.push(6);
|
||||
println!("b2: {:?}", b2);
|
||||
|
||||
// 注意 Box<[T]> 和 Box<[T; n]> 并不相同
|
||||
let b3 = Box::new([2, 2, 3, 4, 5]);
|
||||
println!("b3: {:?}", b3);
|
||||
|
||||
// b2 和 b3 相等,但 b3.deref() 和 v2 无法比较
|
||||
assert!(b2 == b3);
|
||||
// assert!(b3.deref() == v2);
|
||||
}
|
||||
|
||||
|
||||
运行代码可以看到,Vec 可以通过 into_boxed_slice() 转换成 Box<[T]>,Box<[T]> 也可以通过 into_vec() 转换回 Vec。
|
||||
|
||||
这两个转换都是很轻量的转换,只是变换一下结构,不涉及数据的拷贝。区别是,当 Vec 转换成 Box<[T]> 时,没有使用到的容量就会被丢弃,所以整体占用的内存可能会降低。而且Box<[T]> 有一个很好的特性是,不像 Box<[T;n]> 那样在编译时就要确定大小,它可以在运行期生成,以后大小不会再改变。
|
||||
|
||||
所以,当我们需要在堆上创建固定大小的集合数据,且不希望自动增长,那么,可以先创建 Vec,再转换成 Box<[T]>。tokio 在提供 broadcast channel 时,就使用了 Box<[T]> 这个特性,你感兴趣的话,可以自己看看源码。
|
||||
|
||||
小结
|
||||
|
||||
我们讨论了切片以及和切片相关的主要数据类型。切片是一个很重要的数据类型,你可以着重理解它存在的意义,以及使用方式。
|
||||
|
||||
今天学完相信你也看到了,围绕着切片有很多数据结构,而切片将它们抽象成相同的访问方式,实现了在不同数据结构之上的同一抽象,这种方法很值得我们学习。此外,当我们构建自己的数据结构时,如果它内部也有连续排列的等长的数据结构,可以考虑 AsRef 或者 Deref 到切片。
|
||||
|
||||
下图描述了切片和数组 [T;n]、列表 Vec、切片引用 &[T] /&mut [T],以及在堆上分配的切片 Box<[T]> 之间的关系。建议你花些时间理解这张图,也可以用相同的方式去总结学到的其他有关联的数据结构。-
|
||||
|
||||
|
||||
下一讲我们继续学习哈希表……
|
||||
|
||||
思考题
|
||||
|
||||
1.在讲 &str 时,里面的 print_slice1 函数,如果写成这样可不可以?你可以尝试一下,然后说明理由。
|
||||
|
||||
// fn print_slice1<T: AsRef<str>>(s: T) {
|
||||
// println!("{:?}", s.as_ref());
|
||||
// }
|
||||
|
||||
fn print_slice1<T, U>(s: T)
|
||||
where
|
||||
T: AsRef<U>,
|
||||
U: fmt::Debug,
|
||||
{
|
||||
println!("{:?}", s.as_ref());
|
||||
}
|
||||
|
||||
|
||||
2.类似 itertools,你可以试着开发一个新的 Iterator trait IteratorExt,为其提供 window_count 函数,使其可以做下图中的动作(来源):-
|
||||
|
||||
|
||||
感谢你的阅读,如果你觉得有收获,也欢迎你分享给你身边的朋友,邀他一起讨论。你已经完成了Rust学习的第16次打卡啦,我们下节课见。
|
||||
|
||||
参考资料:Rust 的 Iterator 究竟有多快?
|
||||
|
||||
当使用 Iterator 提供的这种函数式编程风格的时候,我们往往会担心性能。虽然我告诉你 Rust 大量使用 inline 来优化,但你可能还心存疑惑。
|
||||
|
||||
下面的代码和截图来自一个 Youtube 视频:Sharing code between iOS & Android with Rust,演讲者通过在使用 Iterator 处理一个很大的图片,比较 Rust/Swift/Kotlin native/C 这几种语言的性能。你也可以看到在处理迭代器时, Rust 代码和 Kotlin 或者 Swift 代码非常类似。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
运行结果,在函数式编程方式下(C 没有函数式编程支持,所以直接使用了 for 循环),Rust 和 C 几乎相当在1s 左右,C 比 Rust 快 20%,Swift 花了 11.8s,而 Kotlin native 直接超时:-
|
||||
|
||||
|
||||
所以 Rust 在对函数式编程,尤其是 Iterator 上的优化,还是非常不错的。这里面除了 inline 外,Rust 闭包的优异性能也提供了很多支持(未来我们会讲为什么)。在使用时,你完全不用担心性能。
|
||||
|
||||
|
||||
|
||||
|
@ -0,0 +1,578 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
30 Unsafe Rust:如何用C++的方式打开Rust?
|
||||
你好,我是陈天。
|
||||
|
||||
到目前为止,我们撰写的代码都在 Rust 精心构造的内存安全的国度里做一个守法好公民。通过遵循所有权、借用检查、生命周期等规则,我们自己的代码一旦编译通过,就相当于信心满满地向全世界宣布:这个代码是安全的!
|
||||
|
||||
然而,安全的 Rust 并不能适应所有的使用场景。
|
||||
|
||||
首先,为了内存安全,Rust 所做的这些规则往往是普适性的,编译器会把一切可疑的行为都严格地制止掉。可是,这种一丝不苟的铁面无情往往会过于严苛,导致错杀。
|
||||
|
||||
就好比“屋子的主人只会使用钥匙开门,如果一个人尝试着撬门,那一定是坏人”,正常情况下,这个逻辑是成立的,所有尝试撬门的小偷,都会被抓获(编译错误);然而,有时候主人丢了钥匙,不得不请开锁匠开门(unsafe code),此时,是正常的诉求,是可以网开一面的。
|
||||
|
||||
其次,无论 Rust 将其内部的世界构建得多么纯粹和完美,它总归是要跟不纯粹也不完美的外界打交道,无论是硬件还是软件。
|
||||
|
||||
计算机硬件本身是 unsafe 的,比如操作 IO 访问外设,或者使用汇编指令进行特殊操作(操作 GPU或者使用 SSE 指令集)。这样的操作,编译器是无法保证内存安全的,所以我们需要 unsafe 来告诉编译器要法外开恩。
|
||||
|
||||
同样的,当 Rust 要访问其它语言比如 C/C++ 的库,因为它们并不满足 Rust 的安全性要求,这种跨语言的 FFI(Foreign Function Interface),也是 unsafe 的。
|
||||
|
||||
这两种使用 unsafe Rust 的方式是不得而为之,所以情有可原,是我们需要使用 unsafe Rust 的主要原因。
|
||||
|
||||
还有一大类使用 unsafe Rust 纯粹是为了性能。比如略过边界检查、使用未初始化内存等。这样的 unsafe 我们要尽量不用,除非通过 benchmark 发现用 unsafe 可以解决某些性能瓶颈,否则使用起来得不偿失。因为,在使用 unsafe 代码的时候,我们已经把 Rust 的内存安全性,降低到了和 C++ 同等的水平。
|
||||
|
||||
可以使用 unsafe 的场景
|
||||
|
||||
好,在了解了为什么需要 unsafe Rust 之后,我们再来看看在日常工作中,都具体有哪些地方会用到 unsafe Rust。
|
||||
|
||||
我们先看可以使用、也推荐使用 unsafe 的场景,根据重要/常用程度,会依次介绍:实现 unsafe trait,主要是 Send/Sync 这两个 trait、调用已有的 unsafe 接口、对裸指针做解引用,以及使用 FFI。
|
||||
|
||||
实现 unsafe trait
|
||||
|
||||
Rust 里,名气最大的 unsafe 代码应该就是 Send/Sync 这两个 trait 了:
|
||||
|
||||
pub unsafe auto trait Send {}
|
||||
pub unsafe auto trait Sync {}
|
||||
|
||||
|
||||
相信你应该对这两个 trait 非常了解了,但凡遇到和并发相关的代码,尤其是接口的类型声明时,少不了要使用 Send/Sync 来约束。我们也知道,绝大多数数据结构都实现了 Send/Sync,但有一些例外,比如 Rc/RefCell /裸指针等。
|
||||
|
||||
因为 Send/Sync 是 auto trait,所以大部分情况下,你自己的数据结构不需要实现 Send/Sync,然而,当你在数据结构里使用裸指针时,因为裸指针是没有实现 Send/Sync 的,连带着你的数据结构也就没有实现 Send/Sync。但很可能你的结构是线程安全的,你也需要它线程安全。
|
||||
|
||||
此时,如果你可以保证它能在线程中安全地移动,那可以实现 Send;如果可以保证它能在线程中安全地共享,也可以去实现 Sync。之前我们讨论过的 Bytes 就在使用裸指针的情况下实现了 Send/Sync:
|
||||
|
||||
pub struct Bytes {
|
||||
ptr: *const u8,
|
||||
len: usize,
|
||||
// inlined "trait object"
|
||||
data: AtomicPtr<()>,
|
||||
vtable: &'static Vtable,
|
||||
}
|
||||
|
||||
// Vtable must enforce this behavior
|
||||
unsafe impl Send for Bytes {}
|
||||
unsafe impl Sync for Bytes {}
|
||||
|
||||
|
||||
但是,在实现 Send/Sync 的时候要特别小心,如果你无法保证数据结构的线程安全,错误实现 Send/Sync之后,会导致程序出现莫名其妙的还不太容易复现的崩溃。
|
||||
|
||||
比如下面的代码,强行为 Evil 实现了 Send,而 Evil 内部携带的 Rc 是不允许实现 Send 的。这段代码通过实现 Send 而规避了 Rust 的并发安全检查,使其可以编译通过(代码):
|
||||
|
||||
use std::{cell::RefCell, rc::Rc, thread};
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
struct Evil {
|
||||
data: Rc<RefCell<usize>>,
|
||||
}
|
||||
|
||||
// 为 Evil 强行实现 Send,这会让 Rc 整个紊乱
|
||||
unsafe impl Send for Evil {}
|
||||
|
||||
fn main() {
|
||||
let v = Evil::default();
|
||||
let v1 = v.clone();
|
||||
let v2 = v.clone();
|
||||
|
||||
let t1 = thread::spawn(move || {
|
||||
let v3 = v.clone();
|
||||
let mut data = v3.data.borrow_mut();
|
||||
*data += 1;
|
||||
println!("v3: {:?}", data);
|
||||
});
|
||||
|
||||
let t2 = thread::spawn(move || {
|
||||
let v4 = v1.clone();
|
||||
let mut data = v4.data.borrow_mut();
|
||||
*data += 1;
|
||||
println!("v4: {:?}", data);
|
||||
});
|
||||
|
||||
t2.join().unwrap();
|
||||
t1.join().unwrap();
|
||||
|
||||
let mut data = v2.data.borrow_mut();
|
||||
*data += 1;
|
||||
|
||||
println!("v2: {:?}", data);
|
||||
}
|
||||
|
||||
|
||||
然而在运行的时候,有一定的几率出现崩溃:
|
||||
|
||||
❯ cargo run --example rc_send
|
||||
v4: 1
|
||||
v3: 2
|
||||
v2: 3
|
||||
|
||||
❯ cargo run --example rc_send
|
||||
v4: 1
|
||||
thread '<unnamed>' panicked at 'already borrowed: BorrowMutError', examples/rc_send.rs:18:32
|
||||
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
|
||||
thread 'main' panicked at 'called `Result::unwrap()` on an `Err` value: Any { .. }', examples/rc_send.rs:31:15
|
||||
|
||||
|
||||
所以,如果你没有十足的把握,不宜胡乱实现 Send/Sync。
|
||||
|
||||
既然我们提到了 unsafe trait,你也许会好奇,什么 trait 会是 unsafe 呢?除了 Send/Sync 外,还会有其他 unsafe trait 么?当然会有。
|
||||
|
||||
任何 trait,只要声明成 unsafe,它就是一个 unsafe trait。而一个正常的 trait 里也可以包含 unsafe 函数,我们看下面的示例(代码):
|
||||
|
||||
// 实现这个 trait 的开发者要保证实现是内存安全的
|
||||
unsafe trait Foo {
|
||||
fn foo(&self);
|
||||
}
|
||||
|
||||
trait Bar {
|
||||
// 调用这个函数的人要保证调用是安全的
|
||||
unsafe fn bar(&self);
|
||||
}
|
||||
|
||||
struct Nonsense;
|
||||
|
||||
unsafe impl Foo for Nonsense {
|
||||
fn foo(&self) {
|
||||
println!("foo!");
|
||||
}
|
||||
}
|
||||
|
||||
impl Bar for Nonsense {
|
||||
unsafe fn bar(&self) {
|
||||
println!("bar!");
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let nonsense = Nonsense;
|
||||
// 调用者无需关心 safety
|
||||
nonsense.foo();
|
||||
|
||||
// 调用者需要为 safety 负责
|
||||
unsafe { nonsense.bar() };
|
||||
}
|
||||
|
||||
|
||||
可以看到,unsafe trait 是对 trait 的实现者的约束,它告诉 trait 的实现者:实现我的时候要小心,要保证内存安全,所以实现的时候需要加 unsafe 关键字。
|
||||
|
||||
但 unsafe trait 对于调用者来说,可以正常调用,不需要任何 unsafe block,因为这里的 safety 已经被实现者保证了,毕竟如果实现者没保证,调用者也做不了什么来保证 safety,就像我们使用 Send/Sync 一样。
|
||||
|
||||
而unsafe fn 是函数对调用者的约束,它告诉函数的调用者:如果你胡乱使用我,会带来内存安全方面的问题,请妥善使用,所以调用 unsafe fn 时,需要加 unsafe block 提醒别人注意。
|
||||
|
||||
再来看一个实现和调用都是 unsafe 的 trait:GlobalAlloc。
|
||||
|
||||
下面这段代码在智能指针的[那一讲]中我们见到过,通过 GlobalAlloc 我们可以实现自己的内存分配器。因为内存分配器对内存安全的影响很大,所以实现者需要保证每个实现都是内存安全的。同时,alloc/dealloc 这样的方法,使用不正确的姿势去调用,也会发生内存安全的问题,所以这两个方法也是 unsafe 的:
|
||||
|
||||
use std::alloc::{GlobalAlloc, Layout, System};
|
||||
|
||||
struct MyAllocator;
|
||||
|
||||
unsafe impl GlobalAlloc for MyAllocator {
|
||||
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
|
||||
let data = System.alloc(layout);
|
||||
eprintln!("ALLOC: {:p}, size {}", data, layout.size());
|
||||
data
|
||||
}
|
||||
|
||||
unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
|
||||
System.dealloc(ptr, layout);
|
||||
eprintln!("FREE: {:p}, size {}", ptr, layout.size());
|
||||
}
|
||||
}
|
||||
|
||||
#[global_allocator]
|
||||
static GLOBAL: MyAllocator = MyAllocator;
|
||||
|
||||
|
||||
好,unsafe trait 就讲这么多,如果你想了解更多详情,可以看 Rust RFC2585。如果你想看一个完整的 unsafe trait 定义到实现的过程,可以看 BufMut。
|
||||
|
||||
调用已有的 unsafe 函数
|
||||
|
||||
接下来我们讲 unsafe 函数。有些时候,你会发现,标准库或者第三方库提供给你的函数本身就标明了 unsafe。比如我们之前为了打印 HashMap 结构所使用的 transmute 函数:
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn main() {
|
||||
let map = HashMap::new();
|
||||
let mut map = explain("empty", map);
|
||||
|
||||
map.insert(String::from("a"), 1);
|
||||
explain("added 1", map);
|
||||
}
|
||||
|
||||
// HashMap 结构有两个 u64 的 RandomState,然后是四个 usize,
|
||||
// 分别是 bucket_mask, ctrl, growth_left 和 items
|
||||
// 我们 transmute 打印之后,再 transmute 回去
|
||||
fn explain<K, V>(name: &str, map: HashMap<K, V>) -> HashMap<K, V> {
|
||||
let arr: [usize; 6] = unsafe { std::mem::transmute(map) };
|
||||
println!(
|
||||
"{}: bucket_mask 0x{:x}, ctrl 0x{:x}, growth_left: {}, items: {}",
|
||||
name, arr[2], arr[3], arr[4], arr[5]
|
||||
);
|
||||
|
||||
// 因为 std:mem::transmute 是一个 unsafe 函数,所以我们需要 unsafe
|
||||
unsafe { std::mem::transmute(arr) }
|
||||
}
|
||||
|
||||
|
||||
前面已经说过,要调用一个 unsafe 函数,你需要使用 unsafe block 把它包裹起来。这相当于在提醒大家,注意啊,这里有 unsafe 代码!
|
||||
|
||||
另一种调用 unsafe 函数的方法是定义 unsafe fn,然后在这个 unsafe fn 里调用其它 unsafe fn。
|
||||
|
||||
如果你阅读一些标准库的代码会发现,有时候同样的功能,Rust 会提供 unsafe 和 safe 的版本,比如,把 &[u8] 里的数据转换成字符串:
|
||||
|
||||
// safe 版本,验证合法性,如果不合法返回错误
|
||||
pub fn from_utf8(v: &[u8]) -> Result<&str, Utf8Error> {
|
||||
run_utf8_validation(v)?;
|
||||
// SAFETY: Just ran validation.
|
||||
Ok(unsafe { from_utf8_unchecked(v) })
|
||||
}
|
||||
|
||||
// 不验证合法性,调用者需要确保 &[u8] 里都是合法的字符
|
||||
pub const unsafe fn from_utf8_unchecked(v: &[u8]) -> &str {
|
||||
// SAFETY: the caller must guarantee that the bytes `v` are valid UTF-8.
|
||||
// Also relies on `&str` and `&[u8]` having the same layout.
|
||||
unsafe { mem::transmute(v) }
|
||||
}
|
||||
|
||||
|
||||
安全的 str::from_utf8() 内部做了一些检查后,实际调用了 str::from_utf8_unchecked()。如果我们不需要做这一层检查,这个调用可以高效很多(可能是一个量级的区别),因为 unsafe 的版本就只是一个类型的转换而已。
|
||||
|
||||
那么这样有两个版本的接口,我们该如何调用呢?
|
||||
|
||||
如果你并不是特别明确,一定要调用安全的版本,不要为了性能的优势而去调用不安全的版本。如果你清楚地知道,&[u8] 你之前已经做过检查,或者它本身就来源于你从 &str 转换成的 &[u8],现在只不过再转换回去,那可以调用不安全的版本,并在注释中注明为什么这里是安全的。
|
||||
|
||||
对裸指针解引用
|
||||
|
||||
unsafe trait 和 unsafe fn 的使用就了解到这里啦,我们再看裸指针。很多时候,如果需要进行一些特殊处理,我们会把得到的数据结构转换成裸指针,比如刚才的 Bytes。
|
||||
|
||||
裸指针在生成的时候无需 unsafe,因为它并没有内存不安全的操作,但裸指针的解引用操作是不安全的,潜在有风险,它也需要使用 unsafe 来明确告诉编译器,以及代码的阅读者,也就是说要使用 unsafe block 包裹起来。
|
||||
|
||||
下面是一段对裸指针解引用的操作(代码):
|
||||
|
||||
fn main() {
|
||||
let mut age = 18;
|
||||
|
||||
// 不可变指针
|
||||
let r1 = &age as *const i32;
|
||||
// 可变指针
|
||||
let r2 = &mut age as *mut i32;
|
||||
|
||||
// 使用裸指针,可以绕过 immutable/mutable borrow rule
|
||||
|
||||
// 然而,对指针解引用需要使用 unsafe
|
||||
unsafe {
|
||||
println!("r1: {}, r2: {}", *r1, *r2);
|
||||
}
|
||||
}
|
||||
|
||||
fn immutable_mutable_cant_coexist() {
|
||||
let mut age = 18;
|
||||
let r1 = &age;
|
||||
// 编译错误
|
||||
let r2 = &mut age;
|
||||
|
||||
println!("r1: {}, r2: {}", *r1, *r2);
|
||||
}
|
||||
|
||||
|
||||
我们可以看到,使用裸指针,可变指针和不可变指针可以共存,不像可变引用和不可变引用无法共存。这是因为裸指针的任何对内存的操作,无论是 ptr::read/ptr::write,还是解引用,都是unsafe 的操作,所以只要读写内存,裸指针的使用者就需要对内存安全负责。
|
||||
|
||||
你也许会觉得奇怪,这里也没有内存不安全的操作啊,为啥需要 unsafe 呢?是的,虽然在这个例子里,裸指针来源于一个可信的内存地址,所有的代码都是安全的,但是,下面的代码就是不安全的,会导致 segment fault(代码):
|
||||
|
||||
fn main() {
|
||||
// 裸指针指向一个有问题的地址
|
||||
let r1 = 0xdeadbeef as *mut u32;
|
||||
|
||||
println!("so far so good!");
|
||||
|
||||
unsafe {
|
||||
// 程序崩溃
|
||||
*r1 += 1;
|
||||
println!("r1: {}", *r1);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
这也是为什么我们在撰写 unsafe Rust 的时候,要慎之又慎,并且在 unsafe 代码中添加足够的注释来阐述为何你觉得可以保证这段代码的安全。
|
||||
|
||||
使用裸指针的时候,大部分操作都是 unsafe 的(下图里表三角惊叹号的):-
|
||||
-
|
||||
如果你对此感兴趣,可以查阅 std::ptr 的文档。
|
||||
|
||||
使用 FFI
|
||||
|
||||
最后一种可以使用 unsafe 的地方是 FFI。
|
||||
|
||||
当 Rust 要使用其它语言的能力时,Rust 编译器并不能保证那些语言具备内存安全,所以和第三方语言交互的接口,一律要使用 unsafe,比如,我们调用 libc 来进行 C 语言开发者熟知的 malloc/free(代码):
|
||||
|
||||
use std::mem::transmute;
|
||||
|
||||
fn main() {
|
||||
let data = unsafe {
|
||||
let p = libc::malloc(8);
|
||||
let arr: &mut [u8; 8] = transmute(p);
|
||||
arr
|
||||
};
|
||||
|
||||
data.copy_from_slice(&[1, 2, 3, 4, 5, 6, 7, 8]);
|
||||
|
||||
println!("data: {:?}", data);
|
||||
|
||||
unsafe { libc::free(transmute(data)) };
|
||||
}
|
||||
|
||||
|
||||
从代码中可以看到,所有的对 libc 函数的调用,都需要使用 unsafe block。下节课我们会花一讲的时间谈谈 Rust 如何做 FFI,到时候细讲。
|
||||
|
||||
不推荐的使用 unsafe 的场景
|
||||
|
||||
以上是我们可以使用 unsafe 的场景。还有一些情况可以使用 unsafe,但是,我并不推荐。比如处理未初始化数据、访问可变静态变量、使用 unsafe 提升性能。
|
||||
|
||||
虽然不推荐使用,但它们作为一种用法,在标准库和第三方库中还是会出现,我们即便自己不写,在遇到的时候,也最好能够读懂它们。
|
||||
|
||||
访问或者修改可变静态变量
|
||||
|
||||
首先是可变静态变量。之前的课程中,我们见识过全局的 static 变量,以及使用 lazy_static 来声明复杂的 static 变量。然而之前遇到的 static 变量都是不可变的。
|
||||
|
||||
Rust 还支持可变的 static 变量,可以使用 static mut 来声明。
|
||||
|
||||
显而易见的是,全局变量如果可写,会潜在有线程不安全的风险,所以如果你声明 static mut 变量,在访问时,统统都需要使用 unsafe。以下的代码就使用了 static mut,并试图在两个线程中分别改动它。你可以感受到,这个代码的危险(代码):
|
||||
|
||||
use std::thread;
|
||||
|
||||
static mut COUNTER: usize = 1;
|
||||
|
||||
fn main() {
|
||||
let t1 = thread::spawn(move || {
|
||||
unsafe { COUNTER += 10 };
|
||||
});
|
||||
|
||||
let t2 = thread::spawn(move || {
|
||||
unsafe { COUNTER *= 10 };
|
||||
});
|
||||
|
||||
t2.join().unwrap();
|
||||
t1.join().unwrap();
|
||||
|
||||
unsafe { println!("COUNTER: {}", COUNTER) };
|
||||
}
|
||||
|
||||
|
||||
其实我们完全没必要这么做。对于上面的场景,我们可以使用 AtomicXXX 来改进:
|
||||
|
||||
use std::{
|
||||
sync::atomic::{AtomicUsize, Ordering},
|
||||
thread,
|
||||
};
|
||||
|
||||
static COUNTER: AtomicUsize = AtomicUsize::new(1);
|
||||
|
||||
fn main() {
|
||||
let t1 = thread::spawn(move || {
|
||||
COUNTER.fetch_add(10, Ordering::SeqCst);
|
||||
});
|
||||
|
||||
let t2 = thread::spawn(move || {
|
||||
COUNTER
|
||||
.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |v| Some(v * 10))
|
||||
.unwrap();
|
||||
});
|
||||
|
||||
t2.join().unwrap();
|
||||
t1.join().unwrap();
|
||||
|
||||
println!("COUNTER: {}", COUNTER.load(Ordering::Relaxed));
|
||||
}
|
||||
|
||||
|
||||
有同学可能会问:如果我的数据结构比较复杂,无法使用 AtomicXXX 呢?
|
||||
|
||||
如果你需要定义全局的可变状态,那么,你还可以使用 Mutex 或者 RwLock 来提供并发安全的写访问,比如:
|
||||
|
||||
use lazy_static::lazy_static;
|
||||
use std::{collections::HashMap, sync::Mutex, thread};
|
||||
|
||||
// 使用 lazy_static 初始化复杂的结构
|
||||
lazy_static! {
|
||||
// 使用 Mutex/RwLock 来提供安全的并发写访问
|
||||
static ref STORE: Mutex<HashMap<&'static str, &'static [u8]>> = Mutex::new(HashMap::new());
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let t1 = thread::spawn(move || {
|
||||
let mut store = STORE.lock().unwrap();
|
||||
store.insert("hello", b"world");
|
||||
});
|
||||
|
||||
let t2 = thread::spawn(move || {
|
||||
let mut store = STORE.lock().unwrap();
|
||||
store.insert("goodbye", b"world");
|
||||
});
|
||||
|
||||
t2.join().unwrap();
|
||||
t1.join().unwrap();
|
||||
|
||||
println!("store: {:?}", STORE.lock().unwrap());
|
||||
}
|
||||
|
||||
|
||||
所以,我非常不建议你使用 static mut。任何需要 static mut 的地方,都可以用 AtomicXXX/Mutex/RwLock 来取代。千万不要为了一时之快,给程序种下长远的祸根。
|
||||
|
||||
在宏里使用 unsafe
|
||||
|
||||
虽然我们并没有介绍宏编程,但已经在很多场合使用过宏了,宏可以在编译时生成代码。
|
||||
|
||||
在宏中使用 unsafe,是非常危险的。
|
||||
|
||||
首先使用你的宏的开发者,可能压根不知道 unsafe 代码的存在;其次,含有 unsafe 代码的宏在被使用到的时候,相当于把 unsafe 代码注入到当前上下文中。在不知情的情况下,开发者到处调用这样的宏,会导致 unsafe 代码充斥在系统的各个角落,不好处理;最后,一旦 unsafe 代码出现问题,你可能都很难找到问题的根本原因。
|
||||
|
||||
以下是 actix_web 代码库中的 downcast_dyn 宏,你可以感受到本来就比较晦涩的宏,跟 unsafe 碰撞在一起,那种令空气都凝固了的死亡气息:
|
||||
|
||||
// Generate implementation for dyn $name
|
||||
macro_rules! downcast_dyn {
|
||||
($name:ident) => {
|
||||
/// A struct with a private constructor, for use with
|
||||
/// `__private_get_type_id__`. Its single field is private,
|
||||
/// ensuring that it can only be constructed from this module
|
||||
#[doc(hidden)]
|
||||
#[allow(dead_code)]
|
||||
pub struct PrivateHelper(());
|
||||
|
||||
impl dyn $name + 'static {
|
||||
/// Downcasts generic body to a specific type.
|
||||
#[allow(dead_code)]
|
||||
pub fn downcast_ref<T: $name + 'static>(&self) -> Option<&T> {
|
||||
if self.__private_get_type_id__(PrivateHelper(())).0
|
||||
== std::any::TypeId::of::<T>()
|
||||
{
|
||||
// SAFETY: external crates cannot override the default
|
||||
// implementation of `__private_get_type_id__`, since
|
||||
// it requires returning a private type. We can therefore
|
||||
// rely on the returned `TypeId`, which ensures that this
|
||||
// case is correct.
|
||||
unsafe { Some(&*(self as *const dyn $name as *const T)) }
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Downcasts a generic body to a mutable specific type.
|
||||
#[allow(dead_code)]
|
||||
pub fn downcast_mut<T: $name + 'static>(&mut self) -> Option<&mut T> {
|
||||
if self.__private_get_type_id__(PrivateHelper(())).0
|
||||
== std::any::TypeId::of::<T>()
|
||||
{
|
||||
// SAFETY: external crates cannot override the default
|
||||
// implementation of `__private_get_type_id__`, since
|
||||
// it requires returning a private type. We can therefore
|
||||
// rely on the returned `TypeId`, which ensures that this
|
||||
// case is correct.
|
||||
unsafe { Some(&mut *(self as *const dyn $name as *const T as *mut T)) }
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
所以,除非你是一个 unsafe 以及宏编程的老手,否则不建议这么做。
|
||||
|
||||
使用 unsafe 提升性能
|
||||
|
||||
unsafe 代码在很多 Rust 基础库中有大量的使用,比如哈希表那一讲提到的 hashbrown,如果看它的代码库,你会发现一共有 222 处使用 unsafe:
|
||||
|
||||
hashbrown on master
|
||||
❯ ag "unsafe" | wc -l
|
||||
222
|
||||
|
||||
|
||||
这些 unsafe 代码,大多是为了性能而做的妥协。
|
||||
|
||||
比如下面的代码就使用了 SIMD 指令来加速处理:
|
||||
|
||||
unsafe {
|
||||
// A byte is EMPTY or DELETED iff the high bit is set
|
||||
BitMask(x86::_mm_movemask_epi8(self.0) as u16)
|
||||
}
|
||||
|
||||
|
||||
然而,如果你不是在撰写非常基础的库,并且这个库处在系统的关键路径上,我也很不建议使用 unsafe 来提升性能。
|
||||
|
||||
性能,是一个系统级的问题。在你没有解决好架构、设计、算法、网络、存储等其他问题时,就来抠某个函数的实现细节的性能,我认为是不妥的,尤其是试图通过使用 unsafe 代码,跳过一些检查来提升性能。
|
||||
|
||||
要知道,好的算法和不好的算法可以有数量级上的性能差异。而有些时候,即便你能够使用 unsafe 让局部性能达到最优,但作为一个整体看的时候,这个局部的优化可能根本没有意义。
|
||||
|
||||
所以,如果你用 Rust 做 Web 开发、做微服务、做客户端,很可能都不需要专门撰写 unsafe 代码来提升性能。
|
||||
|
||||
撰写 unsafe 代码
|
||||
|
||||
了解了unsafe可以使用和不建议使用的具体场景,最后,我们来写一段小小的代码,看看如果实际工作中,遇到不得不写 unsafe 代码时,该怎么做。
|
||||
|
||||
需求是要实现一个 split() 函数,得到一个字符串 s,按照字符 sep 第一次出现的位置,把字符串 s 截成前后两个字符串。这里,当找到字符 sep 的位置 pos 时,我们需要使用一个函数,得到从字符串开头到 pos 的子串,以及从字符 sep 之后到字符串结尾的子串。
|
||||
|
||||
要获得这个子串,Rust 有安全的 get 方法,以及不安全的 get_unchecked 方法。正常情况下,我们应该使用 get() 方法,但这个实例,我们就强迫自己使用 get_unchecked() 来跳过检查。
|
||||
|
||||
先看这个函数的安全性要求:-
|
||||
-
|
||||
在遇到 unsafe 接口时,我们都应该仔细阅读其安全须知,然后思考如何能满足它。如果你自己对外提供 unsafe 函数,也应该在文档中详细地给出类似的安全须知,告诉调用者,怎么样调用你的函数才算安全。
|
||||
|
||||
对于 split 的需求,我们完全可以满足 get_unchecked() 的安全要求,以下是实现(代码):
|
||||
|
||||
fn main() {
|
||||
let mut s = "我爱你!中国".to_string();
|
||||
let r = s.as_mut();
|
||||
|
||||
if let Some((s1, s2)) = split(r, '!') {
|
||||
println!("s1: {}, s2: {}", s1, s2);
|
||||
}
|
||||
}
|
||||
|
||||
fn split(s: &str, sep: char) -> Option<(&str, &str)> {
|
||||
let pos = s.find(sep);
|
||||
|
||||
pos.map(|pos| {
|
||||
let len = s.len();
|
||||
let sep_len = sep.len_utf8();
|
||||
|
||||
// SAFETY: pos 是 find 得到的,它位于字符的边界处,同样 pos + sep_len 也是如此
|
||||
// 所以以下代码是安全的
|
||||
unsafe { (s.get_unchecked(0..pos), s.get_unchecked(pos + sep_len..len)) }
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
同样的,在撰写 unsafe 代码调用别人的 unsafe 函数时,我们一定要用注释声明代码的安全性,这样,别人在阅读我们的代码时,可以明白为什么此处是安全的、是符合这个 unsafe 函数的预期的。
|
||||
|
||||
小结
|
||||
|
||||
unsafe 代码,是 Rust 这样的系统级语言必须包含的部分,当 Rust 跟硬件、操作系统,以及其他语言打交道,unsafe 是必不可少的。-
|
||||
|
||||
|
||||
当我们使用 unsafe 撰写 Rust 代码时,要格外小心,因为此时编译器已经把内存安全的权杖完全交给了你,在打开 unsafe block 的那一刻,你会获得 C/C++ 代码般的自由度,但这个自由背后的代价就是安全性上的妥协。
|
||||
|
||||
好的 unsafe 代码,足够短小、精简,只包含不得不包含的内容。unsafe 代码是开发者对编译器和其它开发者的一种庄重的承诺:我宣誓,这段代码是安全的。
|
||||
|
||||
今天讲的内容里的很多代码都是反面教材,并不建议你大量使用,尤其是初学者。那为什么我们还要讲 unsafe 代码呢?老子说:知其雄守其雌。我们要知道 Rust 的阴暗面(unsafe rust),才更容易守得住它光明的那一面(safe rust)。
|
||||
|
||||
这一讲了解了 unsafe 代码的使用场景,希望你日后,在阅读 unsafe 代码的时候,不再心里发怵;同时,在撰写 unsafe 代码时,能够对其足够敬畏。
|
||||
|
||||
思考题
|
||||
|
||||
上文中,我们使用 s.get_unchecked() 来获取一个子字符串,通过使用合适的 pos,可以把一个字符串 split 成两个。如果我们需要一个 split_mut 接口怎么实现?
|
||||
|
||||
fn split_mut(s: &mut str, sep: char) -> (&mut str, &mut str)
|
||||
|
||||
|
||||
你可以尝试使用 get_unchecked_mut(),看看代码能否编译通过?想想为什么?然后,试着自己构建 unsafe 代码实现一下?
|
||||
|
||||
小提示,你可以把 s 先转换成裸指针,然后再用 std::slice::from_raw_parts_mut() 通过一个指针和一个长度,构建出一个 slice(还记得 &[u8] 其实内部就是一个 ptr + len 么?)。然后,再通过 std::str::from_utf8_unchecked_mut() 构建出 &mut str。
|
||||
|
||||
感谢你的收听,今天你完成了Rust学习的第30次打卡。如果你觉得有收获,也欢迎你分享给身边的朋友,邀他一起讨论。我们下节课见~
|
||||
|
||||
|
||||
|
||||
|
@ -0,0 +1,226 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
10 广播变量 & 累加器:共享变量是用来做什么的?
|
||||
你好,我是吴磊。
|
||||
|
||||
今天是国庆第一天,首先祝你节日快乐。专栏上线以来,有不少同学留言说期待后续内容,所以国庆期间我们仍旧更新正文内容,让我们一起把基础知识模块收个尾。
|
||||
|
||||
学习过RDD常用算子之后,回顾这些算子,你会发现它们都是作用(Apply)在RDD之上的。RDD的计算以数据分区为粒度,依照算子的逻辑,Executors以相互独立的方式,完成不同数据分区的计算与转换。
|
||||
|
||||
不难发现,对于Executors来说,分区中的数据都是局部数据。换句话说,在同一时刻,隶属于某个Executor的数据分区,对于其他Executors来说是不可见的。
|
||||
|
||||
不过,在做应用开发的时候,总会有一些计算逻辑需要访问“全局变量”,比如说全局计数器,而这些全局变量在任意时刻对所有的Executors都是可见的、共享的。那么问题来了,像这样的全局变量,或者说共享变量,Spark又是如何支持的呢?
|
||||
|
||||
今天这一讲,我就来和你聊聊Spark共享变量。按照创建与使用方式的不同,Spark提供了两类共享变量,分别是广播变量(Broadcast variables)和累加器(Accumulators)。接下来,我们就正式进入今天的学习,去深入了解这两种共享变量的用法、以及它们各自的适用场景。
|
||||
|
||||
广播变量(Broadcast variables)
|
||||
|
||||
我们先来说说广播变量。广播变量的用法很简单,给定普通变量x,通过调用SparkContext下的broadcast API即可完成广播变量的创建,我们结合代码例子看一下。
|
||||
|
||||
val list: List[String] = List("Apache", "Spark")
|
||||
|
||||
// sc为SparkContext实例
|
||||
val bc = sc.broadcast(list)
|
||||
|
||||
|
||||
在上面的代码示例中,我们先是定义了一个字符串列表list,它包含“Apache”和“Spark”这两个单词。然后,我们使用broadcast函数来创建广播变量bc,bc封装的内容就是list列表。
|
||||
|
||||
// 读取广播变量内容
|
||||
bc.value
|
||||
// List[String] = List(Apache, Spark)
|
||||
|
||||
// 直接读取列表内容
|
||||
list
|
||||
// List[String] = List(Apache, Spark)
|
||||
|
||||
|
||||
使用broadcast API创建广播变量
|
||||
|
||||
广播变量创建好之后,通过调用它的value函数,我们就可以访问它所封装的数据内容。可以看到调用bc.value的效果,这与直接访问字符串列表list的效果是完全一致的。
|
||||
|
||||
看到这里,你可能会问:“明明通过访问list变量就可以直接获取字符串列表,为什么还要绕个大弯儿,先去封装广播变量,然后又通过它的value函数来获取同样的数据内容呢?”实际上,这是个非常好的问题,要回答这个问题,咱们需要做个推演,看看直接访问list变量会产生哪些弊端。
|
||||
|
||||
在前面的几讲中,我们换着花样地变更Word Count的计算逻辑。尽管Word Count都快被我们“玩坏了”,不过,一以贯之地沿用同一个实例,有助于我们通过对比迅速掌握新的知识点、技能点。因此,为了让你迅速掌握广播变量的“精髓”,咱们不妨“故技重施”,继续在Word Count这个实例上做文章。
|
||||
|
||||
普通变量的痛点
|
||||
|
||||
这一次,为了对比使用广播变量前后的差异,我们把Word Count变更为“定向计数”。
|
||||
|
||||
所谓定向计数,它指的是只对某些单词进行计数,例如,给定单词列表list,我们只对文件wikiOfSpark.txt当中的“Apache”和“Spark”这两个单词做计数,其他单词我们可以忽略。结合[第1讲]Word Count的完整代码,这样的计算逻辑很容易实现,如下表所示。
|
||||
|
||||
import org.apache.spark.rdd.RDD
|
||||
val rootPath: String = _
|
||||
val file: String = s"${rootPath}/wikiOfSpark.txt"
|
||||
// 读取文件内容
|
||||
val lineRDD: RDD[String] = spark.sparkContext.textFile(file)
|
||||
// 以行为单位做分词
|
||||
val wordRDD: RDD[String] = lineRDD.flatMap(line => line.split(" "))
|
||||
|
||||
// 创建单词列表list
|
||||
val list: List[String] = List("Apache", "Spark")
|
||||
// 使用list列表对RDD进行过滤
|
||||
val cleanWordRDD: RDD[String] = wordRDD.filter(word => list.contains(word))
|
||||
// 把RDD元素转换为(Key,Value)的形式
|
||||
val kvRDD: RDD[(String, Int)] = cleanWordRDD.map(word => (word, 1))
|
||||
// 按照单词做分组计数
|
||||
val wordCounts: RDD[(String, Int)] = kvRDD.reduceByKey((x, y) => x + y)
|
||||
// 获取计算结果
|
||||
wordCounts.collect
|
||||
// Array[(String, Int)] = Array((Apache,34), (Spark,63))
|
||||
|
||||
|
||||
将上述代码丢进spark-shell,我们很快就能算出,在wikiOfSpark.txt文件中,“Apache”这个单词出现了34次,而“Spark”则出现了63次。虽说得出计算结果挺容易的,不过知其然,还要知其所以然,接下来,咱们一起来分析一下,这段代码在运行时是如何工作的。
|
||||
|
||||
|
||||
|
||||
如上图所示,list变量本身是在Driver端创建的,它并不是分布式数据集(如lineRDD、wordRDD)的一部分。因此,在分布式计算的过程中,Spark需要把list变量分发给每一个分布式任务(Task),从而对不同数据分区的内容进行过滤。
|
||||
|
||||
在这种工作机制下,如果RDD并行度较高、或是变量的尺寸较大,那么重复的内容分发就会引入大量的网络开销与存储开销,而这些开销会大幅削弱作业的执行性能。为什么这么说呢?
|
||||
|
||||
要知道,Driver端变量的分发是以Task为粒度的,系统中有多少个Task,变量就需要在网络中分发多少次。更要命的是,每个Task接收到变量之后,都需要把它暂存到内存,以备后续过滤之用。换句话说,在同一个Executor内部,多个不同的Task多次重复地缓存了同样的内容拷贝,毫无疑问,这对宝贵的内存资源是一种巨大的浪费。
|
||||
|
||||
RDD并行度较高,意味着RDD的数据分区数量较多,而Task数量与分区数相一致,这就代表系统中有大量的分布式任务需要执行。如果变量本身尺寸较大,大量分布式任务引入的网络开销与内存开销会进一步升级。在工业级应用中,RDD的并行度往往在千、万这个量级,在这种情况下,诸如list这样的变量会在网络中分发成千上万次,作业整体的执行效率自然会很差 。
|
||||
|
||||
面对这样的窘境,我们有没有什么办法,能够避免同一个变量的重复分发与存储呢?答案当然是肯定的,这个时候,我们就可以祭出广播变量这个“杀手锏”。
|
||||
|
||||
广播变量的优势
|
||||
|
||||
想要知道广播变量到底有啥优势,我们可以先用广播变量重写一下前面的代码实现,然后再做个对比,很容易就能发现广播变量为什么能解决普通变量的痛点。
|
||||
|
||||
import org.apache.spark.rdd.RDD
|
||||
val rootPath: String = _
|
||||
val file: String = s"${rootPath}/wikiOfSpark.txt"
|
||||
// 读取文件内容
|
||||
val lineRDD: RDD[String] = spark.sparkContext.textFile(file)
|
||||
// 以行为单位做分词
|
||||
val wordRDD: RDD[String] = lineRDD.flatMap(line => line.split(" "))
|
||||
|
||||
// 创建单词列表list
|
||||
val list: List[String] = List("Apache", "Spark")
|
||||
// 创建广播变量bc
|
||||
val bc = sc.broadcast(list)
|
||||
// 使用bc.value对RDD进行过滤
|
||||
val cleanWordRDD: RDD[String] = wordRDD.filter(word => bc.value.contains(word))
|
||||
// 把RDD元素转换为(Key,Value)的形式
|
||||
val kvRDD: RDD[(String, Int)] = cleanWordRDD.map(word => (word, 1))
|
||||
// 按照单词做分组计数
|
||||
val wordCounts: RDD[(String, Int)] = kvRDD.reduceByKey((x, y) => x + y)
|
||||
// 获取计算结果
|
||||
wordCounts.collect
|
||||
// Array[(String, Int)] = Array((Apache,34), (Spark,63))
|
||||
|
||||
|
||||
可以看到,代码的修改非常简单,我们先是使用broadcast函数来封装list变量,然后在RDD过滤的时候调用bc.value来访问list变量内容。你可不要小看这个改写,尽管代码的改动微乎其微,几乎可以忽略不计,但在运行时,整个计算过程却发生了翻天覆地的变化。
|
||||
|
||||
|
||||
|
||||
在使用广播变量之前,list变量的分发是以Task为粒度的,而在使用广播变量之后,变量分发的粒度变成了以Executors为单位,同一个Executor内多个不同的Tasks只需访问同一份数据拷贝即可。换句话说,变量在网络中分发与存储的次数,从RDD的分区数量,锐减到了集群中Executors的个数。
|
||||
|
||||
要知道,在工业级系统中,Executors个数与RDD并行度相比,二者之间通常会相差至少两个数量级。在这样的量级下,广播变量节省的网络与内存开销会变得非常可观,省去了这些开销,对作业的执行性能自然大有裨益。
|
||||
|
||||
好啦,到现在为止,我们讲解了广播变量的用法、工作原理,以及它的优势所在。在日常的开发工作中,当你遇到需要多个Task共享同一个大型变量(如列表、数组、映射等数据结构)的时候,就可以考虑使用广播变量来优化你的Spark作业。接下来,我们继续来说说Spark支持的第二种共享变量:累加器。
|
||||
|
||||
累加器(Accumulators)
|
||||
|
||||
累加器,顾名思义,它的主要作用是全局计数(Global counter)。与单机系统不同,在分布式系统中,我们不能依赖简单的普通变量来完成全局计数,而是必须依赖像累加器这种特殊的数据结构才能达到目的。
|
||||
|
||||
与广播变量类似,累加器也是在Driver端定义的,但它的更新是通过在RDD算子中调用add函数完成的。在应用执行完毕之后,开发者在Driver端调用累加器的value函数,就能获取全局计数结果。按照惯例,咱们还是通过代码来熟悉累加器的用法。
|
||||
|
||||
聪明的你可能已经猜到了,我们又要对Word Count“动手脚”了。在第1讲的Word Count中,我们过滤掉了空字符串,然后对文件wikiOfSpark.txt中所有的单词做统计计数。
|
||||
|
||||
不过这一次,我们在过滤掉空字符的同时,还想知道文件中到底有多少个空字符串,这样我们对文件中的“脏数据”就能做到心中有数了。
|
||||
|
||||
注意,这里对于空字符串的计数,不是主代码逻辑,它的计算结果不会写入到Word Count最终的统计结果。所以,只是简单地去掉filter环节,是无法实现空字符计数的。
|
||||
|
||||
那么,你自然会问:“不把filter环节去掉,怎么对空字符串做统计呢?”别着急,这样的计算需求,正是累加器可以施展拳脚的地方。你可以先扫一眼下表的代码实现,然后我们再一起来熟悉累加器的用法。
|
||||
|
||||
import org.apache.spark.rdd.RDD
|
||||
val rootPath: String = _
|
||||
val file: String = s"${rootPath}/wikiOfSpark.txt"
|
||||
// 读取文件内容
|
||||
val lineRDD: RDD[String] = spark.sparkContext.textFile(file)
|
||||
// 以行为单位做分词
|
||||
val wordRDD: RDD[String] = lineRDD.flatMap(line => line.split(" "))
|
||||
|
||||
// 定义Long类型的累加器
|
||||
val ac = sc.longAccumulator("Empty string")
|
||||
|
||||
// 定义filter算子的判定函数f,注意,f的返回类型必须是Boolean
|
||||
def f(x: String): Boolean = {
|
||||
if(x.equals("")) {
|
||||
// 当遇到空字符串时,累加器加1
|
||||
ac.add(1)
|
||||
return false
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// 使用f对RDD进行过滤
|
||||
val cleanWordRDD: RDD[String] = wordRDD.filter(f)
|
||||
// 把RDD元素转换为(Key,Value)的形式
|
||||
val kvRDD: RDD[(String, Int)] = cleanWordRDD.map(word => (word, 1))
|
||||
// 按照单词做分组计数
|
||||
val wordCounts: RDD[(String, Int)] = kvRDD.reduceByKey((x, y) => x + y)
|
||||
// 收集计数结果
|
||||
wordCounts.collect
|
||||
|
||||
// 作业执行完毕,通过调用value获取累加器结果
|
||||
ac.value
|
||||
// Long = 79
|
||||
|
||||
|
||||
与第1讲的Word Count相比,这里的代码主要有4处改动:
|
||||
|
||||
|
||||
使用SparkContext下的longAccumulator来定义Long类型的累加器;
|
||||
定义filter算子的判定函数f,当遇到空字符串时,调用add函数为累加器计数;
|
||||
以函数f为参数,调用filter算子对RDD进行过滤;
|
||||
作业完成后,调用累加器的value函数,获取全局计数结果。
|
||||
|
||||
|
||||
你不妨把上面的代码敲入到spark-shell里,直观体验下累加器的用法与效果,ac.value给出的结果是79,这说明以空格作为分隔符切割源文件wikiOfSpark.txt之后,就会留下79个空字符串。
|
||||
|
||||
另外,你还可以验证wordCounts这个RDD,它包含所有单词的计数结果,不过,你会发现它的元素并不包含空字符串,这与我们预期的计算逻辑是一致的。
|
||||
|
||||
除了上面代码中用到的longAccumulator,SparkContext还提供了doubleAccumulator和collectionAccumulator这两种不同类型的累加器,用于满足不同场景下的计算需要,感兴趣的话你不妨自己动手亲自尝试一下。
|
||||
|
||||
其中,doubleAccumulator用于对Double类型的数值做全局计数;而collectionAccumulator允许开发者定义集合类型的累加器,相比数值类型,集合类型可以为业务逻辑的实现,提供更多的灵活性和更大的自由度。
|
||||
|
||||
不过,就这3种累加器来说,尽管类型不同,但它们的用法是完全一致的。都是先定义累加器变量,然后在RDD算子中调用add函数,从而更新累加器状态,最后通过调用value函数来获取累加器的最终结果。
|
||||
|
||||
好啦,到这里,关于累加器的用法,我们就讲完了。在日常的开发中,当你遇到需要做全局计数的场景时,别忘了用上累加器这个实用工具。
|
||||
|
||||
重点回顾
|
||||
|
||||
今天的内容讲完了,我们一起来做个总结。今天这一讲,我们重点讲解了广播变量与累加器的用法与适用场景。
|
||||
|
||||
广播变量由Driver端定义并初始化,各个Executors以只读(Read only)的方式访问广播变量携带的数据内容。累加器也是由Driver定义的,但Driver并不会向累加器中写入任何数据内容,累加器的内容更新,完全是由各个Executors以只写(Write only)的方式来完成,而Driver仅以只读的方式对更新后的内容进行访问。
|
||||
|
||||
关于广播变量,你首先需要掌握它的基本用法。给定任意类型的普通变量,你都可以使用SparkContext下面的broadcast API来创建广播变量。接下来,在RDD的转换与计算过程中,你可以通过调用广播变量的value函数,来访问封装的数据内容,从而辅助RDD的数据处理。
|
||||
|
||||
需要额外注意的是,在Driver与Executors之间,普通变量的分发与存储,是以Task为粒度的,因此,它所引入的网络与内存开销,会成为作业执行性能的一大隐患。在使用广播变量的情况下,数据内容的分发粒度变为以Executors为单位。相比前者,广播变量的优势高下立判,它可以大幅度消除前者引入的网络与内存开销,进而在整体上提升作业的执行效率。
|
||||
|
||||
关于累加器,首先你要清楚它的适用场景,当你需要做全局计数的时候,累加器会是个很好的帮手。其次,你需要掌握累加器的具体用法,可以分为这样3步:
|
||||
|
||||
|
||||
使用SparkContext下的[long | double | collection]Accumulator来定义累加器;
|
||||
在RDD的转换过程中,调用add函数更新累加器状态;
|
||||
在作业完成后,调用value函数,获取累加器的全局结果。
|
||||
|
||||
|
||||
每课一练
|
||||
|
||||
|
||||
在使用累加器对空字符串做全局计数的代码中,请你用普通变量去替换累加器,试一试,在不使用累加器的情况,能否得到预期的计算结果?
|
||||
累加器提供了Long、Double和Collection三种类型的支持,那么广播变量在类型支持上有限制吗?除了普通类型、集合类型之外,广播变量还支持其他类型吗?比如,Spark支持在RDD之上创建广播变量吗?
|
||||
|
||||
|
||||
欢迎你在留言区跟我交流互动,也推荐你把这一讲的内容分享给你身边的朋友,说不定就能帮他解决一个难题。
|
||||
|
||||
|
||||
|
||||
|
@ -0,0 +1,235 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
20 Hive + Spark强强联合:分布式数仓的不二之选
|
||||
你好,我是吴磊。
|
||||
|
||||
在数据源与数据格式,以及数据转换那两讲(第15、16讲),我们介绍了在Spark SQL之上做数据分析应用开发的一般步骤。
|
||||
|
||||
这里我们简单回顾一下:首先,我们通过SparkSession read API从分布式文件系统创建DataFrame。然后,通过创建临时表并使用SQL语句,或是直接使用DataFrame API,来进行各式各样的数据转换、过滤、聚合等操作。最后,我们再用SparkSession的write API把计算结果写回分布式文件系统。
|
||||
|
||||
实际上,直接与文件系统交互,仅仅是Spark SQL数据应用的常见场景之一。Spark SQL另一类非常典型的场景是与Hive做集成、构建分布式数据仓库。我们知道,数据仓库指的是一类带有主题、聚合层次较高的数据集合,它的承载形式,往往是一系列Schema经过精心设计的数据表。在数据分析这类场景中,数据仓库的应用非常普遍。
|
||||
|
||||
在Hive与Spark这对“万金油”组合中,Hive擅长元数据管理,而Spark的专长是高效的分布式计算,二者的结合可谓是“强强联合”。今天这一讲,我们就来聊一聊Spark与Hive集成的两类方式,一类是从Spark的视角出发,我们称之为Spark with Hive;而另一类,则是从Hive的视角出发,业界的通俗说法是:Hive on Spark。
|
||||
|
||||
Hive架构与基本原理
|
||||
|
||||
磨刀不误砍柴工,在讲解这两类集成方式之前,我们不妨先花点时间,来了解一下Hive的架构和工作原理,避免不熟悉Hive的同学听得云里雾里。
|
||||
|
||||
Hive是Apache Hadoop社区用于构建数据仓库的核心组件,它负责提供种类丰富的用户接口,接收用户提交的SQL查询语句。这些查询语句经过Hive的解析与优化之后,往往会被转化为分布式任务,并交付Hadoop MapReduce付诸执行。
|
||||
|
||||
Hive是名副其实的“集大成者”,它的核心部件,其实主要是User Interface(1)和Driver(3)。而不论是元数据库(4)、存储系统(5),还是计算引擎(6),Hive都以“外包”、“可插拔”的方式交给第三方独立组件,所谓“把专业的事交给专业的人去做”,如下图所示。
|
||||
|
||||
|
||||
|
||||
Hive的User Interface为开发者提供SQL接入服务,具体的接入途径有Hive Server 2(2)、CLI和Web Interface(Web界面入口)。其中,CLI与Web Interface直接在本地接收SQL查询语句,而Hive Server 2则通过提供JDBC/ODBC客户端连接,允许开发者从远程提交SQL查询请求。显然,Hive Server 2的接入方式更为灵活,应用也更为广泛。
|
||||
|
||||
我们以响应一个SQL查询为例,看一看Hive是怎样工作的。接收到SQL查询之后,Hive的Driver首先使用其Parser组件,将查询语句转化为AST(Abstract Syntax Tree,查询语法树)。
|
||||
|
||||
紧接着,Planner组件根据AST生成执行计划,而Optimizer则进一步优化执行计划。要完成这一系列的动作,Hive必须要能拿到相关数据表的元信息才行,比如表名、列名、字段类型、数据文件存储路径、文件格式,等等。而这些重要的元信息,通通存储在一个叫作“Hive Metastore”(4)的数据库中。
|
||||
|
||||
本质上,Hive Metastore其实就是一个普通的关系型数据库(RDBMS),它可以是免费的MySQL、Derby,也可以是商业性质的Oracle、IBM DB2。实际上,除了用于辅助SQL语法解析、执行计划的生成与优化,Metastore的重要作用之一,是帮助底层计算引擎高效地定位并访问分布式文件系统中的数据源。
|
||||
|
||||
这里的分布式文件系统,可以是Hadoop生态的HDFS,也可以是云原生的Amazon S3。而在执行方面,Hive目前支持3类计算引擎,分别是Hadoop MapReduce、Tez和Spark。
|
||||
|
||||
当Hive采用Spark作为底层的计算引擎时,我们就把这种集成方式称作“Hive on Spark”。相反,当Spark仅仅是把Hive当成是一种元信息的管理工具时,我们把Spark与Hive的这种集成方式,叫作“Spark with Hive”。
|
||||
|
||||
你可能会觉得很困惑:“这两种说法听上去差不多嘛,两种集成方式,到底有什么本质的不同呢?”接下来,我们就按照“先易后难”的顺序,先来说说“Spark with Hive”这种集成方式,然后再去介绍“Hive on Spark”。
|
||||
|
||||
Spark with Hive
|
||||
|
||||
在开始正式学习Spark with Hive之前,我们先来说说这类集成方式的核心思想。前面我们刚刚说过,Hive Metastore利用RDBMS来存储数据表的元信息,如表名、表类型、表数据的Schema、表(分区)数据的存储路径、以及存储格式,等等。形象点说,Metastore就像是“户口簿”,它记录着分布式文件系统中每一份数据集的“底细”。
|
||||
|
||||
Spark SQL通过访问Hive Metastore这本“户口簿”,即可扩充数据访问来源。而这,就是Spark with Hive集成方式的核心思想。直白点说,在这种集成模式下,Spark是主体,Hive Metastore不过是Spark用来扩充数据来源的辅助工具。厘清Spark与Hive的关系,有助于我们后面区分Hive on Spark与Spark with Hive之间的差异。
|
||||
|
||||
作为开发者,我们可以通过3种途径来实现Spark with Hive的集成方式,它们分别是:
|
||||
|
||||
|
||||
创建SparkSession,访问本地或远程的Hive Metastore;
|
||||
通过Spark内置的spark-sql CLI,访问本地Hive Metastore;
|
||||
通过Beeline客户端,访问Spark Thrift Server。
|
||||
|
||||
|
||||
SparkSession + Hive Metastore
|
||||
|
||||
为了更好地理解Hive与Spark的关系,我们先从第一种途径,也就是通过SparkSession访问Hive Metastore说起。首先,我们使用如下命令来启动Hive Metastore。
|
||||
|
||||
hive --service metastore
|
||||
|
||||
|
||||
Hive Metastore启动之后,我们需要让Spark知道Metastore的访问地址,也就是告诉他数据源的“户口簿”藏在什么地方。
|
||||
|
||||
要传递这个消息,我们有两种办法。一种是在创建SparkSession的时候,通过config函数来明确指定hive.metastore.uris参数。另一种方法是让Spark读取Hive的配置文件hive-site.xml,该文件记录着与Hive相关的各种配置项,其中就包括hive.metastore.uris这一项。把hive-site.xml拷贝到Spark安装目录下的conf子目录,Spark即可自行读取其中的配置内容。
|
||||
|
||||
接下来,我们通过一个小例子,来演示第一种用法。假设Hive中有一张名为“salaries”的薪资表,每条数据都包含id和salary两个字段,表数据存储在HDFS,那么,在spark-shell中敲入下面的代码,我们即可轻松访问Hive中的数据表。
|
||||
|
||||
import org.apache.spark.sql.SparkSession
|
||||
import org.apache.spark.sql.DataFrame
|
||||
|
||||
val hiveHost: String = _
|
||||
// 创建SparkSession实例
|
||||
val spark = SparkSession.builder()
|
||||
.config("hive.metastore.uris", s"thrift://hiveHost:9083")
|
||||
.enableHiveSupport()
|
||||
.getOrCreate()
|
||||
|
||||
// 读取Hive表,创建DataFrame
|
||||
val df: DataFrame = spark.sql(“select * from salaries”)
|
||||
|
||||
df.show
|
||||
|
||||
/** 结果打印
|
||||
+---+------+
|
||||
| id|salary|
|
||||
+---+------+
|
||||
| 1| 26000|
|
||||
| 2| 30000|
|
||||
| 4| 25000|
|
||||
| 3| 20000|
|
||||
+---+------+
|
||||
*/
|
||||
|
||||
|
||||
在[第16讲],我们讲过利用createTempView函数从数据文件创建临时表的方法,临时表创建好之后,我们就可以使用SparkSession的sql API来提交SQL查询语句。连接到Hive Metastore之后,咱们就可以绕过第一步,直接使用sql API去访问Hive中现有的表,是不是很方便?
|
||||
|
||||
更重要的是,createTempView函数创建的临时表,它的生命周期仅限于Spark作业内部,这意味着一旦作业执行完毕,临时表也就不复存在,没有办法被其他应用复用。Hive表则不同,它们的元信息已经持久化到Hive Metastore中,不同的作业、应用、甚至是计算引擎,如Spark、Presto、Impala,等等,都可以通过Hive Metastore来访问Hive表。
|
||||
|
||||
总结下来,在SparkSession + Hive Metastore这种集成方式中,Spark对于Hive的访问,仅仅涉及到Metastore这一环节,对于Hive架构中的其他组件,Spark并未触及。换句话说,在这种集成方式中,Spark仅仅是“白嫖”了Hive的Metastore,拿到数据集的元信息之后,Spark SQL自行加载数据、自行处理,如下图所示。
|
||||
|
||||
|
||||
|
||||
在第一种集成方式下,通过sql API,你可以直接提交复杂的SQL语句,也可以在创建DataFrame之后,再使用第16讲提到的各种算子去实现业务逻辑。
|
||||
|
||||
spark-sql CLI + Hive Metastore
|
||||
|
||||
不过,你可能会说:“既然是搭建数仓,那么能不能像使用普通数据库那样,直接输入SQL查询,绕过SparkSession的sql API呢?”
|
||||
|
||||
答案自然是肯定的,接下来,我们就来说说Spark with Hive的第二种集成方式:spark-sql CLI + Hive Metastore。与spark-shell、spark-submit类似,spark-sql也是Spark内置的系统命令。将配置好hive.metastore.uris参数的hive-site.xml文件放到Spark安装目录的conf下,我们即可在spark-sql中直接使用SQL语句来查询或是处理Hive表。
|
||||
|
||||
显然,在这种集成模式下,Spark和Hive的关系,与刚刚讲的SparkSession + Hive Metastore一样,本质上都是Spark通过Hive Metastore来扩充数据源。
|
||||
|
||||
不过,相比前者,spark-sql CLI的集成方式多了一层限制,那就是在部署上,spark-sql CLI与Hive Metastore必须安装在同一个计算节点。换句话说,spark-sql CLI只能在本地访问Hive Metastore,而没有办法通过远程的方式来做到这一点。
|
||||
|
||||
在绝大多数的工业级生产系统中,不同的大数据组件往往是单独部署的,Hive与Spark也不例外。由于Hive Metastore可用于服务不同的计算引擎,如前面提到的Presto、Impala,因此为了减轻节点的工作负载,Hive Metastore往往会部署到一台相对独立的计算节点。
|
||||
|
||||
在这样的背景下,不得不说,spark-sql CLI本地访问的限制,极大地削弱了它的适用场景,这也是spark-sql CLI + Hive Metastore这种集成方式几乎无人问津的根本原因。不过,这并不妨碍我们学习并了解它,这有助于我们对Spark与Hive之间的关系加深理解。
|
||||
|
||||
Beeline + Spark Thrift Server
|
||||
|
||||
说到这里,你可能会追问:“既然spark-sql CLI有这样那样的限制,那么,还有没有其他集成方式,既能够部署到生产系统,又能让开发者写SQL查询呢?”答案自然是“有”,Spark with Hive集成的第三种途径,就是使用Beeline客户端,去连接Spark Thrift Server,从而完成Hive表的访问与处理。
|
||||
|
||||
Beeline原本是Hive客户端,通过JDBC接入Hive Server 2。Hive Server 2可以同时服务多个客户端,从而提供多租户的Hive查询服务。由于Hive Server 2的实现采用了Thrift RPC协议框架,因此很多时候我们又把Hive Server 2称为“Hive Thrift Server 2”。
|
||||
|
||||
通过Hive Server 2接入的查询请求,经由Hive Driver的解析、规划与优化,交给Hive搭载的计算引擎付诸执行。相应地,查询结果再由Hiver Server 2返还给Beeline客户端,如下图右侧的虚线框所示。
|
||||
|
||||
|
||||
|
||||
Spark Thrift Server脱胎于Hive Server 2,在接收查询、多租户服务、权限管理等方面,这两个服务端的实现逻辑几乎一模一样。它们最大的不同,在于SQL查询接入之后的解析、规划、优化与执行。
|
||||
|
||||
我们刚刚说过,Hive Server 2的“后台”是Hive的那套基础架构。而SQL查询在接入到Spark Thrift Server之后,它首先会交由Spark SQL优化引擎进行一系列的优化。
|
||||
|
||||
在第14讲我们提过,借助于Catalyst与Tungsten这对“左膀右臂”,Spark SQL对SQL查询语句先后进行语法解析、语法树构建、逻辑优化、物理优化、数据结构优化、以及执行代码优化,等等。然后,Spark SQL将优化过后的执行计划,交付给Spark Core执行引擎付诸运行。
|
||||
|
||||
|
||||
|
||||
不难发现,SQL查询在接入Spark Thrift Server之后的执行路径,与DataFrame在Spark中的执行路径是完全一致的。
|
||||
|
||||
理清了Spark Thrift Server与Hive Server 2之间的区别与联系之后,接下来,我们来说说Spark Thrift Server的启动与Beeline的具体用法。要启动Spark Thrift Server,我们只需调用Spark提供的start-thriftserver.sh脚本即可。
|
||||
|
||||
// SPARK_HOME环境变量,指向Spark安装目录
|
||||
cd $SPARK_HOME/sbin
|
||||
|
||||
// 启动Spark Thrift Server
|
||||
./start-thriftserver.sh
|
||||
|
||||
|
||||
脚本执行成功之后,Spark Thrift Server默认在10000端口监听JDBC/ODBC的连接请求。有意思的是,关于监听端口的设置,Spark复用了Hive的hive.server2.thrift.port参数。与其他的Hive参数一样,hive.server2.thrift.port同样要在hive-site.xml配置文件中设置。
|
||||
|
||||
一旦Spark Thrift Server启动成功,我们就可以在任意节点上通过Beeline客户端来访问该服务。在客户端与服务端之间成功建立连接(Connections)之后,咱们就能在Beeline客户端使用SQL语句处理Hive表了。需要注意的是,在这种集成模式下,SQL语句背后的优化与计算引擎是Spark。
|
||||
|
||||
/**
|
||||
用Beeline客户端连接Spark Thrift Server,
|
||||
其中,hostname是Spark Thrift Server服务所在节点
|
||||
*/
|
||||
beeline -u “jdbc:hive2://hostname:10000”
|
||||
|
||||
|
||||
好啦,到此为止,Spark with Hive这类集成方式我们就讲完了。
|
||||
|
||||
为了巩固刚刚学过的内容,咱们趁热打铁,一起来做个简单的小结。不论是SparkSession + Hive Metastore、spark-sql CLI + Hive Metastore,还是Beeline + Spark Thrift Server,Spark扮演的角色都是执行引擎,而Hive的作用主要在于通过Metastore提供底层数据集的元数据。不难发现,在这类集成方式中,Spark唱“主角”,而Hive唱“配角”。
|
||||
|
||||
Hive on Spark
|
||||
|
||||
说到这里,你可能会好奇:“对于Hive社区与Spark社区来说,大家都是平等的,那么有没有Hive唱主角,而Spark唱配角的时候呢?”还真有,这就是Spark与Hive集成的另一种形式:Hive on Spark。
|
||||
|
||||
基本原理
|
||||
|
||||
在这一讲的开头,我们简单介绍了Hive的基础架构。Hive的松耦合设计,使得它的Metastore、底层文件系统、以及执行引擎都是可插拔、可替换的。
|
||||
|
||||
在执行引擎方面,Hive默认搭载的是Hadoop MapReduce,但它同时也支持Tez和Spark。所谓的“Hive on Spark”,实际上指的就是Hive采用Spark作为其后端的分布式执行引擎,如下图所示。
|
||||
|
||||
|
||||
|
||||
从用户的视角来看,使用Hive on MapReduce或是Hive on Tez与使用Hive on Spark没有任何区别,执行引擎的切换对用户来说是完全透明的。不论Hive选择哪一种执行引擎,引擎仅仅负责任务的分布式计算,SQL语句的解析、规划与优化,通通由Hive的Driver来完成。
|
||||
|
||||
为了搭载不同的执行引擎,Hive还需要做一些简单的适配,从而把优化过的执行计划“翻译”成底层计算引擎的语义。
|
||||
|
||||
举例来说,在Hive on Spark的集成方式中,Hive在将SQL语句转换为执行计划之后,还需要把执行计划“翻译”成RDD语义下的DAG,然后再把DAG交付给Spark Core付诸执行。从第14讲到现在,我们一直在强调,Spark SQL除了扮演数据分析子框架的角色之外,还是Spark新一代的优化引擎。
|
||||
|
||||
在Hive on Spark这种集成模式下,Hive与Spark衔接的部分是Spark Core,而不是Spark SQL,这一点需要我们特别注意。这也是为什么,相比Hive on Spark,Spark with Hive的集成在执行性能上会更胜一筹。毕竟,Spark SQL + Spark Core这种原装组合,相比Hive Driver + Spark Core这种适配组合,在契合度上要更高一些。
|
||||
|
||||
集成实现
|
||||
|
||||
分析完原理之后,接下来,我们再来说说,Hive on Spark的集成到底该怎么实现。
|
||||
|
||||
首先,既然我们想让Hive搭载Spark,那么我们事先得准备好一套完备的Spark部署。对于Spark的部署模式,Hive不做任何限定,Spark on Standalone、Spark on Yarn或是Spark on Kubernetes都是可以的。
|
||||
|
||||
Spark集群准备好之后,我们就可以通过修改hive-site.xml中相关的配置项,来轻松地完成Hive on Spark的集成,如下表所示。
|
||||
|
||||
|
||||
|
||||
其中,hive.execution.engine用于指定Hive后端执行引擎,可选值有“mapreduce”、“tez”和“spark”,显然,将该参数设置为“spark”,即表示采用Hive on Spark的集成方式。
|
||||
|
||||
确定了执行引擎之后,接下来我们自然要告诉Hive:“Spark集群部署在哪里”,spark.master正是为了实现这个目的。另外,为了方便Hive调用Spark的相关脚本与Jar包,我们还需要通过spark.home参数来指定Spark的安装目录。
|
||||
|
||||
配置好这3个参数之后,我们就可以用Hive SQL向Hive提交查询请求,而Hive则是先通过访问Metastore在Driver端完成执行计划的制定与优化,然后再将其“翻译”为RDD语义下的DAG,最后把DAG交给后端的Spark去执行分布式计算。
|
||||
|
||||
当你在终端看到“Hive on Spark”的字样时,就证明Hive后台的执行引擎确实是Spark,如下图所示。
|
||||
|
||||
|
||||
|
||||
当然,除了上述3个配置项以外,Hive还提供了更多的参数,用于微调它与Spark之间的交互。对于这些参数,你可以通过访问Hive on Spark配置项列表来查看。不仅如此,在第12讲,我们详细介绍了Spark自身的基础配置项,这些配置项都可以配置到hive-site.xml中,方便你更细粒度地控制Hive与Spark之间的集成。
|
||||
|
||||
重点回顾
|
||||
|
||||
好啦,到此为止,今天的内容就全部讲完啦!内容有点多,我们一起来做个总结。
|
||||
|
||||
今天这一讲,你需要了解Spark与Hive常见的两类集成方式,Spark with Hive和Hive on Spark。前者由Spark社区主导,以Spark为主、Hive为辅;后者则由Hive社区主导,以Hive为主、Spark为辅。两类集成方式各有千秋,适用场景各有不同。
|
||||
|
||||
在Spark with Hive这类集成方式中,Spark主要是利用Hive Metastore来扩充数据源,从而降低分布式文件的管理与维护成本,如路径管理、分区管理、Schema维护,等等。
|
||||
|
||||
对于Spark with Hive,我们至少有3种途径来实现Spark与Hive的集成,分别是SparkSession + Hive Metastore,spark-sql CLI + Hive Metastore和Beeline + Spark Thrift Server。对于这3种集成方式,我把整理了表格,供你随时查看。
|
||||
|
||||
|
||||
|
||||
与Spark with Hive相对,另一类集成方式是Hive on Spark。这种集成方式,本质上是Hive社区为Hive用户提供了一种新的选项,这个选项就是,在执行引擎方面,除了原有的MapReduce与Tez,开发者还可以选择执行性能更佳的Spark。
|
||||
|
||||
因此,在Spark大行其道的当下,习惯使用Hive的团队与开发者,更愿意去尝试和采用Spark作为后端的执行引擎。
|
||||
|
||||
熟悉了不同集成方式的区别与适用场景之后,在日后的工作中,当你需要将Spark与Hive做集成的时候,就可以做到有的放矢、有章可循,加油。
|
||||
|
||||
每课一练
|
||||
|
||||
|
||||
在Hive on Spark的部署模式下,用另外一套Spark部署去访问Hive Metastore,比如,通过创建SparkSession并访问Hive Metastore来扩充数据源。那么,在这种情况下,你能大概说一说用户代码的执行路径吗?
|
||||
|
||||
尽管咱们专栏的主题是Spark,但我强烈建议你学习并牢记Hive的架构设计。松耦合的设计理念让Hive本身非常轻量的同时,还给予了Hive极大的扩展能力。也正因如此,Hive才能一直牢牢占据开源数仓霸主的地位。Hive的设计思想是非常值得我们好好学习的,这样的设计思想可以推而广之,应用到任何需要考虑架构设计的地方,不论是前端、后端,还是大数据与机器学习。
|
||||
|
||||
|
||||
欢迎你在留言区跟我交流互动,也欢迎把这一讲的内容分享给更多同事、朋友。
|
||||
|
||||
|
||||
|
||||
|
@ -0,0 +1,186 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
32 Window操作&Watermark:流处理引擎提供了哪些优秀机制?
|
||||
你好,我是吴磊。
|
||||
|
||||
在上一讲,我们从原理的角度出发,学习了Structured Streaming的计算模型与容错机制。深入理解这些基本原理,会帮我们开发流处理应用打下坚实的基础。
|
||||
|
||||
在“流动的Word Count”[那一讲],我们演示了在Structured Streaming框架下,如何做流处理开发的一般流程。基于readStream API与writeStream API,我们可以像读写DataFrame那样,轻松地从Source获取数据流,并把处理过的数据写入Sink。
|
||||
|
||||
今天这一讲,咱们从功能的视角出发,继续来聊一聊Structured Streaming流处理引擎都为开发者都提供了哪些特性与能力,让你更灵活地设计并实现流处理应用。
|
||||
|
||||
Structured Streaming怎样坐享其成?
|
||||
|
||||
学习过计算模型之后,我们知道,不管是Batch mode的多个Micro-batch、多个作业的执行方式,还是Continuous mode下的一个Long running job,这些作业的执行计划,最终都会交付给Spark SQL与Spark Core付诸优化与执行。
|
||||
|
||||
|
||||
|
||||
而这,会带来两个方面的收益。一方面,凡是Spark SQL支持的开发能力,不论是丰富的DataFrame算子,还是灵活的SQL查询,Structured Streaming引擎都可以拿来即用。基于之前学过的内容,我们可以像处理普通的DataFrame那样,对基于流数据构建的DataFrame做各式各样的转换与聚合。
|
||||
|
||||
另一方面,既然开发入口同为DataFrame,那么流处理应用同样能够享有Spark SQL提供的“性能红利”。在Spark SQL学习模块,我们学习过Catalyst优化器与Tungsten,这两个组件会对用户代码做高度优化,从而提升应用的执行性能。
|
||||
|
||||
因此,就框架的功能来说,我们可以简单地概括为,Spark SQL所拥有的能力,Structured Streaming都有。不过,除了基本的数据处理能力以外,为了更好地支持流计算场景,Structured Streaming引擎还提供了一些专门针对流处理的计算能力,比如说Window操作、Watermark与延迟数据处理,等等。
|
||||
|
||||
Window操作
|
||||
|
||||
我们先来说说Window操作,它指的是,Structured Streaming引擎会基于一定的时间窗口,对数据流中的消息进行消费并处理。这是什么意思呢?首先,我们需要了解两个基本概念:Event Time和Processing Time,也即事件时间和处理时间。
|
||||
|
||||
所谓事件时间,它指的是消息生成的时间,比如,我们在netcat中敲入“Apache Spark”的时间戳是“2021-10-01 09:30:00”,那么这个时间,就是消息“Apache Spark”的事件时间。
|
||||
|
||||
|
||||
|
||||
而处理时间,它指的是,这个消息到达Structured Streaming引擎的时间,因此也有人把处理时间称作是到达时间(Arrival Time),也即消息到达流处理系统的时间。显然,处理时间要滞后于事件时间。
|
||||
|
||||
所谓Window操作,实际上就是Structured Streaming引擎基于事件时间或是处理时间,以固定间隔划定时间窗口,然后以窗口为粒度处理消息。在窗口的划分上,Structured Streaming支持两种划分方式,一种叫做Tumbling Window,另一种叫做Sliding Window。
|
||||
|
||||
我们可以用一句话来记住二者之间的区别,Tumbling Window划分出来的时间窗口“不重不漏”,而Sliding Window划分出来的窗口,可能会重叠、也可能会有遗漏,如下图所示。
|
||||
|
||||
|
||||
|
||||
不难发现,Sliding Window划分出来的窗口是否存在“重、漏”,取决于窗口间隔Interval与窗口大小Size之间的关系。Tumbling Window与Sliding Window并无优劣之分,完全取决于应用场景与业务需要。
|
||||
|
||||
干讲理论总是枯燥无趣,接下来,咱们对之前的“流动的Word Count”稍作调整,来演示Structured Streaming中的Window操作。为了让演示的过程更加清晰明了,这里我们采用Tumbling Window的划分方式,Sliding Window留给你作为课后作业。
|
||||
|
||||
为了完成实验,我们还是需要准备好两个终端。第一个终端用于启动spark-shell并提交流处理代码,而第二个终端用于启动netcat、输入数据流。要基于窗口去统计单词,我们仅需调整数据处理部分的代码,readStream与writeStream(Update Mode)部分的代码不需要任何改动。因此,为了聚焦Window操作的学习,我这里仅贴出了有所变动的部分。
|
||||
|
||||
df = df.withColumn("inputs", split($"value", ","))
|
||||
// 提取事件时间
|
||||
.withColumn("eventTime", element_at(col("inputs"),1).cast("timestamp"))
|
||||
// 提取单词序列
|
||||
.withColumn("words", split(element_at(col("inputs"),2), " "))
|
||||
// 拆分单词
|
||||
.withColumn("word", explode($"words"))
|
||||
// 按照Tumbling Window与单词做分组
|
||||
.groupBy(window(col("eventTime"), "5 minute"), col("word"))
|
||||
// 统计计数
|
||||
.count()
|
||||
|
||||
|
||||
为了模拟事件时间,我们在netcat终端输入的消息,会同时包含时间戳和单词序列。两者之间以逗号分隔,而单词与单词之间,还是用空格分隔,如下表所示。
|
||||
|
||||
|
||||
|
||||
因此,对于输入数据的处理,我们首先要分别提取出时间戳和单词序列,然后再把单词序列展开为单词。接下来,我们按照时间窗口与单词做分组,这里需要我们特别关注这行代码:
|
||||
|
||||
// 按照Tumbling Window与单词做分组
|
||||
.groupBy(window(col("eventTime"), "5 minute"), col("word"))
|
||||
|
||||
|
||||
其中window(col(“eventTime”), “5 minute”)的含义,就是以事件时间为准,以5分钟为间隔,创建Tumbling时间窗口。显然,window函数的第一个参数,就是创建窗口所依赖的时间轴,而第二个参数,则指定了窗口大小Size。说到这里,你可能会问:“如果我想创建Sliding Window,该怎么做呢?”
|
||||
|
||||
其实非常简单,只需要在window函数的调用中,再添加第三个参数即可,也就是窗口间隔Interval。比如说,我们还是想创建大小为5分钟的窗口,但是使用以3分钟为间隔进行滑动的方式去创建,那么我们就可以这样来实现:window(col(“eventTime”), “5 minute”, “3 minute”)。是不是很简单?
|
||||
|
||||
完成基于窗口和单词的分组之后,我们就可以继续调用count来完成计数了。不难发现,代码中的大多数转换操作,实际上都是我们常见的DataFrame算子,这也印证了这讲开头说的,Structured Streaming先天优势就是能坐享其成,享有Spark SQL提供的“性能红利”。
|
||||
|
||||
代码准备好之后,我们就可以把它们陆续敲入到spark-shell,并等待来自netcat的数据流。切换到netcat终端,并陆续(注意,是陆续!)输入刚刚的文本内容,我们就可以在spark-shell终端看到如下的计算结果。
|
||||
|
||||
|
||||
|
||||
可以看到,与“流动的Word Count”不同,这里的统计计数,是以窗口(5分钟)为粒度的。对于每一个时间窗口来说,Structured Streaming引擎都会把事件时间落入该窗口的单词统计在内。不难推断,随着时间向前推进,已经计算过的窗口,将不会再有状态上的更新。
|
||||
|
||||
比方说,当引擎处理到“2021-10-01 09:39:00,Spark Streaming”这条消息(记作消息39)时,理论上,前一个窗口“{2021-10-01 09:30:00, 2021-10-01 09:35:00}”(记作窗口30-35)的状态,也就是不同单词的统计计数,应该不会再有变化。
|
||||
|
||||
说到这里,你可能会有这样的疑问:“那不见得啊!如果在消息39之后,引擎又接收到一条事件时间落在窗口30-35的消息,那该怎么办呢?”要回答这个问题,我们还得从Late data和Structured Streaming的Watermark机制说起。
|
||||
|
||||
Late data与Watermark
|
||||
|
||||
我们先来说Late data,所谓Late data,它指的是那些事件时间与处理时间不一致的消息。虽然听上去有点绕,但通过下面的图解,我们就能瞬间理解Late data的含义。
|
||||
|
||||
|
||||
|
||||
通常来说,消息生成的时间,与消息到达流处理引擎的时间,应该是一致的。也即先生成的消息先到达,而后生成的消息后到达,就像上图中灰色部分消息所示意的那样。
|
||||
|
||||
不过,在现实情况中,总会有一些消息,因为网络延迟或者这样那样的一些原因,它们的处理时间与事件时间存在着比较大的偏差。这些消息到达引擎的时间,甚至晚于那些在它们之后才生成的消息。像这样的消息,我们统称为“Late data”,如图中红色部分的消息所示。
|
||||
|
||||
由于有Late data的存在,流处理引擎就需要一个机制,来判定Late data的有效性,从而决定是否让晚到的消息,参与到之前窗口的计算。
|
||||
|
||||
就拿红色的“Spark is cool”消息来说,在它到达Structured Streaming引擎的时候,属于它的事件时间窗口“{2021-10-01 09:30:00, 2021-10-01 09:35:00}”已经关闭了。那么,在这种情况下,Structured Streaming到底要不要用消息“Spark is cool”中的单词,去更新窗口30-35的状态(单词计数)呢?
|
||||
|
||||
为了解决Late data的问题,Structured Streaming采用了一种叫作Watermark的机制来应对。为了让你能够更容易地理解Watermark机制的原理,在去探讨它之前,我们先来澄清两个极其相似但是又完全不同的概念:水印和水位线。
|
||||
|
||||
要说清楚水印和水位线,咱们不妨来做个思想实验。假设桌子上有一盒鲜牛奶、一个吸管、还有一个玻璃杯。我们把盒子开个口,把牛奶全部倒入玻璃杯,接着,把吸管插入玻璃杯,然后通过吸管喝一口新鲜美味的牛奶。好啦,实验做完了,接下来,我们用它来帮我们澄清概念。
|
||||
|
||||
|
||||
|
||||
如图所示,最开始的时候,我们把牛奶倒到水印标示出来的高度,然后用吸管喝牛奶。不过,不论我们通过吸管喝多少牛奶,水印位置的牛奶痕迹都不会消失,也就是说,水印的位置是相对固定的。而水位线则不同,我们喝得越多,水位线下降得就越快,直到把牛奶喝光,水位线降低到玻璃杯底部。
|
||||
|
||||
好啦,澄清了水印与水位线的概念之后,我们还需要把这两个概念与流处理中的概念对应上。毕竟,“倒牛奶”的思想实验,是用来辅助我们学习Watermark机制的。
|
||||
|
||||
首先,水印与水位线,对标的都是消息的事件时间。水印相当于系统当前接收到的所有消息中最大的事件时间。而水位线指的是水印对应的事件时间,减去用户设置的容忍值。为了叙述方便,我们把这个容忍值记作T。在Structured Streaming中,我们把水位线对应的事件时间,称作Watermark,如下图所示。
|
||||
|
||||
|
||||
|
||||
显然,在流处理引擎不停地接收消息的过程中,水印与水位线也会相应地跟着变化。这个过程,跟我们刚刚操作的“倒牛奶、喝牛奶”的过程很像。每当新到消息的事件时间大于当前水印的时候,系统就会更新水印,这就好比我们往玻璃杯里倒牛奶,一直倒到最大事件时间的位置。然后,我们用吸管喝牛奶,吸掉深度为T的牛奶,让水位线下降到Watermark的位置。
|
||||
|
||||
把不同的概念关联上之后,接下来,我们来正式地介绍Structured Streaming的Watermark机制。我们刚刚说过,Watermark机制是用来决定,哪些Late data可以参与过往窗口状态的更新,而哪些Late data则惨遭抛弃。
|
||||
|
||||
如果用文字去解释Watermark机制,很容易把人说得云里雾里,因此,咱们不妨用一张流程图,来阐释这个过程。
|
||||
|
||||
|
||||
|
||||
可以看到,当有新消息到达系统后,Structured Streaming首先判断它的事件时间,是否大于水印。如果事件时间大于水印的话,Watermark机制则相应地更新水印与水位线,也就是最大事件时间与Watermark。
|
||||
|
||||
相反,假设新到消息的事件时间在当前水印以下,那么系统进一步判断消息的事件时间与“Watermark时间窗口下沿”的关系。所谓“Watermark时间窗口下沿”,它指的是Watermark所属时间窗口的起始时间。
|
||||
|
||||
咱们来举例说明,假设Watermark为“2021-10-01 09:34:00”,且事件时间窗口大小为5分钟,那么,Watermark所在时间窗口就是[“2021-10-01 09:30:00”,“2021-10-01 09:35:00”],也即窗口30-35。这个时候,“Watermark时间窗口下沿”,就是窗口30-35的起始时间,也就是“2021-10-01 09:30:00”,如下图所示。
|
||||
|
||||
|
||||
|
||||
对于最新到达的消息,如果其事件时间大于“Watermark时间窗口下沿”,则消息可以参与过往窗口的状态更新,否则,消息将被系统抛弃,不再参与计算。换句话说,凡是事件时间小于“Watermark时间窗口下沿”的消息,系统都认为这样的消息来得太迟了,没有资格再去更新以往计算过的窗口。
|
||||
|
||||
不难发现,在这个过程中,延迟容忍度T是Watermark机制中的决定性因素,它决定了“多迟”的消息可以被系统容忍并接受。那么问题来了,既然T是由用户设定的,那么用户通过什么途径来设定这个T呢?再者,在Structured Streaming的开发框架下,Watermark机制要如何生效呢?
|
||||
|
||||
其实,要开启Watermark机制、并设置容忍度T,我们只需一行代码即可搞定。接下来,我们就以刚刚“带窗口的流动Word Count”为例,演示并说明Watermark机制的具体用法。
|
||||
|
||||
df = df.withColumn("inputs", split($"value", ","))
|
||||
// 提取事件时间
|
||||
.withColumn("eventTime", element_at(col("inputs"),1).cast("timestamp"))
|
||||
// 提取单词序列
|
||||
.withColumn("words", split(element_at(col("inputs"),2), " "))
|
||||
// 拆分单词
|
||||
.withColumn("word", explode($"words"))
|
||||
// 启用Watermark机制,指定容忍度T为10分钟
|
||||
.withWatermark("eventTime", "10 minute")
|
||||
// 按照Tumbling Window与单词做分组
|
||||
.groupBy(window(col("eventTime"), "5 minute"), col("word"))
|
||||
// 统计计数
|
||||
.count()
|
||||
|
||||
|
||||
可以看到,除了“.withWatermark(“eventTime”, “10 minute”)”这一句代码,其他部分与“带窗口的流动Word Count”都是一样的。这里我们用withWatermark函数来启用Watermark机制,该函数有两个参数,第一个参数是事件时间,而第二个参数就是由用户指定的容忍度T。
|
||||
|
||||
为了演示Watermark机制产生的效果,接下来,咱们对netcat输入的数据流做一些调整,如下表所示。注意,消息7“Test Test”和消息8“Spark is cool”都是Late data。
|
||||
|
||||
|
||||
|
||||
基于我们刚刚对于Watermark机制的分析,在容忍度T为10分钟的情况下,Late data消息8“Spark is cool”会被系统接受并消费,而消息7“Test Test”则将惨遭抛弃。你不妨先花点时间,自行推断出这一结论,然后再来看后面的结果演示。
|
||||
|
||||
|
||||
|
||||
上图中,左侧是输入消息7“Test Test”时spark-shell端的输出,可以看到,消息7被系统丢弃,没能参与计算。而右侧是消息8“Spark is cool”对应的执行结果,可以看到,“Spark”、“is”、“cool”这3个单词成功地更新了之前窗口30-35的状态(注意这里的“Spark”计数为3,而不是1)。
|
||||
|
||||
重点回顾
|
||||
|
||||
好啦,今天的内容,到这里就讲完了,我们一起来做个总结。首先,我们需要知道,在数据处理方面,Structured Streaming完全可以复用Spark SQL现有的功能与性能优势。因此,开发者完全可以“坐享其成”,使用DataFrame算子或是SQL语句,来完成流数据的处理。
|
||||
|
||||
再者,我们需要特别关注并掌握Structured Streaming的Window操作与Watermark机制。Structured Streaming支持两类窗口,一个是“不重不漏”的Tumbling Window,另一个是“可重可漏”的Sliding Window。二者并无高下之分,作为开发者,我们可以使用window函数,结合事件时间、窗口大小、窗口间隔等多个参数,来灵活地在两种窗口之间进行取舍。
|
||||
|
||||
对于Late data的处理,Structured Streaming使用Watermark机制来决定其是否参与过往窗口的计算与更新。关于Watermark机制的工作原理,我把它整理到了下面的流程图中,供你随时查看。
|
||||
|
||||
|
||||
|
||||
每课一练
|
||||
|
||||
|
||||
请你结合Tumbling Window的代码,把Tumbling Window改为Sliding Window。-
|
||||
对于Watermark机制中的示例,请你分析一下,为什么消息8“Spark is cool”会被系统接受并处理,而消息7“Test Test”却惨遭抛弃?
|
||||
|
||||
|
||||
欢迎你在留言区跟我交流讨论,也推荐你把这一讲分享给更多同事、朋友。
|
||||
|
||||
|
||||
|
||||
|
@ -0,0 +1,421 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
34 Spark + Kafka:流计算中的“万金油”
|
||||
你好,我是吴磊。
|
||||
|
||||
在前面的几讲中,咱们不止一次提到,就数据源来说,Kafka是Structured Streaming最重要的Source之一。在工业级的生产系统中,Kafka与Spark这对组合最为常见。因此,掌握Kafka与Spark的集成,对于想从事流计算方向的同学来说,是至关重要的。
|
||||
|
||||
今天这一讲,咱们就来结合实例,说一说Spark与Kafka这对“万金油”组合如何使用。随着业务飞速发展,各家公司的集群规模都是有增无减。在集群规模暴涨的情况下,资源利用率逐渐成为大家越来越关注的焦点。毕竟,不管是自建的Data center,还是公有云,每台机器都是真金白银的投入。
|
||||
|
||||
实例:资源利用率实时计算
|
||||
|
||||
咱们今天的实例,就和资源利用率的实时计算有关。具体来说,我们首先需要搜集集群中每台机器的资源(CPU、内存)利用率,并将其写入Kafka。然后,我们使用Spark的Structured Streaming来消费Kafka数据流,并对资源利用率数据做初步的分析与聚合。最后,再通过Structured Streaming,将聚合结果打印到Console、并写回到Kafka,如下图所示。
|
||||
|
||||
|
||||
|
||||
一般来说,在工业级应用中,上图中的每一个圆角矩形,在部署上都是独立的。绿色矩形代表待监测的服务器集群,蓝色矩形表示独立部署的Kafka集群,而红色的Spark集群,也是独立部署的。所谓独立部署,它指的是,集群之间不共享机器资源,如下图所示。
|
||||
|
||||
|
||||
|
||||
如果你手头上没有这样的部署环境,也不用担心。要完成资源利用率实时计算的实例,咱们不必非要依赖独立部署的分布式集群。实际上,仅在单机环境中,你就可以复现今天的实例。
|
||||
|
||||
课程安排
|
||||
|
||||
今天这一讲涉及的内容比较多,在正式开始课程之前,咱们不妨先梳理一下课程内容,让你做到心中有数。
|
||||
|
||||
|
||||
|
||||
对于上图的1、2、3、4这四个步骤,我们会结合代码实现,分别讲解如下这四个环节:
|
||||
|
||||
|
||||
生成CPU与内存消耗数据流,写入Kafka;-
|
||||
Structured Streaming消费Kafka数据,并做初步聚合;-
|
||||
Structured Streaming将计算结果打印到终端;-
|
||||
Structured Streaming将计算结果写回Kafka,以备后用。
|
||||
|
||||
|
||||
除此之外,为了照顾不熟悉Kafka的同学,咱们还会对Kafka的安装、Topic创建与消费、以及Kafka的基本概念,做一个简单的梳理。
|
||||
|
||||
速读Kafka的架构与运行机制
|
||||
|
||||
在完成前面交代的计算环节之前,我们需要了解Kafka都提供了哪些核心功能。
|
||||
|
||||
在大数据的流计算生态中,Kafka是应用最为广泛的消息中间件(Messaging Queue)。消息中间件的核心功能有以下三点。
|
||||
|
||||
|
||||
连接消息生产者与消息消费者;-
|
||||
缓存生产者生产的消息(或者说事件);-
|
||||
有能力让消费者以最低延迟访问到消息。
|
||||
|
||||
|
||||
所谓消息生产者,它指的是事件或消息的来源与渠道。在我们的例子中,待监测集群就是生产者。集群中的机器,源源不断地生产资源利用率消息。相应地,消息的消费者,它指的是访问并处理消息的系统。显然,在这一讲的例子中,消费者是Spark。Structured Streaming读取并处理Kafka中的资源利用率消息,对其进行聚合、汇总。
|
||||
|
||||
经过前面的分析,我们不难发现,消息中间件的存在,让生产者与消费者这两个系统之间,天然地享有如下三方面的收益。
|
||||
|
||||
|
||||
解耦:双方无需感知对方的存在,二者除了消息本身以外,再无交集;
|
||||
异步:双方都可以按照自己的“节奏”和“步调”,来生产或是消费消息,而不必受制于对方的处理能力;
|
||||
削峰:当消费者订阅了多个生产者的消息,且多个生产者同时生成大量消息时,得益于异步模式,消费者可以灵活地消费并处理消息,从而避免计算资源被撑爆的隐患。
|
||||
|
||||
|
||||
好啦,了解了Kafka的核心功能与特性之后,接下来,我们说一说Kafka的系统架构。与大多数主从架构的大数据组件(如HDFS、YARN、Spark、Presto、Flink,等等)不同,Kafka为无主架构。也就是说,在Kafka集群中,没有Master这样一个角色来维护全局的数据状态。
|
||||
|
||||
集群中的每台Server被称为Kafka Broker,Broker的职责在于存储生产者生产的消息,并为消费者提供数据访问。Broker与Broker之间,都是相互独立的,彼此不存在任何的依赖关系。
|
||||
|
||||
如果就这么平铺直叙去介绍Kafka架构的话,难免让你昏昏欲睡,所以我们上图解。配合示意图解释Kafka中的关键概念,会更加直观易懂。
|
||||
|
||||
|
||||
|
||||
刚刚说过,Kafka为无主架构,它依赖ZooKeeper来存储并维护全局元信息。所谓元信息,它指的是消息在Kafka集群中的分布与状态。在逻辑上,消息隶属于一个又一个的Topic,也就是消息的话题或是主题。在上面的示意图中,蓝色圆角矩形所代表的消息,全部隶属于Topic A;而绿色圆角矩形,则隶属于Topic B。
|
||||
|
||||
而在资源利用率的实例中,我们会创建两个Topic,一个是CPU利用率cpu-monitor,另一个是内存利用率mem-monitor。生产者在向Kafka写入消息的时候,需要明确指明,消息隶属于哪一个Topic。比方说,关于CPU的监控数据,应当发往cpu-monitor,而对于内存的监控数据,则应该发往mem-monitor。
|
||||
|
||||
为了平衡不同Broker之间的工作负载,在物理上,同一个Topic中的消息,以分区、也就是Partition为粒度进行存储,示意图中的圆角矩形,代表的正是一个个数据分区。在Kafka中,一个分区,实际上就是磁盘上的一个文件目录。而消息,则依序存储在分区目录的文件中。
|
||||
|
||||
为了提供数据访问的高可用(HA,High Availability),在生产者把消息写入主分区(Leader)之后,Kafka会把消息同步到多个分区副本(Follower),示意图中的步骤1与步骤2演示了这个过程。
|
||||
|
||||
一般来说,消费者默认会从主分区拉取并消费数据,如图中的步骤3所示。而当主分区出现故障、导致数据不可用时,Kafka就会从剩余的分区副本中,选拔出一个新的主分区来对外提供服务,这个过程,又称作“选主”。
|
||||
|
||||
好啦,到此为止,Kafka的基础功能和运行机制我们就讲完了,尽管这些介绍不足以覆盖Kafka的全貌,但是,对于初学者来说,这些概念足以帮我们进军实战,做好Kafka与Spark的集成。
|
||||
|
||||
Kafka与Spark集成
|
||||
|
||||
接下来,咱们就来围绕着“资源利用率实时计算”这个例子,手把手地带你实现Kafka与Spark的集成过程。首先,第一步,我们先来准备Kafka环境。
|
||||
|
||||
Kafka环境准备
|
||||
|
||||
要配置Kafka环境,我们只需要简单的三个步骤即可:
|
||||
|
||||
|
||||
安装ZooKeeper、安装Kafka,启动ZooKeeper;-
|
||||
修改Kafka配置文件server.properties,设置ZooKeeper相关配置项;-
|
||||
启动Kafka,创建Topic。
|
||||
|
||||
|
||||
首先,咱们从 ZooKeeper官网与 Kafka官网,分别下载二者的安装包。然后,依次解压安装包、并配置相关环境变量即可,如下表所示。
|
||||
|
||||
// 下载ZooKeeper安装包
|
||||
wget https://archive.apache.org/dist/zookeeper/zookeeper-3.7.0/apache-zookeeper-3.7.0-bin.tar.gz
|
||||
// 下载Kafka安装包
|
||||
wget https://archive.apache.org/dist/kafka/2.8.0/kafka_2.12-2.8.0.tgz
|
||||
|
||||
// 把ZooKeeper解压并安装到指定目录
|
||||
tar -zxvf apache-zookeeper-3.7.0-bin.tar.gz -C /opt/zookeeper
|
||||
// 把Kafka解压并安装到指定目录
|
||||
tar -zxvf kafka_2.12-2.8.0.tgz -C /opt/kafka
|
||||
|
||||
// 编辑环境变量
|
||||
vi ~/.bash_profile
|
||||
/** 输入如下内容到文件中
|
||||
export ZOOKEEPER_HOME=/opt/zookeeper/apache-zookeeper-3.7.0-bin
|
||||
export KAFKA_HOME=/opt/kafka/kafka_2.12-2.8.0
|
||||
export PATH=$PATH:$ZOOKEEPER_HOME/bin:$KAFKA_HOME/bin
|
||||
*/
|
||||
|
||||
// 启动ZooKeeper
|
||||
zkServer.sh start
|
||||
|
||||
|
||||
接下来,我们打开Kafka配置目录下(也即$KAFKA_HOME/config)的server.properties文件,将其中的配置项zookeeper.connect,设置为“hostname:2181”,也就是主机名加端口号。
|
||||
|
||||
如果你把ZooKeeper和Kafka安装到同一个节点,那么hostname可以写localhost。而如果是分布式部署,hostname要写ZooKeeper所在的安装节点。一般来说,ZooKeeper默认使用2181端口来提供服务,这里我们使用默认端口即可。
|
||||
|
||||
配置文件设置完毕之后,我们就可以使用如下命令,在多个节点启动Kafka Broker。
|
||||
|
||||
kafka-server-start.sh -daemon $KAFKA_HOME/config/server.properties
|
||||
|
||||
|
||||
Kafka启动之后,咱们就来创建刚刚提到的两个Topic:cpu-monitor和mem-monitor,它们分别用来存储CPU利用率消息与内存利用率消息。
|
||||
|
||||
kafka-topics.sh --zookeeper hostname:2181/kafka --create
|
||||
--topic cpu-monitor
|
||||
--replication-factor 3
|
||||
--partitions 1
|
||||
|
||||
kafka-topics.sh --zookeeper hostname:2181/kafka --create
|
||||
--topic mem-monitor
|
||||
--replication-factor 3
|
||||
--partitions 1
|
||||
|
||||
|
||||
怎么样?是不是很简单?要创建Topic,只要指定ZooKeeper服务地址、Topic名字和副本数量即可。不过,这里需要特别注意的是,副本数量,也就是replication-factor,不能超过集群中的Broker数量。所以,如果你是本地部署的话,也就是所有服务都部署到一台节点,那么这里的replication-factor应该设置为1。
|
||||
|
||||
好啦,到此为止,Kafka环境安装、配置完毕。下一步,我们就该让生产者去生产资源利用率消息,并把消息源源不断地注入Kafka集群了。
|
||||
|
||||
消息的生产
|
||||
|
||||
在咱们的实例中,我们要做的是监测集群中每台机器的资源利用率。因此,我们需要这些机器,每隔一段时间,就把CPU和内存利用率发送出来。而要做到这一点,咱们只需要完成一下两个两个必要步骤:
|
||||
|
||||
|
||||
每台节点从本机收集CPU与内存使用数据;-
|
||||
|
||||
把收集到的数据,按照固定间隔,发送给Kafka集群。-
|
||||
由于消息生产这部分代码比较长,而我们的重点是学习Kafka与Spark的集成,因此,这里咱们只给出这两个步骤所涉及的关键代码片段。完整的代码实现,你可以从这里进行下载。
|
||||
|
||||
import java.lang.management.ManagementFactory
|
||||
import java.lang.reflect.Modifier
|
||||
|
||||
def getUsage(mothedName: String): Any = {
|
||||
// 获取操作系统Java Bean
|
||||
val operatingSystemMXBean = ManagementFactory.getOperatingSystemMXBean
|
||||
// 获取操作系统对象中声明过的方法
|
||||
|
||||
for (method <- operatingSystemMXBean.getClass.getDeclaredMethods) {
|
||||
method.setAccessible(true)
|
||||
|
||||
// 判断是否为我们需要的方法名
|
||||
|
||||
if (method.getName.startsWith(mothedName) && Modifier.isPublic(method.getModifiers)) {
|
||||
|
||||
// 调用并执行方法,获取指定资源(CPU或内存)的利用率
|
||||
|
||||
return method.invoke(operatingSystemMXBean)
|
||||
}
|
||||
}
|
||||
throw new Exception(s"Can not reflect method: ${mothedName}")
|
||||
|
||||
}
|
||||
|
||||
// 获取CPU利用率
|
||||
def getCPUUsage(): String = {
|
||||
|
||||
var usage = 0.0
|
||||
|
||||
try{
|
||||
// 调用getUsage方法,传入”getSystemCpuLoad”参数,获取CPU利用率
|
||||
|
||||
usage = getUsage("getSystemCpuLoad").asInstanceOf[Double] * 100
|
||||
} catch {
|
||||
case e: Exception => throw e
|
||||
}
|
||||
usage.toString
|
||||
|
||||
}
|
||||
|
||||
// 获取内存利用率
|
||||
def getMemoryUsage(): String = {
|
||||
|
||||
var freeMemory = 0L
|
||||
var totalMemory = 0L
|
||||
var usage = 0.0
|
||||
|
||||
try{
|
||||
// 调用getUsage方法,传入相关内存参数,获取内存利用率
|
||||
|
||||
freeMemory = getUsage("getFreePhysicalMemorySize").asInstanceOf[Long]
|
||||
totalMemory = getUsage("getTotalPhysicalMemorySize").asInstanceOf[Long]
|
||||
|
||||
// 用总内存,减去空闲内存,获取当前内存用量
|
||||
|
||||
usage = (totalMemory - freeMemory.doubleValue) / totalMemory * 100
|
||||
} catch {
|
||||
case e: Exception => throw e
|
||||
}
|
||||
usage.toString
|
||||
|
||||
}
|
||||
|
||||
|
||||
利用Java的反射机制,获取资源利用率
|
||||
|
||||
上面的代码,用来获取CPU与内存利用率。在这段代码中,最核心的部分是利用Java的反射机制,来获取操作系统对象的各个公有方法,然后通过调用这些公有方法,来完成资源利用率的获取。
|
||||
|
||||
不过,看到这你可能会说:“我并不了解Java的反射机制,上面的代码看不太懂。”这也没关系,只要你能结合注释,把上述代码的计算逻辑搞清楚即可。获取到资源利用率的数据之后,接下来,我们就可以把它们发送给Kafka了。
|
||||
|
||||
import org.apache.kafka.clients.producer.{Callback, KafkaProducer, ProducerConfig, ProducerRecord}
|
||||
import org.apache.kafka.common.serialization.StringSerializer
|
||||
|
||||
// 初始化属性信息
|
||||
def initConfig(clientID: String): Properties = {
|
||||
val props = new Properties
|
||||
val brokerList = "localhost:9092"
|
||||
// 指定Kafka集群Broker列表
|
||||
props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, brokerList)
|
||||
props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, classOf[StringSerializer].getName)
|
||||
props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, classOf[StringSerializer].getName)
|
||||
props.put(ProducerConfig.CLIENT_ID_CONFIG, clientID)
|
||||
props
|
||||
}
|
||||
|
||||
val clientID = "usage.monitor.client"
|
||||
val cpuTopic = "cpu-monitor"
|
||||
val memTopic = "mem-monitor"
|
||||
|
||||
// 定义属性,其中包括Kafka集群信息、序列化方法,等等
|
||||
val props = initConfig(clientID)
|
||||
// 定义Kafka Producer对象,用于发送消息
|
||||
val producer = new KafkaProducer[String, String](props)
|
||||
// 回调函数,可暂时忽略
|
||||
val usageCallback = _
|
||||
|
||||
while (true) {
|
||||
var cpuUsage = new String
|
||||
var memoryUsage = new String
|
||||
// 调用之前定义的函数,获取CPU、内存利用率
|
||||
cpuUsage = getCPUUsage()
|
||||
memoryUsage = getMemoryUsage()
|
||||
|
||||
// 为CPU Topic生成Kafka消息
|
||||
val cpuRecord = new ProducerRecord[String, String](cpuTopic, clientID, cpuUsage)
|
||||
// 为Memory Topic生成Kafka消息
|
||||
val memRecord = new ProducerRecord[String, String](memTopic, clientID, memoryUsage)
|
||||
// 向Kafka集群发送CPU利用率消息
|
||||
producer.send(cpuRecord, usageCallback)
|
||||
// 向Kafka集群发送内存利用率消息
|
||||
producer.send(memRecord, usageCallback)
|
||||
// 设置发送间隔:2秒
|
||||
Thread.sleep(2000)
|
||||
}
|
||||
|
||||
|
||||
从上面的代码中,我们不难发现,其中的关键步骤有三步:
|
||||
|
||||
|
||||
定义Kafka Producer对象,其中需要我们在属性信息中,指明Kafka集群相关信息;
|
||||
调用之前定义的函数getCPUUsage、getMemoryUsage,获取CPU与内存资源利用率;
|
||||
把资源利用率封装为消息,并发送给对应的Topic。-
|
||||
好啦,到此为止,生产端的事情,我们就全部做完啦。在待监测的集群中,每隔两秒钟,每台机器都会向Kafka集群的cpu-monitor和mem-monitor这两个Topic发送即时消息。Kafka接收到这些消息之后,会把它们落盘到相应的分区中,等待着下游(也就是Spark)的消费。
|
||||
|
||||
|
||||
消息的消费
|
||||
|
||||
接下来,终于要轮到Structured Streaming闪亮登场了。在流计算模块的[第一讲],我们就提到,Structured Streaming支持多种Source(Socket、File、Kafka),而在这些Source中,Kafka的应用最为广泛。在用法上,相比其他Source,从Kafka接收并消费数据并没有什么两样,咱们依然是依赖“万能”的readStream API,如下表所示。
|
||||
|
||||
import org.apache.spark.sql.DataFrame
|
||||
|
||||
// 依然是依赖readStream API
|
||||
val dfCPU:DataFrame = spark.readStream
|
||||
// format要明确指定Kafka
|
||||
.format("kafka")
|
||||
// 指定Kafka集群Broker地址,多个Broker用逗号隔开
|
||||
.option("kafka.bootstrap.servers", "hostname1:9092,hostname2:9092,hostname3:9092")
|
||||
// 订阅相关的Topic,这里以cpu-monitor为例
|
||||
.option("subscribe", "cpu-monitor")
|
||||
.load()
|
||||
|
||||
|
||||
对于readStream API的用法,想必你早已烂熟于心了,上面的代码,你应该会觉得看上去很眼熟。这里需要我们特别注意的,主要有三点:
|
||||
|
||||
|
||||
format中需要明确指定Kafka;
|
||||
为kafka.bootstrap.servers键值指定Kafka集群Broker,多个Broker之间以逗号分隔;
|
||||
为subscribe键值指定需要消费的Topic名,明确Structured Streaming要消费的Topic。
|
||||
|
||||
|
||||
挥完上面的“三板斧”之后,我们就得到了用于承载CPU利用率消息的DataFrame。有了DataFrame,我们就可以利用Spark SQL提供的能力,去做各式各样的数据处理。再者,结合Structured Streaming框架特有的Window和Watermark机制,我们还能以时间窗口为粒度做计数统计,同时决定“多迟”的消息,我们将不再处理。
|
||||
|
||||
不过,在此之前,咱们不妨先来直观看下代码,感受一下存在Kafka中的消息长什么样子。
|
||||
|
||||
import org.apache.spark.sql.streaming.{OutputMode, Trigger}
|
||||
import scala.concurrent.duration._
|
||||
|
||||
dfCPU.writeStream
|
||||
.outputMode("Complete")
|
||||
// 以Console为Sink
|
||||
.format("console")
|
||||
// 每10秒钟,触发一次Micro-batch
|
||||
.trigger(Trigger.ProcessingTime(10.seconds))
|
||||
.start()
|
||||
.awaitTermination()
|
||||
|
||||
|
||||
利用上述代码,通过终端,我们可以直接观察到Structured Streaming获取的Kafka消息,从而对亟待处理的消息,建立一个感性的认知,如下图所示。
|
||||
|
||||
|
||||
|
||||
在上面的数据中,除了Key、Value以外,其他信息都是消息的元信息,也即消息所属Topic、所在分区、消息的偏移地址、录入Kafka的时间,等等。
|
||||
|
||||
在咱们的实例中,Key对应的是发送资源利用率数据的服务器节点,而Value则是具体的CPU或是内存利用率。初步熟悉了消息的Schema与构成之后,接下来,咱们就可以有的放矢地去处理这些实时的数据流了。
|
||||
|
||||
对于这些每两秒钟就产生的资源利用率数据,假设我们仅关心它们在一定时间内(比如10秒钟)的平均值,那么,我们就可以结合Trigger与聚合计算来做到这一点,代码如下所示。
|
||||
|
||||
import org.apache.spark.sql.types.StringType
|
||||
|
||||
dfCPU
|
||||
.withColumn("clientName", $"key".cast(StringType))
|
||||
.withColumn("cpuUsage", $"value".cast(StringType))
|
||||
// 按照服务器做分组
|
||||
.groupBy($"clientName")
|
||||
// 求取均值
|
||||
.agg(avg($"cpuUsage").cast(StringType).alias("avgCPUUsage"))
|
||||
.writeStream
|
||||
.outputMode("Complete")
|
||||
// 以Console为Sink
|
||||
.format("console")
|
||||
// 每10秒触发一次Micro-batch
|
||||
.trigger(Trigger.ProcessingTime(10.seconds))
|
||||
.start()
|
||||
.awaitTermination()
|
||||
|
||||
|
||||
可以看到,我们利用Fixed interval trigger,每隔10秒创建一个Micro-batch。然后,在一个Micro-batch中,我们按照发送消息的服务器做分组,并计算CPU利用率平均值。最后将统计结果打印到终端,如下图所示。
|
||||
|
||||
|
||||
|
||||
再次写入Kafka
|
||||
|
||||
实际上,除了把结果打印到终端外,我们还可以把它写回Kafka。我们知道Structured Streaming支持种类丰富的Sink,除了常用于测试的Console以外,还支持File、Kafka、Foreach(Batch),等等。要把数据写回Kafka也不难,我们只需在writeStream API中,指定format为Kafka并设置相关选项即可,如下表所示。
|
||||
|
||||
dfCPU
|
||||
.withColumn("key", $"key".cast(StringType))
|
||||
.withColumn("value", $"value".cast(StringType))
|
||||
.groupBy($"key")
|
||||
.agg(avg($"value").cast(StringType).alias("value"))
|
||||
.writeStream
|
||||
.outputMode("Complete")
|
||||
// 指定Sink为Kafka
|
||||
.format("kafka")
|
||||
// 设置Kafka集群信息,本例中只有localhost一个Kafka Broker
|
||||
.option("kafka.bootstrap.servers", "localhost:9092")
|
||||
// 指定待写入的Kafka Topic,需事先创建好Topic:cpu-monitor-agg-result
|
||||
.option("topic", "cpu-monitor-agg-result")
|
||||
// 指定WAL Checkpoint目录地址
|
||||
.option("checkpointLocation", "/tmp/checkpoint")
|
||||
.trigger(Trigger.ProcessingTime(10.seconds))
|
||||
.start()
|
||||
.awaitTermination()
|
||||
|
||||
|
||||
我们首先指定Sink为Kafka,然后通过option选项,分别设置Kafka集群信息、待写入的Topic名字,以及WAL Checkpoint目录。将上述代码敲入spark-shell,Structured Streaming会每隔10秒钟,就从Kafka拉取原始的利用率信息(Topic:cpu-monitor),然后按照服务器做分组聚合,最终再把聚合结果写回到Kafka(Topic:cpu-monitor-agg-result)。
|
||||
|
||||
这里有两点需要特别注意,一个是读取与写入的Topic要分开,以免造成逻辑与数据上的混乱。再者,细心的你可能已经发现,写回Kafka的数据,在Schema上必须用“key”和“value”这两个固定的字段,而不能再像写入Console时,可以灵活地定义类似于“clientName”和“avgCPUUsage”这样的字段名,关于这一点,还需要你特别关注。
|
||||
|
||||
重点回顾
|
||||
|
||||
好啦,到此为止,我手把手地带你实现了Kafka与Spark的集成,完成了图中涉及的每一个环节,也即从消息的生产、到写入Kafka,再到消息的消费与处理,并最终写回Kafka。
|
||||
|
||||
|
||||
|
||||
今天的内容比较多,你除了需要掌握集成中的每一个环节与用法外,还需要了解一些有关Kafka的基本概念与特性。Kafka是应用最为广泛的消息中间件(Messaging Queue),它的核心功能有三个:
|
||||
|
||||
|
||||
连接消息生产者与消息消费者;-
|
||||
缓存生产者生产的消息(或者说事件);-
|
||||
有能力让消费者以最低延迟访问到消息。-
|
||||
对于Kafka的一些基本概念,你无需死记硬背,在需要的时候,回顾后面这张架构图即可。这张图中,清楚地标记了Kafka的基础概念,以及消息生产、缓存与消费的简易流程。
|
||||
|
||||
|
||||
|
||||
|
||||
而对于Kafka与Spark两者的集成,不管是Structured Streaming通过readStream API消费Kafka消息,还是使用writeStream API将计算结果写入Kafka,你只需要记住如下几点,即可轻松地搭建这对“万金油”组合。
|
||||
|
||||
|
||||
在format函数中,指定Kafka为Source或Sink;
|
||||
在option选项中,为kafka.bootstrap.servers键值指定Kafka集群Broker;
|
||||
在option选项中,设置subscribe或是topic,指定读取或是写入的Kafka Topic。
|
||||
|
||||
|
||||
每课一练
|
||||
|
||||
请你结合本讲中CPU利用率的代码,针对内存利用率,完成示意图中的各个环节,也即内存利用率消息的生产、写入Kafka(步骤1)、消息的消费与计算(步骤2、3),聚合结果再次写入Kafka(步骤4)。
|
||||
|
||||
|
||||
|
||||
欢迎你把今天这讲内容转发给更多同事、朋友,跟他一起动手试验一下Spark + Kafka的实例,我再留言区等你分享。
|
||||
|
||||
|
||||
|
||||
|
@ -0,0 +1,153 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
15 实践方案:如何用C++自实现链路跟踪?
|
||||
你好,我是徐长龙。
|
||||
|
||||
在前面几节课,我们讲解了MySQL和多个分布式检索系统的关键原理,明白了它们如何实现分布式数据存储和检索。写多读少系统的主要优化思路相信你已经心中有数了,主要包括:用分布式队列汇总日志、利用内存缓存新写入的数据、顺序写入磁盘、多服务器分片、分布式查询可拆分索引。
|
||||
|
||||
不过你可能觉得这些离我们的业务逻辑还有点远,这节课我就分享一下,之前我是怎样用C++来实现链路跟踪系统的。
|
||||
|
||||
通过分析这个系统实现的主要思路和关键细节,你不但能学到业务场景里的实用技巧,更重要的是,把技术理解和业务实现联系在一起,更深入地理解写多读少的系统。
|
||||
|
||||
案例背景
|
||||
|
||||
2016年我在微博任职,那时微博有很多重要但复杂的内部系统,由于相互依赖较为严重,并且不能登陆公用集群,每次排查问题的时候都很痛苦。
|
||||
|
||||
很多问题需要不断加日志试探,三天左右才能摸出眉目。为了更高效地排查线上故障,我们需要一些工具辅助提高排查问题效率,于是我和几个伙伴合作实现了一个分布式链路跟踪的系统。
|
||||
|
||||
由于那时候,我只有两台4核8G内存服务器,可用硬件资源不多,所以分布式链路跟踪的存储和计算的功能是通过C++ 11实现的。这个项目最大的挑战就是如何在有限的资源下,记录下所有请求过程,并能够实时统计监控线上故障,辅助排查问题。
|
||||
|
||||
要想做一个这样的系统,主要分为几个关键功能:日志采集、日志传输、日志存储、日志查询、实时性能统计展示以及故障线索收集。经过讨论,我们确定了具体项目实现思路,如下图所示:
|
||||
|
||||
|
||||
|
||||
链路跟踪的第一步就是收集日志。当时我看了链路跟踪的相关资料后,决定按分布式链路跟踪思路去设计实现。因为这样做,可以通过每次请求入口产生的的TraceID,汇集一次请求的所有相关日志。
|
||||
|
||||
但是具体收集什么日志,才对排查问题更有帮助呢?如果链路跟踪只记录接口的性能,实际就只能辅助我们分析性能问题,对排查逻辑问题意义并不大。
|
||||
|
||||
经过进一步讨论,我们决定给分级日志和异常日志都带上TraceID,方便我们获取更多业务过程状态。另外,我们在请求其他服务的请求Header内也加上TraceID和RPCID,并且记录了API、SQL请求的参数、返回内容和性能数据。综合这些,就能实现完整的全量日志监控跟踪系统,性能问题和逻辑缺陷都能排查。
|
||||
|
||||
接下来,我们就看看这里的主要功能是怎样实现的。
|
||||
|
||||
抓取、采集与传输
|
||||
|
||||
日志采集在我们的系统里怎么实现呢?
|
||||
|
||||
相信你多少能猜到大致做法:一般来说,我们需要在接口被请求时,接收传递过来的TraceID以及RPCID,如果没有传递过来的TraceID,那么自己可以用UUID生成一个,用于标识后续在请求期间所有的日志。
|
||||
|
||||
|
||||
|
||||
服务被请求时,建议记录一条被调用的访问日志,具体可以记录当前被请求的参数以及接口最后返回的结果、httpcode、耗时。通过这个日志,后续可以方便我们分析服务的性能和故障。
|
||||
|
||||
|
||||
|
||||
而对于被请求期间的业务所产生的业务日志、错误日志,以及请求其他资源的日志,都需要做详细记录,比如SQL查询记录、API请求记录以及这些请求的参数、返回、耗时。
|
||||
|
||||
|
||||
|
||||
无论我们想做链路跟踪还是统计系统服务状态,都需要做类似AOP切面拦截,通过切面编程抓取所有操作数据库或API请求前后的数据。为了更好理解这里给你提供一个AOP的实现样例,这是我之前在生产环境中使用的。
|
||||
|
||||
记录了项目的请求依赖资源部分之后,我用了两个传输方式来传输生成的日志:一个是通过memcache的长链接协议,将日志推送到我们日志收集服务上,这种推送日志请求超时超过200ms就会丢弃,这样能避免拖慢接口的性能。
|
||||
|
||||
另外一个方式是落地到本地磁盘,通过Filebeat实时抓取推送,将日志收集汇总起来。当然,第二种方式最稳定,但是由于我们公共服务器集群不让登录的限制,有一部分系统只能使用第一种方式来传递日志。
|
||||
|
||||
前面提到,由于跟踪的都是核心系统,并且业务都很复杂,所以我们对所有的请求过程和参数返回都做了记录。
|
||||
|
||||
可以预见,这样的方式产生的日志量很大,而且日志的写并发吞吐很高,甚至支付系统在某次服务高峰时会出现日志写 100MB/s的情况。因此我们的日志写入及传输都需要有很好的性能服务支持,同时还要保证日志不会丢失。
|
||||
|
||||
为此,我们选择了用Kafka来传输日志,Kafka通过对同一个topic数据做多个分区动态调配来实现负载均衡及动态扩容,当我们流量超过其承受能力时,可以随时通过给服务器群组增加服务器来扩容,从而提供更好的吞吐量。可以说多系统之间的实时高吞吐传输同步,几乎都是使用Kafka实现的。
|
||||
|
||||
可动态扩容的分组消费
|
||||
|
||||
那么Kafka是如何帮助业务动态扩容消费性能的呢?
|
||||
|
||||
|
||||
|
||||
在Kafka消费这里使用的是Consumer Group分组消费,分组消费是一个很棒的实现,我们可以让多个服务同时消费一组数据,比如:启动两个进程消费20个分区的数据,也就是一个服务负责消费10个区的数据。
|
||||
|
||||
如果服务运转期间消费能力不够了,消息出现堆积,我们可以找两台服务器新启动2个消费进程,此时Kafka会对consumer进程自动重新调度(rebalance),让四个消费进程平分20个分区,即自动调度成每个消费进程消费5个分区的数据。
|
||||
|
||||
通过这个功能,我们可以动态扩容消费服务器的能力,比如随时增加消费进程数来提高消费能力,甚至一些消费服务可以随时重启。
|
||||
|
||||
这个功能可以让我们动态扩容变得更容易,对于写并发大的数据流传输或同步的服务帮助很大,几乎大部分最终一致性的数据服务,最终都是靠分布式队列来实现的。微博内部很多系统间的数据同步,最后都改成了使用kafka去做同步。
|
||||
|
||||
基于Kafka的分组特性,我们将服务做成了两组消费服务,一组用于数据的统计,一组用于存储,通过这个方式隔离存储和实时统计服务。
|
||||
|
||||
写多读少的RocksDB
|
||||
|
||||
接下来,我们重点说说分布式存储怎么处理,因为这是自实现最有特色的地方。另外,计算部分的实现和第十三节课的情况大同小异,你可以点这里回看。
|
||||
|
||||
考虑到只有两台存储服务器,我需要提供一个写性能很好并支持“检索”的日志存储检索服务,经过查找和对比,最终我选择了RocksDB。
|
||||
|
||||
RocksDB是Facebook做实验出来的产品,后经不断完善,最终被大量用户使用。它提供了超越LevelDB写性能的服务,能够在Flash、磁盘、HDFS媒介上存储,并且能够充分利用多核以及SSD提供更高性能的高负载数据存储服务。
|
||||
|
||||
由于Rocksdb是嵌入式的,所以我们实现的服务和存储引擎之间没有网络消耗,性能会更好,再配合上Kafka分组消费,就可以实现一个无副本的分布式存储。
|
||||
|
||||
我首先看中的是RocksDB这个引擎的写性能。回想一下我们第十节课讲过的内容,RocksDB利用了内存做缓存,同时利用磁盘顺序写性能最强的特性,能够提供接近单机300M/s的写数据能力,理想情况下,两台存储服务器就可以提供600M/s的写入能力。再加上Kafka缓解写高峰压力,这个设计已经能满足大部分业务需求了。
|
||||
|
||||
其次,RocksDB的接入非常简单,想要在项目中引入它的库,只要保证它的写操作只有一个线程写,其他线程可以实例化 Secondary只读即可。
|
||||
|
||||
此外,RocksDB还支持内存和磁盘冷热数据的自动管理、存储数据压缩等功能,而且单个库就能存储上TB的数据、单个Value 长度能够达到3G,这非常适合在分布式链路跟踪的系统里存储和查找大量的文本日志。
|
||||
|
||||
接下来要解决的问题就是,如何在RocksdDB分配管理我们的Trace日志。
|
||||
|
||||
为了提高查询效率,并且只保留7天日志,我们选择了按天保存日志,一天一个RocksDB库,过期的数据库可以删除或归档到HDFS内。
|
||||
|
||||
汇总保存日志的时候,我们利用了RocksDB的这两个方面的特性。一方面通过Trace日志的TraceID作为key来存储,这样我们直接通过TraceID就可以查到所有相关日志。
|
||||
|
||||
另一方面,是利用Merge操作对KV中的value实现string append。Merge是RocksDB里很少有人提到的一个功能,但用起来还不错,可以帮我们把所有日志高性能地追加到一个Key内。Merge操作的官方demo代码你可以从这里获取,如果对于实现原理感兴趣,还可以参考下 rocksdb-merge-operators。
|
||||
|
||||
分布式查询与计算
|
||||
|
||||
数据存储好后,如何查询呢?
|
||||
|
||||
事实上很简单,我们的Trace SDK会让每个接口返回响应内容的同时,在header中包含了TraceID,debug的时候使用返回traceId进行查询时,界面会对所有存储节点发送查询请求,通过TraceID从RocksDB拿出所有按回车分割的日志后,汇总排序即可。
|
||||
|
||||
另外,日志存储服务集成了Libevent,通过它实现了HTTP和Memcache协议的查询接口服务,由于这里比较复杂有多个模式,这里不对这个做详细介绍了,如果你想了解如何用epoll和Socket实现一个简单的HTTP服务,具体可以看看我闲暇时写的小demo 。
|
||||
|
||||
我再补充说一下,怎么对多节点数据进行查询。由于读操作很少,我们可以通过异步请求多个存储实例直接问询查询内容,再到协调节点进行汇总排序即可。
|
||||
|
||||
说完了数据查询,我们再聊聊分布式计算。
|
||||
|
||||
想要实现服务器状态统计计算,核心还是利用Kafka的分组消费,另外启动一组服务消费日志内容,在内存中对日志进行汇总计算。
|
||||
|
||||
如果想采样服务器的请求情况,可以定期按时间块索引随机采1000个TraceID到RocksDB的时间块索引内,需要展示的时候,将它们取出聚合展示即可。关于实时计算的算法和思路,我在第十三节课中已经讲过了,你可以去回顾一下。
|
||||
|
||||
关于自实现的整体思路我们聊完了。看完以后你可能会好奇,现在硬件资源已经很充裕了,我还用学习这些吗?
|
||||
|
||||
事实上,在硬件资源充裕的时代我们还是要考虑成本。我们推算一下,比如2000台服务器性能提升一倍,就能节省1000台服务器。如果一台每年1w维护费用,那么就是每年能节省1000w。架构师除了解决业务问题外,大部分时间都是在思考如何在保证服务稳定的情况下降低成本。
|
||||
|
||||
另外,我再说说选择开源的一些建议。由于市面很多开源是共建的,并且有一些开源属于个人的习作,没有在生产环境验证过。我们要尽量选择在生产环境验证过的、活跃的社区功能。
|
||||
|
||||
虽然之前我使用C++实现链路跟踪,但现在技术发展得很快,如果放在今天,我是不推荐你也用同样方法做这个服务的。实践的时候,你可以考虑使用Java、GO、Rust等语言去尝试,相信这样会让你节省大量的时间。
|
||||
|
||||
总结
|
||||
|
||||
这节课我和你分享了我用C++实现链路跟踪的实践方案,其中的技术要点你可以参考下图。
|
||||
|
||||
|
||||
|
||||
写多读少的系统,普遍会用分布式的队列服务(类似Kafka)汇总数据,配合多台服务器或分片来消费加工数据,通过这样的架构来应对数据洪流。
|
||||
|
||||
这一章我们详细分析了写多读少系统的几种方案,你会发现它们各有千秋。为了方便你对比学习,我引入了MySQL作为参考。
|
||||
|
||||
你也可以参考后面这张表格的思路,把技术实现的关键点(比如数据传输、写入、分片、扩容、查询等等)列出来,通过这种方式,可以帮你快速分析出哪种技术实现更匹配自己项目的业务需要。
|
||||
|
||||
|
||||
|
||||
思考题
|
||||
|
||||
今天我给你准备了两道思考题。
|
||||
|
||||
第一题,如何解决Kafka消费偶发乱序以及小概率消费重复问题?
|
||||
|
||||
第二题稍有难度,有兴趣的话你可以挑战一下。epoll实现时会分单线程Reactor、单Reactor多线程、多线程Reactor这几种方式,对于存储服务你觉得哪种方式更适合呢?
|
||||
|
||||
欢迎你在留言区与我交流讨论,我们下节课见!
|
||||
|
||||
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user