diff --git a/第38课:抓取网页动态内容.md b/第38课:抓取网页动态内容.md new file mode 100644 index 0000000..f04b86f --- /dev/null +++ b/第38课:抓取网页动态内容.md @@ -0,0 +1,281 @@ +## 第38课:抓取网页动态内容 + +根据权威机构发布的全球互联网可访问性审计报告,全球约有四分之三的网站其内容或部分内容是通过JavaScript动态生成的,这就意味着在浏览器窗口中“查看网页源代码”时无法在HTML代码中找到这些内容,也就是说我们之前用的抓取数据的方式无法正常运转了。解决这样的问题基本上有两种方案,一是获取提供动态内容的数据接口,这种方式也适用于抓取手机 App 的数据;另一种是通过自动化测试工具 Selenium 运行浏览器获取渲染后的动态内容。对于第一种方案,我们可以使用浏览器的“开发者工具”或者更为专业的抓包工具(如:Charles、Fiddler、Wireshark等)来获取到数据接口,后续的操作跟上一个章节中讲解的获取“360图片”网站的数据是一样的,这里我们不再进行赘述。这一章我们重点讲解如何使用自动化测试工具 Selenium 来获取网站的动态内容。 + +### Selenium 介绍 + +Selenium 是一个自动化测试工具,利用它可以驱动浏览器执行特定的行为,最终帮助爬虫开发者获取到网页的动态内容。简单的说,只要我们在浏览器窗口中能够看到的内容,都可以使用 Selenium 获取到,对于那些使用了 JavaScript 动态渲染技术的网站,Selenium 会是一个重要的选择。下面,我们还是以 Chrome 浏览器为例,来讲解 Selenium 的用法,大家需要先安装 Chrome 浏览器并下载它的驱动。Chrome 浏览器的驱动程序可以在[ChromeDriver官网](https://chromedriver.chromium.org/downloads)进行下载,驱动的版本要跟浏览器的版本对应,如果没有完全对应的版本,就选择版本代号最为接近的版本。 + + + +### 使用Selenium + +我们可以先通过`pip`来安装 Selenium,命令如下所示。 + +```Shell +pip install selenium +``` + +#### 加载页面 + +接下来,我们通过下面的代码驱动 Chrome 浏览器打开百度。 + +```Python +from selenium import webdriver + +# 创建Chrome浏览器对象 +browser = webdriver.Chrome() +# 加载指定的页面 +browser.get('https://www.baidu.com/') +``` + +如果不愿意使用 Chrome 浏览器,也可以修改上面的代码操控其他浏览器,只需创建对应的浏览器对象(如 Firefox、Safari 等)即可。运行上面的程序,如果看到如下所示的错误提示,那是说明我们还没有将 Chrome 浏览器的驱动添加到 PATH 环境变量中,也没有在程序中指定 Chrome 浏览器驱动所在的位置。 + +```Shell +selenium.common.exceptions.WebDriverException: Message: 'chromedriver' executable needs to be in PATH. Please see https://sites.google.com/a/chromium.org/chromedriver/home +``` + +解决这个问题的办法有三种: + +1. 将下载的 ChromeDriver 放到已有的 PATH 环境变量下,建议直接跟 Python 解释器放在同一个目录,因为之前安装 Python 的时候我们已经将 Python 解释器的路径放到 PATH 环境变量中了。 + +2. 将 ChromeDriver 放到项目虚拟环境下的 `bin` 文件夹中(Windows 系统对应的目录是 `Scripts`),这样 ChromeDriver 就跟虚拟环境下的 Python 解释器在同一个位置,肯定是能够找到的。 + +3. 修改上面的代码,在创建 Chrome 对象时,通过`service`参数配置`Service`对象,并通过创建`Service`对象的`executable_path`参数指定 ChromeDriver 所在的位置,如下所示: + + ```Python + from selenium import webdriver + from selenium.webdriver.chrome.service import Service + + browser = webdriver.Chrome(service=Service(executable_path='venv/bin/chromedriver')) + browser.get('https://www.baidu.com/') + ``` + +#### 查找元素和模拟用户行为 + +接下来,我们可以尝试模拟用户在百度首页的文本框输入搜索关键字并点击“百度一下”按钮。在完成页面加载后,可以通过`Chrome`对象的`find_element`和`find_elements`方法来获取页面元素,Selenium 支持多种获取元素的方式,包括:CSS 选择器、XPath、元素名字(标签名)、元素 ID、类名等,前者可以获取单个页面元素(`WebElement`对象),后者可以获取多个页面元素构成的列表。获取到`WebElement`对象以后,可以通过`send_keys`来模拟用户输入行为,可以通过`click`来模拟用户点击操作,代码如下所示。 + +```Python +from selenium import webdriver +from selenium.webdriver.common.by import By + +browser = webdriver.Chrome() +browser.get('https://www.baidu.com/') +# 通过元素ID获取元素 +kw_input = browser.find_element(By.ID, 'kw') +# 模拟用户输入行为 +kw_input.send_keys('Python') +# 通过CSS选择器获取元素 +su_button = browser.find_element(By.CSS_SELECTOR, '#su') +# 模拟用户点击行为 +su_button.click() +``` + +如果要执行一个系列动作,例如模拟拖拽操作,可以创建`ActionChains`对象,有兴趣的读者可以自行研究。 + +#### 隐式等待和显式等待 + +这里还有一个细节需要大家知道,网页上的元素可能是动态生成的,在我们使用`find_element`或`find_elements`方法获取的时候,可能还没有完成渲染,这时会引发`NoSuchElementException`错误。为了解决这个问题,我们可以使用隐式等待的方式,通过设置等待时间让浏览器完成对页面元素的渲染。除此之外,我们还可以使用显示等待,通过创建`WebDriverWait`对象,并设置等待时间和条件,当条件没有满足时,我们可以先等待再尝试进行后续的操作,具体的代码如下所示。 + +```Python +from selenium import webdriver +from selenium.webdriver.common.by import By +from selenium.webdriver.support import expected_conditions +from selenium.webdriver.support.wait import WebDriverWait + +browser = webdriver.Chrome() +# 设置浏览器窗口大小 +browser.set_window_size(1200, 800) +browser.get('https://www.baidu.com/') +# 设置隐式等待时间为10秒 +browser.implicitly_wait(10) +kw_input = browser.find_element(By.ID, 'kw') +kw_input.send_keys('Python') +su_button = browser.find_element(By.CSS_SELECTOR, '#su') +su_button.click() +# 创建显示等待对象 +wait_obj = WebDriverWait(browser, 10) +# 设置等待条件(等搜索结果的div出现) +wait_obj.until( + expected_conditions.presence_of_element_located( + (By.CSS_SELECTOR, '#content_left') + ) +) +# 截屏 +browser.get_screenshot_as_file('python_result.png') +``` + +上面设置的等待条件`presence_of_element_located`表示等待指定元素出现,下面的表格列出了常用的等待条件及其含义。 + +| 等待条件 | 具体含义 | +| ---------------------------------------- | ------------------------------------- | +| `title_is / title_contains` | 标题是指定的内容 / 标题包含指定的内容 | +| `visibility_of` | 元素可见 | +| `presence_of_element_located` | 定位的元素加载完成 | +| `visibility_of_element_located` | 定位的元素变得可见 | +| `invisibility_of_element_located` | 定位的元素变得不可见 | +| `presence_of_all_elements_located` | 定位的所有元素加载完成 | +| `text_to_be_present_in_element` | 元素包含指定的内容 | +| `text_to_be_present_in_element_value` | 元素的`value`属性包含指定的内容 | +| `frame_to_be_available_and_switch_to_it` | 载入并切换到指定的内部窗口 | +| `element_to_be_clickable` | 元素可点击 | +| `element_to_be_selected` | 元素被选中 | +| `element_located_to_be_selected` | 定位的元素被选中 | +| `alert_is_present` | 出现 Alert 弹窗 | + +#### 执行JavaScript代码 + +对于使用瀑布式加载的页面,如果希望在浏览器窗口中加载更多的内容,可以通过浏览器对象的`execute_scripts`方法执行 JavaScript 代码来实现。对于一些高级的爬取操作,也很有可能会用到类似的操作,如果你的爬虫代码需要 JavaScript 的支持,建议先对 JavaScript 进行适当的了解,尤其是 JavaScript 中的 BOM 和 DOM 操作。我们在上面的代码中截屏之前加入下面的代码,这样就可以利用 JavaScript 将网页滚到最下方。 + +```Python +# 执行JavaScript代码 +browser.execute_script('document.documentElement.scrollTop = document.documentElement.scrollHeight') +``` + +#### Selenium反爬的破解 + +有一些网站专门针对 Selenium 设置了反爬措施,因为使用 Selenium 驱动的浏览器,在控制台中可以看到如下所示的`webdriver`属性值为`true`,如果要绕过这项检查,可以在加载页面之前,先通过执行 JavaScript 代码将其修改为`undefined`。 + + + +另一方面,我们还可以将浏览器窗口上的“Chrome正受到自动测试软件的控制”隐藏掉,完整的代码如下所示。 + +```Python +# 创建Chrome参数对象 +options = webdriver.ChromeOptions() +# 添加试验性参数 +options.add_experimental_option('excludeSwitches', ['enable-automation']) +options.add_experimental_option('useAutomationExtension', False) +# 创建Chrome浏览器对象并传入参数 +browser = webdriver.Chrome(options=options) +# 执行Chrome开发者协议命令(在加载页面时执行指定的JavaScript代码) +browser.execute_cdp_cmd( + 'Page.addScriptToEvaluateOnNewDocument', + {'source': 'Object.defineProperty(navigator, "webdriver", {get: () => undefined})'} +) +browser.set_window_size(1200, 800) +browser.get('https://www.baidu.com/') +``` + +#### 无头浏览器 + +很多时候,我们在爬取数据时并不需要看到浏览器窗口,只要有 Chrome 浏览器以及对应的驱动程序,我们的爬虫就能够运转起来。如果不想看到浏览器窗口,我们可以通过下面的方式设置使用无头浏览器。 + +```Python +options = webdriver.ChromeOptions() +options.add_argument('--headless') +browser = webdriver.Chrome(options=options) +``` + +### API参考 + +Selenium 相关的知识还有很多,我们在此就不一一赘述了,下面为大家罗列一些浏览器对象和`WebElement`对象常用的属性和方法。具体的内容大家还可以参考 Selenium [官方文档的中文翻译](https://selenium-python-zh.readthedocs.io/en/latest/index.html)。 + +#### 浏览器对象 + +表1. 常用属性 + +| 属性名 | 描述 | +| ----------------------- | -------------------------------- | +| `current_url` | 当前页面的URL | +| `current_window_handle` | 当前窗口的句柄(引用) | +| `name` | 浏览器的名称 | +| `orientation` | 当前设备的方向(横屏、竖屏) | +| `page_source` | 当前页面的源代码(包括动态内容) | +| `title` | 当前页面的标题 | +| `window_handles` | 浏览器打开的所有窗口的句柄 | + +表2. 常用方法 + +| 方法名 | 描述 | +| -------------------------------------- | ----------------------------------- | +| `back` / `forward` | 在浏览历史记录中后退/前进 | +| `close` / `quit` | 关闭当前浏览器窗口 / 退出浏览器实例 | +| `get` | 加载指定 URL 的页面到浏览器中 | +| `maximize_window` | 将浏览器窗口最大化 | +| `refresh` | 刷新当前页面 | +| `set_page_load_timeout` | 设置页面加载超时时间 | +| `set_script_timeout` | 设置 JavaScript 执行超时时间 | +| `implicit_wait` | 设置等待元素被找到或目标指令完成 | +| `get_cookie` / `get_cookies` | 获取指定的Cookie / 获取所有Cookie | +| `add_cookie` | 添加 Cookie 信息 | +| `delete_cookie` / `delete_all_cookies` | 删除指定的 Cookie / 删除所有 Cookie | +| `find_element` / `find_elements` | 查找单个元素 / 查找一系列元素 | + +#### WebElement对象 + +表1. WebElement常用属性 + +| 属性名 | 描述 | +| ---------- | -------------- | +| `location` | 元素的位置 | +| `size` | 元素的尺寸 | +| `text` | 元素的文本内容 | +| `id` | 元素的 ID | +| `tag_name` | 元素的标签名 | + +表2. 常用方法 + +| 方法名 | 描述 | +| -------------------------------- | ------------------------------------ | +| `clear` | 清空文本框或文本域中的内容 | +| `click` | 点击元素 | +| `get_attribute` | 获取元素的属性值 | +| `is_displayed` | 判断元素对于用户是否可见 | +| `is_enabled` | 判断元素是否处于可用状态 | +| `is_selected` | 判断元素(单选框和复选框)是否被选中 | +| `send_keys` | 模拟输入文本 | +| `submit` | 提交表单 | +| `value_of_css_property` | 获取指定的CSS属性值 | +| `find_element` / `find_elements` | 获取单个子元素 / 获取一系列子元素 | +| `screenshot` | 为元素生成快照 | + +### 简单案例 + +下面的例子演示了如何使用 Selenium 从“360图片”网站搜索和下载图片。 + +```Python +import os +import time +from concurrent.futures import ThreadPoolExecutor + +import requests +from selenium import webdriver +from selenium.webdriver.common.by import By +from selenium.webdriver.common.keys import Keys + +DOWNLOAD_PATH = 'images/' + + +def download_picture(picture_url: str): + """ + 下载保存图片 + :param picture_url: 图片的URL + """ + filename = picture_url[picture_url.rfind('/') + 1:] + resp = requests.get(picture_url) + with open(os.path.join(DOWNLOAD_PATH, filename), 'wb') as file: + file.write(resp.content) + + +if not os.path.exists(DOWNLOAD_PATH): + os.makedirs(DOWNLOAD_PATH) +browser = webdriver.Chrome() +browser.get('https://image.so.com/z?ch=beauty') +browser.implicitly_wait(10) +kw_input = browser.find_element(By.CSS_SELECTOR, 'input[name=q]') +kw_input.send_keys('苍老师') +kw_input.send_keys(Keys.ENTER) +for _ in range(10): + browser.execute_script( + 'document.documentElement.scrollTop = document.documentElement.scrollHeight' + ) + time.sleep(1) +imgs = browser.find_elements(By.CSS_SELECTOR, 'div.waterfall img') +with ThreadPoolExecutor(max_workers=32) as pool: + for img in imgs: + pic_url = img.get_attribute('src') + pool.submit(download_picture, pic_url) +``` + +运行上面的代码,检查指定的目录下是否下载了根据关键词搜索到的图片。 diff --git a/第39课:爬虫框架Scrapy简介.md b/第39课:爬虫框架Scrapy简介.md new file mode 100644 index 0000000..5b2ca62 --- /dev/null +++ b/第39课:爬虫框架Scrapy简介.md @@ -0,0 +1,250 @@ +## 第39课:爬虫框架Scrapy简介 + +当你写了很多个爬虫程序之后,你会发现每次写爬虫程序时,都需要将页面获取、页面解析、爬虫调度、异常处理、反爬应对这些代码从头至尾实现一遍,这里面有很多工作其实都是简单乏味的重复劳动。那么,有没有什么办法可以提升我们编写爬虫代码的效率呢?答案是肯定的,那就是利用爬虫框架,而在所有的爬虫框架中,Scrapy 应该是最流行、最强大的框架。 + +### Scrapy 概述 + +Scrapy 是基于 Python 的一个非常流行的网络爬虫框架,可以用来抓取 Web 站点并从页面中提取结构化的数据。下图展示了 Scrapy 的基本架构,其中包含了主要组件和系统的数据处理流程(图中带数字的红色箭头)。 + +![](https://gitee.com/jackfrued/mypic/raw/master/20210824003638.png) + +#### Scrapy的组件 + +我们先来说说 Scrapy 中的组件。 + +1. Scrapy 引擎(Engine):用来控制整个系统的数据处理流程。 +2. 调度器(Scheduler):调度器从引擎接受请求并排序列入队列,并在引擎发出请求后返还给它们。 +3. 下载器(Downloader):下载器的主要职责是抓取网页并将网页内容返还给蜘蛛(Spiders)。 +4. 蜘蛛程序(Spiders):蜘蛛是用户自定义的用来解析网页并抓取特定URL的类,每个蜘蛛都能处理一个域名或一组域名,简单的说就是用来定义特定网站的抓取和解析规则的模块。 +5. 数据管道(Item Pipeline):管道的主要责任是负责处理有蜘蛛从网页中抽取的数据条目,它的主要任务是清理、验证和存储数据。当页面被蜘蛛解析后,将被发送到数据管道,并经过几个特定的次序处理数据。每个数据管道组件都是一个 Python 类,它们获取了数据条目并执行对数据条目进行处理的方法,同时还需要确定是否需要在数据管道中继续执行下一步或是直接丢弃掉不处理。数据管道通常执行的任务有:清理 HTML 数据、验证解析到的数据(检查条目是否包含必要的字段)、检查是不是重复数据(如果重复就丢弃)、将解析到的数据存储到数据库(关系型数据库或 NoSQL 数据库)中。 +6. 中间件(Middlewares):中间件是介于引擎和其他组件之间的一个钩子框架,主要是为了提供自定义的代码来拓展 Scrapy 的功能,包括下载器中间件和蜘蛛中间件。 + +#### 数据处理流程 + +Scrapy 的整个数据处理流程由引擎进行控制,通常的运转流程包括以下的步骤: + +1. 引擎询问蜘蛛需要处理哪个网站,并让蜘蛛将第一个需要处理的 URL 交给它。 + +2. 引擎让调度器将需要处理的 URL 放在队列中。 + +3. 引擎从调度那获取接下来进行爬取的页面。 + +4. 调度将下一个爬取的 URL 返回给引擎,引擎将它通过下载中间件发送到下载器。 + +5. 当网页被下载器下载完成以后,响应内容通过下载中间件被发送到引擎;如果下载失败了,引擎会通知调度器记录这个 URL,待会再重新下载。 + +6. 引擎收到下载器的响应并将它通过蜘蛛中间件发送到蜘蛛进行处理。 + +7. 蜘蛛处理响应并返回爬取到的数据条目,此外还要将需要跟进的新的 URL 发送给引擎。 + +8. 引擎将抓取到的数据条目送入数据管道,把新的 URL 发送给调度器放入队列中。 + +上述操作中的第2步到第8步会一直重复直到调度器中没有需要请求的 URL,爬虫就停止工作。 + +### 安装和使用Scrapy + +可以使用 Python 的包管理工具`pip`来安装 Scrapy。 + +```Shell +pip install scrapy +``` + +在命令行中使用`scrapy`命令创建名为`demo`的项目。 + +```Bash +scrapy startproject demo +``` + +项目的目录结构如下图所示。 + +```Shell +demo +|____ demo +|________ spiders +|____________ __init__.py +|________ __init__.py +|________ items.py +|________ middlewares.py +|________ pipelines.py +|________ settings.py +|____ scrapy.cfg +``` + +切换到`demo` 目录,用下面的命令创建名为`douban`的蜘蛛程序。 + +```Bash +scrapy genspider douban movie.douban.com +``` + +#### 一个简单的例子 + +接下来,我们实现一个爬取豆瓣电影 Top250 电影标题、评分和金句的爬虫。 + +1. 在`items.py`的`Item`类中定义字段,这些字段用来保存数据,方便后续的操作。 + + ```Python + import scrapy + + + class DoubanItem(scrapy.Item): + title = scrapy.Field() + score = scrapy.Field() + motto = scrapy.Field() + ``` + +2. 修改`spiders`文件夹中名为`douban.py` 的文件,它是蜘蛛程序的核心,需要我们添加解析页面的代码。在这里,我们可以通过对`Response`对象的解析,获取电影的信息,代码如下所示。 + + ```Python + import scrapy + from scrapy import Selector, Request + from scrapy.http import HtmlResponse + + from demo.items import MovieItem + + + class DoubanSpider(scrapy.Spider): + name = 'douban' + allowed_domains = ['movie.douban.com'] + start_urls = ['https://movie.douban.com/top250?start=0&filter='] + + def parse(self, response: HtmlResponse): + sel = Selector(response) + movie_items = sel.css('#content > div > div.article > ol > li') + for movie_sel in movie_items: + item = MovieItem() + item['title'] = movie_sel.css('.title::text').extract_first() + item['score'] = movie_sel.css('.rating_num::text').extract_first() + item['motto'] = movie_sel.css('.inq::text').extract_first() + yield item + ``` + 通过上面的代码不难看出,我们可以使用 CSS 选择器进行页面解析。当然,如果你愿意也可以使用 XPath 或正则表达式进行页面解析,对应的方法分别是`xpath`和`re`。 + + 如果还要生成后续爬取的请求,我们可以用`yield`产出`Request`对象。`Request`对象有两个非常重要的属性,一个是`url`,它代表了要请求的地址;一个是`callback`,它代表了获得响应之后要执行的回调函数。我们可以将上面的代码稍作修改。 + + ```Python + import scrapy + from scrapy import Selector, Request + from scrapy.http import HtmlResponse + + from demo.items import MovieItem + + + class DoubanSpider(scrapy.Spider): + name = 'douban' + allowed_domains = ['movie.douban.com'] + start_urls = ['https://movie.douban.com/top250?start=0&filter='] + + def parse(self, response: HtmlResponse): + sel = Selector(response) + movie_items = sel.css('#content > div > div.article > ol > li') + for movie_sel in movie_items: + item = MovieItem() + item['title'] = movie_sel.css('.title::text').extract_first() + item['score'] = movie_sel.css('.rating_num::text').extract_first() + item['motto'] = movie_sel.css('.inq::text').extract_first() + yield item + + hrefs = sel.css('#content > div > div.article > div.paginator > a::attr("href")') + for href in hrefs: + full_url = response.urljoin(href.extract()) + yield Request(url=full_url) + ``` + + 到这里,我们已经可以通过下面的命令让爬虫运转起来。 + + ```Shell + scrapy crawl movie + ``` + + 可以在控制台看到爬取到的数据,如果想将这些数据保存到文件中,可以通过`-o`参数来指定文件名,Scrapy 支持我们将爬取到的数据导出成 JSON、CSV、XML 等格式。 + + ```Shell + scrapy crawl moive -o result.json + ``` + + 不知大家是否注意到,通过运行爬虫获得的 JSON 文件中有`275`条数据,那是因为首页被重复爬取了。要解决这个问题,可以对上面的代码稍作调整,不在`parse`方法中解析获取新页面的 URL,而是通过`start_requests`方法提前准备好待爬取页面的 URL,调整后的代码如下所示。 + + ```Python + import scrapy + from scrapy import Selector, Request + from scrapy.http import HtmlResponse + + from demo.items import MovieItem + + + class DoubanSpider(scrapy.Spider): + name = 'douban' + allowed_domains = ['movie.douban.com'] + + def start_requests(self): + for page in range(10): + yield Request(url=f'https://movie.douban.com/top250?start={page * 25}') + + def parse(self, response: HtmlResponse): + sel = Selector(response) + movie_items = sel.css('#content > div > div.article > ol > li') + for movie_sel in movie_items: + item = MovieItem() + item['title'] = movie_sel.css('.title::text').extract_first() + item['score'] = movie_sel.css('.rating_num::text').extract_first() + item['motto'] = movie_sel.css('.inq::text').extract_first() + yield item + ``` + +3. 如果希望完成爬虫数据的持久化,可以在数据管道中处理蜘蛛程序产生的`Item`对象。例如,我们可以通过前面讲到的`openpyxl`操作 Excel 文件,将数据写入 Excel 文件中,代码如下所示。 + + ```Python + import openpyxl + + from demo.items import MovieItem + + + class MovieItemPipeline: + + def __init__(self): + self.wb = openpyxl.Workbook() + self.sheet = self.wb.active + self.sheet.title = 'Top250' + self.sheet.append(('名称', '评分', '名言')) + + def process_item(self, item: MovieItem, spider): + self.sheet.append((item['title'], item['score'], item['motto'])) + return item + + def close_spider(self, spider): + self.wb.save('豆瓣电影数据.xlsx') + ``` + + 上面的`process_item`和`close_spider`都是回调方法(钩子函数), 简单的说就是 Scrapy 框架会自动去调用的方法。当蜘蛛程序产生一个`Item`对象交给引擎时,引擎会将该`Item`对象交给数据管道,这时我们配置好的数据管道的`parse_item`方法就会被执行,所以我们可以在该方法中获取数据并完成数据的持久化操作。另一个方法`close_spider`是在爬虫结束运行前会自动执行的方法,在上面的代码中,我们在这个地方进行了保存 Excel 文件的操作,相信这段代码大家是很容易读懂的。 + + 总而言之,数据管道可以帮助我们完成以下操作: + + - 清理 HTML 数据,验证爬取的数据。 + - 丢弃重复的不必要的内容。 + - 将爬取的结果进行持久化操作。 + +4. 修改`settings.py`文件对项目进行配置,主要需要修改以下几个配置。 + + ```Python + # 用户浏览器 + USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.159 Safari/537.36' + + # 并发请求数量 + CONCURRENT_REQUESTS = 4 + + # 下载延迟 + DOWNLOAD_DELAY = 3 + # 随机化下载延迟 + RANDOMIZE_DOWNLOAD_DELAY = True + + # 是否遵守爬虫协议 + ROBOTSTXT_OBEY = True + + # 配置数据管道 + ITEM_PIPELINES = { + 'demo.pipelines.MovieItemPipeline': 300, + } + ``` + + > **说明**:上面配置文件中的`ITEM_PIPELINES`选项是一个字典,可以配置多个处理数据的管道,后面的数字代表了执行的优先级,数字小的先执行。 + diff --git a/第40课:关系型数据库和MySQL概述.md b/第40课:关系型数据库和MySQL概述.md new file mode 100644 index 0000000..63dc6cf --- /dev/null +++ b/第40课:关系型数据库和MySQL概述.md @@ -0,0 +1,388 @@ +## 第40课:关系型数据库和MySQL概述 + +### 关系型数据库概述 + +1. 数据持久化 - 将数据保存到能够长久保存数据的存储介质中,在掉电的情况下数据也不会丢失。 + +2. 数据库发展史 - 网状数据库、层次数据库、关系数据库、NoSQL 数据库、NewSQL 数据库。 + + > 1970年,IBM的研究员E.F.Codd在*Communication of the ACM*上发表了名为*A Relational Model of Data for Large Shared Data Banks*的论文,提出了**关系模型**的概念,奠定了关系模型的理论基础。后来Codd又陆续发表多篇文章,论述了范式理论和衡量关系系统的12条标准,用数学理论奠定了关系数据库的基础。 + +3. 关系数据库特点。 + + - 理论基础:**关系代数**(关系运算、集合论、一阶谓词逻辑)。 + + - 具体表象:用**二维表**(有行和列)组织数据。 + + - 编程语言:**结构化查询语言**(SQL)。 + +4. ER模型(实体关系模型)和概念模型图。 + + **ER模型**,全称为**实体关系模型**(Entity-Relationship Model),由美籍华裔计算机科学家陈品山先生提出,是概念数据模型的高层描述方式,如下图所示。 + + + + - 实体 - 矩形框 + - 属性 - 椭圆框 + - 关系 - 菱形框 + - 重数 - 1:1(一对一) / 1:N(一对多) / M:N(多对多) + + 实际项目开发中,我们可以利用数据库建模工具(如:PowerDesigner)来绘制概念数据模型(其本质就是 ER 模型),然后再设置好目标数据库系统,将概念模型转换成物理模型,最终生成创建二维表的 SQL(很多工具都可以根据我们设计的物理模型图以及设定的目标数据库来导出 SQL 或直接生成数据表)。 + + ![](https://gitee.com/jackfrued/mypic/raw/master/20210826003212.png) + +5. 关系数据库产品。 + - [Oracle](https://www.oracle.com/index.html) - 目前世界上使用最为广泛的数据库管理系统,作为一个通用的数据库系统,它具有完整的数据管理功能;作为一个关系数据库,它是一个完备关系的产品;作为分布式数据库,它实现了分布式处理的功能。在 Oracle 最新的 12c 版本中,还引入了多承租方架构,使用该架构可轻松部署和管理数据库云。 + - [DB2](https://www.ibm.com/analytics/us/en/db2/) - IBM 公司开发的、主要运行于 Unix(包括 IBM 自家的 [AIX](https://zh.wikipedia.org/wiki/AIX))、Linux、以及 Windows 服务器版等系统的关系数据库产品。DB2 历史悠久且被认为是最早使用 SQL 的数据库产品,它拥有较为强大的商业智能功能。 + - [SQL Server](https://www.microsoft.com/en-us/sql-server/) - 由 Microsoft 开发和推广的关系型数据库产品,最初适用于中小企业的数据管理,但是近年来它的应用范围有所扩展,部分大企业甚至是跨国公司也开始基于它来构建自己的数据管理系统。 + - [MySQL](https://www.mysql.com/) - MySQL 是开放源代码的,任何人都可以在 GPL(General Public License)的许可下下载并根据个性化的需要对其进行修改。MySQL 因为其速度、可靠性和适应性而备受关注。 + - [PostgreSQL]() - 在 BSD 许可证下发行的开放源代码的关系数据库产品。 + +### MySQL 简介 + +MySQL 最早是由瑞典的 MySQL AB 公司开发的一个开放源码的关系数据库管理系统,该公司于2008年被昇阳微系统公司(Sun Microsystems)收购。在2009年,甲骨文公司(Oracle)收购昇阳微系统公司,因此 MySQL 目前也是 Oracle 旗下产品。 + +MySQL 在过去由于性能高、成本低、可靠性好,已经成为最流行的开源数据库,因此被广泛地应用于中小型网站开发。随着 MySQL 的不断成熟,它也逐渐被应用于更多大规模网站和应用,比如维基百科、谷歌(Google)、脸书(Facebook)、淘宝网等网站都使用了 MySQL 来提供数据持久化服务。 + +甲骨文公司收购后昇阳微系统公司,大幅调涨 MySQL 商业版的售价,且甲骨文公司不再支持另一个自由软件项目 [OpenSolaris ](https://zh.wikipedia.org/wiki/OpenSolaris) 的发展,因此导致自由软件社区对于 Oracle 是否还会持续支持 MySQL 社区版(MySQL 的各个发行版本中唯一免费的版本)有所担忧,MySQL 的创始人麦克尔·维德纽斯以 MySQL 为基础,创建了 [MariaDB](https://zh.wikipedia.org/wiki/MariaDB)(以他女儿的名字命名的数据库)分支。有许多原来使用 MySQL 数据库的公司(例如:维基百科)已经陆续完成了从 MySQL 数据库到 MariaDB 数据库的迁移。 + +### 安装 MySQL + +#### Windows 环境 + +1. 通过[官方网站](https://www.mysql.com/)提供的[下载链接](https://dev.mysql.com/downloads/windows/installer/8.0.html)下载“MySQL社区版服务器”安装程序,如下图所示,建议大家下载离线安装版的MySQL Installer。 + + + +2. 运行 Installer,按照下面的步骤进行安装。 + + - 选择自定义安装。 + + + + - 选择需要安装的组件。 + + + + - 如果缺少依赖项,需要先安装依赖项。 + + + + - 准备开始安装。 + + + + - 安装完成。 + + + + - 准备执行配置向导。 + + + +3. 执行安装后的配置向导。 + + - 配置服务器类型和网络。 + + + + - 配置认证方法(保护密码的方式)。 + + + + - 配置用户和角色。 + + + + - 配置Windows服务名以及是否开机自启。 + + + + - 配置日志。 + + + + - 配置高级选项。 + + ACAC15B8633133B65476286A49BFBD7E + + - 应用配置。 + + + +4. 可以在 Windows 系统的“服务”窗口中启动或停止 MySQL。 + + + +5. 配置 PATH 环境变量,以便在命令行提示符窗口使用 MySQL 客户端工具。 + + - 打开 Windows 的“系统”窗口并点击“高级系统设置”。 + + + + - 在“系统属性”的“高级”窗口,点击“环境变量”按钮。 + + + + - 修改PATH环境变量,将MySQL安装路径下的`bin`文件夹的路径配置到PATH环境变量中。 + + + + - 配置完成后,可以尝试在“命令提示符”下使用 MySQL 的命令行工具。 + + + +#### Linux 环境 + +下面以 CentOS 7.x 环境为例,演示如何安装 MySQL 5.7.x,如果需要在其他 Linux 系统下安装其他版本的 MySQL,请读者自行在网络上查找对应的安装教程。 + +1. 安装 MySQL。 + + 可以在 [MySQL 官方网站]()下载安装文件。首先在下载页面中选择平台和版本,然后找到对应的下载链接,直接下载包含所有安装文件的归档文件,解归档之后通过包管理工具进行安装。 + + ```Shell + wget https://dev.mysql.com/get/Downloads/MySQL-5.7/mysql-5.7.26-1.el7.x86_64.rpm-bundle.tar + tar -xvf mysql-5.7.26-1.el7.x86_64.rpm-bundle.tar + ``` + + 如果系统上有 MariaDB 相关的文件,需要先移除 MariaDB 相关的文件。 + + ```Shell + yum list installed | grep mariadb | awk '{print $1}' | xargs yum erase -y + ``` + + 更新和安装可能用到的底层依赖库。 + + ```Bash + yum update + yum install -y libaio libaio-devel + ``` + + 接下来可以按照如下所示的顺序用 RPM(Redhat Package Manager)工具安装 MySQL。 + + ```Shell + rpm -ivh mysql-community-common-5.7.26-1.el7.x86_64.rpm + rpm -ivh mysql-community-libs-5.7.26-1.el7.x86_64.rpm + rpm -ivh mysql-community-libs-compat-5.7.26-1.el7.x86_64.rpm + rpm -ivh mysql-community-devel-5.7.26-1.el7.x86_64.rpm + rpm -ivh mysql-community-client-5.7.26-1.el7.x86_64.rpm + rpm -ivh mysql-community-server-5.7.26-1.el7.x86_64.rpm + ``` + + 可以使用下面的命令查看已经安装的 MySQL 相关的包。 + + ```Shell + rpm -qa | grep mysql + ``` + +2. 配置 MySQL。 + + MySQL 的配置文件在`/etc`目录下,名为`my.cnf`,默认的配置文件内容如下所示。 + + ```Shell + cat /etc/my.cnf + ``` + + ```INI + # For advice on how to change settings please see + # http://dev.mysql.com/doc/refman/5.7/en/server-configuration-defaults.html + + [mysqld] + # + # Remove leading # and set to the amount of RAM for the most important data + # cache in MySQL. Start at 70% of total RAM for dedicated server, else 10%. + # innodb_buffer_pool_size = 128M + # + # Remove leading # to turn on a very important data integrity option: logging + # changes to the binary log between backups. + # log_bin + # + # Remove leading # to set options mainly useful for reporting servers. + # The server defaults are faster for transactions and fast SELECTs. + # Adjust sizes as needed, experiment to find the optimal values. + # join_buffer_size = 128M + # sort_buffer_size = 2M + # read_rnd_buffer_size = 2M + datadir=/var/lib/mysql + socket=/var/lib/mysql/mysql.sock + + # Disabling symbolic-links is recommended to prevent assorted security risks + symbolic-links=0 + + log-error=/var/log/mysqld.log + pid-file=/var/run/mysqld/mysqld.pid + ``` + + 通过配置文件,我们可以修改 MySQL 服务使用的端口、字符集、最大连接数、套接字队列大小、最大数据包大小、日志文件的位置、日志过期时间等配置。当然,我们还可以通过修改配置文件来对 MySQL 服务器进行性能调优和安全管控。 + +3. 启动 MySQL 服务。 + + 可以使用下面的命令来启动 MySQL。 + + ```Shell + service mysqld start + ``` + + 在 CentOS 7 中,更推荐使用下面的命令来启动 MySQL。 + + ```Shell + systemctl start mysqld + ``` + + 启动 MySQL 成功后,可以通过下面的命令来检查网络端口使用情况,MySQL 默认使用`3306`端口。 + + ```Shell + netstat -ntlp | grep mysql + ``` + + 也可以使用下面的命令查找是否有名为`mysqld`的进程。 + + ```Shell + pgrep mysqld + ``` + +4. 使用 MySQL 客户端工具连接服务器。 + + 命令行工具: + + ```Shell + mysql -u root -p + ``` + + > 说明:启动客户端时,`-u`参数用来指定用户名,MySQL 默认的超级管理账号为`root`;`-p`表示要输入密码(用户口令);如果连接的是其他主机而非本机,可以用`-h`来指定连接主机的主机名或IP地址。 + + 如果是首次安装 MySQL,可以使用下面的命令来找到默认的初始密码。 + + ```Shell + cat /var/log/mysqld.log | grep password + ``` + + 上面的命令会查看 MySQL 的日志带有`password`的行,在显示的结果中`root@localhost:`后面的部分就是默认设置的初始密码。 + + 进入客户端工具后,可以通过下面的指令来修改超级管理员(root)的访问口令为`123456`。 + + ```SQL + set global validate_password_policy=0; + set global validate_password_length=6; + alter user 'root'@'localhost' identified by '123456'; + ``` + + > **说明**:MySQL 较新的版本默认不允许使用弱口令作为用户口令,所以上面的代码修改了验证用户口令的策略和口令的长度。事实上我们不应该使用弱口令,因为存在用户口令被暴力破解的风险。近年来,**攻击数据库窃取数据和劫持数据库勒索比特币**的事件屡见不鲜,要避免这些潜在的风险,最为重要的一点是**不要让数据库服务器暴露在公网上**(最好的做法是将数据库置于内网,至少要做到不向公网开放数据库服务器的访问端口),另外要保管好`root`账号的口令,应用系统需要访问数据库时,通常不使用`root`账号进行访问,而是**创建其他拥有适当权限的账号来访问**。 + + 再次使用客户端工具连接 MySQL 服务器时,就可以使用新设置的口令了。在实际开发中,为了方便用户操作,可以选择图形化的客户端工具来连接 MySQL 服务器,包括: + + - MySQL Workbench(官方工具) + + + + - Navicat for MySQL(界面简单友好) + + + + +#### macOS环境 + +macOS 系统安装 MySQL 是比较简单的,只需要从刚才说到的官方网站下载 DMG 安装文件并运行就可以了,下载的时候需要根据自己使用的是 Intel 的芯片还是苹果的 M1 芯片选择下载链接,如下图所示。 + + + +安装成功后,可以在“系统偏好设置”中找到“MySQL”,在如下所示的画面中,可以启动和停止 MySQL 服务器,也可以对 MySQL 核心文件的路径进行配置。 + + + +### MySQL 基本命令 + +#### 查看命令 + +1. 查看所有数据库 + +```SQL +show databases; +``` + +2. 查看所有字符集 + +```SQL +show character set; +``` + +3. 查看所有的排序规则 + +```SQL +show collation; +``` + +4. 查看所有的引擎 + +```SQL +show engines; +``` + +5. 查看所有日志文件 + +```SQL +show binary logs; +``` + +6. 查看数据库下所有表 + +```SQL +show tables; +``` + +#### 获取帮助 + +在 MySQL 命令行工具中,可以使用`help`命令或`?`来获取帮助,如下所示。 + +1. 查看`show`命令的帮助。 + + ```MySQL + ? show + ``` + +2. 查看有哪些帮助内容。 + + ```MySQL + ? contents + ``` + +3. 获取函数的帮助。 + + ```MySQL + ? functions + ``` + +4. 获取数据类型的帮助。 + + ```MySQL + ? data types + ``` + +#### 其他命令 + +1. 新建/重建服务器连接 - `connect` / `resetconnection`。 + +2. 清空当前输入 - `\c`。在输入错误时,可以及时使用`\c`清空当前输入并重新开始。 + +3. 修改终止符(定界符)- `delimiter`。默认的终止符是`;`,可以使用该命令修改成其他的字符,例如修改为`$`符号,可以用`delimiter $`命令。 + +4. 打开系统默认编辑器 - `edit`。编辑完成保存关闭之后,命令行会自动执行编辑的内容。 + +5. 查看服务器状态 - `status`。 + +6. 修改默认提示符 - `prompt`。 + +7. 执行系统命令 - `system`。可以将系统命令跟在`system`命令的后面执行,`system`命令也可以缩写为`\!`。 + +8. 执行 SQL 文件 - `source`。`source`命令后面跟 SQL 文件路径。 + +9. 重定向输出 - `tee` / `notee`。可以将命令的输出重定向到指定的文件中。 + +10. 切换数据库 - `use`。 + +11. 显示警告信息 - `warnings`。 + +12. 退出命令行 - `quit`或`exit`。 + + + diff --git a/第41课:SQL详解.md b/第41课:SQL详解.md new file mode 100644 index 0000000..9559e32 --- /dev/null +++ b/第41课:SQL详解.md @@ -0,0 +1,804 @@ +## 第41课:SQL详解 + +我们通常可以将 SQL 分为四类,分别是 DDL(数据定义语言)、DML(数据操作语言)、DQL(数据查询语言)和 DCL(数据控制语言)。DDL 主要用于创建、删除、修改数据库中的对象,比如创建、删除和修改二维表,核心的关键字包括`create`、`drop`和`alter`;DML 主要负责数据的插入、删除和更新,关键词包括`insert`、`delete`和`update`;DQL 负责数据查询,最重要的一个关键词是`select`;DCL 通常用于授予和召回权限,核心关键词是`grant`和`revoke`。 + +> **说明**:SQL 是不区分大小写的语言,为了书写和识别方便,下面的 SQL 都使用了小写字母来书写。 + +### DDL(数据定义语言) + +下面我们来实现一个选课系统的数据库,如下所示的 SQL 创建了名为`school`的数据库和五张表,分别是学院表(`tb_college`)、学生表(`tb_student`)、教师表(`tb_teacher`)、课程表(`tb_course`)和选课记录表(`tb_record`),其中学生和教师跟学院之间是多对一关系,课程跟老师之间也是多对一关系,学生和课程是多对多关系,选课记录表就是维持学生跟课程多对多关系的中间表。 + +```SQL +-- 如果存在名为school的数据库就删除它 +drop database if exists `school`; + +-- 创建名为school的数据库并设置默认的字符集和排序方式 +create database `school` default character set utf8mb4 collate utf8mb4_general_ci; + +-- 切换到school数据库上下文环境 +use `school`; + +-- 创建学院表 +create table `tb_college` +( +`col_id` int unsigned auto_increment comment '编号', +`col_name` varchar(50) not null comment '名称', +`col_intro` varchar(500) default '' comment '介绍', +primary key (`col_id`) +) engine=innodb auto_increment=1 comment '学院表'; + +-- 创建学生表 +create table `tb_student` +( +`stu_id` int unsigned not null comment '学号', +`stu_name` varchar(20) not null comment '姓名', +`stu_sex` boolean default 1 not null comment '性别', +`stu_birth` date not null comment '出生日期', +`stu_addr` varchar(255) default '' comment '籍贯', +`col_id` int unsigned not null comment '所属学院', +primary key (`stu_id`), +constraint `fk_student_col_id` foreign key (`col_id`) references `tb_college` (`col_id`) +) engine=innodb comment '学生表'; + +-- 创建教师表 +create table `tb_teacher` +( +`tea_id` int unsigned not null comment '工号', +`tea_name` varchar(20) not null comment '姓名', +`tea_title` varchar(10) default '助教' comment '职称', +`col_id` int unsigned not null comment '所属学院', +primary key (`tea_id`), +constraint `fk_teacher_col_id` foreign key (`col_id`) references `tb_college` (`col_id`) +) engine=innodb comment '老师表'; + +-- 创建课程表 +create table `tb_course` +( +`cou_id` int unsigned not null comment '编号', +`cou_name` varchar(50) not null comment '名称', +`cou_credit` int not null comment '学分', +`tea_id` int unsigned not null comment '授课老师', +primary key (`cou_id`), +constraint `fk_course_tea_id` foreign key (`tea_id`) references `tb_teacher` (`tea_id`) +) engine=innodb comment '课程表'; + +-- 创建选课记录表 +create table `tb_record` +( +`rec_id` bigint unsigned auto_increment comment '选课记录号', +`stu_id` int unsigned not null comment '学号', +`cou_id` int unsigned not null comment '课程编号', +`sel_date` date not null comment '选课日期', +`score` decimal(4,1) comment '考试成绩', +primary key (`rec_id`), +constraint `fk_record_stu_id` foreign key (`stu_id`) references `tb_student` (`stu_id`), +constraint `fk_record_cou_id` foreign key (`cou_id`) references `tb_course` (`cou_id`), +constraint `uk_record_stu_cou` unique (`stu_id`, `cou_id`) +) engine=innodb comment '选课记录表'; +``` + +上面的DDL有几个地方需要强调一下: + +- 创建数据库时,我们通过`default character set utf8mb4`指定了数据库默认使用的字符集为`utf8mb4`(最大`4`字节的`utf-8`编码),我们推荐使用该字符集,它也是 MySQL 8.x 默认使用的字符集,因为它能够支持国际化编码,还可以存储 Emoji 字符。可以通过下面的命令查看 MySQL 支持的字符集以及默认的排序规则。 + + ```SQL + show character set; + ``` + + ``` + +----------+---------------------------------+---------------------+--------+ + | Charset | Description | Default collation | Maxlen | + +----------+---------------------------------+---------------------+--------+ + | big5 | Big5 Traditional Chinese | big5_chinese_ci | 2 | + | dec8 | DEC West European | dec8_swedish_ci | 1 | + | cp850 | DOS West European | cp850_general_ci | 1 | + | hp8 | HP West European | hp8_english_ci | 1 | + | koi8r | KOI8-R Relcom Russian | koi8r_general_ci | 1 | + | latin1 | cp1252 West European | latin1_swedish_ci | 1 | + | latin2 | ISO 8859-2 Central European | latin2_general_ci | 1 | + | swe7 | 7bit Swedish | swe7_swedish_ci | 1 | + | ascii | US ASCII | ascii_general_ci | 1 | + | ujis | EUC-JP Japanese | ujis_japanese_ci | 3 | + | sjis | Shift-JIS Japanese | sjis_japanese_ci | 2 | + | hebrew | ISO 8859-8 Hebrew | hebrew_general_ci | 1 | + | tis620 | TIS620 Thai | tis620_thai_ci | 1 | + | euckr | EUC-KR Korean | euckr_korean_ci | 2 | + | koi8u | KOI8-U Ukrainian | koi8u_general_ci | 1 | + | gb2312 | GB2312 Simplified Chinese | gb2312_chinese_ci | 2 | + | greek | ISO 8859-7 Greek | greek_general_ci | 1 | + | cp1250 | Windows Central European | cp1250_general_ci | 1 | + | gbk | GBK Simplified Chinese | gbk_chinese_ci | 2 | + | latin5 | ISO 8859-9 Turkish | latin5_turkish_ci | 1 | + | armscii8 | ARMSCII-8 Armenian | armscii8_general_ci | 1 | + | utf8 | UTF-8 Unicode | utf8_general_ci | 3 | + | ucs2 | UCS-2 Unicode | ucs2_general_ci | 2 | + | cp866 | DOS Russian | cp866_general_ci | 1 | + | keybcs2 | DOS Kamenicky Czech-Slovak | keybcs2_general_ci | 1 | + | macce | Mac Central European | macce_general_ci | 1 | + | macroman | Mac West European | macroman_general_ci | 1 | + | cp852 | DOS Central European | cp852_general_ci | 1 | + | latin7 | ISO 8859-13 Baltic | latin7_general_ci | 1 | + | utf8mb4 | UTF-8 Unicode | utf8mb4_general_ci | 4 | + | cp1251 | Windows Cyrillic | cp1251_general_ci | 1 | + | utf16 | UTF-16 Unicode | utf16_general_ci | 4 | + | utf16le | UTF-16LE Unicode | utf16le_general_ci | 4 | + | cp1256 | Windows Arabic | cp1256_general_ci | 1 | + | cp1257 | Windows Baltic | cp1257_general_ci | 1 | + | utf32 | UTF-32 Unicode | utf32_general_ci | 4 | + | binary | Binary pseudo charset | binary | 1 | + | geostd8 | GEOSTD8 Georgian | geostd8_general_ci | 1 | + | cp932 | SJIS for Windows Japanese | cp932_japanese_ci | 2 | + | eucjpms | UJIS for Windows Japanese | eucjpms_japanese_ci | 3 | + | gb18030 | China National Standard GB18030 | gb18030_chinese_ci | 4 | + +----------+---------------------------------+---------------------+--------+ + 41 rows in set (0.00 sec) + ``` + + 如果要设置 MySQL 服务启动时默认使用的字符集,可以修改MySQL的配置并添加以下内容。 + + ```INI + [mysqld] + character-set-server=utf8 + ``` + +- 在创建表的时候,可以自行选择底层的存储引擎。MySQL 支持多种存储引擎,可以通过`show engines`命令进行查看。MySQL 5.5 以后的版本默认使用的存储引擎是 InnoDB,它是我们推荐大家使用的存储引擎(因为更适合当下互联网应用对高并发、性能以及事务支持等方面的需求),为了 SQL 语句的向下兼容性,我们可以在建表语句结束处右圆括号的后面通过`engine=innodb`来指定使用 InnoDB 存储引擎。 + + ```SQL + show engines\G + ``` + + ``` + *************************** 1. row *************************** + Engine: InnoDB + Support: DEFAULT + Comment: Supports transactions, row-level locking, and foreign keys + Transactions: YES + XA: YES + Savepoints: YES + *************************** 2. row *************************** + Engine: MRG_MYISAM + Support: YES + Comment: Collection of identical MyISAM tables + Transactions: NO + XA: NO + Savepoints: NO + *************************** 3. row *************************** + Engine: MEMORY + Support: YES + Comment: Hash based, stored in memory, useful for temporary tables + Transactions: NO + XA: NO + Savepoints: NO + *************************** 4. row *************************** + Engine: BLACKHOLE + Support: YES + Comment: /dev/null storage engine (anything you write to it disappears) + Transactions: NO + XA: NO + Savepoints: NO + *************************** 5. row *************************** + Engine: MyISAM + Support: YES + Comment: MyISAM storage engine + Transactions: NO + XA: NO + Savepoints: NO + *************************** 6. row *************************** + Engine: CSV + Support: YES + Comment: CSV storage engine + Transactions: NO + XA: NO + Savepoints: NO + *************************** 7. row *************************** + Engine: ARCHIVE + Support: YES + Comment: Archive storage engine + Transactions: NO + XA: NO + Savepoints: NO + *************************** 8. row *************************** + Engine: PERFORMANCE_SCHEMA + Support: YES + Comment: Performance Schema + Transactions: NO + XA: NO + Savepoints: NO + *************************** 9. row *************************** + Engine: FEDERATED + Support: NO + Comment: Federated MySQL storage engine + Transactions: NULL + XA: NULL + Savepoints: NULL + 9 rows in set (0.00 sec) + ``` + + 下面的表格对MySQL几种常用的数据引擎进行了简单的对比。 + + | 特性 | InnoDB | MRG_MYISAM | MEMORY | MyISAM | + | ------------ | ------------ | ---------- | ------ | ------ | + | 存储限制 | 有 | 没有 | 有 | 有 | + | 事务 | 支持 | | | | + | 锁机制 | 行锁 | 表锁 | 表锁 | 表锁 | + | B树索引 | 支持 | 支持 | 支持 | 支持 | + | 哈希索引 | | | 支持 | | + | 全文检索 | 支持(5.6+) | | | 支持 | + | 集群索引 | 支持 | | | | + | 数据缓存 | 支持 | | 支持 | | + | 索引缓存 | 支持 | 支持 | 支持 | 支持 | + | 数据可压缩 | | | | 支持 | + | 内存使用 | 高 | 低 | 中 | 低 | + | 存储空间使用 | 高 | 低 | | 低 | + | 批量插入性能 | 低 | 高 | 高 | 高 | + | 是否支持外键 | 支持 | | | | + + 通过上面的比较我们可以了解到,InnoDB 是唯一能够支持外键、事务以及行锁的存储引擎,所以我们之前说它更适合互联网应用,而且在较新版本的 MySQL 中,它也是默认使用的存储引擎。 + +- 在定义表结构为每个字段选择数据类型时,如果不清楚哪个数据类型更合适,可以通过 MySQL 的帮助系统来了解每种数据类型的特性、数据的长度和精度等相关信息。 + + ```SQL + ? data types + ``` + + ``` + You asked for help about help category: "Data Types" + For more information, type 'help ', where is one of the following + topics: + AUTO_INCREMENT + BIGINT + BINARY + BIT + BLOB + BLOB DATA TYPE + BOOLEAN + CHAR + CHAR BYTE + DATE + DATETIME + DEC + DECIMAL + DOUBLE + DOUBLE PRECISION + ENUM + FLOAT + INT + INTEGER + LONGBLOB + LONGTEXT + MEDIUMBLOB + MEDIUMINT + MEDIUMTEXT + SET DATA TYPE + SMALLINT + TEXT + TIME + TIMESTAMP + TINYBLOB + TINYINT + TINYTEXT + VARBINARY + VARCHAR + YEAR DATA TYPE + ``` + + ```SQL + ? varchar + ``` + + ``` + Name: 'VARCHAR' + Description: + [NATIONAL] VARCHAR(M) [CHARACTER SET charset_name] [COLLATE + collation_name] + + A variable-length string. M represents the maximum column length in + characters. The range of M is 0 to 65,535. The effective maximum length + of a VARCHAR is subject to the maximum row size (65,535 bytes, which is + shared among all columns) and the character set used. For example, utf8 + characters can require up to three bytes per character, so a VARCHAR + column that uses the utf8 character set can be declared to be a maximum + of 21,844 characters. See + http://dev.mysql.com/doc/refman/5.7/en/column-count-limit.html. + + MySQL stores VARCHAR values as a 1-byte or 2-byte length prefix plus + data. The length prefix indicates the number of bytes in the value. A + VARCHAR column uses one length byte if values require no more than 255 + bytes, two length bytes if values may require more than 255 bytes. + + *Note*: + + MySQL follows the standard SQL specification, and does not remove + trailing spaces from VARCHAR values. + + VARCHAR is shorthand for CHARACTER VARYING. NATIONAL VARCHAR is the + standard SQL way to define that a VARCHAR column should use some + predefined character set. MySQL uses utf8 as this predefined character + set. http://dev.mysql.com/doc/refman/5.7/en/charset-national.html. + NVARCHAR is shorthand for NATIONAL VARCHAR. + + URL: http://dev.mysql.com/doc/refman/5.7/en/string-type-overview.html + ``` + + 在数据类型的选择上,保存字符串数据通常都使用`VARCHAR`和`CHAR`两种类型,前者通常称为变长字符串,而后者通常称为定长字符串;对于 InnoDB 存储引擎,行存储格式没有区分固定长度和可变长度列,因此`VARCHAR`类型和`CHAR`类型没有本质区别,后者不一定比前者性能更好。如果要保存的很大字符串,可以使用`TEXT`类型;如果要保存很大的字节串,可以使用`BLOB`(二进制大对象)类型。在 MySQL 中,`TEXT`和`BLOB`又分别包括`TEXT`、`MEDIUMTEXT`、`LONGTEXT`和`BLOB`、`MEDIUMBLOB`、`LONGBLOB`三种不同的类型,它们主要的区别在于存储数据的最大大小不同。保存浮点数可以用`FLOAT`或`DOUBLE`类型,`FLOAT`已经不推荐使用了,而且在 MySQL 后续的版本中可能会被移除掉。而保存定点数应该使用`DECIMAL`类型。如果要保存时间日期,`DATETIME`类型优于`TIMESTAMP`类型,因为前者能表示的时间日期范围更大。 + +### DML(数据操作语言) + +我们通过如下所示的 SQL 给上面创建的表添加数据。 + +```SQL +use school; + +-- 插入学院数据 +insert into `tb_college` + (`col_name`, `col_intro`) +values + ('计算机学院', '计算机学院1958年设立计算机专业,1981年建立计算机科学系,1998年设立计算机学院,2005年5月,为了进一步整合教学和科研资源,学校决定,计算机学院和软件学院行政班子合并统一运作、实行教学和学生管理独立运行的模式。 学院下设三个系:计算机科学与技术系、物联网工程系、计算金融系;两个研究所:图象图形研究所、网络空间安全研究院(2015年成立);三个教学实验中心:计算机基础教学实验中心、IBM技术中心和计算机专业实验中心。'), + ('外国语学院', '外国语学院设有7个教学单位,6个文理兼收的本科专业;拥有1个一级学科博士授予点,3个二级学科博士授予点,5个一级学科硕士学位授权点,5个二级学科硕士学位授权点,5个硕士专业授权领域,同时还有2个硕士专业学位(MTI)专业;有教职员工210余人,其中教授、副教授80余人,教师中获得中国国内外名校博士学位和正在职攻读博士学位的教师比例占专任教师的60%以上。'), + ('经济管理学院', '经济学院前身是创办于1905年的经济科;已故经济学家彭迪先、张与九、蒋学模、胡寄窗、陶大镛、胡代光,以及当代学者刘诗白等曾先后在此任教或学习。'); + +-- 插入学生数据 +insert into `tb_student` + (`stu_id`, `stu_name`, `stu_sex`, `stu_birth`, `stu_addr`, `col_id`) +values + (1001, '杨过', 1, '1990-3-4', '湖南长沙', 1), + (1002, '任我行', 1, '1992-2-2', '湖南长沙', 1), + (1033, '王语嫣', 0, '1989-12-3', '四川成都', 1), + (1572, '岳不群', 1, '1993-7-19', '陕西咸阳', 1), + (1378, '纪嫣然', 0, '1995-8-12', '四川绵阳', 1), + (1954, '林平之', 1, '1994-9-20', '福建莆田', 1), + (2035, '东方不败', 1, '1988-6-30', null, 2), + (3011, '林震南', 1, '1985-12-12', '福建莆田', 3), + (3755, '项少龙', 1, '1993-1-25', '四川成都', 3), + (3923, '杨不悔', 0, '1985-4-17', '四川成都', 3); + +-- 插入老师数据 +insert into `tb_teacher` + (`tea_id`, `tea_name`, `tea_title`, `col_id`) +values + (1122, '张三丰', '教授', 1), + (1133, '宋远桥', '副教授', 1), + (1144, '杨逍', '副教授', 1), + (2255, '范遥', '副教授', 2), + (3366, '韦一笑', default, 3); + +-- 插入课程数据 +insert into `tb_course` + (`cou_id`, `cou_name`, `cou_credit`, `tea_id`) +values + (1111, 'Python程序设计', 3, 1122), + (2222, 'Web前端开发', 2, 1122), + (3333, '操作系统', 4, 1122), + (4444, '计算机网络', 2, 1133), + (5555, '编译原理', 4, 1144), + (6666, '算法和数据结构', 3, 1144), + (7777, '经贸法语', 3, 2255), + (8888, '成本会计', 2, 3366), + (9999, '审计学', 3, 3366); + +-- 插入选课数据 +insert into `tb_record` + (`stu_id`, `cou_id`, `sel_date`, `score`) +values + (1001, 1111, '2017-09-01', 95), + (1001, 2222, '2017-09-01', 87.5), + (1001, 3333, '2017-09-01', 100), + (1001, 4444, '2018-09-03', null), + (1001, 6666, '2017-09-02', 100), + (1002, 1111, '2017-09-03', 65), + (1002, 5555, '2017-09-01', 42), + (1033, 1111, '2017-09-03', 92.5), + (1033, 4444, '2017-09-01', 78), + (1033, 5555, '2017-09-01', 82.5), + (1572, 1111, '2017-09-02', 78), + (1378, 1111, '2017-09-05', 82), + (1378, 7777, '2017-09-02', 65.5), + (2035, 7777, '2018-09-03', 88), + (2035, 9999, '2019-09-02', null), + (3755, 1111, '2019-09-02', null), + (3755, 8888, '2019-09-02', null), + (3755, 9999, '2017-09-01', 92); +``` + +> **注意**:上面的`insert`语句使用了批处理的方式来插入数据,这种做法插入数据的效率比较高。 + +### DQL(数据查询语言) + +接下来,我们完成如下所示的查询。 + +```SQL +-- 查询所有学生的所有信息 +select * from `tb_student`; + +-- 查询学生的学号、姓名和籍贯(投影) +select `stu_id`, `stu_name`, `stu_addr` from `tb_student`; + +-- 查询所有课程的名称及学分(投影和别名) +select `cou_name` as 课程名称, `cou_credit` as 学分 from `tb_course`; + +-- 查询所有女学生的姓名和出生日期(筛选) +select `stu_name`, `stu_birth` from `tb_student` where `stu_sex`=0; + +-- 查询籍贯为“四川成都”的女学生的姓名和出生日期(筛选) +select `stu_name`, `stu_birth` from `tb_student` where `stu_sex`=0 and `stu_addr`='四川成都'; + +-- 查询籍贯为“四川成都”或者性别为“女生”的学生 +select `stu_name`, `stu_birth` from `tb_student` where `stu_sex`=0 or `stu_addr`='四川成都'; + +-- 查询所有80后学生的姓名、性别和出生日期(筛选) +select `stu_name`, `stu_sex`, `stu_birth` from `tb_student` +where `stu_birth`>='1980-1-1' and `stu_birth`<='1989-12-31'; + +select `stu_name`, `stu_sex`, `stu_birth` from `tb_student` +where `stu_birth` between '1980-1-1' and '1989-12-31'; + +-- 补充:将表示性别的 1 和 0 处理成 “男” 和 “女” +select + `stu_name` as 姓名, + if(`stu_sex`, '男', '女') as 性别, + `stu_birth` as 出生日期 +from `tb_student` +where `stu_birth` between '1980-1-1' and '1989-12-31'; + +select + `stu_name` as 姓名, + case `stu_sex` when 1 then '男' else '女' end as 性别, + `stu_birth` as 出生日期 +from `tb_student` +where `stu_birth` between '1980-1-1' and '1989-12-31'; + +-- 查询学分大于2的课程的名称和学分(筛选) +select `cou_name`, `cou_credit` from `tb_course` where `cou_credit`>2; + +-- 查询学分是奇数的课程的名称和学分(筛选) +select `cou_name`, `cou_credit` from `tb_course` where `cou_credit`%2<>0; + +select `cou_name`, `cou_credit` from `tb_course` where `cou_credit` mod 2<>0; + +-- 查询选择选了1111的课程考试成绩在90分以上的学生学号(筛选) +select `stu_id` from `tb_record` where `cou_id`=1111 and `score`>90; + +-- 查询名字叫“杨过”的学生的姓名和性别 +select `stu_name`, `stu_sex` from `tb_student` where `stu_name`='杨过'; + +-- 查询姓“杨”的学生姓名和性别(模糊) +-- % - 通配符(wildcard),它可以匹配0个或任意多个字符 +select `stu_name`, `stu_sex` from `tb_student` where `stu_name` like '杨%'; + +-- 查询姓“杨”名字两个字的学生姓名和性别(模糊) +-- _ - 通配符(wildcard),它可以精确匹配一个字符 +select `stu_name`, `stu_sex` from `tb_student` where `stu_name` like '杨_'; + +-- 查询姓“杨”名字三个字的学生姓名和性别(模糊) +select `stu_name`, `stu_sex` from `tb_student` where `stu_name` like '杨__'; + +-- 查询名字中有“不”字或“嫣”字的学生的姓名(模糊) +select `stu_name` from `tb_student` where `stu_name` like '%不%' or `stu_name` like '%嫣%'; + +-- 将“岳不群”改名为“岳不嫣”,比较下面两个查询的区别 +update `tb_student` set `stu_name`='岳不嫣' where `stu_id`=1572; + +select `stu_name` from `tb_student` where `stu_name` like '%不%' +union +select `stu_name` from `tb_student` where `stu_name` like '%嫣%'; + +select `stu_name` from `tb_student` where `stu_name` like '%不%' +union all +select `stu_name` from `tb_student` where `stu_name` like '%嫣%'; + +-- 查询姓“杨”或姓“林”名字三个字的学生的姓名(正则表达式模糊查询) +select `stu_name` from `tb_student` where `stu_name` regexp '[杨林].{2}'; + +-- 查询没有录入籍贯的学生姓名(空值处理) +select `stu_name` from `tb_student` where `stu_addr` is null; + +select `stu_name` from `tb_student` where `stu_addr` <=> null; + +-- 查询录入了籍贯的学生姓名(空值处理) +select `stu_name` from `tb_student` where `stu_addr` is not null; + +-- 下面的查询什么也查不到,三值逻辑 --> true / false / unknown +select `stu_name` from `tb_student` where `stu_addr`=null or `stu_addr`<>null; + +-- 查询学生选课的所有日期(去重) +select distinct `sel_date` from `tb_record`; + +-- 查询学生的籍贯(去重) +select distinct `stu_addr` from `tb_student` where `stu_addr` is not null; + +-- 查询男学生的姓名和生日按年龄从大到小排列(排序) +-- 升序:从小到大 - asc,降序:从大到小 - desc +select `stu_id`, `stu_name`, `stu_birth` from `tb_student` +where `stu_sex`=1 order by `stu_birth` asc, `stu_id` desc; + +-- 补充:将上面的生日换算成年龄(日期函数、数值函数) +select + `stu_id` as 学号, + `stu_name` as 姓名, + floor(datediff(curdate(), `stu_birth`)/365) as 年龄 +from `tb_student` +where `stu_sex`=1 order by 年龄 desc, `stu_id` desc; + +-- 查询年龄最大的学生的出生日期(聚合函数) +select min(`stu_birth`) from `tb_student`; + +-- 查询年龄最小的学生的出生日期(聚合函数) +select max(`stu_birth`) from `tb_student`; + +-- 查询编号为1111的课程考试成绩的最高分(聚合函数) +select max(`score`) from `tb_record` where `cou_id`=1111; + +-- 查询学号为1001的学生考试成绩的最低分(聚合函数) +select min(`score`) from `tb_record` where `stu_id`=1001; + +-- 查询学号为1001的学生考试成绩的平均分(聚合函数) +select avg(`score`) from `tb_record` where `stu_id`=1001; + +select sum(`score`) / count(`score`) from `tb_record` where `stu_id`=1001; + +-- 查询学号为1001的学生考试成绩的平均分,如果有null值,null值算0分(聚合函数) +select sum(`score`) / count(*) from `tb_record` where `stu_id`=1001; + +select avg(ifnull(`score`, 0)) from `tb_record` where `stu_id`=1001; + +-- 查询学号为1001的学生考试成绩的标准差(聚合函数) +select std(`score`), variance(`score`) from `tb_record` where `stu_id`=1001; + +-- 查询男女学生的人数(分组和聚合函数) +select + case `stu_sex` when 1 then '男' else '女' end as 性别, + count(*) as 人数 +from `tb_student` group by `stu_sex`; + +-- 查询每个学院学生人数(分组和聚合函数) +select + `col_id` as 学院, + count(*) as 人数 +from `tb_student` group by `col_id` with rollup; + +-- 查询每个学院男女学生人数(分组和聚合函数) +select + `col_id` as 学院, + if(`stu_sex`, '男', '女') as 性别, + count(*) as 人数 +from `tb_student` group by `col_id`, `stu_sex`; + +-- 查询每个学生的学号和平均成绩(分组和聚合函数) +select + `stu_id`, + round(avg(`score`), 1) as avg_score +from `tb_record` group by `stu_id`; + +-- 查询平均成绩大于等于90分的学生的学号和平均成绩 +-- 分组以前的筛选使用where子句,分组以后的筛选使用having子句 +select + `stu_id`, + round(avg(`score`), 1) as avg_score +from `tb_record` +group by `stu_id` having avg_score>=90; + +-- 查询1111、2222、3333三门课程平均成绩大于等于90分的学生的学号和平均成绩 +select + `stu_id`, + round(avg(`score`), 1) as avg_score +from `tb_record` where `cou_id` in (1111, 2222, 3333) +group by `stu_id` having avg_score>=90; + +-- 查询年龄最大的学生的姓名(子查询/嵌套查询) +-- 嵌套查询:把一个select的结果作为另一个select的一部分来使用 +select `stu_name` from `tb_student` +where `stu_birth`=( + select min(`stu_birth`) from `tb_student` +); + +-- 查询选了两门以上的课程的学生姓名(子查询/分组条件/集合运算) +select `stu_name` from `tb_student` +where `stu_id` in ( + select `stu_id` from `tb_record` + group by `stu_id` having count(*)>2 +); + +-- 查询学生的姓名、生日和所在学院名称 +select `stu_name`, `stu_birth`, `col_name` +from `tb_student`, `tb_college` +where `tb_student`.`col_id`=`tb_college`.`col_id`; + +select `stu_name`, `stu_birth`, `col_name` +from `tb_student` inner join `tb_college` +on `tb_student`.`col_id`=`tb_college`.`col_id`; + +select `stu_name`, `stu_birth`, `col_name` +from `tb_student` natural join `tb_college`; + +-- 查询学生姓名、课程名称以及成绩(连接查询/联结查询) +select `stu_name`, `cou_name`, `score` +from `tb_student`, `tb_course`, `tb_record` +where `tb_student`.`stu_id`=`tb_record`.`stu_id` +and `tb_course`.`cou_id`=`tb_record`.`cou_id` +and `score` is not null; + +select `stu_name`, `cou_name`, `score` from `tb_student` +inner join `tb_record` on `tb_student`.`stu_id`=`tb_record`.`stu_id` +inner join `tb_course` on `tb_course`.`cou_id`=`tb_record`.`cou_id` +where `score` is not null; + +select `stu_name`, `cou_name`, `score` from `tb_student` +natural join `tb_record` +natural join `tb_course` +where `score` is not null; + +-- 补充:上面的查询结果取前5条数据(分页查询) +select `stu_name`, `cou_name`, `score` +from `tb_student`, `tb_course`, `tb_record` +where `tb_student`.`stu_id`=`tb_record`.`stu_id` +and `tb_course`.`cou_id`=`tb_record`.`cou_id` +and `score` is not null +order by `score` desc +limit 0,5; + +-- 补充:上面的查询结果取第6-10条数据(分页查询) +select `stu_name`, `cou_name`, `score` +from `tb_student`, `tb_course`, `tb_record` +where `tb_student`.`stu_id`=`tb_record`.`stu_id` +and `tb_course`.`cou_id`=`tb_record`.`cou_id` +and `score` is not null +order by `score` desc +limit 5 offset 5; + +-- 补充:上面的查询结果取第11-15条数据(分页查询) +select `stu_name`, `cou_name`, `score` +from `tb_student`, `tb_course`, `tb_record` +where `tb_student`.`stu_id`=`tb_record`.`stu_id` +and `tb_course`.`cou_id`=`tb_record`.`cou_id` +and `score` is not null +order by `score` desc +limit 5 offset 10; + +-- 查询选课学生的姓名和平均成绩(子查询和连接查询) +select `stu_name`, `avg_score` +from `tb_student` inner join ( + select `stu_id` as `sid`, round(avg(`score`), 1) as avg_score + from `tb_record` group by `stu_id` +) as `t2` on `stu_id`=`sid`; + +-- 查询学生的姓名和选课的数量 +select `stu_name`, `total` from `tb_student` as `t1` +inner join ( + select `stu_id`, count(*) as `total` + from `tb_record` group by `stu_id` +) as `t2` on `t1`.`stu_id`=`t2`.`stu_id`; + +-- 查询每个学生的姓名和选课数量(左外连接和子查询) +-- 左外连接:左表(写在join左边的表)的每条记录都可以查出来,不满足连表条件的地方填充null。 +select `stu_name`, coalesce(`total`, 0) as `total` +from `tb_student` as `t1` +left outer join ( + select `stu_id`, count(*) as `total` + from `tb_record` group by `stu_id` +) as `t2` on `t1`.`stu_id`=`t2`.`stu_id`; + +-- 修改选课记录表,去掉 stu_id 列的外键约束 +alter table `tb_record` drop foreign key `fk_record_stu_id`; + +-- 插入两条新纪录(注意:没有学号为 5566 的学生) +insert into `tb_record` +values + (default, 5566, 1111, '2019-09-02', 80), + (default, 5566, 2222, '2019-09-02', 70); + +-- 右外连接:右表(写在join右边的表)的每条记录都可以查出来,不满足连表条件的地方填充null。 +select `stu_name`, `total` from `tb_student` as `t1` +right outer join ( + select `stu_id`, count(*) as `total` + from `tb_record` group by `stu_id` +) as `t2` on `t1`.`stu_id`=`t2`.`stu_id`; + +-- 全外连接:左表和右表的每条记录都可以查出来,不满足连表条件的地方填充null。 +-- 说明:MySQL不支持全外连接,所以用左外连接和右外连接的并集来表示。 +select `stu_name`, `total` +from `tb_student` as `t1` +left outer join ( + select `stu_id`, count(*) as `total` + from `tb_record` group by `stu_id` +) as `t2` on `t1`.`stu_id`=`t2`.`stu_id` +union +select `stu_name`, `total` from `tb_student` as `t1` +right outer join ( + select `stu_id`, count(*) as `total` + from `tb_record` group by `stu_id` +) as `t2` on `t1`.`stu_id`=`t2`.`stu_id`; +``` + +上面的DML有几个地方需要加以说明: + +1. MySQL目前的版本不支持全外连接,上面我们通过`union`操作,将左外连接和右外连接的结果求并集实现全外连接的效果。大家可以通过下面的图来加深对连表操作的认识。 + + + +2. MySQL 中支持多种类型的运算符,包括:算术运算符(`+`、`-`、`*`、`/`、`%`)、比较运算符(`=`、`<>`、`<=>`、`<`、`<=`、`>`、`>=`、`BETWEEN...AND..`.、`IN`、`IS NULL`、`IS NOT NULL`、`LIKE`、`RLIKE`、`REGEXP`)、逻辑运算符(`NOT`、`AND`、`OR`、`XOR`)和位运算符(`&`、`|`、`^`、`~`、`>>`、`<<`),我们可以在 DML 中使用这些运算符处理数据。 + +3. 在查询数据时,可以在`SELECT`语句及其子句(如`WHERE`子句、`ORDER BY`子句、`HAVING`子句等)中使用函数,这些函数包括字符串函数、数值函数、时间日期函数、流程函数等,如下面的表格所示。 + + 常用字符串函数。 + + | 函数 | 功能 | + | --------------------------- | ----------------------------------------------------- | + | `CONCAT` | 将多个字符串连接成一个字符串 | + | `FORMAT` | 将数值格式化成字符串并指定保留几位小数 | + | `FROM_BASE64` / `TO_BASE64` | BASE64解码/编码 | + | `BIN` / `OCT` / `HEX` | 将数值转换成二进制/八进制/十六进制字符串 | + | `LOCATE` | 在字符串中查找一个子串的位置 | + | `LEFT` / `RIGHT` | 返回一个字符串左边/右边指定长度的字符 | + | `LENGTH` / `CHAR_LENGTH` | 返回字符串的长度以字节/字符为单位 | + | `LOWER` / `UPPER` | 返回字符串的小写/大写形式 | + | `LPAD` / `RPAD` | 如果字符串的长度不足,在字符串左边/右边填充指定的字符 | + | `LTRIM` / `RTRIM` | 去掉字符串前面/后面的空格 | + | `ORD` / `CHAR` | 返回字符对应的编码/返回编码对应的字符 | + | `STRCMP` | 比较字符串,返回-1、0、1分别表示小于、等于、大于 | + | `SUBSTRING` | 返回字符串指定范围的子串 | + + 常用数值函数。 + + | 函数 | 功能 | + | -------------------------------------------------------- | ---------------------------------- | + | `ABS` | 返回一个数的绝度值 | + | `CEILING` / `FLOOR` | 返回一个数上取整/下取整的结果 | + | `CONV` | 将一个数从一种进制转换成另一种进制 | + | `CRC32` | 计算循环冗余校验码 | + | `EXP` / `LOG` / `LOG2` / `LOG10` | 计算指数/对数 | + | `POW` | 求幂 | + | `RAND` | 返回[0,1)范围的随机数 | + | `ROUND` | 返回一个数四舍五入后的结果 | + | `SQRT` | 返回一个数的平方根 | + | `TRUNCATE` | 截断一个数到指定的精度 | + | `SIN` / `COS` / `TAN` / `COT` / `ASIN` / `ACOS` / `ATAN` | 三角函数 | + + 常用时间日期函数。 + + | 函数 | 功能 | + | ----------------------------- | ------------------------------------- | + | `CURDATE` / `CURTIME` / `NOW` | 获取当前日期/时间/日期和时间 | + | `ADDDATE` / `SUBDATE` | 将两个日期表达式相加/相减并返回结果 | + | `DATE` / `TIME` | 从字符串中获取日期/时间 | + | `YEAR` / `MONTH` / `DAY` | 从日期中获取年/月/日 | + | `HOUR` / `MINUTE` / `SECOND` | 从时间中获取时/分/秒 | + | `DATEDIFF` / `TIMEDIFF` | 返回两个时间日期表达式相差多少天/小时 | + | `MAKEDATE` / `MAKETIME` | 制造一个日期/时间 | + + 常用流程函数。 + + | 函数 | 功能 | + | -------- | ------------------------------------------------ | + | `IF` | 根据条件是否成立返回不同的值 | + | `IFNULL` | 如果为NULL则返回指定的值否则就返回本身 | + | `NULLIF` | 两个表达式相等就返回NULL否则返回第一个表达式的值 | + + 其他常用函数。 + + | 函数 | 功能 | + | -------------------------- | ----------------------------- | + | `MD5` / `SHA1` / `SHA2` | 返回字符串对应的哈希摘要 | + | `CHARSET` / `COLLATION` | 返回字符集/校对规则 | + | `USER` / `CURRENT_USER` | 返回当前用户 | + | `DATABASE` | 返回当前数据库名 | + | `VERSION` | 返回当前数据库版本 | + | `FOUND_ROWS` / `ROW_COUNT` | 返回查询到的行数/受影响的行数 | + | `LAST_INSERT_ID` | 返回最后一个自增主键的值 | + | `UUID` / `UUID_SHORT` | 返回全局唯一标识符 | + +### DCL(数据控制语言) + +数据控制语言用于给指定的用户授权或者从召回指定用户的指定权限,这组操作对数据库管理员来说比较重要,将一个用户的权限最小化(刚好够用)是非常重要的,对数据库的安全至关重要。 + +```SQL +-- 创建名为 wangdachui 的账号并为其指定口令,允许该账号从任意主机访问 +create user 'wangdachui'@'%' identified by '123456'; + +-- 授权 wangdachui 可以对名为school的数据库执行 select 和 insert 操作 +grant select, insert on `school`.* to 'wangdachui'@'%'; + +-- 召回 wangdachui 对school数据库的 insert 权限 +revoke insert on `school`.* from 'wangdachui'@'%'; +``` + +> **说明**:创建一个可以允许任意主机登录并且具有超级管理员权限的用户在现实中并不是一个明智的决定,因为一旦该账号的口令泄露或者被破解,数据库将会面临灾难级的风险。 diff --git a/第42课:深入MySQL.md b/第42课:深入MySQL.md new file mode 100644 index 0000000..27b941a --- /dev/null +++ b/第42课:深入MySQL.md @@ -0,0 +1,661 @@ +## 第42课:深入MySQL + +### 索引 + +索引是关系型数据库中用来提升查询性能最为重要的手段。关系型数据库中的索引就像一本书的目录,我们可以想象一下,如果要从一本书中找出某个知识点,但是这本书没有目录,这将是意见多么可怕的事情!我们估计得一篇一篇的翻下去,才能确定这个知识点到底在什么位置。创建索引虽然会带来存储空间上的开销,就像一本书的目录会占用一部分篇幅一样,但是在牺牲空间后换来的查询时间的减少也是非常显著的。 + +MySQL 数据库中所有数据类型的列都可以被索引。对于MySQL 8.0 版本的 InnoDB 存储引擎来说,它支持三种类型的索引,分别是 B+ 树索引、全文索引和 R 树索引。这里,我们只介绍使用得最为广泛的 B+ 树索引。使用 B+ 树的原因非常简单,因为它是目前在基于磁盘进行海量数据存储和排序上最有效率的数据结构。B+ 树是一棵[平衡树](https://zh.wikipedia.org/zh-cn/%E5%B9%B3%E8%A1%A1%E6%A0%91),树的高度通常为3或4,但是却可以保存从百万级到十亿级的数据,而从这些数据里面查询一条数据,只需要3次或4次 I/O 操作。 + +B+ 树由根节点、中间节点和叶子节点构成,其中叶子节点用来保存排序后的数据。由于记录在索引上是排序过的,因此在一个叶子节点内查找数据时可以使用二分查找,这种查找方式效率非常的高。当数据很少的时候,B+ 树只有一个根节点,数据也就保存在根节点上。随着记录越来越多,B+ 树会发生分裂,根节点不再保存数据,而是提供了访问下一层节点的指针,帮助快速确定数据在哪个叶子节点上。 + +在创建二维表时,我们通常都会为表指定主键列,主键列上默认会创建索引,而对于 MySQL InnoDB 存储引擎来说,因为它使用的是索引组织表这种数据存储结构,所以主键上的索引就是整张表的数据,而这种索引我们也将其称之为**聚集索引**(clustered index)。很显然,一张表只能有一个聚集索引,否则表的数据岂不是要保存多次。我们自己创建的索引都是二级索引(secondary index),更常见的叫法是**非聚集索引**(non-clustered index)。通过我们自定义的非聚集索引只能定位记录的主键,在获取数据时可能需要再通过主键上的聚集索引进行查询,这种现象称为“回表”,因此通过非聚集索引检索数据通常比使用聚集索引检索数据要慢。 + +接下来我们通过一个简单的例子来说明索引的意义,比如我们要根据学生的姓名来查找学生,这个场景在实际开发中应该经常遇到,就跟通过商品名称查找商品是一个道理。我们可以使用 MySQL 的`explain`关键字来查看 SQL 的执行计划(数据库执行 SQL 语句的具体步骤)。 + +```SQL +explain select * from tb_student where stuname='林震南'\G +``` + +``` +*************************** 1. row *************************** + id: 1 + select_type: SIMPLE + table: tb_student + partitions: NULL + type: ALL +possible_keys: NULL + key: NULL + key_len: NULL + ref: NULL + rows: 11 + filtered: 10.00 + Extra: Using where +1 row in set, 1 warning (0.00 sec) +``` + +在上面的 SQL 执行计划中,有几项值得我们关注: + +1. `select_type`:查询的类型。 + - `SIMPLE`:简单 SELECT,不需要使用 UNION 操作或子查询。 + - `PRIMARY`:如果查询包含子查询,最外层的 SELECT 被标记为 PRIMARY。 + - `UNION`:UNION 操作中第二个或后面的 SELECT 语句。 + - `SUBQUERY`:子查询中的第一个 SELECT。 + - `DERIVED`:派生表的 SELECT 子查询。 +2. `table`:查询对应的表。 +3. `type`:MySQL 在表中找到满足条件的行的方式,也称为访问类型,包括:`ALL`(全表扫描)、`index`(索引全扫描,只遍历索引树)、`range`(索引范围扫描)、`ref`(非唯一索引扫描)、`eq_ref`(唯一索引扫描)、`const` / `system`(常量级查询)、`NULL`(不需要访问表或索引)。在所有的访问类型中,很显然 ALL 是性能最差的,它代表的全表扫描是指要扫描表中的每一行才能找到匹配的行。 +4. `possible_keys`:MySQL 可以选择的索引,但是**有可能不会使用**。 +5. `key`:MySQL 真正使用的索引,如果为`NULL`就表示没有使用索引。 +6. `key_len`:使用的索引的长度,在不影响查询的情况下肯定是长度越短越好。 +7. `rows`:执行查询需要扫描的行数,这是一个**预估值**。 +8. `extra`:关于查询额外的信息。 + - `Using filesort`:MySQL 无法利用索引完成排序操作。 + - `Using index`:只使用索引的信息而不需要进一步查表来获取更多的信息。 + - `Using temporary`:MySQL 需要使用临时表来存储结果集,常用于分组和排序。 + - `Impossible where`:`where`子句会导致没有符合条件的行。 + - `Distinct`:MySQL 发现第一个匹配行后,停止为当前的行组合搜索更多的行。 + - `Using where`:查询的列未被索引覆盖,筛选条件并不是索引的前导列。 + +从上面的执行计划可以看出,当我们通过学生名字查询学生时实际上是进行了全表扫描,不言而喻这个查询性能肯定是非常糟糕的,尤其是在表中的行很多的时候。如果我们需要经常通过学生姓名来查询学生,那么就应该在学生姓名对应的列上创建索引,通过索引来加速查询。 + +```SQL +create index idx_student_name on tb_student(stuname); +``` + +再次查看刚才的 SQL 对应的执行计划。 + +```SQL +explain select * from tb_student where stuname='林震南'\G +``` + +``` +*************************** 1. row *************************** + id: 1 + select_type: SIMPLE + table: tb_student + partitions: NULL + type: ref +possible_keys: idx_student_name + key: idx_student_name + key_len: 62 + ref: const + rows: 1 + filtered: 100.00 + Extra: NULL +1 row in set, 1 warning (0.00 sec) +``` + +可以注意到,在对学生姓名创建索引后,刚才的查询已经不是全表扫描而是基于索引的查询,而且扫描的行只有唯一的一行,这显然大大的提升了查询的性能。MySQL 中还允许创建前缀索引,即对索引字段的前N个字符创建索引,这样的话可以减少索引占用的空间(但节省了空间很有可能会浪费时间,**时间和空间是不可调和的矛盾**),如下所示。 + +```SQL +create index idx_student_name_1 on tb_student(stuname(1)); +``` + +上面的索引相当于是根据学生姓名的第一个字来创建的索引,我们再看看 SQL 执行计划。 + +```SQL +explain select * from tb_student where stuname='林震南'\G +``` + +``` +*************************** 1. row *************************** + id: 1 + select_type: SIMPLE + table: tb_student + partitions: NULL + type: ref +possible_keys: idx_student_name + key: idx_student_name + key_len: 5 + ref: const + rows: 2 + filtered: 100.00 + Extra: Using where +1 row in set, 1 warning (0.00 sec) +``` + +不知道大家是否注意到,这一次扫描的行变成了2行,因为学生表中有两个姓“林”的学生,我们只用姓名的第一个字作为索引的话,在查询时通过索引就会找到这两行。 + +如果要删除索引,可以使用下面的SQL。 + +```SQL +alter table tb_student drop index idx_student_name; +``` + +或者 + +```SQL +drop index idx_student_name on tb_student; +``` + +在创建索引时,我们还可以使用复合索引、函数索引(MySQL 5.7 开始支持),用好复合索引实现**索引覆盖**可以减少不必要的排序和回表操作,这样就会让查询的性能成倍的提升,有兴趣的读者可以自行研究。 + +我们简单的为大家总结一下索引的设计原则: + +1. **最适合**索引的列是出现在**WHERE子句**和连接子句中的列。 +2. 索引列的基数越大(取值多、重复值少),索引的效果就越好。 +3. 使用**前缀索引**可以减少索引占用的空间,内存中可以缓存更多的索引。 +4. **索引不是越多越好**,虽然索引加速了读操作(查询),但是写操作(增、删、改)都会变得更慢,因为数据的变化会导致索引的更新,就如同书籍章节的增删需要更新目录一样。 +5. 使用 InnoDB 存储引擎时,表的普通索引都会保存主键的值,所以**主键要尽可能选择较短的数据类型**,这样可以有效的减少索引占用的空间,提升索引的缓存效果。 + +最后,还有一点需要说明,InnoDB 使用的 B-tree 索引,数值类型的列除了等值判断时索引会生效之外,使用`>`、`<`、`>=`、`<=`、`BETWEEN...AND... `、`<>`时,索引仍然生效;对于字符串类型的列,如果使用不以通配符开头的模糊查询,索引也是起作用的,但是其他的情况会导致索引失效,这就意味着很有可能会做全表查询。 + +### 视图 + +视图是关系型数据库中将一组查询指令构成的结果集组合成可查询的数据表的对象。简单的说,视图就是虚拟的表,但与数据表不同的是,数据表是一种实体结构,而视图是一种虚拟结构,你也可以将视图理解为保存在数据库中被赋予名字的 SQL 语句。 + +使用视图可以获得以下好处: + +1. 可以将实体数据表隐藏起来,让外部程序无法得知实际的数据结构,让访问者可以使用表的组成部分而不是整个表,降低数据库被攻击的风险。 +2. 在大多数的情况下视图是只读的(更新视图的操作通常都有诸多的限制),外部程序无法直接透过视图修改数据。 +3. 重用 SQL 语句,将高度复杂的查询包装在视图表中,直接访问该视图即可取出需要的数据;也可以将视图视为数据表进行连接查询。 +4. 视图可以返回与实体数据表不同格式的数据,在创建视图的时候可以对数据进行格式化处理。 + +创建视图。 + +```SQL +-- 创建视图 +create view `vw_avg_score` +as + select `stu_id`, round(avg(`score`), 1) as `avg_score` + from `tb_record` group by `stu_id`; + +-- 基于已有的视图创建视图 +create view `vw_student_score` +as + select `stu_name`, `avg_score` + from `tb_student` natural join `vw_avg_score`; +``` + +> **提示**:因为视图不包含数据,所以每次使用视图时,都必须执行查询以获得数据,如果你使用了连接查询、嵌套查询创建了较为复杂的视图,你可能会发现查询性能下降得很厉害。因此,在使用复杂的视图前,应该进行测试以确保其性能能够满足应用的需求。 + +使用视图。 + +```SQL +select * from `vw_student_score` order by `avg_score` desc; +``` + +``` ++--------------+----------+ +| stuname | avgscore | ++--------------+----------+ +| 杨过 | 95.6 | +| 任我行 | 53.5 | +| 王语嫣 | 84.3 | +| 纪嫣然 | 73.8 | +| 岳不群 | 78.0 | +| 东方不败 | 88.0 | +| 项少龙 | 92.0 | ++--------------+----------+ +``` + +既然视图是一张虚拟的表,那么视图的中的数据可以更新吗?视图的可更新性要视具体情况而定,以下类型的视图是不能更新的: + +1. 使用了聚合函数(`SUM`、`MIN`、`MAX`、`AVG`、`COUNT`等)、`DISTINCT`、`GROUP BY`、`HAVING`、`UNION`或者`UNION ALL`的视图。 +2. `SELECT`中包含了子查询的视图。 +3. `FROM`子句中包含了一个不能更新的视图的视图。 +4. `WHERE`子句的子查询引用了`FROM`子句中的表的视图。 + +删除视图。 + +```SQL +drop view vw_student_score; +``` + +> **说明**:如果希望更新视图,可以先用上面的命令删除视图,也可以通过`create or replace view`来更新视图。 + +视图的规则和限制。 + +1. 视图可以嵌套,可以利用从其他视图中检索的数据来构造一个新的视图。视图也可以和表一起使用。 +2. 创建视图时可以使用`order by`子句,但如果从视图中检索数据时也使用了`order by`,那么该视图中原先的`order by`会被覆盖。 +3. 视图无法使用索引,也不会激发触发器(实际开发中因为性能等各方面的考虑,通常不建议使用触发器,所以我们也不对这个概念进行介绍)的执行。 + +### 函数 + +MySQL 中的函数跟 Python 中的函数太多的差异,因为函数都是用来封装功能上相对独立且会被重复使用的代码的。如果非要找出一些差别来,那么 MySQL 中的函数是可以执行 SQL 语句的。下面的例子,我们通过自定义函数实现了截断超长字符串的功能。 + +```SQL +delimiter $$ + +create function truncate_string( + content varchar(10000), + max_length int unsigned +) returns varchar(10000) no sql +begin + declare result varchar(10000) default content; + if char_length(content) > max_length then + set result = left(content, max_length); + set result = concat(result, '……'); + end if; + return result; +end $$ + +delimiter ; +``` + +> **说明1**:函数声明后面的`no sql`是声明函数体并没有使用 SQL 语句;如果函数体中需要通过 SQL 读取数据,需要声明为`reads sql data`。 +> +> **说明2**:定义函数前后的`delimiter`命令是为了修改定界符,因为函数体中的语句都是用`;`表示结束,如果不重新定义定界符,那么遇到的`;`的时候代码就会被截断执行,显然这不是我们想要的效果。 + +在查询中调用自定义函数。 + +```SQL +select truncate_string('和我在成都的街头走一走,直到所有的灯都熄灭了也不停留', 10) as short_string; +``` + +``` ++--------------------------------------+ +| short_string | ++--------------------------------------+ +| 和我在成都的街头走一…… | ++--------------------------------------+ +``` + +### 过程 + +过程(又称存储过程)是事先编译好存储在数据库中的一组 SQL 的集合,调用过程可以简化应用程序开发人员的工作,减少与数据库服务器之间的通信,对于提升数据操作的性能也是有帮助的。其实迄今为止,我们使用的 SQL 语句都是针对一个或多个表的单条语句,但在实际开发中经常会遇到某个操作需要多条 SQL 语句才能完成的情况。例如,电商网站在受理用户订单时,需要做以下一系列的处理。 + +1. 通过查询来核对库存中是否有对应的物品以及库存是否充足。 +2. 如果库存有物品,需要锁定库存以确保这些物品不再卖给别人, 并且要减少可用的物品数量以反映正确的库存量。 +3. 如果库存不足,可能需要进一步与供应商进行交互或者至少产生一条系统提示消息。 +4. 不管受理订单是否成功,都需要产生流水记录,而且需要给对应的用户产生一条通知信息。 + +我们可以通过过程将复杂的操作封装起来,这样不仅有助于保证数据的一致性,而且将来如果业务发生了变动,只需要调整和修改过程即可。对于调用过程的用户来说,过程并没有暴露数据表的细节,而且执行过程比一条条的执行一组 SQL 要快得多。 + +下面的过程实现了查询某门课程的最高分、最低分和平均分。 + +```SQL +drop procedure if exists sp_score_stat; + +delimiter $$ + +create procedure sp_score_stat( + courseId int, + out maxScore decimal(4,1), + out minScore decimal(4,1), + out avgScore decimal(4,1) +) +begin + select max(score) into maxScore from tb_record where cou_id=courseId; + select min(score) into minScore from tb_record where cou_id=courseId; + select avg(score) into avgScore from tb_record where cou_id=courseId; +end $$ + +delimiter ; +``` + +> **说明**:在定义过程时,因为可能需要书写多条 SQL,而分隔这些 SQL 需要使用分号作为分隔符,如果这个时候,仍然用分号表示整段代码结束,那么定义过程的 SQL 就会出现错误,所以上面我们用`delimiter $$`将整段代码结束的标记定义为`$$`,那么代码中的分号将不再表示整段代码的结束,整段代码只会在遇到`end $$`时才会执行。在定义完过程后,通过`delimiter ;`将结束符重新改回成分号(恢复现场)。 + +上面定义的过程有四个参数,其中第一个参数是输入参数,代表课程的编号,后面的参数都是输出参数,因为过程不能定义返回值,只能通过输出参数将执行结果带出,定义输出参数的关键字是`out`,默认情况下参数都是输入参数。 + +调用过程。 + +```SQL +call sp_score_stat(1111, @a, @b, @c); +``` + +获取输出参数的值。 + +```SQL +select @a as 最高分, @b as 最低分, @c as 平均分; +``` + +删除过程。 + +```SQL +drop procedure sp_score_stat; +``` + +在过程中,我们可以定义变量、条件,可以使用分支和循环语句,可以通过游标操作查询结果,还可以使用事件调度器,这些内容我们暂时不在此处进行介绍。虽然我们说了很多过程的好处,但是在实际开发中,如果频繁的使用过程并将大量复杂的运算放到过程中,会给据库服务器造成巨大的压力,而数据库往往都是性能瓶颈所在,使用过程无疑是雪上加霜的操作。所以,对于互联网产品开发,我们一般建议让数据库只做好存储,复杂的运算和处理交给应用服务器上的程序去完成,如果应用服务器变得不堪重负了,我们可以比较容易的部署多台应用服务器来分摊这些压力。 + +如果大家对上面讲到的视图、函数、过程包括我们没有讲到的触发器这些知识有兴趣,建议大家阅读 MySQL 的入门读物[《MySQL必知必会》](https://item.jd.com/12818982.html)进行一般性了解即可,因为这些知识点在大家将来的工作中未必用得上,学了也可能仅仅是为了应付面试而已。 + +### MySQL 新特性 + +#### JSON类型 + +很多开发者在使用关系型数据库做数据持久化的时候,常常感到结构化的存储缺乏灵活性,因为必须事先设计好所有的列以及对应的数据类型。在业务发展和变化的过程中,如果需要修改表结构,这绝对是比较麻烦和难受的事情。从 MySQL 5.7 版本开始,MySQL引入了对 JSON 数据类型的支持(MySQL 8.0 解决了 JSON 的日志性能瓶颈问题),用好 JSON 类型,其实就是打破了关系型数据库和非关系型数据库之间的界限,为数据持久化操作带来了更多的便捷。 + +JSON 类型主要分为 JSON 对象和 JSON数组两种,如下所示。 + +1. JSON 对象 + +```JSON +{"name": "骆昊", "tel": "13122335566", "QQ": "957658"} +``` + +2. JSON 数组 + +```JSON +[1, 2, 3] +``` + +```JSON +[{"name": "骆昊", "tel": "13122335566"}, {"name": "王大锤", "QQ": "123456"}] +``` + +哪些地方需要用到JSON类型呢?举一个简单的例子,现在很多产品的用户登录都支持多种方式,例如手机号、微信、QQ、新浪微博等,但是一般情况下我们又不会要求用户提供所有的这些信息,那么用传统的设计方式,就需要设计多个列来对应多种登录方式,可能还需要允许这些列存在空值,这显然不是很好的选择;另一方面,如果产品又增加了一种登录方式,那么就必然要修改之前的表结构,这就更让人痛苦了。但是,有了 JSON 类型,刚才的问题就迎刃而解了,我们可以做出如下所示的设计。 + +```SQL +create table `tb_test` +( +`user_id` bigint unsigned, +`login_info` json, +primary key (`user_id`) +) engine=innodb; + +insert into `tb_test` values + (1, '{"tel": "13122335566", "QQ": "654321", "wechat": "jackfrued"}'), + (2, '{"tel": "13599876543", "weibo": "wangdachui123"}'); +``` + +如果要查询用户的手机和微信号,可以用如下所示的 SQL 语句。 + +```SQL +select + `user_id`, + json_unquote(json_extract(`login_info`, '$.tel')) as 手机号, + json_unquote(json_extract(`login_info`, '$.wechat')) as 微信 +from `tb_test`; +``` + +``` ++---------+-------------+-----------+ +| user_id | 手机号 | 微信 | ++---------+-------------+-----------+ +| 1 | 13122335566 | jackfrued | +| 2 | 13599876543 | NULL | ++---------+-------------+-----------+ +``` + +因为支持 JSON 类型,MySQL 也提供了配套的处理 JSON 数据的函数,就像上面用到的`json_extract`和`json_unquote`。当然,上面的 SQL 还有更为便捷的写法,如下所示。 + +```SQL +select + `user_id`, + `login_info` ->> '$.tel' as 手机号, + `login_info` ->> '$.wechat' as 微信 +from `tb_test`; +``` + +再举个例子,如果我们的产品要实现用户画像功能(给用户打标签),然后基于用户画像给用户推荐平台的服务或消费品之类的东西,我们也可以使用 JSON 类型来保存用户画像数据,示意代码如下所示。 + +创建画像标签表。 + +```SQL +create table `tb_tags` +( +`tag_id` int unsigned not null comment '标签ID', +`tag_name` varchar(20) not null comment '标签名', +primary key (`tag_id`) +) engine=innodb; + +insert into `tb_tags` (`tag_id`, `tag_name`) +values + (1, '70后'), + (2, '80后'), + (3, '90后'), + (4, '00后'), + (5, '爱运动'), + (6, '高学历'), + (7, '小资'), + (8, '有房'), + (9, '有车'), + (10, '爱看电影'), + (11, '爱网购'), + (12, '常点外卖'); +``` + +为用户打标签。 + +```SQL +create table `tb_users_tags` +( +`user_id` bigint unsigned not null comment '用户ID', +`user_tags` json not null comment '用户标签' +) engine=innodb; + +insert into `tb_users_tags` values + (1, '[2, 6, 8, 10]'), + (2, '[3, 10, 12]'), + (3, '[3, 8, 9, 11]'); +``` + +接下来,我们通过一组查询来了解 JSON 类型的巧妙之处。 + +1. 查询爱看电影(有`10`这个标签)的用户ID。 + + ```SQL + select * from `tb_users` where 10 member of (user_tags->'$'); + ``` + +2. 查询爱看电影(有`10`这个标签)的80后(有`2`这个标签)用户ID。 + + ``` + select * from `tb_users` where json_contains(user_tags->'$', '[2, 10]'); + +3. 查询爱看电影或80后或90后的用户ID。 + + ```SQL + select `user_id` from `tb_users_tags` where json_overlaps(user_tags->'$', '[2, 3, 10]'); + ``` + +> **说明**:上面的查询用到了`member of`谓词和两个 JSON 函数,`json_contains`可以检查 JSON 数组是否包含了指定的元素,而`json_overlaps`可以检查 JSON 数组是否与指定的数组有重叠部分。 + +#### 窗口函数 + +MySQL 从8.0开始支持窗口函数,大多数商业数据库和一些开源数据库早已提供了对窗口函数的支持,有的也将其称之为 OLAP(联机分析和处理)函数,听名字就知道跟统计和分析相关。为了帮助大家理解窗口函数,我们先说说窗口的概念。 + +窗口可以理解为记录的集合,窗口函数也就是在满足某种条件的记录集合上执行的特殊函数,对于每条记录都要在此窗口内执行函数。窗口函数和我们上面讲到的聚合函数比较容易混淆,二者的区别主要在于聚合函数是将多条记录聚合为一条记录,窗口函数是每条记录都会执行,执行后记录条数不会变。窗口函数不仅仅是几个函数,它是一套完整的语法,函数只是该语法的一部分,基本语法如下所示: + +```SQL +<窗口函数> over (partition by <用于分组的列名> order by <用户排序的列名>) +``` + +上面语法中,窗口函数的位置可以放以下两种函数: + +1. 专用窗口函数,包括:`lead`、`lag`、`first_value`、`last_value`、`rank`、`dense_rank`和`row_number`等。 +2. 聚合函数,包括:`sum`、`avg`、`max`、`min`和`count`等。 + +下面为大家举几个使用窗口函数的简单例子,我们先用如下所示的 SQL 建库建表。 + +```SQL +-- 创建名为hrs的数据库并指定默认的字符集 +create database `hrs` default charset utf8mb4; + +-- 切换到hrs数据库 +use `hrs`; + +-- 创建部门表 +create table `tb_dept` +( +`dno` int not null comment '编号', +`dname` varchar(10) not null comment '名称', +`dloc` varchar(20) not null comment '所在地', +primary key (`dno`) +); + +-- 插入4个部门 +insert into `tb_dept` values + (10, '会计部', '北京'), + (20, '研发部', '成都'), + (30, '销售部', '重庆'), + (40, '运维部', '深圳'); + +-- 创建员工表 +create table `tb_emp` +( +`eno` int not null comment '员工编号', +`ename` varchar(20) not null comment '员工姓名', +`job` varchar(20) not null comment '员工职位', +`mgr` int comment '主管编号', +`sal` int not null comment '员工月薪', +`comm` int comment '每月补贴', +`dno` int not null comment '所在部门编号', +primary key (`eno`), +constraint `fk_emp_mgr` foreign key (`mgr`) references tb_emp (`eno`), +constraint `fk_emp_dno` foreign key (`dno`) references tb_dept (`dno`) +); + +-- 插入14个员工 +insert into `tb_emp` values + (7800, '张三丰', '总裁', null, 9000, 1200, 20), + (2056, '乔峰', '分析师', 7800, 5000, 1500, 20), + (3088, '李莫愁', '设计师', 2056, 3500, 800, 20), + (3211, '张无忌', '程序员', 2056, 3200, null, 20), + (3233, '丘处机', '程序员', 2056, 3400, null, 20), + (3251, '张翠山', '程序员', 2056, 4000, null, 20), + (5566, '宋远桥', '会计师', 7800, 4000, 1000, 10), + (5234, '郭靖', '出纳', 5566, 2000, null, 10), + (3344, '黄蓉', '销售主管', 7800, 3000, 800, 30), + (1359, '胡一刀', '销售员', 3344, 1800, 200, 30), + (4466, '苗人凤', '销售员', 3344, 2500, null, 30), + (3244, '欧阳锋', '程序员', 3088, 3200, null, 20), + (3577, '杨过', '会计', 5566, 2200, null, 10), + (3588, '朱九真', '会计', 5566, 2500, null, 10); +``` + +例子1:查询按月薪从高到低排在第4到第6名的员工的姓名和月薪。 + +```SQL +select * from ( + select + `ename`, `sal`, + row_number() over (order by `sal` desc) as `rank` + from `tb_emp` +) `temp` where `rank` between 4 and 6; +``` + +> **说明**:上面使用的函数`row_number()`可以为每条记录生成一个行号,在实际工作中可以根据需要将其替换为`rank()`或`dense_rank()`函数,三者的区别可以参考官方文档或阅读[《通俗易懂的学会:SQL窗口函数》](https://zhuanlan.zhihu.com/p/92654574)进行了解。在MySQL 8以前的版本,我们可以通过下面的方式来完成类似的操作。 +> +> ```SQL +> select `rank`, `ename`, `sal` from ( +> select @a:=@a+1 as `rank`, `ename`, `sal` +> from `tb_emp`, (select @a:=0) as t1 order by `sal` desc +> ) as `temp` where `rank` between 4 and 6; +> ``` + +例子2:查询每个部门月薪最高的两名的员工的姓名和部门名称。 + +```SQL +select `ename`, `sal`, `dname` +from ( + select + `ename`, `sal`, `dno`, + rank() over (partition by `dno` order by `sal` desc) as `rank` + from `tb_emp` +) as `temp` natural join `tb_dept` where `rank`<=2; +``` + +> 说明:在MySQL 8以前的版本,我们可以通过下面的方式来完成类似的操作。 +> +> ```SQL +> select `ename`, `sal`, `dname` from `tb_emp` as `t1` +natural join `tb_dept` +where ( + select count(*) from `tb_emp` as `t2` + where `t1`.`dno`=`t2`.`dno` and `t2`.`sal`>`t1`.`sal` +)<2 order by `dno` asc, `sal` desc; +> ``` + +### 其他内容 + +#### 范式理论 + +范式理论是设计关系型数据库中二维表的指导思想。 + +1. 第一范式:数据表的每个列的值域都是由原子值组成的,不能够再分割。 +2. 第二范式:数据表里的所有数据都要和该数据表的键(主键与候选键)有完全依赖关系。 +3. 第三范式:所有非键属性都只和候选键有相关性,也就是说非键属性之间应该是独立无关的。 + +> **说明**:实际工作中,出于效率的考虑,我们在设计表时很有可能做出反范式设计,即故意降低方式级别,增加冗余数据来获得更好的操作性能。 + +#### 数据完整性 + +1. 实体完整性 - 每个实体都是独一无二的 + + - 主键(`primary key`) / 唯一约束(`unique`) +2. 引用完整性(参照完整性)- 关系中不允许引用不存在的实体 + + - 外键(`foreign key`) +3. 域(domain)完整性 - 数据是有效的 + - 数据类型及长度 + + - 非空约束(`not null`) + + - 默认值约束(`default`) + + - 检查约束(`check`) + + > **说明**:在 MySQL 8.x 以前,检查约束并不起作用。 + +#### 数据一致性 + +1. 事务:一系列对数据库进行读/写的操作,这些操作要么全都成功,要么全都失败。 + +2. 事务的 ACID 特性 + - 原子性:事务作为一个整体被执行,包含在其中的对数据库的操作要么全部被执行,要么都不执行 + - 一致性:事务应确保数据库的状态从一个一致状态转变为另一个一致状态 + - 隔离性:多个事务并发执行时,一个事务的执行不应影响其他事务的执行 + - 持久性:已被提交的事务对数据库的修改应该永久保存在数据库中 + +3. MySQL 中的事务操作 + + - 开启事务环境 + + ```SQL + start transaction + ``` + + - 提交事务 + + ```SQL + commit + ``` + + - 回滚事务 + + ```SQL + rollback + ``` + +4. 查看事务隔离级别 + + ```SQL + show variables like 'transaction_isolation'; + ``` + + ``` + +-----------------------+-----------------+ + | Variable_name | Value | + +-----------------------+-----------------+ + | transaction_isolation | REPEATABLE-READ | + +-----------------------+-----------------+ + ``` + + 可以看出,MySQL 默认的事务隔离级别是`REPEATABLE-READ`。 + +5. 修改(当前会话)事务隔离级别 + + ```SQL + set session transaction isolation level read committed; + ``` + + 重新查看事务隔离级别,结果如下所示。 + + ``` + +-----------------------+----------------+ + | Variable_name | Value | + +-----------------------+----------------+ + | transaction_isolation | READ-COMMITTED | + +-----------------------+----------------+ + ``` + +关系型数据库的事务是一个很大的话题,因为当存在多个并发事务访问数据时,就有可能出现三类读数据的问题(脏读、不可重复读、幻读)和两类更新数据的问题(第一类丢失更新、第二类丢失更新)。想了解这五类问题的,可以阅读我发布在 CSDN 网站上的[《Java面试题全集(上)》](https://blog.csdn.net/jackfrued/article/details/44921941)一文的第80题。为了避免这些问题,关系型数据库底层是有对应的锁机制的,按锁定对象不同可以分为表级锁和行级锁,按并发事务锁定关系可以分为共享锁和独占锁。然而直接使用锁是非常麻烦的,为此数据库为用户提供了自动锁机制,只要用户指定适当的事务隔离级别,数据库就会通过分析 SQL 语句,然后为事务访问的资源加上合适的锁。此外,数据库还会维护这些锁通过各种手段提高系统的性能,这些对用户来说都是透明的。想了解 MySQL 事务和锁的细节知识,推荐大家阅读进阶读物[《高性能MySQL》](https://item.jd.com/11220393.html),这也是数据库方面的经典书籍。 + +ANSI/ISO SQL 92标准定义了4个等级的事务隔离级别,如下表所示。需要说明的是,事务隔离级别和数据访问的并发性是对立的,事务隔离级别越高并发性就越差。所以要根据具体的应用来确定到底使用哪种事务隔离级别,这个地方没有万能的原则。 + + + +### 总结 + +关于 MySQL 的知识肯定远远不止上面列出的这些,比如 MySQL 性能调优、MySQL 运维相关工具、MySQL 数据的备份和恢复、监控 MySQL 服务、部署高可用架构等,这一系列的问题在这里都没有办法逐一展开来讨论,那就留到有需要的时候再进行讲解吧,各位读者也可以自行探索。 diff --git a/第43课:Python程序接入MySQL数据库.md b/第43课:Python程序接入MySQL数据库.md new file mode 100644 index 0000000..49c5e71 --- /dev/null +++ b/第43课:Python程序接入MySQL数据库.md @@ -0,0 +1,295 @@ +## 第43课:Python程序接入MySQL数据库 + +在 Python3 中,我们可以使用`mysqlclient`或者`pymysql`三方库来接入 MySQL 数据库并实现数据持久化操作。二者的用法完全相同,只是导入的模块名不一样。我们推荐大家使用纯 Python 的三方库`pymysql`,因为它更容易安装成功。下面我们仍然以之前创建的名为`hrs`的数据库为例,为大家演示如何通过 Python 程序操作 MySQL 数据库实现数据持久化操作。 + +### 建库建表 + +```SQL +-- 创建名为hrs的数据库并指定默认的字符集 +create database `hrs` default character set utf8mb4; + +-- 切换到hrs数据库 +use `hrs`; + +-- 创建部门表 +create table `tb_dept` +( +`dno` int not null comment '编号', +`dname` varchar(10) not null comment '名称', +`dloc` varchar(20) not null comment '所在地', +primary key (`dno`) +); + +-- 插入4个部门 +insert into `tb_dept` values + (10, '会计部', '北京'), + (20, '研发部', '成都'), + (30, '销售部', '重庆'), + (40, '运维部', '深圳'); + +-- 创建员工表 +create table `tb_emp` +( +`eno` int not null comment '员工编号', +`ename` varchar(20) not null comment '员工姓名', +`job` varchar(20) not null comment '员工职位', +`mgr` int comment '主管编号', +`sal` int not null comment '员工月薪', +`comm` int comment '每月补贴', +`dno` int not null comment '所在部门编号', +primary key (`eno`), +constraint `fk_emp_mgr` foreign key (`mgr`) references tb_emp (`eno`), +constraint `fk_emp_dno` foreign key (`dno`) references tb_dept (`dno`) +); + +-- 插入14个员工 +insert into `tb_emp` values + (7800, '张三丰', '总裁', null, 9000, 1200, 20), + (2056, '乔峰', '分析师', 7800, 5000, 1500, 20), + (3088, '李莫愁', '设计师', 2056, 3500, 800, 20), + (3211, '张无忌', '程序员', 2056, 3200, null, 20), + (3233, '丘处机', '程序员', 2056, 3400, null, 20), + (3251, '张翠山', '程序员', 2056, 4000, null, 20), + (5566, '宋远桥', '会计师', 7800, 4000, 1000, 10), + (5234, '郭靖', '出纳', 5566, 2000, null, 10), + (3344, '黄蓉', '销售主管', 7800, 3000, 800, 30), + (1359, '胡一刀', '销售员', 3344, 1800, 200, 30), + (4466, '苗人凤', '销售员', 3344, 2500, null, 30), + (3244, '欧阳锋', '程序员', 3088, 3200, null, 20), + (3577, '杨过', '会计', 5566, 2200, null, 10), + (3588, '朱九真', '会计', 5566, 2500, null, 10); +``` + +### 接入MySQL + +首先,我们可以在命令行或者 PyCharm 的终端中通过下面的命令安装`pymysql`,如果需要接入 MySQL 8,还需要安装一个名为`cryptography`的三方库来支持 MySQL 8 的密码认证方式。 + +```Shell +pip install pymysql cryptography +``` + +使用`pymysql`操作 MySQL 的步骤如下所示: + +1. 创建连接。MySQL 服务器启动后,提供了基于 TCP (传输控制协议)的网络服务。我们可以通过`pymysql`模块的`connect`函数连接 MySQL 服务器。在调用`connect`函数时,需要指定主机(`host`)、端口(`port`)、用户名(`user`)、口令(`password`)、数据库(`database`)、字符集(`charset`)等参数,该函数会返回一个`Connection`对象。 +2. 获取游标。连接 MySQL 服务器成功后,接下来要做的就是向数据库服务器发送 SQL 语句,MySQL 会执行接收到的 SQL 并将执行结果通过网络返回。要实现这项操作,需要先通过连接对象的`cursor`方法获取游标(`Cursor`)对象。 +3. 发出 SQL。通过游标对象的`execute`方法,我们可以向数据库发出 SQL 语句。 +4. 如果执行`insert`、`delete`或`update`操作,需要根据实际情况提交或回滚事务。因为创建连接时,默认开启了事务环境,在操作完成后,需要使用连接对象的`commit`或`rollback`方法,实现事务的提交或回滚,`rollback`方法通常会放在异常捕获代码块`except`中。如果执行`select`操作,需要通过游标对象抓取查询的结果,对应的方法有三个,分别是:`fetchone`、`fetchmany`和`fetchall`。其中`fetchone`方法会抓取到一条记录,并以元组或字典的方式返回;`fetchmany`和`fetchall`方法会抓取到多条记录,以嵌套元组或列表装字典的方式返回。 +5. 关闭连接。在完成持久化操作后,请不要忘记关闭连接,释放外部资源。我们通常会在`finally`代码块中使用连接对象的`close`方法来关闭连接。 + +### 代码实操 + +下面,我们通过代码实操的方式为大家演示上面说的五个步骤。 + +#### 插入数据 + +```Python +import pymysql + +no = int(input('部门编号: ')) +name = input('部门名称: ') +location = input('部门所在地: ') + +# 1. 创建连接(Connection) +conn = pymysql.connect(host='127.0.0.1', port=3306, + user='guest', password='Guest.618', + database='hrs', charset='utf8mb4') +try: + # 2. 获取游标对象(Cursor) + with conn.cursor() as cursor: + # 3. 通过游标对象向数据库服务器发出SQL语句 + affected_rows = cursor.execute( + 'insert into `tb_dept` values (%s, %s, %s)', + (no, name, location) + ) + if affected_rows == 1: + print('新增部门成功!!!') + # 4. 提交事务(transaction) + conn.commit() +except pymysql.MySQLError as err: + # 4. 回滚事务 + conn.rollback() + print(type(err), err) +finally: + # 5. 关闭连接释放资源 + conn.close() +``` + +> **说明**:上面的`127.0.0.1`称为回环地址,它代表的是本机。下面的`guest`是我提前创建好的用户,该用户拥有对`hrs`数据库的`insert`、`delete`、`update`和`select`权限。我们不建议大家在项目中直接使用`root`超级管理员账号访问数据库,这样做实在是太危险了。我们可以使用下面的命令创建名为`guest`的用户并为其授权。 +> +> ```SQL +> create user 'guest'@'%' identified by 'Guest.618'; +> grant insert, delete, update, select on `hrs`.* to 'guest'@'%'; +> ``` + +如果要插入大量数据,建议使用游标对象的`executemany`方法做批处理(一个`insert`操作后面跟上多组数据),大家可以尝试向一张表插入10000条记录,然后看看不使用批处理一条条的插入和使用批处理有什么差别。游标对象的`executemany`方法第一个参数仍然是 SQL 语句,第二个参数可以是包含多组数据的列表或元组。 + +#### 删除数据 + +```Python +import pymysql + +no = int(input('部门编号: ')) + +# 1. 创建连接(Connection) +conn = pymysql.connect(host='127.0.0.1', port=3306, + user='guest', password='Guest.618', + database='hrs', charset='utf8mb4', + autocommit=True) +try: + # 2. 获取游标对象(Cursor) + with conn.cursor() as cursor: + # 3. 通过游标对象向数据库服务器发出SQL语句 + affected_rows = cursor.execute( + 'delete from `tb_dept` where `dno`=%s', + (no, ) + ) + if affected_rows == 1: + print('删除部门成功!!!') +finally: + # 5. 关闭连接释放资源 + conn.close() +``` + +> **说明**:如果不希望每次 SQL 操作之后手动提交或回滚事务,可以`connect`函数中加一个名为`autocommit`的参数并将它的值设置为`True`,表示每次执行 SQL 成功后自动提交。但是我们建议大家手动提交或回滚,这样可以根据实际业务需要来构造事务环境。如果不愿意捕获异常并进行处理,可以在`try`代码块后直接跟`finally`块,省略`except`意味着发生异常时,代码会直接崩溃并将异常栈显示在终端中。 + +#### 更新数据 + +```Python +import pymysql + +no = int(input('部门编号: ')) +name = input('部门名称: ') +location = input('部门所在地: ') + +# 1. 创建连接(Connection) +conn = pymysql.connect(host='127.0.0.1', port=3306, + user='guest', password='Guest.618', + database='hrs', charset='utf8mb4') +try: + # 2. 获取游标对象(Cursor) + with conn.cursor() as cursor: + # 3. 通过游标对象向数据库服务器发出SQL语句 + affected_rows = cursor.execute( + 'update `tb_dept` set `dname`=%s, `dloc`=%s where `dno`=%s', + (name, location, no) + ) + if affected_rows == 1: + print('更新部门信息成功!!!') + # 4. 提交事务 + conn.commit() +except pymysql.MySQLError as err: + # 4. 回滚事务 + conn.rollback() + print(type(err), err) +finally: + # 5. 关闭连接释放资源 + conn.close() +``` + +#### 查询数据 + +1. 查询部门表的数据。 + +```Python +import pymysql + +# 1. 创建连接(Connection) +conn = pymysql.connect(host='127.0.0.1', port=3306, + user='guest', password='Guest.618', + database='hrs', charset='utf8mb4') +try: + # 2. 获取游标对象(Cursor) + with conn.cursor() as cursor: + # 3. 通过游标对象向数据库服务器发出SQL语句 + cursor.execute('select `dno`, `dname`, `dloc` from `tb_dept`') + # 4. 通过游标对象抓取数据 + row = cursor.fetchone() + while row: + print(row) + row = cursor.fetchone() +except pymysql.MySQLError as err: + print(type(err), err) +finally: + # 5. 关闭连接释放资源 + conn.close() +``` +>**说明**:上面的代码中,我们通过构造一个`while`循环实现了逐行抓取查询结果的操作。这种方式特别适合查询结果有非常多行的场景。因为如果使用`fetchall`一次性将所有记录抓取到一个嵌套元组中,会造成非常大的内存开销,这在很多场景下并不是一个好主意。如果不愿意使用`while`循环,还可以考虑使用`iter`函数构造一个迭代器来逐行抓取数据,有兴趣的读者可以自行研究。 + +2. 分页查询员工表的数据。 + +```Python +import pymysql + +page = int(input('页码: ')) +size = int(input('大小: ')) + +# 1. 创建连接(Connection) +con = pymysql.connect(host='127.0.0.1', port=3306, + user='guest', password='Guest.618', + database='hrs', charset='utf8') +try: + # 2. 获取游标对象(Cursor) + with con.cursor(pymysql.cursors.DictCursor) as cursor: + # 3. 通过游标对象向数据库服务器发出SQL语句 + cursor.execute( + 'select `eno`, `ename`, `job`, `sal` from `tb_emp` order by `sal` desc limit %s,%s', + ((page - 1) * size, size) + ) + # 4. 通过游标对象抓取数据 + for emp_dict in cursor.fetchall(): + print(emp_dict) +finally: + # 5. 关闭连接释放资源 + con.close() +``` + +### 案例讲解 + +下面我们为大家讲解一个将数据库表数据导出到 Excel 文件的例子,我们需要先安装`openpyxl`三方库,命令如下所示。 + +```Bash +pip install openpyxl +``` + +接下来,我们通过下面的代码实现了将数据库`hrs`中所有员工的编号、姓名、职位、月薪、补贴和部门名称导出到一个 Excel 文件中。 + +```Python +import openpyxl +import pymysql + +# 创建工作簿对象 +workbook = openpyxl.Workbook() +# 获得默认的工作表 +sheet = workbook.active +# 修改工作表的标题 +sheet.title = '员工基本信息' +# 给工作表添加表头 +sheet.append(('工号', '姓名', '职位', '月薪', '补贴', '部门')) +# 创建连接(Connection) +conn = pymysql.connect(host='127.0.0.1', port=3306, + user='guest', password='Guest.618', + database='hrs', charset='utf8mb4') +try: + # 获取游标对象(Cursor) + with conn.cursor() as cursor: + # 通过游标对象执行SQL语句 + cursor.execute( + 'select `eno`, `ename`, `job`, `sal`, coalesce(`comm`, 0), `dname` ' + 'from `tb_emp` natural join `tb_dept`' + ) + # 通过游标抓取数据 + row = cursor.fetchone() + while row: + # 将数据逐行写入工作表中 + sheet.append(row) + row = cursor.fetchone() + # 保存工作簿 + workbook.save('hrs.xlsx') +except pymysql.MySQLError as err: + print(err) +finally: + # 关闭连接释放资源 + conn.close() +``` + +大家可以参考上面的例子,试一试把 Excel 文件的数据导入到指定数据库的指定表中,看看是否可以成功。 \ No newline at end of file