数据库原理入门 (3) - 事务

154 阅读24分钟

事务

想像一下以下情景。现在有一个航空预定系统, 它是由两张表构成的:

SEATS(FlightId, NumAvailable, Price)

CUST(CustId, BalanceDue)

一张是坐位表, 存有现在航班的坐位, 另一张是乘客表, 存有乘客id和他们的剩额。假设有多用户执行以下SQL:


// 查询现有航班座位, 票价

SELECT NumAvailable, Price from SEATS WHERE FlightId = x  (用户想查询的航班id);

// 买票

UPDATE SEATS SET NumAvailable = y (航班查的票数减一) WHERE FlightId = x;

  


// 更新乘客剩额

SELECT BalanceDue FROM CUST WHERE CustId = z;

UPDATE CUST SET BalanceDue = i (乘客的剩额减去票价) WHERE CustId = z;

a. 假设有用户a, b同时执行上述SQL

  1. 用户a执行完查询航班后中断

  2. 用户b执行完

  3. 用户a完成执行

在这情况下, 两个座位被订, 但剩余座位只减1

b. 假设在订完位子后系统崩了, 剩额没有扣减, 但位子订了

c. 一位用户把SQL跑完, 但缓冲管理没有立即写到硬盘, 如果过了一段时间, 系统崩了。那就不知道到底哪些内存页写入硬盘, 有可能是订了位子, 没扣减剩额, 或甚至没写入过。

数据库通过事务来解决上述问题。所谓事务, 就是指一组行动, 运作起来像一个行动, 它具有ACID特性:

Atomicity (原子性): 即 all or nothing, 要不全部成功, 要不全部失败

Consistency (一致性): 表示每一个事务必须处于一致状态, 即把一组行动视作一单元, 独立于其他事务

Isolation (隔离性): 事务要看起来像是只有一条线程在操作数据库, 执行结果要看起来像串行

Durability (持久性): 所以提交的变更必须保证持久化

上述场景违反了一些ACID特性。场景a违反了隔离性, 因为两位用户都读取同样的票数, 但接下来执行, 第二位用户则读取第一位用户写的值。场景b违反了原子性, 而场景c则是持久性。

原子性和持久性反映了提交和回滚的适当行为, 即提交的一定是持久化的, 没提交则要保证它的行为可以回滚。一致性和隔离性则反映多用户并发操作时的适当行为, 数据库要保证用户之间不会有冲突。

事务类api

在实现事务之前, 先研究下事务类可以做什么。


public Transaction(FileMgr fm, LogMgr lm, BufferMgr bm);

public void commit();

public void rollback();

public void recover();

  


public void pin(BlockId blk);

public void unpin(BlockId blk);

public int getInt(BlockId blk, int offset);

public String getString(BlockId blk, int offset);

public void setInt(BlockId blk, int offset, int val,

boolean okToLog);

public void setString(BlockId blk, int offset, String val,

public int availableBuffs();

  


public int size(String filename);

public Block append(String filename);

public int blockSize()

api大致可以分为三类, 第一类是它的生命周期, 创建至提交, 或回滚。第二类则与缓冲相关, 封装与缓冲相关操作, 以便作相应的恢复和并发处理, 例如上锁或写日志之类。第三类与文件管理相关。事务通过封装缓冲, 文件操作, 以保证数据库操作具有ACID特性。

恢复管理

恢复管理用来处理日志。它有三大功能: 写日志, 回滚事务和系统崩了后恢复。

要想回滚, 得先知道做了什么, 因此要把需要记录的行动, 写在日志。日志记录基本可分为四种: 开始, 提交, 回滚, 更新。大概以以下形式记录:


<START, 1>

<COMMIT, 1>

<START, 2>

<SETINT, 2, testfile, 1, 80, 1, 2>

<SETSTRING, 2, testfile, 1, 40, one, one!>

<COMMIT, 2>

<START, 3>

<SETINT, 3, testfile, 1, 80, 2, 9999>

<ROLLBACK, 3>

<START, 4>

<COMMIT, 4>

START指的是开始事务, 右边的1表示事务编号。COMMIT则是提交, 表示这事务的生命周期结束。SET前缀的则是更新, 后面的参数依次是: 事务编号, 文件名, 文件块编号, 偏移位置, 旧值和新值。ROLLBACK表示回滚。

一般来说, 日志都是并发多事务写入, 所以事务是分散展开的。

回滚

有了更新日志后, 就可以做回滚了, 步骤如下:

  1. 从日志最底部开始倒后查阅

  2. 看到相关日志更新记录, 把它恢复成旧值, 继续倒后

  3. 直到事务的开始记录结束, 表示已经回滚了所有行动

  4. 在日志最底部添加相关事务回滚记录

用之前的例子改一下, 作示范:


<START, 1>

<COMMIT, 1>

<START, 2>

<SETINT, 2, testfile, 1, 80, 1, 2>

<SETSTRING, 2, testfile, 1, 40, one, one!>

<COMMIT, 2>

<START, 3>

<SETINT, 3, testfile, 1, 80, 2, 9999>

<SETINT, 3, testfile, 1, 80, 9999, 4>

<START, 4>

<COMMIT, 4>

现在要回滚事务3:

  1. 先把指针指向最底部, 即 <COMMIT, 4>

  2. 一直向上移, 直到 <SETINT, 3, testfile, 1, 80, 9999, 4>, 查到是事务3的更新, 回滚, 把testfile, blockId为1, 偏移为80的位置, 从4改为9999

  3. 查到 <SETINT, 3, testfile, 1, 80, 9999, 4>, 也是事务3的更新, 回滚, 把testfile, blockId为1, 偏移为80的位置, 从9999改为2

  4. 查到 <START, 3>, 到达事务3开始位置, 表示所有行动已回滚, 在日志最底部添添加 <COMMIT, 3>

之所以要从底部向上有两个原因:

  1. 通常回滚都是最近的事务, 从上往下则是从旧到新, 会花费更多时间遍歴

  2. 更为重要的是, 假如一个值在同一事务下作多次修改, 应该让它回滚至最旧的值, 逻辑上应该是从底向上回滚

恢复

日志的另一用途是恢复, 即把数据库恢复成以下状态:

  1. 把所有未提交的事务回滚

  2. 把所有提交的事务写入硬盘

这听起来很容易, 但要是系统崩溃, 或硬件原因导致故障, 要保证这两点不是容易的事。有可能虽然提交, 但还没写入硬盘, 或未提交的, 但相关记录没有写入日志, 那就很难保证恢复成正常状态。这里其实暗含一点, 就是要想保证容灾性, 最好先保证日志记录先写入硬盘, 再保证数据写入, 因为即使挂了, 那怕事务提交未写入, 日志有记录, 最终可以保证是写入硬盘, 转个角度, 可以说 log is data, 日志即数据, 只是角度不同, 日志记录的是数据在一定时间内的活动记录, 而我们所存的数据则是当前时间的最新记录。

假设日志是没有任何问题, 现在数据库运行中途挂了, 要怎样利用日志恢复? 第一种是 回滚-恢复 算法:

回滚阶段:

  1. 从底部向上读取每条日志记录:

情况a: 如果当前记录是提交记录, 把该事务加到提交事务列表

情况b: 回滚记录, 则把该事务加到回滚事务列表

情况c: 更新记录, 看一下它的相关事务有没有在上述的事务列表, 没有的话, 则回滚至旧值

恢复阶段:

从开始查阅日志记录, 如果是更新记录, 而且相关事务是在提交事务列表, 则恢复新值

由于回滚阶段是从底部向上查阅, 所以可以保证如果一条更新记录是提交或回滚, 它的事务会在其中一个列表里。

回滚-恢复 算法有两大特点:

  1. 幂等

  2. 可能会有多余的硬盘写入

幂等指的是不管重复多少次上述算法, 最后结果依然是相同, 这就保证了它不会受到当前数据库状态影响, 从而保证算法的可靠性。但另一方面, 因为它不管当前状态, 也意味着它有可能重复写入

回滚恢复

回滚-恢复 虽然是幂等的, 但效率不高。假设能保证所有提交修改都写进硬盘, 恢复那一步就可以忽略。但这意味着必须保证提交事务写入硬盘, 然后写入日志。更新日志只需记录旧值, 因此日志大小会比之前的小, 但IO操作频率比以前多。

如果系统很少情况下崩溃, 回滚-恢复 算法会更优。

恢复 (Redo-Only Recovery)

如果可以保证未提交的缓冲没有被写入硬盘, 回滚-恢复可以忽略第一步。这也意味着缓冲区的事务在没有提交前, 要锁定在内存中, 内存就要承受更大负担。

日志记录优先

回忆 回滚-恢复 算法的回滚部分, 这里隐含前提: 所有数据库更新都有对应的日志记录, 否则, 数据库的数据则无从回滚。

假设一条未完成的事务修改数据, 然后创建新的更新日志, 突然系统崩了, 有四种可能:

  1. 修改和日志一起写入硬盘

  2. 只有修改写入

  3. 只有日志写入

  4. 都没有写入

第一种可能是没问题的, 直接用日志回滚就可以了。2的话则严重了, 没法回滚。3的话可以回滚, 虽然在数据库上做的是不正确, 但不会有任何负面效果。4的话则什么都没有, 也是没有问题。

从上述可知, 如果日志优先写入, 则能保证即使系统挂了, 未完成事务不会有任何负面影响。

Mysql: Redo-Log

说起日志记录优先 (Writing Ahead Logging), 就要提到Mysql的innodb的 redo-log, 这可以让我们更好理解它的实践。

在Mysql中, 如果数据库是用innodb引擎, 执行一条更新sql, 如:


UPDATE T SET c=c+1 WHERE ID=2;

它可不是直接写入缓冲, 然后再写入硬盘。而是先写入redo-log。它的结构类似下图:

redolog.png

可以看出, 它是固定大小, 分为四组, 例如每组1G。其中有两个指针, write pos表示当前写的位置, 到了一定时间, checkpoint移动, 把数据写入数据库。write pos与checkpoint之间的空间是空闲空间, 可以继续写入数据。

也就是说, 更新只管保证写入redo log日志就好了。好处有以下三点:

  1. redo-log写的是物理信息, 即它的内容载有的是要写入文件, 文件块的位置, 写入的值等, 而不是像binlog般的逻辑信息, 即存有一条条的sql信息, 相对来说, 日志记录的信息更小, 写入硬盘时的速度更快

  2. 相对于每次缓冲把修改改入特定位置, redo-log只需顺着往下写入就好了

  3. 提高可用性。假设数据库中途挂了, 只要日志在, 恢复时直接把修改写入硬盘就好了

静态检查点

随着日志越来越大, 要是恢复得把日志全部跑一遍, 那不得花很多时间, 所以得要有一个checkpoint来界定一个恢复范围, checkpoint前的记录是保证写入硬盘的, 只需管后面的就可以了。这个点就叫 静态检查点, 它的设立步骤如下:

  1. 停止一切新的事务

  2. 等于现有事务完成

  3. 把所有有修改的缓冲写入硬盘

  4. 在日志里添加静态检查点, 然后写入硬盘

  5. 开始接受新的事务

效果如下所示:


<START, 0>

<SETINT, 0, junk, 33, 8, 542, 543>

<START, 1>

<START, 2>

<COMMIT, 1>

<SETSTRING, 2, junk, 44, 20, hello, ciao>

//The quiescent checkpoint procedure starts here

<SETSTRING, 0, junk, 33, 12, joe, joseph>

<COMMIT, 0>

//tx 3 wants to start here, but must wait

<SETINT, 2, junk, 66, 8, 0, 116>

<COMMIT, 2>

<CHECKPOINT>

<START, 3>

<SETINT, 3, junk, 33, 8, 543,

前的事务确定是提交了, 在此期间新增的事务需要等待。那到底什么时候设点比较好?最好是在新的事务出现比较少的情况下, 例如系统启动, 恢复期间。

非静态检查点

虽然静态检查点实现简单, 但是它的生成需要阻塞事务, 这对于商业数据库是不能接受的。所以得用 非静态检查点:

  1. 让事务t1,....tk作为当前运行事务

  2. 暂停接受新事务

  3. 把缓冲写入硬盘

  4. 把记录 <NQCKPT T1,..., Tk> 写入日志

  5. 接受新的事务

非静态检查点与之前相比, 就是checkpoint的写入不用保证当前所有事务必须提交, 写入硬盘, 只需在checkpoint写入时, 暂停接受事务, 把缓冲写入就可以了。

恢复的话, 用一个例子作示范:


<START, 0>

<SETINT, 0, junk, 33, 8, 542, 543>

<START, 1>

<START, 2>

<COMMIT, 1>

<SETSTRING, 2, junk, 44, 20, hello, ciao>

<NQCKPT, 0, 2>

<SETSTRING, 0, junk, 33, 12, joe, joseph>

<COMMIT, 0>

<START, 3

<SETINT, 2, junk, 66, 8, 0, 116>

<SETINT, 3, junk, 33, 8, 543, 12>

  1. 当它看到 <SETINT 3>, 看看事务是否在提交事务列表里。由于当前是空的, 所以做回滚

  2. **<SETINT, 2, junk, 66, 8, 0, 116>**也是一样

  3. <COMMIT, 0> 则把事务0加入提交事务列表

  4. **<SETSTRING, 0, junk, 33, 12, joe, joseph>**被忽略, 因为已提交

  5. <NQCKPT, 0, 2> 得知, 当前事务只有0, 2, 0则提交, 所以只管事务2, 2之前的忽略

  6. <SETSTRING, 2, junk, 44, 20, hello, ciao> 忽略, 因为 NQCKPT之前的已经保证写入硬盘

  7. <START, 2> 到了终点, 现在又向前走

  8. <SETSTRING, 0, junk, 33, 12, joe, joseph> 恢复为新值, 即写入joseph, 因为它在 NQCKPT之后, 修改没有写入硬盘

实现恢复管理

到了实现部分。恢复管理类 RecoveryMgr 主要做的是为事务记录日志, 还有实现回滚和恢复。这里使用回滚恢复 (undo-only recovery)作示范。

日志记录

先看日志记录。日志种类可以分好多种, 如 START, COMMIT, SETINT等。因此得要有接口规范日志记录类:


public interface LogRecord {

    static final int CHECKPOINT = 0, START = 1,

            COMMIT = 2, ROLLBACK  = 3,

            SETINT = 4, SETSTRING = 5;

  


    /**

     * Returns the log record's type.

     * @return the log record's type

     */

    int op();

  


    /**

     * Returns the transaction id stored with

     * the log record.

     * @return the log record's transaction id

     */

    int txNumber();

  


    /**

     * Undoes the operation encoded by this log record.

     * The only log record types for which this method

     * does anything interesting are SETINT and SETSTRING.

     * @param txnum the id of the transaction that is performing the undo.

     */

    void undo(Transaction tx);

  


    /**

     * Interpret the bytes returned by the log iterator.

     * @param bytes

     * @return

     */

    static LogRecord createLogRecord(byte[] bytes) {

        Page p = new Page(bytes);

        switch (p.getInt(0)) {

            case CHECKPOINT:

                return new CheckpointRecord();

            case START:

                return new StartRecord(p);

            case COMMIT:

                return new CommitRecord(p);

            case ROLLBACK:

                return new RollbackRecord(p);

            case SETINT:

                return new SetIntRecord(p);

            case SETSTRING:

                return new SetStringRecord(p);

            default:

                return null;

        }

    }

}

然后逐一实现它们的实现类。undo方法只有更新日志类才需要处理, 其他为空方法就好了。看一下例子:


public class StartRecord implements LogRecord {

  


    private int txnum;

  


    public StartRecord(Page p) {

        int tpos = Integer.BYTES;

        txnum = p.getInt(tpos);

    }

  


    public int op() {

        return START;

    }

  


    public int txNumber() {

        return txnum;

    }

  


    /**

     * Does nothing, because a start record

     * contains no undo information.

     */

    public void undo(Transaction tx) {}

  


    public String toString() {

        return "<START " + txnum + ">";

    }

  


    public static int writeToLog(LogMgr lm, int txnum) {

        byte[] rec = new byte[2 * Integer.BYTES];

        Page p = new Page(rec);

        p.setInt(0, START);

        p.setInt(Integer.BYTES, txnum);

        return lm.append(rec);

    }

}

构造方法提取页存有的事务编号。writeToLog是一个静态方法则按格式编写记录, 追加至日志。再看一下更新日志类:


public class SetStringRecord implements LogRecord {

  


    private int txnum, offset;

    private String val;

    private BlockId blk;

  


    public SetStringRecord(Page p) {

        // transaction num

        int tpos = Integer.BYTES;

        txnum = p.getInt(tpos);

  


        // file name

        int fpos = tpos + Integer.BYTES;

        String fileName = p.getString(fpos);

  


        // block

        int bpos = fpos + Page.maxLength(fileName.length());

        int blknum = p.getInt(bpos);

        blk = new BlockId(fileName, blknum);

  


        // offset

        int opos = bpos + Integer.BYTES;

        offset = p.getInt(opos);

  


        // value

        int vpos = opos + Integer.BYTES;

        val = p.getString(vpos);

    }

  


    public int op() {

        return SETSTRING;

    }

  


    public int txNumber() {

        return txnum;

    }

  


    public String toString() {

        return "<SETSTRING " + txnum + " " + blk + " " + offset + " " + val + ">";

    }

  


    /**

     * Replace the specified data value with the value saved in the log record.

     * The method pins a buffer to the specified block,

     * calls setInt to restore the saved value,

     * and unpins the buffer.

     * @see simpledb.tx.recovery.LogRecord#undo(int)

     */

    public void undo(Transaction tx) {

        tx.pin(blk);

        tx.setString(blk, offset, val, false); // don't log the undo!

        tx.unpin(blk);

    }

  


    /**

     * A static method to write a setInt record to the log.

     * This log record contains the SETINT operator,

     * followed by the transaction id, the filename, number,

     * and offset of the modified block, and the previous

     * integer value at that offset.

     * @return the LSN of the last log value

     */

    public static int writeToLog(LogMgr lm, int txnum, BlockId blk, int offset, String val) {

        int tpos = Integer.BYTES;

        int fpos = tpos + Integer.BYTES;

        int bpos = fpos + Page.maxLength(blk.fileName().length());

        int opos = bpos + Integer.BYTES;

        int vpos = opos + Integer.BYTES;

        int reclen = vpos + Page.maxLength(val.length());

  


        byte[] rec = new byte[reclen];

        Page p = new Page(rec);

  


        p.setInt(0, SETSTRING);

        p.setInt(tpos, txnum);

        p.setString(fpos, blk.fileName());

        p.setInt(bpos, blk.number());

        p.setInt(opos, offset);

        p.setString(vpos, val);

  


        return lm.append(rec);

    }

}

  


更新日志类也是类似, 只是它多了undo实现方法:


    /**

     * Replace the specified data value with the value saved in the log record.

     * The method pins a buffer to the specified block,

     * calls setInt to restore the saved value,

     * and unpins the buffer.

     * @see simpledb.tx.recovery.LogRecord#undo(int)

     */

    public void undo(Transaction tx) {

        tx.pin(blk);

        tx.setString(blk, offset, val, false); // don't log the undo!

        tx.unpin(blk);

    }

事务类现在没有实现, 先不管它, 但逻辑挺简单的, 就是先占用内存页, 把旧值写入相应的文件块, 然后释放内存页。

最后是RecoverMgr:


/**

 * The recovery manager.  Each transaction has its own recovery manager.

 * @author Edward Sciore

 */

public class RecoveryMgr {

  


    private LogMgr lm;

    private BufferMgr bm;

    private Transaction tx;

    private int txnum;

  


    /**

     * Create a recovery manager for the specified transaction.

     * @param txnum the ID of the specified transaction

     */

    public RecoveryMgr(Transaction tx, int txnum, LogMgr lm, BufferMgr bm) {

        this.tx = tx;

        this.txnum = txnum;

        this.lm = lm;

        this.bm = bm;

        // start to write the log

        StartRecord.writeToLog(lm, txnum);

    }

  


    /**

     * Write a commit record to the log, and flushes it to disk.

     */

    public void commit() {

        bm.flushAll(txnum);

        int lsn = CommitRecord.writeToLog(lm, txnum);

        lm.flush(lsn);

    }

  


    /**

     * Write a rollback record to the log and flush it to disk.

     */

    public void rollback() {

        doRollback();

        bm.flushAll(txnum);

        int lsn = RollbackRecord.writeToLog(lm, txnum);

        lm.flush(lsn);

    }

  


    public void recover() {

        doRecover();

        bm.flushAll(txnum);

        int lsn = CheckpointRecord.writeToLog(lm);

        lm.flush(lsn);

    }

  


    /**

     * Write a setint record to the log and return its lsn.

     * @param buff the buffer containing the page

     * @param offset the offset of the value in the page

     * @param newval the value to be written

     */

    public int setInt(Buffer buff, int offset, int newval) {

        int oldval = buff.contents().getInt(offset);

        BlockId blk = buff.block();

        return SetIntRecord.writeToLog(lm, txnum, blk, offset, oldval);

    }

  


    /**

     * Write a setstring record to the log and return its lsn.

     * @param buff the buffer containing the page

     * @param offset the offset of the value in the page

     * @param newval the value to be written

     */

    public int setString(Buffer buff, int offset, String newval) {

        String oldval = buff.contents().getString(offset);

        BlockId blk = buff.block();

        return SetStringRecord.writeToLog(lm, txnum, blk, offset, oldval);

    }

  


    /**

     * Rollback the transaction, by iterating

     * through the log records until it finds

     * the transaction's START record,

     * calling undo() for each of the transaction's

     * log records.

     */

    private void doRollback() {

        Iterator<byte[]> iter = lm.iterator();

        // 这里是从底开始看

        while (iter.hasNext()) {

            byte[] bytes = iter.next();

            LogRecord rec = LogRecord.createLogRecord(bytes);

            if (rec.txNumber() == txnum) {

                if (rec.op() == LogRecord.START)

                    return;

                rec.undo(tx);

            }

        }

    }

  


    private void doRecover() {

        Collection<Integer> finishedTxs = new ArrayList<>();

        Iterator<byte[]> iter = lm.iterator();

        while (iter.hasNext()) {

            byte[] bytes = iter.next();

            LogRecord rec = LogRecord.createLogRecord(bytes);

            // checkpoint do nothing

            if (rec.op() == LogRecord.CHECKPOINT)

                return;

            // 如果是提交或回滚的, 确定已经是写入硬盘, 就不用管了

            if (rec.op() == LogRecord.COMMIT || rec.op() == LogRecord.ROLLBACK)

                finishedTxs.add(rec.txNumber());

            else if (!finishedTxs.contains(rec.txNumber()))

                rec.undo(tx);

        }

    }

}

  


要注意的地方不多, 基本按之前的内容实现回滚和恢复, 唯一要注意的是日志是从底向上遍历。

并发管理

并发管理是要让并发事务正确执行, 那所谓的 正确 到底指的是什么。

假设现在只有一条事务, 那它肯定是正确的。如果多了一条事务, 串行执行呢?例如:

|  事务   | 执行  |

|  ----  | ----  |

| tx1  | w1(b1), w1(b2) |

| tx2  | w2(b1), w2(b2) |

tx表示事务, w表示写入, b表示文件块。不同事务先执行, 结果一定是不同的, 如tx1写b1的值是x, tx2是y, b1的值是由最后写入那个决定。这种情况下则看串行是怎么安排, 决定好的则是正确。

如果非串行的调度结果和串行一样, 也可以说是串行。例如:

W1(b1); W2(b1); W1(b2); W2(b2)

这里假设是事务1开始, 最后结果是b1, b2的值是事务2写入, 和串行结果一样, 也可以说它是正确。但如果是这样:

W1(b1); W2(b1); W2(b2); W1(b2)

明明是事务1先开始, 最后b2的值是w1决定, 这和串行的结果不一致, 所以说是不正确。

总之, 结果是串行的结果则是正确。为什么强调串行是正确? 因为像刚才的例子就违反了ACID的I, 即隔离性。试想想, 你是事务2用户, 你是最后执行, 最后看到的结果是b1的值是事务1写入, 肯定觉得好奇怪。

锁表

想要保持串行的一个普遍方法是上锁。锁可以分为共享锁和独占锁。共享锁锁定资源, 其他共享锁也可以访问, 除了独占锁; 独占锁的话, 则是独占锁和共享锁都不能访问。对应到数据库, 则是读时上共享锁, 写时上独占锁, 因为读是幂等操作, 没有任何副作用, 但写则不同, 必须保持只有一个事务可以写。用代码实现如下:


/**

 * The lock table, which provides methods to lock and unlock blocks.

 * If a transaction requests a lock that causes a conflict with an

 * existing lock, then that transaction is placed on a wait list.

 * There is only one wait list for all blocks.

 * When the last lock on a block is unlocked, then all transactions

 * are removed from the wait list and rescheduled.

 * If one of those transactions discovers that the lock it is waiting for

 * is still locked, it will place itself back on the wait list.

 * @author Edward Sciore

 */

public class LockTable {

  


    private static final long MAX_TIME = 10000; // 10 seconds

  


    private Map<BlockId, Integer> locks = new HashMap<>();

  


    /**

     * Grant an SLock on the specified block.

     * If an XLock exists when the method is called,

     * then the calling thread will be placed on a wait list

     * until the lock is released.

     * If the thread remains on the wait list for a certain

     * amount of time (currently 10 seconds),

     * then an exception is thrown.

     * @param blk a reference to the disk block

     */

    public synchronized void sLock(BlockId blk) {

        try {

            long timestamp = System.currentTimeMillis();

  


            while (hasXlock(blk) && !waitingTooLong(timestamp)) {

                wait(MAX_TIME);

            }

            if (hasXlock(blk))

                throw new LockAbortException();

            int val = getLockVal(blk); // will not be negative

            locks.put(blk, val + 1);

        } catch (InterruptedException e) {

            throw new LockAbortException();

        }

    }

  


    /**

     * Grant an XLock on the specified block.

     * If a lock of any type exists when the method is called,

     * then the calling thread will be placed on a wait list

     * until the locks are released.

     * If the thread remains on the wait list for a certain

     * amount of time (currently 10 seconds),

     * then an exception is thrown.

     * @param blk a reference to the disk block

     */

    synchronized void xLock(BlockId blk) {

        try {

            long timestamp = System.currentTimeMillis();

            while (hasOtherSLocks(blk) && !waitingTooLong(timestamp))

                wait(MAX_TIME);

            if (hasOtherSLocks(blk))

                throw new LockAbortException();

            locks.put(blk, -1);

        } catch (InterruptedException e) {

            throw new LockAbortException();

        }

    }

  


    /**

     * Release a lock on the specified block.

     * If this lock is the last lock on that block,

     * then the waiting transactions are notified.

     * @param blk a reference to the disk block

     */

    synchronized void unlock(BlockId blk) {

        int val = getLockVal(blk);

        if (val > 1)

            locks.put(blk, val - 1);

        else {

            locks.remove(blk);

            notifyAll();

        }

    }

  


    private boolean hasXlock(BlockId blk) {

        return getLockVal(blk) < 0;

    }

  


    private boolean hasOtherSLocks(BlockId blk) {

        return getLockVal(blk) > 1;

    }

  


    private boolean waitingTooLong(long starttime) {

        return System.currentTimeMillis() - starttime > MAX_TIME;

    }

  


    private int getLockVal(BlockId blk) {

        Integer ival = locks.get(blk);

        return (ival == null) ? 0 : ival.intValue();

    }

}

用一个哈希表管理文件块有没有上锁, 上独占锁, 写入-1, 共享锁则不断加1。解锁时则独占锁归0, 而共享锁不断减-1, 直到0。

尽管锁是一个普遍工具, 但它也带来一些问题。

首先是串行问题。假设某一事务t1有以下行动:


T1: ... R(x); UL(x); SL(y); R(y); ....

如果在t1解锁x, 但又没有对y上共享锁, 这时突然有事务2要修改y, 对y上了独占锁, 修改提交。t1则在解锁x时要等待t2执行完, 这就不是串行, 结果不正确。当然t1可以在做操作前, 先对它要操作的资源先上锁, 操作完提交后才解锁 即:


T1: ... SL(x), SL(y), R(x); R(y); UL(x); UL(y) ....

虽然理论上可以解决问题, 但锁的颗粒度大, 要是涉及好几个资源, 而且并发情况, 数据库得卡死。

第二个问题是会可能有回滚问题。假设有两事务执行如下行动:


... W1(b); UL1(b); SL2(b); R2(b); ...

如果t1最后提交, 就没什么问题, 但如果它回滚, 事务2如果读的是事务1未提交的数据, 则会有脏读情况发生。这情况下, 事务2也被迫回滚。要是有好几个事务互相干涉, 可能导致所有都得回滚, 成了回滚瀑布。

死锁

假设以下情况:


T1: W(b1); W(b2)

T2: W(b2); W(b1)

假如是t2先对b2写入, 上锁, 与此同时t1也对b1写入, 上锁, 接下来双方等待对方解锁, 一直卡死, 这就是死锁局面。只要四个条件发生, 就有死锁可能:

  1. 互斥,共享资源 X 和 Y 只能被一个线程占用

  2. 占有且等待,线程 T1 已经取得共享资源 X,在等待共享资源 Y 的时候,不释放共享资源 X

  3. 不可抢占,其他线程不能强行抢占线程 T1 占有的资源

  4. 循环等待,线程 T1 等待线程 T2 占有的资源,线程 T2 等待线程 T1 占有的资源,就是循环等待。

只要破坏其一个条件, 就可以破除死锁。例如t2也是先写b1, 然后写b2。或是写完直接释放。

当然, 上述例子大大简化现实情况。现实中数据数百万条, 在高并发情况下, 始终有死锁可能。有好些方法可以处理死锁, 比较简单就是设一个等待时间, 如果事务等待超过时间, 就假定死锁, 回滚。虽然等待超过一定时间不一定是死锁, 但这种方法简单, 不需要另外用变量去记录数据的上锁情况, 来决定是否真的死锁。

MVCC

无论读还是写, 都必须得加锁, 而加锁意味着效率会减少。有没有方法减少上锁? 首先写就不用想, 必须加锁。读呢?读本身是不需要加锁, 之所以要加锁, 是因为有写的存在, 读和写不是同一个值, 则不需要。假设把值作为版本般看待, 就好像git般, 每次事务提交, 提交都是版本, 可以根据提交时间来获取不同版本的值, 在这种情况下, 每次事务开始作查询, 查的是事务开始前最新的版本值, 假如这时另外有事务想对查询的值作写入, 它只需上独占锁, 写入就好了, 因为它关注的是当前提交的最新值, 而查询的事务则是关注它开始之前最新的版本值。这种让读写互不影响的机制叫MVCC (Multi-Version Concurrency Control), 多版本并发控制。

MVCC的实现挺复杂, 这里不详写, 有兴趣可以自己搜一下。

并发管理

前面已经实现了锁, 我们可以基于锁来实现并发管理:


/**

 * The concurrency manager for the transaction.

 * Each transaction has its own concurrency manager.

 * The concurrency manager keeps track of which locks the

 * transaction currently has, and interacts with the

 * global lock table as needed.

 * @author Edward Sciore

 */

public class ConcurrencyMgr {

  


    /**

     * The global lock table. This variable is static because

     * all transactions share the same table.

     */

    private static LockTable locktbl = new LockTable();

  


    private Map<BlockId, String> locks = new HashMap<>();

  


    /**

     * Obtain an SLock on the block, if necessary.

     * The method will ask the lock table for an SLock

     * if the transaction currently has no locks on that block.

     * @param blk a reference to the disk block

     */

    public void sLock(BlockId blk) {

        if (locks.get(blk) == null) {

            locktbl.sLock(blk);

            locks.put(blk, "S");

        }

    }

  


    /**

     * Obtain an XLock on the block, if necessary.

     * If the transaction does not have an XLock on that block,

     * then the method first gets an SLock on that block

     * (if necessary), and then upgrades it to an XLock.

     * @param blk a reference to the disk block

     */

    public void xLock(BlockId blk) {

        if (!hasXLock(blk)) {

            sLock(blk);

            locktbl.xLock(blk);

            locks.put(blk, "X");

        }

    }

  


    /**

     * Release all locks by asking the lock table to

     * unlock each one.

     */

    public void release() {

        for (BlockId blk : locks.keySet())

            locktbl.unlock(blk);

        locks.clear();

    }

  


    private boolean hasXLock(BlockId blk) {

        String locktype = locks.get(blk);

        return locktype != null && locktype.equals("X");

    }

}

  


并发管理没什么, 就对LockTable做一层封装, 管理事务的锁操作, 每个事务都有一个ConcurrencyMgr, 所有事务共用一个LockTable

事务

实现了恢复管理和并发管理, 就可以实现事务。在此之前, 还得实现BufferList, 用来封装事务对缓冲的操作。


/**

 * Manage the transaction's currently-pinned buffers.

 * @author Edward Sciore

 */

public class BufferList {

  


    private Map<BlockId, Buffer> buffers = new HashMap<>();

    private List<BlockId> pins = new ArrayList<>();

    private BufferMgr bm;

  


    public BufferList(BufferMgr bm) {

        this.bm = bm;

    }

  


    /**

     * Return the buffer pinned to the specified block.

     * The method returns null if the transaction has not

     * pinned the block.

     * @param blk a reference to the disk block

     * @return the buffer pinned to that block

     */

    Buffer getBuffer(BlockId blk) {

        return buffers.get(blk);

    }

  
  


    /**

     * Pin the block and keep track of the buffer internally.

     * @param blk a reference to the disk block

     */

    void pin(BlockId blk) {

        Buffer buff = bm.pin(blk);

        buffers.put(blk, buff);

        pins.add(blk);

    }

  


    /**

     * Unpin the specified block.

     * @param blk a reference to the disk block

     */

    void unpin(BlockId blk) {

        Buffer buff = buffers.get(blk);

        bm.unpin(buff);

        pins.remove(blk);

        if (!pins.contains(blk))

            buffers.remove(blk);

    }

  


    /**

     * Unpin any buffers still pinned by this transaction.

     */

    void unpinAll() {

        for (BlockId blk : pins) {

            Buffer buff = buffers.get(blk);

            bm.unpin(buff);

        }

        buffers.clear();

        pins.clear();

    }

}

利用BufferList可以知道事务在访问哪个文件块, 操作哪个缓冲。

事务类 Transaction 实现的目标有三点。首先是提交和回滚, 它们要做的是:

  1. 释放占用的缓冲

  2. 调用恢复管理来提交或回滚事务

  3. 调用并发管理来释放锁

第二点是对文件块的访问。读时加共享锁, 写时独占锁, 看看是否需要写入日志。第三点则是访问块的相关属性。


/**

 * Provide transaction management for clients,

 * ensuring that all transactions are serializable, recoverable,

 * and in general satisfy the ACID properties.

 * @author Edward Sciore

 */

@Slf4j

public class Transaction {

  


    private static int nextTxNum = 0;

    private static final int END_OF_FILE = -1;

    private RecoveryMgr recoveryMgr;

    private ConcurrencyMgr concurMgr;

    private BufferMgr bm;

    private FileMgr fm;

    private int txnum;

    private BufferList mybuffers;

  


    /**

     * Create a new transaction and its associated

     * recovery and concurrency managers.

     * This constructor depends on the file, log, and buffer

     * managers that it gets from the class

     * {@link simpledb.server.SimpleDB}.

     * Those objects are created during system initialization.

     * Thus this constructor cannot be called until either

     * {@link simpledb.server.SimpleDB#init(String)} or

     * {@link simpledb.server.SimpleDB#initFileLogAndBufferMgr(String)} or

     * is called first.

     */

    public Transaction(FileMgr fm, LogMgr lm, BufferMgr bm) {

        this.fm = fm;

        this.bm = bm;

        txnum = nextTxNumber();

        recoveryMgr = new RecoveryMgr(this, txnum, lm, bm);

        concurMgr = new ConcurrencyMgr();

        mybuffers = new BufferList(bm);

    }

  


    /**

     * Commit the current transaction.

     * Flush all modified buffers (and their log records),

     * write and flush a commit record to the log,

     * release all locks, and unpin any pinned buffers.

     */

    public void commit() {

        recoveryMgr.commit();

        log.info("transaction {} committed", txnum);

        concurMgr.release();

        mybuffers.unpinAll();

    }

  


    /**

     * Rollback the current transaction.

     * Undo any modified values,

     * flush those buffers,

     * write and flush a rollback record to the log,

     * release all locks, and unpin any pinned buffers.

     */

    public void rollback() {

        recoveryMgr.rollback();

        log.info("transaction {} rolled back", txnum);

        concurMgr.release();

        mybuffers.unpinAll();

    }

  


    /**

     * Flush all modified buffers.

     * Then go through the log, rolling back all

     * uncommitted transactions.  Finally,

     * write a quiescent checkpoint record to the log.

     * This method is called during system startup,

     * before user transactions begin.

     */

    public void recover() {

        bm.flushAll(txnum);

        recoveryMgr.recover();

    }

  


    /**

     * Pin the specified block.

     * The transaction manages the buffer for the client.

     * @param blk a reference to the disk block

     */

    public void pin(BlockId blk) {

        mybuffers.pin(blk);

    }

  


    /**

     * Unpin the specified block.

     * The transaction looks up the buffer pinned to this block,

     * and unpins it.

     * @param blk a reference to the disk block

     */

    public void unpin(BlockId blk) {

        mybuffers.unpin(blk);

    }

  


    /**

     * Return the integer value stored at the

     * specified offset of the specified block.

     * The method first obtains an SLock on the block,

     * then it calls the buffer to retrieve the value.

     * @param blk a reference to a disk block

     * @param offset the byte offset within the block

     * @return the integer stored at that offset

     */

    public int getInt(BlockId blk, int offset) {

        concurMgr.sLock(blk);

        Buffer buff = mybuffers.getBuffer(blk);

        return buff.contents().getInt(offset);

    }

  


    /**

     * Return the string value stored at the

     * specified offset of the specified block.

     * The method first obtains an SLock on the block,

     * then it calls the buffer to retrieve the value.

     * @param blk a reference to a disk block

     * @param offset the byte offset within the block

     * @return the string stored at that offset

     */

    public String getString(BlockId blk, int offset) {

        concurMgr.sLock(blk);

        Buffer buff = mybuffers.getBuffer(blk);

        return buff.contents().getString(offset);

    }

  


    /**

     * Store an integer at the specified offset

     * of the specified block.

     * The method first obtains an XLock on the block.

     * It then reads the current value at that offset,

     * puts it into an update log record, and

     * writes that record to the log.

     * Finally, it calls the buffer to store the value,

     * passing in the LSN of the log record and the transaction's id.

     * @param blk a reference to the disk block

     * @param offset a byte offset within that block

     * @param val the value to be stored

     */

    public void setInt(BlockId blk, int offset, int val, boolean okToLog) {

        concurMgr.xLock(blk);

        Buffer buff = mybuffers.getBuffer(blk);

        int lsn = -1;

        if (okToLog)

            lsn = recoveryMgr.setInt(buff, offset, val);

        Page p = buff.contents();

        p.setInt(offset, val);

        buff.setModified(txnum, lsn);

    }

  


    /**

     * Store a string at the specified offset

     * of the specified block.

     * The method first obtains an XLock on the block.

     * It then reads the current value at that offset,

     * puts it into an update log record, and

     * writes that record to the log.

     * Finally, it calls the buffer to store the value,

     * passing in the LSN of the log record and the transaction's id.

     * @param blk a reference to the disk block

     * @param offset a byte offset within that block

     * @param val the value to be stored

     */

    public void setString(BlockId blk, int offset, String val, boolean okToLog) {

        concurMgr.xLock(blk);

        Buffer buff = mybuffers.getBuffer(blk);

        int lsn = -1;

        if (okToLog)

            lsn = recoveryMgr.setString(buff, offset, val);

        Page p = buff.contents();

        p.setString(offset, val);

        buff.setModified(txnum, lsn);

    }

  


    /**

     * Return the number of blocks in the specified file.

     * This method first obtains an SLock on the

     * "end of the file", before asking the file manager

     * to return the file size.

     * @param filename the name of the file

     * @return the number of blocks in the file

     */

    public int size(String filename) {

        BlockId dummyblk = new BlockId(filename, END_OF_FILE);

        concurMgr.sLock(dummyblk);

        return fm.length(filename);

    }

  


    /**

     * Append a new block to the end of the specified file

     * and returns a reference to it.

     * This method first obtains an XLock on the

     * "end of the file", before performing the append.

     * @param filename the name of the file

     * @return a reference to the newly-created disk block

     */

    public BlockId append(String filename) {

        BlockId dummyblk = new BlockId(filename, END_OF_FILE);

        concurMgr.xLock(dummyblk);

        return fm.append(filename);

    }

  


    public int blockSize() {

        return fm.blockSize();

    }

  


    public int availableBuffs() {

        return bm.available();

    }

  


    private static synchronized int nextTxNumber() {

        nextTxNum++;

        return nextTxNum;

    }

  


}