CMU15445 (Fall 2020) 数据库系统 Project#4 - Concurrency Control 详解
前言
一个合格的事务处理系统,应该具备四个性质:原子性(atomicity)、一致性(consistency)、隔离性(isolation)和持久性(durability)。隔离性保证了一个活跃的事务(还没提交或者回滚)对数据库所做的系统对于其他的活跃事务是不可见的,看起来就像某一时刻就只有一个事务在操作数据库。然而完美的隔离性会导致数据库系统并发性能的下降,有些时候我们可以容忍数据的不一致性,所以可以牺牲一部分隔离性,来换取更好的并发性。这篇博客将介绍 CMU15-445 Fall2020 第四个实验 Concurrency Control 的实现过程,使用两阶段锁机制实现读未提交、读已提交和可重复读这三种隔离级别,同时使用等待图来定期检测死锁。
两阶段锁
可串行化是最高等级的隔离级别,如果一种事务调度是可串行化的,那么最终的效果等同于将多事务串行执行。如果两个事务都只对数据库进行读操作,就不会有冲突问题,不管怎么调度,都不会有一致性问题。但是一旦有一个事务有写操作,就会产生冲突。
对于下图所示的调度,可以看到里面存在冲突,T1 和 T2 中都存在读写操作。由于 R(B)
和 W(A)
、W(B)
和 R(A)
互不影响,我们可以通过调整调度他们的顺序,实现和串行化相同的效果,这时候称为冲突可串行化。
调整之后的调度如下图所示,最终的效果就是 T2 写了记录 A 和 B。
但是有些调度,不管你如何对调顺序,也无法串行化。
如果无法通过调度实现冲突串行化,我们就只能回滚事务,这样就做了很多的无用功。一种鉴定冲突是否可串行化的方式是根据调度构造出一个优先图,每个事务是图中的顶点,图中的边代表一种冲突操作。下图存在 W-R 和 R-W 冲突,构造出来的优先图是有环的,所以无法串行化。
虽然构造优先图的方法很好使,但是这要求我们事先知道整个调度长什么样子。为了解决此问题,两阶段锁协议横空出世。该协议要求每个事务分为两个阶段提出锁请求:
- 增长阶段:事务可以获得锁,但不能释放锁
- 缩减阶段:事务可以释放锁,但不能获得新锁
最初事务处于增长阶段,根据需要获得所,一旦释放锁就进入缩减阶段。假设下图中 T1 在 R(A)
之前加的是读锁,那么在 R(A)
之后不能通过释放读锁的方式来重新获取写锁,只能通过锁升级的方式将读锁转换为写锁。
虽然两阶段锁可以实现冲突可串行化,但是他无法避免脏读和级联回滚问题,这时候需要使用强两阶段锁协议,只在 Commit 之后一次性释放事务持有的所有锁。
锁管理器
为确保事务操作正确交错,数据库管理系统将使用锁管理器来控制何时允许事务访问数据项。锁管理器的基本思想是维护一个关于当前被活动事务所持有的锁的内部数据结构,事务在允许访问数据项之前向锁管理器发出锁请求。锁管理器将要么将锁授予调用事务,要么阻塞该事务,要么将其中止。
Bustub 中使用的数据结构是锁表 unordered_map<RID, LockRequestQueue> lock_table_
,如下图所示。锁表实际上是一个哈希表,键为 tuple 的行 ID,值为一个链表,记录了所有对此 tuple 发出加锁请求的事务。深蓝色的事务持有锁,浅蓝色的事务处于阻塞状态。
锁管理器处理锁请求的机制为:
- 当一条锁请求消息到达时,如果相应数据项的链表存在,在该链表末尾增加一个记录;否则,新建一个仅包含该请求记录的链表。在当前没有加锁的数据项上总是授予第一次加锁请求,但当事务向已被加锁的数据项申请加锁时,只有当该请求与当前持有的锁相容,并且所有先前的请求都已授予锁的条件下,锁管理器才为该请求授予锁,否则,该请求只好等待。
- 当锁管理器收到一个事务的解锁消息时,它将与该事务相对应的数据项链表中的记录删除,然后检查随后的记录,如果有,如前所述,就看该请求能否被授权,如果能,锁管理器授权该请求并处理其后记录,如果还有,类似地一个接一个地处理。
- 如果一个事务中止,锁管理器删除该事务产生的正在等待加锁的所有请求。一旦数据库系统采取适当动作撤销该事务,该中止事务持有的所有锁将被释放。
可以看到,这个机制和读写锁是一样的,可以保证锁请求不会发生饿死现象。
LockRequestQueue
的定义如下所示,我们在 LockRequestQueue
中添加了 reader_count_
和 writer_enter_
成员,搭配条件变量 cv_
和锁管理器中的 std::mutex latch_
可以实现读写锁:
enum class LockMode { SHARED, EXCLUSIVE };
class LockRequest {
public:
LockRequest(txn_id_t txn_id, LockMode lock_mode) : txn_id_(txn_id), lock_mode_(lock_mode), granted_(false) {}
txn_id_t txn_id_;
LockMode lock_mode_;
bool granted_;
};
class LockRequestQueue {
public:
std::list<LockRequest> request_queue_;
std::condition_variable cv_; // for notifying blocked transactions on this rid
bool upgrading_ = false;
uint32_t reader_count_ = 0;
bool writer_enter_ = false;
};
加共享锁
实验要求我们实现三种隔离级别:
- 读未提交:事务不需要持有锁,直接访问 tuple 就行,所以调用
LockShared()
的时候需要抛出异常 - 读已提交:事务需要在读 tuple 的时候加共享锁,读完之后解锁,这样在持有锁之前只会读到别人已提交的数据(因为别的事务持有的写锁统一在Commit 后释放),持有锁之后不会有人更新 tuple 的数据
- 可重复读:事务需要在读 tuple 的时候加共享锁,Commit 的时候才解锁(强两阶段锁协议),这样在 Commit 之前读到的数据都不会被别的事务所修改
对于加共享锁的请求,如果前面有排它锁请求,就需要阻塞当前事务,直到持有排它锁的事务释放锁。
bool LockManager::LockShared(Transaction *txn, const RID &rid) {
std::unique_lock<std::mutex> lock(latch_);
// 收缩阶段不允许上锁
CheckShrinking(txn);
// 不需要重复上锁
if (txn->IsSharedLocked(rid)) {
return true;
}
auto txn_id = txn->GetTransactionId();
// 读未提交不需要加读锁
if (txn->GetIsolationLevel() == IsolationLevel::READ_UNCOMMITTED) {
txn->SetState(TransactionState::ABORTED);
throw TransactionAbortException(txn_id, AbortReason::LOCKSHARED_ON_READ_UNCOMMITTED);
}
// 创建一个加锁请求
auto &queue = lock_table_[rid];
auto &request = queue.request_queue_.emplace_back(txn_id, LockMode::SHARED);
// 没拿到锁就进入阻塞状态
queue.cv_.wait(lock, [&] { return !queue.writer_enter_ || txn->IsAborted(); });
// 死锁会导致事务中止
CheckAborted(txn);
// 更新锁请求的状态
queue.reader_count_++;
request.granted_ = true;
txn->GetSharedLockSet()->emplace(rid);
return true;
}
void LockManager::CheckShrinking(Transaction *txn) {
if (txn->GetState() == TransactionState::SHRINKING) {
txn->SetState(TransactionState::ABORTED);
throw TransactionAbortException(txn->GetTransactionId(), AbortReason::LOCK_ON_SHRINKING);
}
}
void LockManager::CheckAborted(Transaction *txn) {
if (txn->IsAborted()) {
throw TransactionAbortException(txn->GetTransactionId(), AbortReason::DEADLOCK);
}
}
加排它锁
对于加排它锁的请求,需要等待前方的写请求和读请求完成才能拿到锁:
bool LockManager::LockExclusive(Transaction *txn, const RID &rid) {
std::unique_lock<std::mutex> lock(latch_);
CheckShrinking(txn);
if (txn->IsExclusiveLocked(rid)) {
return true;
}
// 创建加锁请求
auto &queue = lock_table_[rid];
auto &request = queue.request_queue_.emplace_back(txn->GetTransactionId(), LockMode::EXCLUSIVE);
// 没有拿到写锁就进入阻塞状态
queue.cv_.wait(lock, [&] { return (!queue.writer_enter_ && queue.reader_count_ == 0) || txn->IsAborted(); });
// 死锁会导致事务中止
CheckAborted(txn);
queue.writer_enter_ = true;
request.granted_ = true;
txn->GetExclusiveLockSet()->emplace(rid);
return true;
}
锁升级
如果前方有事务提出升级锁的请求,需要抛出异常并中止当前事务,否则得等到当前请求变成请求队列的第一个请求时才能完成升级操作:
bool LockManager::LockUpgrade(Transaction *txn, const RID &rid) {
std::unique_lock<std::mutex> lock(latch_);
txn->GetSharedLockSet()->erase(rid);
auto &queue = lock_table_[rid];
queue.reader_count_--;
auto request_it = GetRequest(txn->GetTransactionId(), rid);
request_it->lock_mode_ = LockMode::EXCLUSIVE;
request_it->granted_ = false;
// 如果前面有正在排队升级锁的事务就直接返回
if (queue.upgrading_) {
txn->SetState(TransactionState::ABORTED);
throw TransactionAbortException(txn->GetTransactionId(), AbortReason::UPGRADE_CONFLICT);
}
queue.upgrading_ = true;
queue.cv_.wait(lock, [&] { return (!queue.writer_enter_ && queue.reader_count_ == 0) || txn->IsAborted(); });
// 死锁会导致事务中止
CheckAborted(txn);
queue.upgrading_ = false;
queue.writer_enter_ = true;
request_it->granted_ = true;
txn->GetExclusiveLockSet()->emplace(rid);
return true;
}
释放锁
释放锁之后需要及时唤醒其他被阻塞的事务:
bool LockManager::Unlock(Transaction *txn, const RID &rid) {
std::unique_lock<std::mutex> lock(latch_);
txn->GetSharedLockSet()->erase(rid);
txn->GetExclusiveLockSet()->erase(rid);
auto request_it = GetRequest(txn->GetTransactionId(), rid);
auto lock_mode = request_it->lock_mode_;
// 更新事务状态,读已提交不需要两阶段锁机制
if (txn->GetState() == TransactionState::GROWING &&
!(lock_mode == LockMode::SHARED && txn->GetIsolationLevel() == IsolationLevel::READ_COMMITTED)) {
txn->SetState(TransactionState::SHRINKING);
}
// 从加锁请求队列中移除事务的请求
auto &queue = lock_table_[rid];
queue.request_queue_.erase(request_it);
if (lock_mode == LockMode::SHARED) {
// 唤醒等待读锁的线程
if (--queue.reader_count_ == 0) {
queue.cv_.notify_all();
}
} else {
// 唤醒等待写锁的线程
queue.writer_enter_ = false;
queue.cv_.notify_all();
}
return true;
}
死锁检测
两阶段锁无法避免死锁现象的出现,所以我们需要有一个后台线程来定期做死锁检测。死锁检测的方式是根据事务的等待情况构建一个有向的等待图,如果等待图中有环,就中止环中最年轻的事务,直到没有环出现。
LockManager
中的 unordered_map<txn_id_t, vector<txn_id_t>> waits_for_
代表等待图,键为事务 T(顶点),值为事务 T 在等待解锁的其他事务的集合,二者组成了等待图中的边。
void LockManager::AddEdge(txn_id_t t1, txn_id_t t2) {
txns_.insert(t1);
txns_.insert(t2);
auto &neighbors = waits_for_[t1];
auto it = std::find(neighbors.begin(), neighbors.end(), t2);
if (it == neighbors.end()) {
neighbors.push_back(t2);
}
}
void LockManager::RemoveEdge(txn_id_t t1, txn_id_t t2) {
auto &neighbors = waits_for_[t1];
auto it = std::find(neighbors.begin(), neighbors.end(), t2);
if (it != neighbors.end()) {
neighbors.erase(it);
}
}
std::vector<std::pair<txn_id_t, txn_id_t>> LockManager::GetEdgeList() {
std::vector<std::pair<txn_id_t, txn_id_t>> edges;
for (auto &[t1, neighbors] : waits_for_) {
for (auto t2 : neighbors) {
edges.emplace_back(t1, t2);
}
}
return edges;
}
要检测环,需要使用深度优先算法:从某个顶点 \(v_s\) 出发,遍历顶点的邻接顶点,如果最终能回到 \(v_s\),就说明存在环。具体实现时是在集合 on_stack_txns_
中维护正在访问的顶点,如果遍历临接顶点的时候能在集合中找到 \(v_s\) 就说明有环。
bool LockManager::HasCycle(txn_id_t *txn_id) {
for (auto &t1 : txns_) {
DFS(t1);
if (has_cycle_) {
*txn_id = *on_stack_txns_.rbegin();
on_stack_txns_.clear();
has_cycle_ = false;
return true;
}
}
on_stack_txns_.clear();
return false;
}
void LockManager::DFS(txn_id_t txn_id) {
if (has_cycle_) {
return;
}
on_stack_txns_.insert(txn_id);
auto &neighbors = waits_for_[txn_id];
std::sort(neighbors.begin(), neighbors.end());
for (auto t2 : neighbors) {
if (!on_stack_txns_.count(t2)) {
DFS(t2);
} else {
has_cycle_ = true;
return;
}
}
on_stack_txns_.erase(txn_id);
}
定期检测环时需要先根据锁表构建等待图,然后移除所有环并中止相关事务,最后清空等待图:
void LockManager::RunCycleDetection() {
while (enable_cycle_detection_) {
std::this_thread::sleep_for(cycle_detection_interval);
{
std::unique_lock<std::mutex> l(latch_);
// 构建等待图
for (auto &[rid, queue] : lock_table_) {
std::vector<txn_id_t> grants;
auto it = queue.request_queue_.begin();
while (it != queue.request_queue_.end() && it->granted_) {
grants.push_back(it->txn_id_);
it++;
}
while (it != queue.request_queue_.end()) {
for (auto &t2 : grants) {
AddEdge(it->txn_id_, t2);
}
wait_rids_[it->txn_id_] = rid;
it++;
}
}
// 移除环中 id 最小的事务
txn_id_t txn_id;
while (HasCycle(&txn_id)) {
AbortTransaction(txn_id);
}
// 清空图
waits_for_.clear();
wait_rids_.clear();
txns_.clear();
}
}
}
void LockManager::AbortTransaction(txn_id_t txn_id) {
auto txn = TransactionManager::GetTransaction(txn_id);
txn->SetState(TransactionState::ABORTED);
waits_for_.erase(txn_id);
// 释放所有 txn 持有的写锁
for (auto &rid : *txn->GetExclusiveLockSet()) {
for (auto &req : lock_table_[rid].request_queue_) {
if (!req.granted_) {
RemoveEdge(req.txn_id_, txn_id);
}
}
}
// 释放所有 txn 持有的读锁
for (auto &rid : *txn->GetSharedLockSet()) {
for (auto &req : lock_table_[rid].request_queue_) {
if (!req.granted_) {
RemoveEdge(req.txn_id_, txn_id);
}
}
}
// 通知 txn 所在线程事务被终止了
lock_table_[wait_rids_[txn_id]].cv_.notify_all();
}
并发执行
上一节中我们实现了单线程的执行器,现在需要对其进行修改,使其支持三种隔离级别的并发事务。
全表扫描
如果隔离级别高于读未提交,就给事务加上共享锁,读已提交的事务需要在返回 tuple 之前释放锁:
void SeqScanExecutor::Unlock(Transaction *txn, const RID &rid) {
if (txn->GetIsolationLevel() == IsolationLevel::READ_COMMITTED) {
exec_ctx_->GetLockManager()->Unlock(txn, rid);
}
}
bool SeqScanExecutor::Next(Tuple *tuple, RID *rid) {
auto predicate = plan_->GetPredicate();
auto txn = exec_ctx_->GetTransaction();
while (it_ != table_metadata_->table_->End()) {
*rid = it_->GetRid();
// 上锁
if (txn->GetIsolationLevel() != IsolationLevel::READ_UNCOMMITTED && !txn->IsExclusiveLocked(*rid)) {
exec_ctx_->GetLockManager()->LockShared(txn, *rid);
}
*tuple = *it_++;
if (!predicate || predicate->Evaluate(tuple, &table_metadata_->schema_).GetAs<bool>()) {
// 只保留输出列
std::vector<Value> values;
for (auto &col : GetOutputSchema()->GetColumns()) {
values.push_back(col.GetExpr()->Evaluate(tuple, &table_metadata_->schema_));
}
*tuple = {values, GetOutputSchema()};
// 解锁
Unlock(txn, *rid);
return true;
}
Unlock(txn, *rid);
}
return false;
}
插入
插入的时候需要给 tuple 加写锁,但是不应该在 TableHeap::InsertTuple()
后面加,而是应该在函数里面加,这样插入之后才不会有其他事务改动了此 tuple。为了实现回滚操作,还需要添加一条 IndexWriteRecord
。由于 TableHeap::InsertTuple()
内部已经添加了 TableWriteRecord
,所以我们无需再次添加。
void InsertExecutor::InsertTuple(Tuple *tuple, RID *rid) {
// 更新数据表,需要在 TablePage::InsertTuple 中加锁
table_metadata_->table_->InsertTuple(*tuple, rid, exec_ctx_->GetTransaction());
// 更新索引
for (auto &index_info : index_infos_) {
index_info->index_->InsertEntry(
tuple->KeyFromTuple(table_metadata_->schema_, index_info->key_schema_, index_info->index_->GetKeyAttrs()), *rid,
exec_ctx_->GetTransaction());
IndexWriteRecord record(*rid, table_metadata_->oid_, WType::INSERT, *tuple, index_info->index_oid_,
exec_ctx_->GetCatalog());
exec_ctx_->GetTransaction()->AppendTableWriteRecord(record);
}
}
更新
更新之前需要将读锁升级为写锁,如果先前没有拿锁就需要直接请求写锁。
bool UpdateExecutor::Next([[maybe_unused]] Tuple *tuple, RID *rid) {
if (!child_executor_->Next(tuple, rid)) {
return false;
}
// 更新数据表
auto new_tuple = GenerateUpdatedTuple(*tuple);
// 加锁
auto txn = exec_ctx_->GetTransaction();
if (txn->IsSharedLocked(*rid)) {
exec_ctx_->GetLockManager()->LockUpgrade(txn, *rid);
} else {
exec_ctx_->GetLockManager()->LockExclusive(txn, *rid);
}
table_info_->table_->UpdateTuple(new_tuple, *rid, exec_ctx_->GetTransaction());
// 更新索引
for (auto &index_info : index_infos_) {
// 删除旧的 tuple
index_info->index_->DeleteEntry(
tuple->KeyFromTuple(table_info_->schema_, index_info->key_schema_, index_info->index_->GetKeyAttrs()), *rid,
exec_ctx_->GetTransaction());
// 插入新的 tuple
index_info->index_->InsertEntry(
new_tuple.KeyFromTuple(table_info_->schema_, index_info->key_schema_, index_info->index_->GetKeyAttrs()), *rid,
exec_ctx_->GetTransaction());
IndexWriteRecord record(*rid, table_info_->oid_, WType::UPDATE, *tuple, index_info->index_oid_,
exec_ctx_->GetCatalog());
exec_ctx_->GetTransaction()->AppendTableWriteRecord(record);
}
return true;
}
后记
通过这次实验,可以加深对隔离级别和两阶段锁协议的理解,代码上层面多了对多线程同步技术的要求,不过做过去年实验的话应该问题也不大,以上~
CMU15445 (Fall 2020) 数据库系统 Project#4 - Concurrency Control 详解的更多相关文章
- CMU15445 (Fall 2019) 之 Project#1 - Buffer Pool 详解
前言 这个实验有两个任务:时钟替换算法和缓冲池管理器,分别对应 ClockReplacer 和 BufferPoolManager 类,BufferPoolManager 会用 ClockReplac ...
- CMU15445 (Fall 2019) 之 Project#4 - Logging & Recovery 详解
前言 这是 Fall 2019 的最后一个实验,要求我们实现预写式日志.系统恢复和存档点功能,这三个功能分别对应三个类 LogManager.LogRecovery 和 CheckpointManag ...
- CMU15445 (Fall 2019) 之 Project#2 - Hash Table 详解
前言 该实验要求实现一个基于线性探测法的哈希表,但是与直接放在内存中的哈希表不同的是,该实验假设哈希表非常大,无法整个放入内存中,因此需要将哈希表进行分割,将多个键值对放在一个 Page 中,然后搭配 ...
- CMU15445 (Fall 2019) 之 Project#3 - Query Execution 详解
前言 经过前面两个实验的铺垫,终于到了给数据库系统添加执行查询计划功能的时候了.给定一条 SQL 语句,我们可以将其中的操作符组织为一棵树,树中的每一个父节点都能从子节点获取 tuple 并处理成操作 ...
- iOS学习——iOS项目Project 和 Targets配置详解
最近开始学习完整iOS项目的开发流程和思路,在实际的项目开发过程中,我们通常需要对项目代码和资料进行版本控制和管理,一般比较常用的SVN或者Github进行代码版本控制和项目管理.我们iOS项目的开发 ...
- CMU15445 之 Project#0 - C++ Primer 详解
前言 这个实验主要用来测试大家对现代 C++ 的掌握程度,实验要求如下: 简单翻译一下上述要求,就是我们需要实现定义在 src/include/primer/p0_starter.h 中的三个类 Ma ...
- Java 编程要点之并发(Concurrency)详解
计算机用户想当然地认为他们的系统在一个时间可以做多件事.他们认为,他们可以工作在一个字处理器,而其他应用程序在下载文件,管理打印队列和音频流.即使是单一的应用程序通常也是被期望在一个时间来做多件事.例 ...
- Project Server 2010 配置详解
应公司要求,需要加强对项目的管理.安排我学习一下微软的Project是如何进行项目管理的,并且在公司服务器上搭建出这样的一个项目管理工具.可以通过浏览器就可以访问.我因为用的单机是Project Pr ...
- [Project] SpellCorrect源码详解
该Project原来的应用场景是对电商网站中输入一个错误的商品名称进行智能纠错,比如iphoae纠错为iphone.以下介绍的这个版本对其作了简化,项目源代码地址参见我的github:https:// ...
- intellij idea - Project Structure 项目结构详解(简单明了)
IDEA Project Structure 设置 可以点击 按钮,或者使用快捷键 Ctrl + Shift + Alt + S 打开 Project Structure .如下如所示: 项目的左 ...
随机推荐
- vue拖拽排序插件vuedraggable的使用 附原生使用方法
Vue中使用 先下载依赖: npm install vuedraggable -S 项目中引入 import draggable from 'vuedraggable' 注册 components: ...
- .NET周报 【4月第1期 2023-04-02】
国内文章 探索 SK 示例 -- GitHub 存储库中的机器人 https://www.cnblogs.com/shanyou/p/17280627.html 微软 3月22日 一篇文章" ...
- pandas之loc/iloc操作
在数据分析过程中,很多时候需要从数据表中提取出相应的数据,而这么做的前提是需要先"索引"出这一部分数据.虽然通过 Python 提供的索引操作符"[]"和属性操 ...
- SpringBoot项目中使用缓存Cache的正确姿势!!!
前言 缓存可以通过将经常访问的数据存储在内存中,减少底层数据源如数据库的压力,从而有效提高系统的性能和稳定性.我想大家的项目中或多或少都有使用过,我们项目也不例外,但是最近在review公司的代码的时 ...
- day20:正则表达式
单个字符的匹配 findall(正则表达式,字符串) 把符合正则表达式的字符串存在列表中返回 预定义字符集(8) \d 匹配数字 \D 匹配非数字 \w 匹配数字字母下划线 \W 匹配非数字或字母或下 ...
- C++模板(函数模板 & 类模板)
模板编程可称范型编程,是一种忽视数据类型的编程方式,这样的好处是什么?且看下面一个例子: 简单使用 求解最值问题,返回两个值中的较大值: int Max(int a, int b) { return ...
- Docker入门实践笔记-基本使用
容器是一个系统中被隔离的特殊环境,进程可以在其中不受干扰地运行,使用Docker来实现容器化 容器化 运行容器时,要先拉取一个镜像(image),再通过这个镜像来启动容器: $ sudo docker ...
- 测试环境治理之MYSQL索引优化篇
作者:京东物流 李光新 1 治理背景 测试环境这个话题对于开发和测试同学一定不陌生,大家几乎每天都会接触.但是说到对测试环境的印象,却鲜有好评: •环境不稳定,测试五分钟,排查两小时 •基础建设不全, ...
- Java SE 20 新增特性
Java SE 20 新增特性 作者:Grey 原文地址: 博客园:Java SE 20 新增特性 CSDN:Java SE 20 新增特性 源码 源仓库: Github:java_new_featu ...
- 我的web系统设计规范
以下是我自己在工作中总结的,仅供参考. ·应对所有用户输入进行trim()去除两头空格,若是需要空格的应用 转义代替,不应在js里trim(),而应该在数据库端或后端控制,且只在一处拦截控制,更改策略 ...