CMU15445 (Fall 2019) 之 Project#4 - Logging & Recovery 详解
前言
这是 Fall 2019 的最后一个实验,要求我们实现预写式日志、系统恢复和存档点功能,这三个功能分别对应三个类 LogManager
、LogRecovery
和 CheckpointManager
,下面进入正题。
代码实现
日志管理器
为了达到原子性和持久性的目标,数据库系统会将描述事务所做修改的信息保存硬盘中。这些信息确保已提交事务中执行的所有修改都反映在数据库中,还可以确保系统崩溃并重新启动后,由中止或失败的事务所做的修改不会保留在数据库中。本次实验使用预写日志记录这些修改,预写日志也是使用的最广泛的记录方式,基本原理如下图所示。
在内存中有一块缓冲区域 WAL Buffer,用于记录任何事务中执行的操作,每当执行一个操作,就会在缓冲区中添加一条记录,记录的格式有三种:物理日志、逻辑日志和混合式日志。物理日志记录了操作前后每个数据位的修改,逻辑日志只记录了 SQL 语句,而混合日志和物理日志很像,不过将偏移量换成了槽号。逻辑日志存在一些问题,比如重新执行 Now()
时间会发生改变,而物理日志的偏移量也会有问题,如果对页进行碎片整理会导致偏移量失效,所以实际上使用的是混合式的日志。
在 WAL Buffer 中添加完一条记录后,才会修改缓冲池中的数据,当日志被提交或者 WAL Buffer 满了之后(取决于具体策略),会将日志写到硬盘上,虽然这时候缓冲池中的脏页可能还没同步到硬盘上,但是只要保存完日志,我们就能保证数据已经安全了。正是因为缓冲池未动,日志先行,所以这种策略称为预写式日志。
日志管理器 LogManager
的声明如下所示,可以看到内部有两个缓冲区: log_buffer_
和 flush_buffer_
,前者用于添加记录,当满足一定条件时(后面会说到),需要交换这两个缓冲区的内容,然后使用 flush_thread_
将 flush_buffer_
写到硬盘上:
class LogManager {
public:
explicit LogManager(DiskManager *disk_manager)
: next_lsn_(0), persistent_lsn_(INVALID_LSN), disk_manager_(disk_manager) {
log_buffer_ = new char[LOG_BUFFER_SIZE];
flush_buffer_ = new char[LOG_BUFFER_SIZE];
}
~LogManager() {
delete[] log_buffer_;
delete[] flush_buffer_;
log_buffer_ = nullptr;
flush_buffer_ = nullptr;
}
void RunFlushThread();
void StopFlushThread();
/* flush log to disk */
void Flush();
lsn_t AppendLogRecord(LogRecord *log_record);
inline lsn_t GetNextLSN() { return next_lsn_; }
inline lsn_t GetPersistentLSN() { return persistent_lsn_; }
inline void SetPersistentLSN(lsn_t lsn) { persistent_lsn_ = lsn; }
inline char *GetLogBuffer() { return log_buffer_; }
private:
/** The atomic counter which records the next log sequence number. */
std::atomic<lsn_t> next_lsn_;
/** The log records before and including the persistent lsn have been written to disk. */
std::atomic<lsn_t> persistent_lsn_;
char *log_buffer_;
char *flush_buffer_;
int log_buffer_offset_ = 0;
int flush_buffer_offset_ = 0;
std::mutex latch_;
std::thread *flush_thread_;
std::condition_variable cv_;
std::condition_variable cv_append_;
std::atomic_bool need_flush_ = false;
DiskManager *disk_manager_;
};
启动日志线程
当满足下述条件之一时,我们会使用日志线程将日志写到硬盘上:
log_buffer_
的剩余空间不足以插入新的记录- 距离上一次保存日志的时间超过了
log_timeout
- 缓冲池换出了一个脏页
实验提示说要用到 Future 和 Promise,但是感觉条件变量就够用了,加上一个 need_flush_
判断条件避免发生虚假唤醒:
void LogManager::RunFlushThread() {
if (enable_logging) {
return;
}
enable_logging = true;
flush_thread_ = new std::thread([&] {
while (enable_logging) {
std::unique_lock<std::mutex> lock(latch_);
// flush log to disk if log time out or log buffer is full
cv_.wait_for(lock, log_timeout, [&] { return need_flush_.load(); });
if (log_buffer_offset_ > 0) {
std::swap(log_buffer_, flush_buffer_);
std::swap(log_buffer_offset_, flush_buffer_offset_);
disk_manager_->WriteLog(flush_buffer_, flush_buffer_offset_);
flush_buffer_offset_ = 0;
SetPersistentLSN(next_lsn_ - 1);
}
need_flush_ = false;
cv_append_.notify_all();
}
});
}
停止日志线程
当数据库系统被关闭时,我们应该停止日志线程,同时将 log_buffer_
中的记录全部保存到硬盘中:
void LogManager::StopFlushThread() {
enable_logging = false;
Flush();
flush_thread_->join();
delete flush_thread_;
flush_thread_ = nullptr;
}
void LogManager::Flush() {
if (!enable_logging) {
return;
}
std::unique_lock<std::mutex> lock(latch_);
need_flush_ = true;
cv_.notify_one();
// block thread until flush finished
cv_append_.wait(lock, [&] { return !need_flush_.load(); });
}
添加日志记录
根据执行操作的不同,日志记录也分为多个种类:
enum class LogRecordType {
INVALID = 0,
INSERT,
MARKDELETE,
APPLYDELETE,
ROLLBACKDELETE,
UPDATE,
BEGIN,
COMMIT,
ABORT,
/** Creating a new page in the table heap. */
NEWPAGE,
};
日志记录由 LogRecord
类描述,每一种记录的格式如下所示:
Header (每种类型都拥有 Header,共 20 字节)
--------------------------------------------
| size | LSN | transID | prevLSN | LogType |
--------------------------------------------
插入类型日志记录
--------------------------------------------------------------
| HEADER | tuple_rid | tuple_size | tuple_data(char[] array) |
--------------------------------------------------------------
删除类型日志记录 (包括 markdelete, rollbackdelete, applydelete)
--------------------------------------------------------------
| HEADER | tuple_rid | tuple_size | tuple_data(char[] array) |
--------------------------------------------------------------
更新类型日志记录
----------------------------------------------------------------------------------
| HEADER | tuple_rid | tuple_size | old_tuple_data | tuple_size | new_tuple_data |
----------------------------------------------------------------------------------
新页类型日志记录
-----------------------------------
| HEADER | prev_page_id | page_id |
-----------------------------------
我们需要根据不同类型日志记录的格式将日志记录序列化到 log_buffer_
中:
lsn_t LogManager::AppendLogRecord(LogRecord *log_record) {
std::unique_lock<std::mutex> lock(latch_);
// flush log to disk when the log buffer is full
if (log_record->size_ + log_buffer_offset_ > LOG_BUFFER_SIZE) {
// wake up flush thread to write log
need_flush_ = true;
cv_.notify_one();
// block current thread until log buffer is emptied
cv_append_.wait(lock, [&] { return log_record->size_ + log_buffer_offset_ <= LOG_BUFFER_SIZE; });
}
// serialize header
log_record->lsn_ = next_lsn_++;
memcpy(log_buffer_ + log_buffer_offset_, log_record, LogRecord::HEADER_SIZE);
int pos = log_buffer_offset_ + LogRecord::HEADER_SIZE;
// serialize body
switch (log_record->GetLogRecordType()) {
case LogRecordType::INSERT:
memcpy(log_buffer_ + pos, &log_record->insert_rid_, sizeof(RID));
pos += sizeof(RID);
log_record->insert_tuple_.SerializeTo(log_buffer_ + pos);
break;
case LogRecordType::MARKDELETE:
case LogRecordType::APPLYDELETE:
case LogRecordType::ROLLBACKDELETE:
memcpy(log_buffer_ + pos, &log_record->delete_rid_, sizeof(RID));
pos += sizeof(RID);
log_record->delete_tuple_.SerializeTo(log_buffer_ + pos);
break;
case LogRecordType::UPDATE:
memcpy(log_buffer_ + pos, &log_record->update_rid_, sizeof(RID));
pos += sizeof(RID);
log_record->old_tuple_.SerializeTo(log_buffer_ + pos);
pos += 4 + static_cast<int>(log_record->old_tuple_.GetLength());
log_record->new_tuple_.SerializeTo(log_buffer_ + pos);
break;
case LogRecordType::NEWPAGE:
memcpy(log_buffer_ + pos, &log_record->prev_page_id_, sizeof(page_id_t));
pos += sizeof(page_id_t);
memcpy(log_buffer_ + pos, &log_record->page_id_, sizeof(page_id_t));
break;
default:
break;
}
// update log buffer offset
log_buffer_offset_ += log_record->size_;
return log_record->lsn_;
}
事务管理器和缓冲池管理器
在我们调用 TablePage::InsertTuple
等方法的时候,内部会调用 LogManager::AppendLogRecord
添加日志记录,但是事务开始、提交或者终止时也需要我们添加记录:
Transaction *TransactionManager::Begin(Transaction *txn) {
// Acquire the global transaction latch in shared mode.
global_txn_latch_.RLock();
if (txn == nullptr) {
txn = new Transaction(next_txn_id_++);
}
if (enable_logging) {
LogRecord log_record(txn->GetTransactionId(), txn->GetPrevLSN(), LogRecordType::BEGIN);
auto lsn = log_manager_->AppendLogRecord(&log_record);
txn->SetPrevLSN(lsn);
}
txn_map[txn->GetTransactionId()] = txn;
return txn;
}
void TransactionManager::Commit(Transaction *txn) {
txn->SetState(TransactionState::COMMITTED);
// 省略部分代码
if (enable_logging) {
LogRecord log_record(txn->GetTransactionId(), txn->GetPrevLSN(), LogRecordType::COMMIT);
auto lsn = log_manager_->AppendLogRecord(&log_record);
txn->SetPrevLSN(lsn);
}
// Release all the locks.
ReleaseLocks(txn);
// Release the global transaction latch.
global_txn_latch_.RUnlock();
}
void TransactionManager::Abort(Transaction *txn) {
txn->SetState(TransactionState::ABORTED);
// 省略部分代码
if (enable_logging) {
LogRecord log_record(txn->GetTransactionId(), txn->GetPrevLSN(), LogRecordType::ABORT);
auto lsn = log_manager_->AppendLogRecord(&log_record);
txn->SetPrevLSN(lsn);
}
// Release all the locks.
ReleaseLocks(txn);
// Release the global transaction latch.
global_txn_latch_.RUnlock();
}
如前所述,将缓冲池中的脏页换出时需要强制保存日志,在 Buffer Pool Manager 实验中我们实现了一个 GetVictimFrameId
方法,只要略作修改即可:
frame_id_t BufferPoolManager::GetVictimFrameId() {
frame_id_t frame_id;
if (!free_list_.empty()) {
frame_id = free_list_.front();
free_list_.pop_front();
} else {
if (!replacer_->Victim(&frame_id)) {
return INVALID_PAGE_ID;
}
// flush log to disk when victim a dirty page
if (enable_logging) {
Page &page = pages_[frame_id];
if (page.IsDirty() && page.GetLSN() > log_manager_->GetPersistentLSN()) {
log_manager_->Flush();
}
}
}
return frame_id;
}
测试
日志管理器的测试结果如下所示:
系统恢复
本次实验使用的系统恢复策略较为简单,由于没有使用 Fuzzy Checkpoint,所以少了 Analysis
阶段,直接变成在 LogRecovery::Redo
函数中分析出当前活跃事务表 ATT 并进行重放,在 LogRecovery::Undo
函数中进行回滚。
日志记录反序列化
LogRecovery
会不断调用 DiskManager::ReadLog
函数直到读取完整个日志,由于我们先前将 LogRecord
进行了序列化,此处需要进行反序列化以访问记录中的信息。由于 log_buffer_
的大小为 LOG_BUFFER_SIZE
,所以将日志文件读取到 log_buffer_
的过程中可能截断最后一条记录,这时候需要返回 false
以表示反序列化失败:
bool LogRecovery::DeserializeLogRecord(const char *data, LogRecord *log_record) {
// convert data to record and check header
auto record = reinterpret_cast<const LogRecord *>(data);
if (record->size_ <= 0 || data + record->size_ > log_buffer_ + LOG_BUFFER_SIZE) {
return false;
}
// copy header
memcpy(reinterpret_cast<char *>(log_record), data, LogRecord::HEADER_SIZE);
// copy body
int pos = LogRecord::HEADER_SIZE;
switch (log_record->GetLogRecordType()) {
case LogRecordType::INSERT:
memcpy(&log_record->insert_rid_, data + pos, sizeof(RID));
pos += sizeof(RID);
log_record->insert_tuple_.DeserializeFrom(data + pos);
break;
case LogRecordType::MARKDELETE:
case LogRecordType::APPLYDELETE:
case LogRecordType::ROLLBACKDELETE:
memcpy(&log_record->delete_rid_, data + pos, sizeof(RID));
pos += sizeof(RID);
log_record->delete_tuple_.DeserializeFrom(data + pos);
break;
case LogRecordType::UPDATE:
memcpy(&log_record->update_rid_, data + pos, sizeof(RID));
pos += sizeof(RID);
log_record->old_tuple_.DeserializeFrom(data + pos);
pos += 4 + log_record->old_tuple_.GetLength();
log_record->new_tuple_.DeserializeFrom(data + pos);
break;
case LogRecordType::NEWPAGE:
memcpy(&log_record->prev_page_id_, data + pos, sizeof(page_id_t));
pos += sizeof(page_id_t);
memcpy(&log_record->page_id_, data + pos, sizeof(page_id_t));
break;
case LogRecordType::BEGIN:
case LogRecordType::COMMIT:
case LogRecordType::ABORT:
break;
default:
return false;
}
return true;
}
重放
重放过程十分简单粗暴,遍历整个日志的记录,如果记录的日志序号大于记录操作的 tuple 保存到磁盘上的日志序列号,说明 tuple 被修改后还没保存到磁盘上就宕机了,这时候需要进行回放。在遍历的时候先无脑将记录对应的事务添加到 ATT 中,直到事务被提交或者中断才将其移出 ATT。
void LogRecovery::Redo() {
while (disk_manager_->ReadLog(log_buffer_, LOG_BUFFER_SIZE, offset_)) {
// offset of current log buffer
size_t pos = 0;
LogRecord log_record;
// deserialize log entry to record
while (DeserializeLogRecord(log_buffer_ + pos, &log_record)) {
// update lsn mapping
auto lsn = log_record.lsn_;
lsn_mapping_[lsn] = offset_ + pos;
// Add txn to ATT with status UNDO
active_txn_[log_record.txn_id_] = lsn;
pos += log_record.size_;
// redo if page was not wirtten to disk when crash happened
switch (log_record.log_record_type_) {
case LogRecordType::INSERT: {
auto page = getTablePage(log_record.insert_rid_);
if (page->GetLSN() < lsn) {
page->WLatch();
page->InsertTuple(log_record.insert_tuple_, &log_record.insert_rid_, nullptr, nullptr, nullptr);
page->WUnlatch();
}
buffer_pool_manager_->UnpinPage(page->GetPageId(), page->GetLSN() < lsn);
break;
}
case LogRecordType::UPDATE: {
auto page = getTablePage(log_record.update_rid_);
if (page->GetLSN() < lsn) {
page->WLatch();
page->UpdateTuple(log_record.new_tuple_, &log_record.old_tuple_, log_record.update_rid_, nullptr, nullptr,
nullptr);
page->WUnlatch();
}
buffer_pool_manager_->UnpinPage(page->GetPageId(), page->GetLSN() < lsn);
break;
}
case LogRecordType::MARKDELETE:
case LogRecordType::APPLYDELETE:
case LogRecordType::ROLLBACKDELETE: {
auto page = getTablePage(log_record.delete_rid_);
if (page->GetLSN() < lsn) {
page->WLatch();
if (log_record.log_record_type_ == LogRecordType::MARKDELETE) {
page->MarkDelete(log_record.delete_rid_, nullptr, nullptr, nullptr);
} else if (log_record.log_record_type_ == LogRecordType::APPLYDELETE) {
page->ApplyDelete(log_record.delete_rid_, nullptr, nullptr);
} else {
page->RollbackDelete(log_record.delete_rid_, nullptr, nullptr);
}
page->WUnlatch();
}
buffer_pool_manager_->UnpinPage(page->GetPageId(), page->GetLSN() < lsn);
break;
}
case LogRecordType::COMMIT:
case LogRecordType::ABORT:
active_txn_.erase(log_record.txn_id_);
break;
case LogRecordType::NEWPAGE: {
auto page_id = log_record.page_id_;
auto page = getTablePage(page_id);
if (page->GetLSN() < lsn) {
auto prev_page_id = log_record.prev_page_id_;
page->WLatch();
page->Init(page_id, PAGE_SIZE, prev_page_id, nullptr, nullptr);
page->WUnlatch();
if (prev_page_id != INVALID_PAGE_ID) {
auto prev_page = getTablePage(prev_page_id);
if (prev_page->GetNextPageId() != page_id) {
prev_page->SetNextPageId(page_id);
buffer_pool_manager_->UnpinPage(prev_page_id, true);
} else {
buffer_pool_manager_->UnpinPage(prev_page_id, false);
}
}
}
buffer_pool_manager_->UnpinPage(page_id, page->GetLSN() < lsn);
break;
}
default:
break;
}
}
offset_ += pos;
}
}
回滚
LogRecovery::Undo
会遍历 ATT 中的每一个事务,对事务的操作进行回滚,回滚的规则如下:
- 如果日志记录类型为
LogRecordType::INSERT
,使用TablePage::ApplyDelete
进行回滚 - 如果日志记录类型为
LogRecordType::UPDATE
,使用TablePage::UpdateTuple
进行回滚 - 如果日志记录类型为
LogRecordType::APPLYDELETE
,使用TablePage::InsertTuple
进行回滚 - 如果日志记录类型为
LogRecordType::MARKDELETE
,使用TablePage::RollbackDelete
进行回滚 - 如果日志记录类型为
LogRecordType::ROLLBACKDELETE
,使用TablePage::MarkDelete
进行回滚
void LogRecovery::Undo() {
for (auto [txn_id, lsn] : active_txn_) {
while (lsn != INVALID_LSN) {
// read log from dist and convert log buffer entry to log record
LogRecord log_record;
auto offset = lsn_mapping_[lsn];
disk_manager_->ReadLog(log_buffer_, LOG_BUFFER_SIZE, offset);
DeserializeLogRecord(log_buffer_, &log_record);
lsn = log_record.GetPrevLSN();
// rollback
switch (log_record.GetLogRecordType()) {
case LogRecordType::INSERT: {
auto page = getTablePage(log_record.insert_rid_);
page->WLatch();
page->ApplyDelete(log_record.insert_rid_, nullptr, nullptr);
page->WUnlatch();
buffer_pool_manager_->UnpinPage(page->GetPageId(), true);
break;
}
case LogRecordType::UPDATE: {
auto page = getTablePage(log_record.update_rid_);
page->WLatch();
page->UpdateTuple(log_record.old_tuple_, &log_record.new_tuple_, log_record.update_rid_, nullptr, nullptr,
nullptr);
page->WUnlatch();
buffer_pool_manager_->UnpinPage(page->GetPageId(), true);
break;
}
case LogRecordType::MARKDELETE:
case LogRecordType::APPLYDELETE:
case LogRecordType::ROLLBACKDELETE: {
auto page = getTablePage(log_record.delete_rid_);
page->WLatch();
if (log_record.log_record_type_ == LogRecordType::MARKDELETE) {
page->RollbackDelete(log_record.delete_rid_, nullptr, nullptr);
} else if (log_record.log_record_type_ == LogRecordType::APPLYDELETE) {
page->InsertTuple(log_record.delete_tuple_, &log_record.delete_rid_, nullptr, nullptr, nullptr);
} else {
page->MarkDelete(log_record.delete_rid_, nullptr, nullptr, nullptr);
}
page->WUnlatch();
buffer_pool_manager_->UnpinPage(page->GetPageId(), true);
break;
}
default:
break;
}
}
}
active_txn_.clear();
lsn_mapping_.clear();
}
存档点管理器
存档点管理器用于保存日志并刷出缓冲池中所有的脏页,同时阻塞正在进行的事务。
void CheckpointManager::BeginCheckpoint() {
transaction_manager_->BlockAllTransactions();
log_manager_->Flush();
buffer_pool_manager_->FlushAllPages();
}
void CheckpointManager::EndCheckpoint() {
transaction_manager_->ResumeTransactions();
}
测试
日志恢复和存档点的测试结果如下:
总结
本次实验主要考察对预写式日志和数据库系统恢复的理解,代码上层面多了对多线程同步技术的要求,整个实验做下来感觉比较顺(除了被段错误坑了亿点时间外),以上~~
CMU15445 (Fall 2019) 之 Project#4 - Logging & Recovery 详解的更多相关文章
- CMU15445 (Fall 2019) 之 Project#1 - Buffer Pool 详解
前言 这个实验有两个任务:时钟替换算法和缓冲池管理器,分别对应 ClockReplacer 和 BufferPoolManager 类,BufferPoolManager 会用 ClockReplac ...
- CMU15445 (Fall 2019) 之 Project#2 - Hash Table 详解
前言 该实验要求实现一个基于线性探测法的哈希表,但是与直接放在内存中的哈希表不同的是,该实验假设哈希表非常大,无法整个放入内存中,因此需要将哈希表进行分割,将多个键值对放在一个 Page 中,然后搭配 ...
- CMU15445 (Fall 2019) 之 Project#3 - Query Execution 详解
前言 经过前面两个实验的铺垫,终于到了给数据库系统添加执行查询计划功能的时候了.给定一条 SQL 语句,我们可以将其中的操作符组织为一棵树,树中的每一个父节点都能从子节点获取 tuple 并处理成操作 ...
- Python的logging模块详解
Python的logging模块详解 作者:尹正杰 版权声明:原创作品,谢绝转载!否则将追究法律责任. 一.日志级别 日志级别指的是产生的日志的事件的严重程度. 设置一个级别后,严重程度 ...
- iOS学习——iOS项目Project 和 Targets配置详解
最近开始学习完整iOS项目的开发流程和思路,在实际的项目开发过程中,我们通常需要对项目代码和资料进行版本控制和管理,一般比较常用的SVN或者Github进行代码版本控制和项目管理.我们iOS项目的开发 ...
- CMU15445 之 Project#0 - C++ Primer 详解
前言 这个实验主要用来测试大家对现代 C++ 的掌握程度,实验要求如下: 简单翻译一下上述要求,就是我们需要实现定义在 src/include/primer/p0_starter.h 中的三个类 Ma ...
- Project Server 2010 配置详解
应公司要求,需要加强对项目的管理.安排我学习一下微软的Project是如何进行项目管理的,并且在公司服务器上搭建出这样的一个项目管理工具.可以通过浏览器就可以访问.我因为用的单机是Project Pr ...
- python logging模块详解[转]
一.简单将日志打印到屏幕: import logging logging.debug('debug message') logging.info('info message') logging.war ...
- Python logging模块详解
简单将日志打印到屏幕: import logging logging.debug('debug message') logging.info('info message') logging.warni ...
随机推荐
- clion 预编译文件的查看
看了一圈网上也没有我能一下就能看的懂的配置教程 我就手打一篇给在用clion的同学来参考一下 本文适用于g++编译 cmake Ninja生成器 clion 默认使用的是CMAKE来构建程序 生成器用 ...
- NLP教程(3) | 神经网络与反向传播
作者:韩信子@ShowMeAI 教程地址:http://www.showmeai.tech/tutorials/36 本文地址:http://www.showmeai.tech/article-det ...
- iptables系列教程(三)| iptables 实战篇
一个执着于技术的公众号 实战1 服务器禁止ping iptables -A INPUT -p icmp --icmp-type 8 -s 0/0 -j DROP // 禁止任何人ping通本机 &qu ...
- navicat软件、 python操作MySQL
查询关键字之having过滤 having与where的功能是一模一样的 都是对数据进行筛选 where用在分组之前的筛选 havng用在分组之后的筛选 为了更好的区分 所以将where说成筛选 ha ...
- strlen获取字符数组为什么是255
为什么是255呢? strlen函数的规则是,读取到0则判断字符串结束. char为1字节,只有8位. 所以...... -1就是 1111 1111, -2就是 1111 1110, 直到-128: ...
- 好客租房21-react组件的两种创建方式(函数组件)
1使用函数创建组件 函数组件:使用js的函数或者箭头函数创建的组件 约定1:函数组件名称必须以 开头 约定2:函数组件必须有返回值 表示该组件的结构 如果返回值为null 表示不渲染任何内容 2.1使 ...
- Ajax——Get请求
Get.html: <!DOCTYPE html> <html lang="en"> <head> <meta charset=" ...
- 安装Speedtest到CentOS(YUM)
Speedtest是一个由Python语言编写的一个网络测速脚本,提供多个外网的测试站点,我们可以使用它测试网络的IO速度. 如果由于网络问题导致无法下载软件包,则可以通过安装模块到Python的方式 ...
- 【产品】如何写好APP描述
你有没有想过越是需要花钱购买的 App,用户会更认真阅读你的 App描述?本文列举了15个 app 描述,看看哪些是我们以前忽略了的,哪些是我们也犯过的错误.图中有红色背景的文字是需要强调的地方,这些 ...
- 命令行传参——JavaSE基础
命令行传参 可以在运行一个程序时再传递给它消息,可以依靠命令行传参给mian()函数实现 public class CommandLine(){ public static void main(Str ...