1.前言

锁在生活中随处可见,电子锁、挂锁、门锁等等。在数据库中,同样也存在着各式各样的锁,表级锁、行级锁、页锁等等,数据库的并发能力除了和它的并发控制机制有关, 还和数据库的锁粒度控制息息相关,粒度越细冲突范围就越小,并发能力就越强。锁的最终目的无外乎是为了保证数据的一致性和完整性。

2.锁

为了确保复杂的事务可以安全地同时运行,TBase提供了各种级别的锁来控制对各种数据对象的并发访问,使得对数据库关键部分的更改序列化。事务并发运行,直到它们尝试获取互相冲突的锁为止(比如两个事务更新同一行时)。当多个事务同时在数据库中运行时,并发控制是一种用于维持一致性和隔离性的技术,在TBase中,使用快照隔离Sanpshot Isolation(简称SI)来实现多版本并发控制,同时以两阶段锁定(2PL)机制为辅。在执行DDL时使用2PL,在执行DML时使用SI。

在TBase中,最主要的是表级锁与行级锁,此外还有页锁、咨询锁和死锁等。

锁根据是否对用户可见可以进一步划分,Regular Locks、Advisory Locks和Dead Locks属于可见的锁,而Spin Lock、LWLock和SIRead属于不可见的锁。

2.1.Table-Level Locks

在TBase中,表级锁分为8种,如下:

上图不方便理解和记忆,将对应的锁转换成常见的SQL就明了了,如下:

ACCESS SHARE和ACCESS EXCLUSIVE可以理解为多版本读/写,SELECT会在查询的表上获取ACCESS SHARE,而那些很硬的操作,诸如TRUNCATE、DROP TABLE等都会获取ACCESS EXCLUSIVE。

ROW SHARE和ROW EXCLUSIVE可以理解为意向读/写锁,意向锁根据名字,就是意向做一件事,但并非实际执行,所以可以看到ROW SHARE和ROW EXCLUSIVE之间互不冲突。当要更新插入时,需要先在对应的表上获取ROW EXCLUSIVE锁。

SHARE和EXCLUSIVE为传统的读写锁,在TBASE中有点变化,EXCLUSIVE锁出现频率很低,SHARE锁用在了创建索引的时候,因为SHARE锁不自斥,所以也就意味着在一张表上可以同时创建多个索引,但是会堵塞插入更新等(和ROW EXCLUSIVE冲突)。

而SHARE UPDATE EXCLUSIVE和SHARE ROW EXCLUSIVE两种锁比较难记忆,SHARE UPDATE EXCLUSIVE在VACUUM和ALTER TABLE SET(XXX)等操作时会获取,因为SHARE UPDATE EXCLUSIVE是自斥的,所以目前TBASE无法做到表级的并行VACUUM,但可以做到库级的并行VACUUM(好消息是,在刚刚发布不久的POSTGRESQL13中,对索引的清理支持并行了,TBase在不久也会合入相关代码);SHARE ROW EXCLUSIVE出现的概率较低,一般常见的是创建触发器。

2.2.Row-Level Locks

表级锁存在于TBase的shared buffers中,所以可以很直观的通过pg_locks查看。但是行级锁不一样,TBase不会将已获得的行级锁信息存储在内存中,如果在pg_locks中查看到了行锁信息,并不代表行锁信息存储在内存中,这种情况往往出现在行锁等待,比如后文会介绍的“tuple锁”,行锁通常只在tuple的header中设置标记位来标识此行记录已经被锁。这两个关键的标记为xmax和infomask(HEAP_UPDATED)。xmax放置当前事务的xid,infomask放置标记位。

假设每个行锁都在内存有一条记录的话,全表更新时,表有多少行,就在内存中有多少条行锁信息,那么内存会吃不消。设置infomask的目的是为了与正常的dead tuple区别开来,xmax是用来标识被删除的记录。基于这点,理论上一次可以锁定的行数没有限制。

另外这里可以衍生出一个经典PG系面试题,select是否会产生写?除了较为熟知的设置标志位(后面的DML或者DQL,VACUUM等SQL扫描到对应的TUPLE时,会触发对前面扫描到的行set bits的操作),还有这里的行级锁也会有写IO。当然,行级锁也并非不能观察,可以通过pgrowlocks和pageinspect插件来观察。

行级锁的冲突矩阵如下:

FOR UPDATE:对整行进行更新,包括删除行

FOR NO KEY UPDATE:对除主(唯一)键外的字段更新

FOR SHARE:读该行,不允许对行进行更新

FOR KEY SHARE:读该行的键值,但允许对除键外的其他字段更新。在外键检查时使用该锁

2.3.Page-Level Locks

TBase中,有对于表的数据页的共享/互斥锁,一旦这些行读取或者更改完成后,相应的页锁就被释放。

比较常见的有:

WalInsertLock,wal buffer是固定大小的,向wal buffer中写wal record需要竞争的锁,如果把synchronous_commit关闭,这个锁的竞争会更加激烈;

WALWriteLock:一般都是同步提交,要保证commit时,wal是刷盘的,那么刷盘就会竞争这个锁。将页面写入磁盘会受WALWriteLock锁保护,一次只能有一个进程可以执行此操作。

ProcArrayLock,对于每一个连接,在shared buffer中都有一个结构体PGPROC用于追踪正在运行的后端进程和事务,每个backend在事务在commit或abort时,都要对其PGPROC和PGXACT中关于事务状态的属性进行设置,标记对应的事务不再运行。

一般不怎么需要关心页锁,但是假如在pg_stat_activity视图里面看到了大量相关的等待事件,也需要特别注意。比如出现了大量的BufferMapping,说明buffer发生了频繁替换导致了锁等待,可能存在大量表扫描,buffer太小了或io存在瓶颈、磁盘太烂了等原因。

2.4.Spin Lock

编程中常听说的自旋锁,TBase中同样也有,SpinLock主要用于对于临界变量的并发访问控制,所保护的临界区通常是简单的赋值语句,读取语句等。另外,自旋锁的显著特点就是死等,占着茅坑不拉屎,SpinLock没有等待队列、死锁检测机制,在事务结束之后不会自动释放,需要每次显式释放。在TBase中,使用CPU指令集test-and-set或者信号量来实现自旋锁。

2.5.Deadlocks

死锁在各大主流数据库中都有,当一个事务试图获取另一个事务已在使用的资源,而第二个事务又试图获取第一个事务在使用的资源时,就会发生死锁,在TBase中,有lock_timeout、deadlock_timeout、log_lock_waits(日志记录)等参数控制,另外TBase中还有一个专门的死锁检测进程pg_unlock。死锁检测是一件十分耗费资源的事情,大多数时候,一旦检测到死锁,则其中一个事务(大多数情况下是发起检查的事务)将被迫中止。

2.6.Advisory Locks

当MVCC模型和锁策略不符合应用时,采用咨询锁。TBase允许用户创建咨询锁,该锁与数据库本身没有关系,比如多个进程访问同一个数据库时,如果想协调这些进程对一些非数据库资源的并发访问,就可以使用咨询锁。咨询锁是提供给应用层显示调用的锁方法,在表中存储一个标记位能够实现同样的功能,但是咨询锁更快;并且避免表膨胀,且会话(或事务)结束后能够被自动清理。

2.7.实战

TBase是CN + DN + GTM的经典架构,锁的情况在CN和DN上是独立的,需要在各个节点上观察。

在CN上创建一张test_lock的测试表:

psql (PostgreSQL 10.0 TBase V2)

Type "help" for help.

postgres=# create table test_lock(id int primary key,info text);

CREATE TABLE

postgres=# insert into test_lock values(1,'test1'),(2,'test2');

COPY 2

postgres=# begin;

BEGIN

postgres=# select pg_backend_pid(),txid_current();

pg_backend_pid | txid_current

----------------+--------------

17729 |       142160

(1 row)

postgres=# update test_lock set info = 'mytest_lock' where id = 1;

UPDATE 1

此处未提交,同时再开启一个会话,进行更新

postgres=# begin;

BEGIN

postgres=# select pg_backend_pid(),txid_current();

pg_backend_pid | txid_current

----------------+--------------

19135 |       142162

(1 row)

postgres=# update test_lock set info = 'mytest_lock2' where id = 1;

此处会夯住

在CN上查看,未发现锁等待(granted = t)

postgres=# select locktype,relation::regclass as relname,page,tuple,pid,mode,granted from pg_locks where pid in (17729,19135);

locktype    |  relname  | page | tuple |  pid  |       mode       | granted

---------------+-----------+------+-------+-------+------------------+---------

relation      | test_lock |      |       | 19135 | AccessShareLock  | t

relation      | test_lock |      |       | 19135 | RowExclusiveLock | t

virtualxid    |           |      |       | 19135 | ExclusiveLock    | t

relation      | test_lock |      |       | 17729 | AccessShareLock  | t

relation      | test_lock |      |       | 17729 | RowExclusiveLock | t

virtualxid    |           |      |       | 17729 | ExclusiveLock    | t

transactionid |           |      |       | 19135 | ExclusiveLock    | t

transactionid |           |      |       | 17729 | ExclusiveLock    | t

(8 rows)

锁的阻塞通常需要在DN上查看。以下可以看到,1289这个进程被1290阻塞住,1290进程处于idle in transaction的状态,也就是还未提交,1289进程正在等待某一个事务结束。

postgres=# select pid,pg_blocking_pids(pid) as blocked,state,query,wait_event,wait_event_type from pg_stat_activity where query like '%UPDATE%' and pid <> pg_backend_pid();

pid  | blocked |        state        |                              query                              |  wait_event   | wait_event_type

------+---------+---------------------+-----------------------------------------------------------------+---------------+-----------------

1289 | {1290}  | active              | UPDATE test_lock SET info = 'mytest_lock2'::text WHERE (id = 1) | transactionid | Lock

1290 | {}      | idle in transaction | UPDATE test_lock SET info = 'mytest_lock'::text WHERE (id = 1)  | ClientRead    | Client

(2 rows)

pg_stat_activity视图可以看一个大概,具体锁信息还需要结合pg_locks查看,SQL如下:select relation::regclass as relname,locktype,'('||page||','||tuple||')' as ctid,transactionid,virtualtransaction,virtualxid,pid,mode,granted from pg_locks where pid in (1289,1290) order by pid;

可以看到,1290这个进程持有两个ExclusiveLock,分别是事务ID和虚拟事务ID,virtualxid是虚拟事务ID,每产生一个事务ID,都会在commit log(CLOG)文件中占用2bit。但有些事务没有产生任何实质的变更,比如只读事务或空事务,这样给它们也分配一个事务ID就很浪费。因此对这类没有实质变更的事务只分配虚拟事务ID,比如查询。另外也可以防止事务ID快速回卷(大约每满21亿就会发生回卷)。

在整个事务运行过程中,服务器进程对虚拟事务ID持有排他锁。如果分配了永久性事务ID,它还将对永久性事务ID持有排他锁,直到结束。所以印证了上面的说法。同时1290这个进程对test_lock表加上了RowExclusiveLock,这个仍然是表级锁。当1290进程想要修改表上的某一行时,会在这一行上加上行级锁。但在加行级锁之前,还需要先在这张表上加上一把意向锁,表示自己将会在表中的若干行上加锁,这样其他事务来了就可以知道,表上还有意向锁,说明已经有某些行上持有了锁。因此,RowExclusiveLock是行级锁(对应级别低的资源)的上面资源的锁(对应级别高的资源,此处为表),换言之,给表加上了RowExclusiveLock也就意味着自己准备在表内的某些行上加锁。

1289这个进程,同样可以看到持有两个RowExclusiveLock锁,因为还涉及到了主键索引;同时也对事务ID和虚拟ID持有ExclusiveLock,当某个进程发现需要等待另一笔事务结束时,它还会通过尝试获取另一笔事务ID(可能是虚拟ID也可能是永久事务ID)上的共享锁。仅当另一个事务终止并释放锁时,该操作才会成功。所以此处可以看到,1289进程在申请150841事务的共享锁,由于1290进程已经持有对150841事务的ExclusiveLock,所以会阻塞住。其实换个角度思考也不难理解,因为第一个150841事务对test_lock表进行了更改,但未提交,所以也就可能提交也可能回滚,然后150843事务也需要对表的同一行进行更改,并且依赖于150841事务的“结果”,所以会等待150841事务。而看到的locktype = tuple的这一行,注意不要和行级锁混淆,此处的tuple代表tuple锁,tuple锁可以保证多个修改事务加锁的顺序问题,原则是先来先拿锁,修改完tuple后,tuple锁会立即释放,而事务锁不会释放。所以并未看到1290进程的tuple锁,原因是更改成功并立即释放了。假设有3个事务,A、B、C依次对同一行修改,均未提交。那么这个时候,A处于idle in transaction状态,B持有tuple锁但是等待A的事务锁,C等待B持有的tuple锁。

在CN上再开一个会话,更新同一行,DN上可以看到,新来的24268进程被1289阻塞,1289进程被1290阻塞,位于一个锁队列中,同时可以看到24268进程在等待给tuple加锁,但1289进程已经持有了tuple锁,但在等待1290进程的事务锁。

postgres=# select pid,pg_blocking_pids(pid) as blocked,state,query,wait_event,wait_event_type from pg_stat_activity where query like '%UPDATE%' and pid <> pg_backend_pid();

pid  | blocked |        state        |                              query                              |  wait_event   | wait_event_type

-------+---------+---------------------+-----------------------------------------------------------------+---------------+-----------------

1289 | {1290}  | active              | UPDATE test_lock SET info = 'mytest_lock2'::text WHERE (id = 1) | transactionid | Lock

1290 | {}      | idle in transaction | UPDATE test_lock SET info = 'mytest_lock'::text WHERE (id = 1)  | ClientRead    | Client

24268 | {1289}  | active              | UPDATE test_lock SET info = 'mytest_lock2'::text WHERE (id = 1) | tuple         | Lock

(3 rows)

postgres=# select relation::regclass as relname,locktype,'('||page||','||tuple||')' as ctid,transactionid,virtualtransaction,virtualxid,pid,mode,granted from pg_locks where pid in (1289,1290,24268) and locktype = 'tuple' order by pid;

relname  | locktype | ctid  | transactionid | virtualtransaction | virtualxid |  pid  |     mode      | granted

-----------+----------+-------+---------------+--------------------+------------+-------+---------------+---------

test_lock | tuple    | (0,1) |               | 15/1214049         |            |  1289 | ExclusiveLock | t

test_lock | tuple    | (0,1) |               | 92/331586          |            | 24268 | ExclusiveLock | f

(2 rows)

至此,锁分析清楚了。由于TBase暂未集成pageinspect和pgrowlocks插件,所以为了演示,以下在PostgreSQL中的环境,大同小异。老样子开两个会话

postgres=# begin;

BEGIN

postgres=# select txid_current(),pg_backend_pid();

txid_current | pg_backend_pid

--------------+----------------

545 |          10698

(1 row)

postgres=# update test_lock set id = 100 where id = 1;

UPDATE 1

session 2更新同一行

postgres=# begin;

BEGIN

postgres=# select txid_current(),pg_backend_pid();

txid_current | pg_backend_pid

--------------+----------------

547 |          10903

(1 row)

postgres=# update test_lock set id = 100 where id = 1;

此处夯住

通过pgrowlocks查看行级锁,很明了,10698进程的545这个事务,持有No Key Update的行级锁,因为没有多个事务,所以此处的multi为f

postgres=# select * from test_lock as t,pgrowlocks('test_lock') as lc where t.ctid = lc.locked_row;

id | locked_row | locker | multi | xids  |       modes       |  pids

----+------------+--------+-------+-------+-------------------+---------

1 | (0,1)      |    545 | f     | {545} | {"No Key Update"} | {10698}

(1 row)

如果是多个事务同时去上锁一行记录,那么就会使用multixact。继续在会话1中执行:

postgres=# select * from test_lock where id = 2 for update;

id

----

2

(1 row)

postgres=# select * from test_lock where id = 3 for share;

id

----

3

(1 row)

查看行锁

postgres=# select * from test_lock as t,pgrowlocks('test_lock') as lc where t.ctid = lc.locked_row;

id | locked_row | locker | multi | xids  |       modes       |  pids

----+------------+--------+-------+-------+-------------------+---------

1 | (0,1)      |    545 | f     | {545} | {"No Key Update"} | {10698}

2 | (0,2)      |    545 | f     | {545} | {"For Update"}    | {10698}

3 | (0,3)      |    545 | f     | {545} | {"For Share"}     | {10698}

(3 rows)

新开一个会话,并且也获取for share的行级锁

postgres=# begin;

BEGIN

postgres=# select pg_backend_pid(),txid_current();

pg_backend_pid | txid_current

----------------+--------------

17936 |          551

(1 row)

postgres=# select * from test_lock where id = 3 for share;

id

----

3

(1 row)

再次查看行锁,这次可以看到使用了multixact。因为对于FOR SHARE和FOR KEY SHARE,一行上面可能会被多个事务加锁,Tuple上动态维护这些事务代价很高,为此引入了multixact机制,将多个事务记录到MultiXactId,再将MultiXactId记录到tuple的xmax中。

postgres=# select * from test_lock as t,pgrowlocks('test_lock') as lc where t.ctid = lc.locked_row;

id | locked_row | locker | multi |   xids    |       modes       |     pids

----+------------+--------+-------+-----------+-------------------+---------------

1 | (0,1)      |    545 | f     | {545}     | {"No Key Update"} | {10698}

2 | (0,2)      |    545 | f     | {545}     | {"For Update"}    | {10698}

3 | (0,3)      |      1 | t     | {545,551} | {Share,Share}     | {10698,17936}

(3 rows)

此外,对于锁,可以使用如下SQL快速观察,也可以创建成视图

postgres=#   SELECT blocked_locks.pid     AS blocked_pid,

blocked_activity.usename  AS blocked_user,

blocking_locks.pid     AS blocking_pid,

blocking_activity.usename AS blocking_user,

blocked_activity.query    AS blocked_statement,

blocking_activity.query   AS current_statement_in_blocking_process

FROM  pg_catalog.pg_locks         blocked_locks

JOIN pg_catalog.pg_stat_activity blocked_activity  ON blocked_activity.pid = blocked_locks.pid

JOIN pg_catalog.pg_locks         blocking_locks

ON blocking_locks.locktype = blocked_locks.locktype

AND blocking_locks.database IS NOT DISTINCT FROM blocked_locks.database

AND blocking_locks.relation IS NOT DISTINCT FROM blocked_locks.relation

AND blocking_locks.page IS NOT DISTINCT FROM blocked_locks.page

AND blocking_locks.tuple IS NOT DISTINCT FROM blocked_locks.tuple

AND blocking_locks.virtualxid IS NOT DISTINCT FROM blocked_locks.virtualxid

AND blocking_locks.transactionid IS NOT DISTINCT FROM blocked_locks.transactionid

AND blocking_locks.classid IS NOT DISTINCT FROM blocked_locks.classid

AND blocking_locks.objid IS NOT DISTINCT FROM blocked_locks.objid

AND blocking_locks.objsubid IS NOT DISTINCT FROM blocked_locks.objsubid

AND blocking_locks.pid != blocked_locks.pid

JOIN pg_catalog.pg_stat_activity blocking_activity ON blocking_activity.pid = blocking_locks.pid

WHERE NOT blocked_locks.granted;

blocked_pid | blocked_user | blocking_pid | blocking_user |              blocked_statement              |    current_statement_in_blocking_process

-------------+--------------+--------------+---------------+---------------------------------------------+---------------------------------------------

27592 | postgres     |        27574 | postgres      | update test_lock set id = 100 where id = 1; | update test_lock set id = 100 where id = 1;

可以看到,27592进程被27574进程阻塞,被阻塞语句是update test_lock set id = 100 where id = 1;

3.小结

本篇主要介绍了TBase中常见的锁,其中最为常见的是RegularLock,RegularLock又可以分为表级锁和行级锁,由于行级锁不在内存中,一般行级锁的等待表现为等待另一个会话的transacitonid Exclusive Lock的释放,显示的形式通常是一个事务等待另一个事务,而不是等待某个具体的行锁。相比于SpinLock、LWLock,RegularLock加锁的开销更大,但是提供更加丰富的锁模式,为数据库不同的操作场景提供了更细粒度的锁冲突控制,尽可能地提供了数据库的高并发访问。而LWLock主要提供对共享内存变量的互斥访问,比如Clog buffer(事务提交状态缓存)、Shared buffers(数据页缓存)、Substran buffer(子事务缓存)等等,所以backend进程独有的buffer是不需要锁的,因为它只被本backend进程独享,是local的。如临时表等。SpinLock主要用于对于临界变量的并发访问控制,另外TBase中的咨询锁对于应用来说也是一个福音,可以避免争用数据库资源,至于死锁,在任何数据库中都是需要特别注意的DB Killer。

4.参考

https://habr.com/en/company/postgrespro/blog/504498/

https://www.postgresql.org/docs/12/explicit-locking.html

https://wiki.postgresql.org/wiki/Lock_Monitoring

https://www.postgresql.org/docs/current/monitoring-stats.html#WAIT-EVENT-ACTIVITY-TABLE

磁盘一把锁一个感叹号_TBase中的一些锁相关推荐

  1. android 监听锁屏 权限,Android中监听锁屏变化和防止锁屏

    Android app中可能存在某些可视化耗时操作,需要防止锁屏. 一.监听锁屏 添加权限 首先来看如何监听锁屏,使用BroadcastReceivercaset来监听 1.锁屏监听 public c ...

  2. 小米id锁状态查询_Mysql中的三类锁,你知道吗?

    点击上方"码农沉思录",选择"设为星标" 优质文章,及时送达 导读 正所谓有人(锁)的地方就有江湖(事务),人在江湖飘,怎能一无所知? 今天不聊江湖,来细说一下 ...

  3. 磁盘一把锁一个感叹号_电脑C盘出现一把锁和黄色感叹号是什么原因,求大神赐教。...

    bitlocker功能要到控制面板bitlocker里面关闭www.mh456.com防采集. 1.打开2113控制面板--进入bitlocker磁盘加密.5261 Chkdsk 命令,是微软提供的在 ...

  4. java lock unlock_详解Java中的ReentrantLock锁

    ReentrantLock锁 ReentrantLock是Java中常用的锁,属于乐观锁类型,多线程并发情况下.能保证共享数据安全性,线程间有序性 ReentrantLock通过原子操作和阻塞实现锁原 ...

  5. MySQL中的各种锁(行锁、间隙锁、临键锁等等LBCC)

    目录 1.快照读和锁定读 1.1 一致性读 / 快照读 1.2 锁定读 1.2.1 共享锁和独占锁 1.2.2 锁定读的语句 1.2.2.1 Lock In Share Mode 对记录加S共享锁 1 ...

  6. 可重入锁 不可重入锁_什么是可重入锁?

    可重入锁 不可重入锁 在Java 5.0中,增加了一个新功能以增强内部锁定功能,称为可重入锁定. 在此之前,"同步"和"易失性"是实现并发的手段. public ...

  7. 可重入锁_什么是可重入锁?

    可重入锁 在Java 5.0中,增加了一个新功能以增强内部锁定功能,称为Reentrant Lock. 在此之前,"同步"和"易失"是实现并发的手段. publ ...

  8. mysql5.7官网直译锁操作优化--并发添加,元数据锁,外部闭锁

    8.11.3 Concurrent Inserts 并发插入 MyISAM存储引擎支持并发插入从而来减少对读写对给出表的竞争:如果一张MyISAM表的数据文件没有漏洞存在(也就是在表中删除了中间的行) ...

  9. 将磁盘上的一个文本文件的内容复制到另一个文件中

    <程序设计基础实训指导教程-c语言> ISBN 978-7-03-032846-5 p198 8.1.2 上级实训内容 [实训内容2]将磁盘上的一个文本文件的内容复制到另一个文件中 #in ...

最新文章

  1. 一口一个,超灵活的Python迷你项目
  2. 浅析Java内存模型--ClassLoader
  3. 怎么把模组直接装在Java里面_如何使用jythonj将python模块添加到java中
  4. markdown如何设置图片大小_不会吧,还不会用markdown排版吗
  5. asterisk概述和代码分析
  6. ES6新特性_let使用案例---JavaScript_ECMAScript_ES6-ES11新特性工作笔记004
  7. 你的代码,“拯救”过多少人?
  8. java 为何 无效_java – 为什么compareTo无效导致Collections.sor...
  9. 洛谷 1563 玩具谜题——模拟水题
  10. 声纹识别的模式识别方法
  11. Eclipse安装Tomcat插件全攻略
  12. c语言编译器turbo,C语言编译器TurboC使用技巧解析
  13. Java常用实现八种排序算法与代码实现
  14. android代码改字体颜色,如何更改Android Studio的代码字体和颜色
  15. 速卖通店铺流量下滑什么原因,如何做提升?(测评补单)
  16. Unity3D上路_01-2D太空射击游戏
  17. 计算机中数据的格式化,分享一个电脑格式化数据恢复方法-数据恢复百科
  18. Windows Azure 虚机密码忘记处理
  19. DirectX12(D3D12)基础教程(六)——多线程渲染
  20. echart 三维可视化地图_实测三个工具后,我终于找到了地图可视化的神器

热门文章

  1. 如何使用pattern recognition letter 的word写作模板
  2. DisplayLink 安装错误
  3. Windows IIS 服务器配置HTTPS启用TLS协议。
  4. C#资源,自定义控件等
  5. Cookies 和 Session的区别
  6. Node聊天程序实例04:chat_ui.js
  7. CSS的伪类 :before 和 :after
  8. timeSetEvent的用法(一)
  9. 多数据点拟合曲线,最小二乘法,矩阵
  10. DataGridView绑定list的注意事项