[译] 如何防止丢失任何 bash 历史命令?

原文链接: http://mywiki.wooledge.org/BashFAQ/088
译者: Felix Yan

注: 这个方法是为了让你保存一个用户的完整命令记录; 它不是用来对用户输入的命令做安全审计的 – 对这个用途, 请阅读提升 bash 安全-防止命令历史被移除 (英文)

默认情况下, bash 只在退出的时候更新命令历史, 而且这个”更新”是用新版直接覆盖旧版. 这会使你无法保持一份完整的命令历史记录, 原因有两个:

  • 如果一个用户登录多次, 这种覆盖的机制会使得只有最后一个退出的 bash 能保存它的历史记录. (一个登录的用户打开多个终端模拟器, 或者使用 screen/tmux 等工具启动多个 bash 等也在此列 – 译者注)
  • 如果你的 bash 异常退出了 – 比如网络故障, 防火墙更改, 或者它的进程被杀掉了 – 会话中所有的历史记录都会丢失.

为了解决前一个问题, 我们设置命令行选项 histappend 来采用”追加”的方式写入新的命令历史记录, 并保证了多次登录不会覆盖彼此的历史记录.

为了使得命令历史记录不因为 bash 异常退出而丢失, 我们需要保证每次命令之行后, 对应命令的历史记录就被写入. 我们可以用 bash 内置的 history -a 命令来强制立刻写入命令历史记录, 而且我们可以将这个命令添加到 PROMPT_COMMAND 环境变量中, 使得这个过程自动化. 这个环境变量会在每次新的命令提示符出现前被执行一次, 因此会在每个命令执行后被执行.


注意, 在每个命令后运行 history -a 会产生两个副作用:

  • 一个新的会话里可以立刻回查到已有会话里刚才输入过的命令. 所以如果你想在两个会话里运行同样的命令, 在第一个里先执行, 然后初始化第二个终端(或者bash, 或者 screen/tmux 里开新的分屏等 – 译者注), 你可以立刻获取到这个命令.
  • 一个缺点是, 同时运行的命令行中的指令历史会混杂在一起, 因此指令历史里顺序的指令事实上并不能保证是在一个单独的会话里连续执行的. 你可能会发现这会使你在回顾整个命令历史文件, 希望找到连续的一段命令而不是单独的命令本身的时候造成混乱. 这或许只是一个当多人使用同一个账户的时候会产生的问题, 这个场景本身也并不科学(对于桌面用户, 或者在服务器上使用 screen/tmux 的用户而言, 这却是一个非常常见的使用场景, 所以读者要仔细考虑哦 <(=^_^=)> – 译者注).

在你自己的 ~/.bashrc 文件里添加这些指令来完成上述设置:

HISTFILESIZE=400000000
HISTSIZE=10000
PROMPT_COMMAND="history -a"
export HISTSIZE PROMPT_COMMAND
shopt -s histappend

在上面的设置中, 我们还增大了内存里保存的历史记录条数, 并移除了历史记录文件里的条数限制. 这两个限制的默认值都是 500 条, 因此如果你是一个活跃用户的话, 你会很快开始丢失历史记录. 设置 $HISTFILESIZE 为一个相当大的值之后, 在使用中我们可以认为它是无限 – 而设置 $HISTSIZE 之后, 我们限制了内存里持续驻留的条目数. 不幸的是, bash 会首先读取整个历史记录文件, 然后再把它截短到 $HISTSIZE 所定义的长度 – 因此如果你的历史记录文件变得非常非常大, bash 的启动时间会受到一定影响. 更糟糕的是, 加载一个过大的历史记录文件然后再把它截短的过程中会产生明显的资源占用, 最后导致 bash 的内存占用相比历史记录文件只有 $HISTSIZE 大小的情况要多出很多. 因此如果你希望你的历史记录文件变得非常大, 比如 20000 行, 你应该把它定期存档. 存档的方法稍后讲述.

PROMPT_COMMAND 可能已经被你的配置文件使用了, 比如包含了用于根据当前提示符位置更新 XTerm 的显示栏的控制码. 如果已经用过的话, 你可以这样追加指令:

PROMPT_COMMAND="${PROMPT_COMMAND:-:} ; history -a"

你还可能会用到 HISTIGNORE 和 HISTCONTROL 来控制哪些命令被保存, 比如移除重复的历史记录 – 虽然这么做会让你无法知道一个命令被用户执行了多少次, 和准确的执行时间 (如果还设置了 HISTTIMEFORMAT).

最后需要注意的是, 因为 PROMPT_COMMAND 是在新的命令提示符输出时才执行的, 如果你的命令行在最后一行命令仍然执行中的时候异常退出了, 你依然会丢失这最后一行命令, 比如:

这个命令才不可能进入历史记录呢 ; kill -9 $

压缩历史记录文件

上面设置的结果会产生一个有非常多重复命令的历史记录文件. 每次把各个 bash 进程自己加载的历史记录都追加在历史记录文件里会导致你的历史记录文件变得越来越大.

更重要的是, 我们保存历史记录的主要原因是为了能够找到以前执行过的语句. 下面这个脚本会移除历史记录文件里所有重复的命令, 并且保持历史文件的顺序, 只保留最近一次该命令被执行的记录.

awk 'NR==FNR && !/^#/{lines[$0]=FNR;next} lines[$0]==FNR' "$HISTFILE" "$HISTFILE" > "$HISTFILE.compressed" && mv "$HISTFILE.compressed" "$HISTFILE"

过了几个月之后, 这个方法把我的历史记录文件从 761474 行压缩到了 2349 行.

存档历史记录文件

使用这些方法之后, 你可能会觉得你的 bash 历史记录变得越来越有价值, 它可以让你回忆起你以前任何时候执行过的命令. 因此, 你应该将历史记录文件加入日常备份的范畴里.

你可以还想定时存档历史记录文件, 以阻止完整的历史记录被每个新运行的 bash 载入内存. bash 在载入一个有 10000 条记录的历史记录文件时, 在 Solaris 10 里大约会花费 5.5MB 内存, 没有可以感觉到的启动延迟. 然而, 如果是一个有 100000 条历史记录的文件的话, 内存占用会增长到 10MB, 而启动延迟会明显增加到 3 到 5 秒. 将导出历史记录中最旧的部分记录, 并从当前历史中移除的存档操作定期进行是一个明智的选择, 这样可以避免资源被浪费, 尤其是当你的内存不那么大的时候. (我最大的 ~/.bash_history 文件包含 7500 条记录, 在一个半月以后.)

这件事情最好通过一个能仅仅存档 bash 历史记录一部分的工具来进行. 这里是可以实现这个功能的一个简易的脚本:

#!/bin/bash
umask 077
max_lines=10000
linecount=$(wc -l < ~/.bash_history)
if (($linecount > $max_lines)); then
  prune_lines=$(($linecount - $max_lines))
  head -$prune_lines ~/.bash_history >> ~/.bash_history.archive \
    && sed -e "1,${prune_lines}d"  ~/.bash_history > ~/.bash_history.tmp$ \
    && mv ~/.bash_history.tmp$ ~/.bash_history
fi

这个脚本通过移除 bash 历史记录文件顶部的行来使得剩下的行数不超过定义的 max_lines 变量, 并把移除的部分追加到 ~/.bash_history.archive 文件中. 这其实有点接近 HISTFILESIZE 的工作方式, 只不过它并不只是删除剩下这些记录, 而是把它们存档起来 – 保证你总能通过 grep ~/.bash_history* 来查询到所有的历史记录.

这个脚本可以通过配置你自己的 crontab 来以每天或者每周的频率运行. 注意这个脚本并没有考虑多用户的情况, 它只会存档当前用户的历史记录. 扩展这个脚本的功能让它可以以 root 方式运行并为系统中所有用户工作的任务就作为练习留给读者了.

36 thoughts on “[译] 如何防止丢失任何 bash 历史命令?”

  1. 先是突然想起来窝也是这么做的只是没有归档……然后history blabla | grep blabla的时候有点(ry
    然后我又突然意识到上个月重装的时候忘记备份了……………………………………

    1. 嗯嗯,原来 bash 要实现类似的功能这么麻烦,还没有看到命令前加空格就不记录的特性呢

      _zdir=${ZDOTDIR:-$HOME}
      HISTFILE=${_zdir}/.histfile
      HISTSIZE=10000
      SAVEHIST=10000
      
      # 不保留重复的历史记录项
      setopt hist_ignore_all_dups
      # 在命令前添加空格,不将此命令添加到记录文件中
      setopt hist_ignore_space
      # zsh 4.3.6 doesn't have this option
      setopt hist_fcntl_lock 2>/dev/null
      setopt hist_reduce_blanks # 这个选项好好用哦~
      

      =w=

      1. Pia!>(=o ‵-′)ノ☆zsh
        然后…

        1. 要做到rotate文件乃们还是需要后面那个脚本嘛
        2. 要做到每个命令后都立刻写历史记录也需要类似的办法?
        3. 其实”在命令前添加空格,不将此命令添加到记录文件中”是bash的默认行为哦

        1. 喵呜!

          1. 要 rotate 的话,用 logrotate 如何?
          2. SHARE_HISTORY 选项会导致大量磁盘 I/O 的!
          3. 是么,那你还不去加个「译者注」什么的。

            1. 大约这样就好了?

              /home/lilydjwg/.histfile* {
              	missingok
              	create 640 lilydjwg lilydjwg
              	sharedscripts
              	compress
              }
  2. #!/bin/bash
    umask 077
    max_lines=10000

    linecount=$(wc -l $max_lines)); then
    cat ~/.bash_history >> ~/.bash_history.archive
    &amp;&amp; tail -$max_lines ~/.bash_history.archive > ~/.bash_history
    fi

    感嚼这样子代码更简单……

      1. 感嚼命令历史 2k 行(不包含重复行),已经足够了……
        而且干嘛不用 zsh? zsh 只保留不重复的命令历史,不用写麻烦的脚本,多省事(

        1. 不保留重复的命令的话有个问题:在使用 Ctrl-O 召出之前执行过的命令序列的下一条时被重复的命令会消失掉,于是命令序列被打断了 🙁

          1. Ctrl-O 是啥快捷键?
            俺觉得自己只会用到最近的20个命令历史……其它多出来的只有统计意义,所以,就算是重复的命令抽出来,又排到了队尾,影响也不大(

      1. 乃文章里的最后那个脚本又没自带删除重复行的功能,俺只是把脚本的逻辑改简单了而已(

        1. 我的意思是, 它本来是把超过定义行数外的行切出来放进archive, 这样archive里只保留了超出定义行数外的行, 而原来的history里是最新的, 没有重复; 而乃的会导致最新的$max_lines行也留在archive里, 这样下次超过的时候又会进去一次…

          1. 肥猫菊苣好眼力(

            #!/bin/bash
            umask 077
            max_lines=10000
             
            linecount=$(wc -l < ~/.bash_history)
             
            if (($linecount > $max_lines)); then
              prune_lines=$(($linecount - $max_lines))
              head -$prune_lines ~/.bash_history >> ~/.bash_history.archive
              && sed -i'' -e "1,${prune_lines}d" ~/.bash_history
            fi

            所以,只能这样写啦(

                1. 我其实只用这篇文章里前面那部分的内容, 后面的等真的觉得bash太慢再说啦(
                  哈哈哈哈哈哈哈哈哈哈哈(

                  1. 我已经找到了一个比 bash 更加好用的 shell 了,可惜这里空白太少,写不下了。

      1. 猫龘你好~…不要黑我~…定了什么时候过来告诉我一声,我要包大腿…快活不下去了~…

Leave a Reply

Your email address will not be published. Required fields are marked *

QR Code Business Card