ぱと隊長日誌

ブログ運用もエンジニアとしての生き方も模索中

DB Online Day 2018 Summer「データで見る、経験で語る、日本のデータベーススペシャリストのリアリティ」聴講メモ

セッションについて

DB Online Day 2018 Summer (DB Online Day 2018 Summer)
スペシャルセッション データで見る、経験で語る、日本のデータベーススペシャリストのリアリティ
の聴講メモです。

本セッションはパネルディスカッション形式で行われました。
モデレーター:DB Onlineチーフキュレーター 谷川 耕一 さん
パネリスト:株式会社アシスト 関 俊洋 さん
パネリスト:日本オラクル株式会社 小田 圭二 さん

自分のメモをベースにまとめています。発言の聞き間違い、解釈違いの可能性があることをご了承ください。

テーマ1 3種のデータベースエンジニア

Oracleでは社内外(主に社外)のDBAのスキル調査を行っている。このデータの分析結果をお話ししたい。

DBAを3つに分類し、各役割を整理する。

開発側DBA SQLチューニング、インデックス設計、バグ調査。
運用側DBA キャパシティ管理、トラブル対応、バックアップリカバリ、SQLチューニング。
全体最適DBA 一部の会社で任命している。品質管理、障害の横展開、標準ガイド作成、レビュー、難易度の高いトラブル対応。

DBAにはインフラスキルも必要。これがないとスキルが伸びない。

経験とスキルは比例している。DBAとして一人前といえるレベルには5~10年ぐらいの経験が必要になる。3年目では若干足りないぐらい。

SQLチューニングは運用フェーズでのタスクと思われがちだが、本来は開発段階から意識すべき。少ないデータ量で性能テストして本番で火を噴くのが最悪パターン。

開発側にいるとDBAとしての幅広い経験ができない。運用側のスキルが伸び悩む。運用の経験が大事。
運用は比較的速くスキルが伸びる傾向にある。
品質管理チームは経験が少なくともスキルの高い傾向にある。チームメンバーの年齢層が高いので、比例してドキュメンテーション能力が高い。

情シスは経験年数を積んでも一人前レベルに達していない。
でも、SIerも同じ傾向。特に大手は手を動かせていない印象がある。

アーキテクトやコンサルはスキルが高い傾向にある。

ドキュメンテーションやコミュニケーションスキルはかなり重用。時には技術スキルより重用となる場面がある。
データからもドキュメンテーション・コミュニケーション・インフラのスキルを持つエンジニアは年収が高い傾向にある。

データベースはインフラを知っていると応用が利く。SQLチューニングだけではその分野に特化してしまう。チューニングを専門にしている会社ではスポットでプロジェクトに参画し、仕事を終えると抜けることが多い。

劣化した性能をチューニングして回復させたとしても、元を越えることはできない。その壁を越えるためにインフラスキルが必要となる。

テーマ2 DBエンジニアの専門性スキルをどう捉えれば良いのか

チューニングで大切なのは目標を設定すること。また、SQLチューニング以外の手段も含め提案できること。

チューニングは今後もなくならない領域。自動化できる領域は増えるが、匠の領域がある。匠の領域では中身を理解している必要がある。

インスタンスチューニングで求められることの一つがメモリー関連パラメーターのチューニング。これには自動化・ユーザー数・セッション数・ロック等が関連してくる。これらの問題を解決することがインフラエンジニアとして求められる。

開発側が提示するパラメーター設定資料にはしばしばパラメーターの設定理由が書いていない。設定の見直しにあたっては根拠をもって見直す事が大切。

Exadataにはベストプラクティスがあるが、既存からのリプレースだと、過去のパラメーターを変えることに難色を示されてしまうことがある。

アシストの実感として、テーブル設計をできるエンジニアは減ってきている。データモデルを考えることのできるメンバーがベテラン層に寄ってきている。
データベースエンジニアと呼ばれる人はシステムが使われる業務の現場から離れていることが多い。
モデラーと呼ばれる人は変わった人が良い。何人か集まればみんな違った持論を展開する。

バックアップを設定したことはあっても、実際にリカバリーしたことのない方が多い。事前に検証環境でもいいからやっておくべき。そうでないと、現場でヒリヒリした思いをすることになる。
データベースの内部構造を知らないと、障害の発生している現場での疑問に答えることができない。これは場数を踏んで経験を積むしかない。

トラブル時は例外的な壊れ方をすることが多い。これはツールで救えないこともしばしばある。

英語は使わないと覚えない。まずはドキュメントを読む。下手でも良いからしゃべる。IT人材不足の話が出たが、人が減れば外国のエンジニアとの協業が必要になる。そこで英語が必要となるはず。
Oracleに関しては日本語ドキュメントがある程度そろっているが、クラウド系だと英語ドキュメントを参照する必要がある。
英語ドキュメントはGoogle翻訳でも良いから読んでみる。翻訳を待っていると時間がかかりすぎる。
Oracle内部では未だに現地の言葉に翻訳しているのは日本ぐらいと言われている。

テーマ3 DBエンジニアのスキルアップにどうアプローチする?

キャリアのスタートがインフラか開発かでデータベースへの見方が決定的に違う。開発から始めたエンジニアはデータベースを単なるデータの入れ物とみていることが多い。開発エンジニアもインフラ視点で見ると気づきがあるはず。

開発側も経験することのメリットにアルゴリズムの理解が早くなることが挙げられる。

運用側から入ったエンジニアはインフラ全般、特にトラブル対応のスキルが伸びやすい。一方、安定稼働してトラブル対応が減った現場ではエンジニアのスキルが伸び悩むこともある。

トラブルが起こった現場に行くと、ステークホルダーの関係性や業務影響のインパクトを肌で感じられる。

クラウド時代にネットワークのスキルは大切だが、全てのスキルを満点にする必要はない。得意分野を持ち、苦手分野は任せるかお金で買えば良い。お金で時間を買う。データベースエンジニアとしてはデータベースを中心に、ネットワーク、ストレージのような他の2,3スキルを掛け算する。

どれだけAIが進化しても、お客様とビジネスを一緒に考える仕事はなくならない。

データ活用はそんなに簡単じゃない。データはあっても定義がなかったりする。

アシストではデータベースの活用はほかの部署と分けている。それぐらい難しいし、専門的スキルが必要になる。

ユーザーのリクエストに応えて正しいデータを提供できているのは半分以下という報告もある。

分析の時間は5%以下。それ以外の大部分の時間はデータクレンジングに使われている。

テーマ4 クラウドになるとDBエンジニアはどうかわるのか

Oracleクラウドはバックアップの自動化ができる。細かい設定はできないが、難しいバックアップ設計を任せることができる。

Oracleはオートノマスで自動的にチューニングする。そのため、デフォルトではインデックス作成すらできない(エラーになる)。

OracleでDBエンジニアのタスクを整理したところ、120ぐらいあった。だから、何を自動化に任せ、何を尖ってスキルとして身につけるかを考えていく必要がある。Oracleとしては自動化でDBエンジニアのタスクを半分程度に減らせると考えている。ただし、現場によって状況は変わる事に注意。

ベンダーやSIerとしてはオートノマスで食い扶持が減ると感じるかもしれないが、工数が減ることで期日を守りやすくなることをメリットとして捉えることもできるはず。
例えばバックアップの自動化にはOracleのベストプラクティスが適用されている。都度設計しなくて済むことは大きい。

日本のミッションクリティカルなシステムにはIaaSがあっている。リフト&シフトのやり方。だが、PaaSにしていかないと構築や運用タスクが減らない。IaaSで始め、PaaSに進めていくのが良いのでは。

クラウド化してもチューニングの要素はなくならない。SQLチューニングだけでなく、スケールアップやキャッシュなどのクラウドならではのテクニックが使える。
クラウドによってパケットキャプチャでの解析が改めて重要になってくる場面も出てきている。

アシストではExadataの価格を自社サイトで公開しており、このページへのアクセス数が多い。価格に興味を持つ方が多いということ。クラウドサービスでも価格情報がオープンになっている。ユーザー自らコストを踏まえて判断・提案するようになってきたし、エンジニア側もコストについて理解していないといけない。

Ask The Speaker

セッション後、登壇者のお二人に質問してみました。

Q.
セッション中に技術分野を深堀するという話が出たが、どこまで深堀して進むべきか?どこであきらめるか?

A.
人によって壁に突き当たるところがある。そこまで進めて、一旦他の事へ進め、また必要になったり壁を乗り越えられるようになったら進む。(小田さん)
マネージャーとしては会社の方向性と個人の方向性にギャップがあるとき、個人のやりたいことを伸ばしてやり、マネージャーがそのギャップを埋める。マネージャーは忙しくなるが…。(関さん)

所感

今回のセッションを通し、知識と経験は両輪であり、どちらも必要なことであると改めて感じました。
例えば、パフォーマンスチューニングを解説した資料は数多くありますが、現場でそれを適用しても思ったような効果を得ることは難しいと感じています。ですが、そうした知識が無駄なわけではありません。なぜそれがうまくいかないかを考え、別の方法を考え付くためにさらに幅広い知識が必要となります。そして、そこでの経験が次回のチューニング作業を効率化してくれます。

自動化が進めば半端な知識や経験では太刀打ちできなくなるでしょう。そんな状況下でもエンジニアとしての価値を出すためには日々の研鑽あるのみ、ということではないでしょうか。

PostgreSQLのシリアライザブルとコミット/ロールバックと遅延可能な読み取り専用トランザクションの関係

はじめに

PostgreSQLトランザクション分離レベルにはシリアライザブル(Serializable)があります。ドキュメントのシリアライザブル分離レベルの説明には以下の記載があります。

異常を防止するためにシリアライザブルトランザクションを使用するのであれば、恒久的なユーザテーブルから読み取られたいかなるデータも、それを読んだトランザクションがコミットされるまで有効とは認められない点は重要です。 このことは読み取り専用トランザクションにも当てはまりますが、遅延可能な読み取り専用トランザクション内で読み込まれたデータは例外で、読み込まれてすぐに有効とみなされます。 なぜなら、遅延可能なトランザクションはすべてのデータを読み込む前にこのような問題がないことを保証されているスナップショットを取得できるまで待機するからです。 それ以外の全ての場合において、後に中止されたトランザクション内で読み込まれた結果をアプリケーションは信用してはならず、アプリケーションはトランザクションが成功するまで再試行すべきです。

13.2. トランザクションの分離

この説明を実例で確認し、トランザクション設計で注意すべきポイントを探ります。

検証環境・シナリオ

PostgreSQL 10.3 で検証を行いました。

PostgreSQLに Serializable Snapshot Isolation (SSI) が実装されたのは 9.1 からです。よって、9.1 以降であればほぼ同様の結果が得られると思われます。

検証SQL(データ含む)は PostgreSQL wiki の SSI 解説記事(以下、記事)を参照しました。
SSI - PostgreSQL wiki
2.4.1 Deposit Report を参照ください。

記事に従い、postgresql.confのパラメータを以下の設定とします。

default_transaction_isolation = 'serializable'

初期セットアップとして、以下のSQLを実行します。

create table control
  (
    deposit_no int not null
  );
insert into control values (1);
create table receipt
  (
    receipt_no serial primary key,
    deposit_no int not null,
    payee text not null,
    amount money not null
  );
insert into receipt
  (deposit_no, payee, amount)
  values ((select deposit_no from control), 'Crosby', '100');
insert into receipt
  (deposit_no, payee, amount)
  values ((select deposit_no from control), 'Stills', '200');
insert into receipt
  (deposit_no, payee, amount)
  values ((select deposit_no from control), 'Nash', '300');

検証環境のロケールによっては金額表示に円記号"¥"が用いられますが、ここでは記事に沿ってドル記号"$"で表記しています。

検証シナリオ概説

銀行の預金管理を想定しています。(もしかすると、記事では小切手を想定しているかもしれないのですが、シナリオの本質には影響しないので、本エントリでは預金の想定で進めます)。

control テーブルと receipt テーブルがあります。

◆ control テーブル

列名 説明
deposit_no 預金番号。日次バッチ処理の単位となり、締め処理が行われるとインクリメントされ、翌営業日扱いとなります。

◆ receipt テーブル

列名 説明
receipt_no 受領番号。シリアルな値です。
deposit_no 預金番号。control テーブルの deposit_no を参照します。
payee 預金者。
amount 預金額。

シナリオでは以下の3つのトランザクションがほぼ同時に起こります。

トランザクション 説明
T1 預金の預け入れ。
T2 預金の預け入れの締め処理。続けてT3の処理を行う。
T3 T2で締めた預け入れ分の預金の一覧出力。

この時、それぞれのトランザクションがどのように扱われるかを確認します。

実験結果・解説

T1完了前にT2, T3が進行する

記事のオリジナルのケースです。
当日の預金の預け入れ処理(T1)が完了する前に締め処理(T2, T3)が進行します。

BEGIN; -- T1

INSERT INTO receipt
  (deposit_no, payee, amount)
  VALUES
  (
    (SELECT deposit_no FROM control),
    'Young', '100'
  );

SELECT * FROM receipt;
receipt_no deposit_no payee amount
1 1 Crosby $100.00
2 1 Stills $200.00
3 1 Nash $300.00
4 1 Young $100.00
BEGIN; -- T2

SELECT deposit_no FROM control;
deposit_no
1
-- T2

UPDATE control SET deposit_no = 2;

COMMIT; -- コミット処理が成功する。
BEGIN; -- T3

SELECT * FROM receipt WHERE deposit_no = 1;
receipt_no deposit_no payee amount
1 1 Crosby $100.00
2 1 Stills $200.00
3 1 Nash $300.00
-- T1

COMMIT;

ERROR:  could not serialize access due to read/write dependencies among transactions
DETAIL:  Reason code: Canceled on identification as a pivot, during commit attempt.
HINT:  The transaction might succeed if retried.
-- T3

COMMIT; -- コミット処理が成功する。

トランザクション間に T1 ⇒ T2 ⇒ T3 ⇒ T1 という順序関係が発生したため、シリアライズすることができず、T1 のコミットに失敗しました。

トランザクションの順序関係に矛盾が生じていることは、T1とT3のSELECT結果が異なることからわかります。 T1 ⇒ T3 が成り立つのであれば、T3にはT1のINSERTが反映されているはずですが、実際には反映されていません。

T2とT3の間にT1がコミットする

オリジナルではT3の SELECT 実行後にT1がコミットを実行(そして失敗)していましたが、T1のコミットがT3の前であればどうであったかを確認します。

BEGIN; -- T1

INSERT INTO receipt
  (deposit_no, payee, amount)
  VALUES
  (
    (SELECT deposit_no FROM control),
    'Young', '100'
  );

SELECT * FROM receipt;
receipt_no deposit_no payee amount
1 1 Crosby $100.00
2 1 Stills $200.00
3 1 Nash $300.00
4 1 Young $100.00
BEGIN; -- T2

SELECT deposit_no FROM control;
deposit_no
1
-- T2

UPDATE control SET deposit_no = 2;

COMMIT; -- コミット処理が成功する。
-- T1

COMMIT; -- コミット処理が成功する。
BEGIN;  -- T3

SELECT * FROM receipt WHERE deposit_no = 1;
receipt_no deposit_no payee amount
1 1 Crosby $100.00
2 1 Stills $200.00
3 1 Nash $300.00
4 1 Young $100.00
-- T3

COMMIT; -- コミット処理が成功する。

トランザクションを T1 ⇒ T2 ⇒ T3 という順序関係で解決することができるため、いずれもコミット処理が成功しました。

T1のコミット前にT3がコミットする

オリジナルではT3のコミットが一番最後でしたが、もしT1のコミット前にT3がコミットしたらどうなるかを確認します。

BEGIN; -- T1

INSERT INTO receipt
  (deposit_no, payee, amount)
  VALUES
  (
    (SELECT deposit_no FROM control),
    'Young', '100'
  );

SELECT * FROM receipt;
receipt_no deposit_no payee amount
1 1 Crosby $100.00
2 1 Stills $200.00
3 1 Nash $300.00
4 1 Young $100.00
BEGIN; -- T2

SELECT deposit_no FROM control;
deposit_no
1
-- T2

UPDATE control SET deposit_no = 2;

COMMIT; -- コミット処理が成功する。
BEGIN;  -- T3

SELECT * FROM receipt WHERE deposit_no = 1;
receipt_no deposit_no payee amount
1 1 Crosby $100.00
2 1 Stills $200.00
3 1 Nash $300.00
-- T3

COMMIT; -- コミット処理が成功する。
-- T1

COMMIT;

ERROR:  could not serialize access due to read/write dependencies among transactions
DETAIL:  Reason code: Canceled on identification as a pivot, during commit attempt.
HINT:  The transaction might succeed if retried.

T3のコミット処理は成功し、T1のコミット処理を失敗させることで、T2 ⇒ T3 となりました。T3のコミット処理を失敗させて、T1のコミット処理を成功させることで、T1 ⇒ T2 にすることもできたはずですが、そうなりませんでした。

T1のコミット前にT3がロールバックする

前の例ではT1のコミット前にT3がコミットすることで、T1のコミットが失敗しました。ではT3がロールバックすればT3はなかったことになり、T1のコミットは成功するのでしょうか?

BEGIN; -- T1

INSERT INTO receipt
  (deposit_no, payee, amount)
  VALUES
  (
    (SELECT deposit_no FROM control),
    'Young', '100'
  );

SELECT * FROM receipt;
receipt_no deposit_no payee amount
1 1 Crosby $100.00
2 1 Stills $200.00
3 1 Nash $300.00
4 1 Young $100.00
BEGIN; -- T2

SELECT deposit_no FROM control;
deposit_no
1
-- T2

UPDATE control SET deposit_no = 2;

COMMIT; -- コミット処理が成功する。
BEGIN;  -- T3

SELECT * FROM receipt WHERE deposit_no = 1;
receipt_no deposit_no payee amount
1 1 Crosby $100.00
2 1 Stills $200.00
3 1 Nash $300.00
-- T3

ROLLBACK; -- ロールバック処理が成功する。
-- T1

COMMIT;

ERROR:  could not serialize access due to read/write dependencies among transactions
DETAIL:  Reason code: Canceled on identification as a pivot, during commit attempt.
HINT:  The transaction might succeed if retried.

T3がロールバックした後にT1がコミットしても、T1のコミットは結局失敗しました。不思議に思えますが、全体でみればT2のみ実行されたことになり、シリアライズは成立しています。推測となりますが、このケースで本来は成功してもよいT1のコミットが失敗するのは PostgreSQL の SSI の設計もしくは実装上の制約と思われます。

T3を遅延可能な読み取り専用トランザクションで実行する

処理時間が長く、途中での失敗を避けたい処理(例:締め処理)であれば、「遅延可能な読み取り専用トランザクション」内で実行することが一つの手法となります。
マニュアルから該当の記述を引用します。

DEFERRABLEトランザクション属性は、トランザクションがSERIALIZABLEかつREAD ONLYである場合のみ効果があります。 あるトランザクションでこれら3つの属性がすべて選択されている場合、最初にスナップショットを獲得する時にブロックされる可能性があります。 その後、そのトランザクションをSERIALIZABLEトランザクションの通常のオーバーヘッドを伴わず、またシリアライズ処理の失敗を引き起こす恐れやシリアライズ処理の失敗によりキャンセルされる恐れもなく実行することができます。 これは時間がかかるレポート処理やバックアップによく適しています。

SET TRANSACTION
BEGIN; -- T1

INSERT INTO receipt
  (deposit_no, payee, amount)
  VALUES
  (
    (SELECT deposit_no FROM control),
    'Young', '100'
  );

SELECT * FROM receipt;
receipt_no deposit_no payee amount
1 1 Crosby $100.00
2 1 Stills $200.00
3 1 Nash $300.00
4 1 Young $100.00
BEGIN; -- T2

SELECT deposit_no FROM control;
deposit_no
1
-- T2

UPDATE control SET deposit_no = 2;

COMMIT; -- コミット処理が成功する。
BEGIN;  -- T3

SET TRANSACTION ISOLATION LEVEL SERIALIZABLE, READ ONLY, DEFERRABLE; -- 遅延可能な読み取り専用トランザクション

SELECT * FROM receipt WHERE deposit_no = 1;
-- レスポンスが保留される。
-- T1

COMMIT; -- コミット処理が成功する。

ここでT3のレスポンスが返ってきます。

receipt_no deposit_no payee amount
1 1 Crosby $100.00
2 1 Stills $200.00
3 1 Nash $300.00
4 1 Young $100.00
-- T3

COMMIT; -- コミット処理が成功する。

T3のSELECTのレスポンスがT1のコミット処理を待ち合わせたことで、T1 ⇒ T2 ⇒ T3 の順序関係となり、全てのトランザクションが成功しました。

まとめ

リアライザブル分離レベルではトランザクションシリアライズに実行されることを保証しています。ただ、PostgreSQLがその保証を満たすためにどのトランザクションを失敗させるかを事前に知ることは困難です。また、トランザクションロールバックしても、後続の処理に影響を与える場合があります。実行する処理の特性をふまえ、遅延可能な読み取り専用トランザクションで実行するなどの対応が必要です。

Bitmap Index Scan の後の Bitmap Heap Scan でRecheck処理が行われることの解説

はじめに

PostgreSQL の実行計画において、Bitmap Index Scan の後に実行される Bitmap Heap Scan で "Recheck cond" と出力されます。Index Scan をしているにも関わらず、なぜ Heap Scan でインデックスの検索条件を再チェックする必要があるのか解説します。

事前に以下の資料を一読頂くことをお勧めします。
Explain説明資料(第20回しくみ勉強会).pdf
特に「Bitmap Scanについて」「work_mem と lossy storage」をご確認ください。

本エントリの内容を適用できるPostgreSQLのバージョン範囲を特定できていませんが、9.4系や10系で適用できることを確認しています。実際にはもっと幅広く適用できると思われます。

解説編

本件に関し、海外のQAサイトで丁寧な解説がありました。
postgresql - "Recheck Cond:" line in query plans with a bitmap index scan - Database Administrators Stack Exchange
解説編ではこの回答をベースにポイントをまとめます。

exact と lossy モード

Bitmap Index Scan の結果(検索条件に合致するインデックスが指し示す行の情報)をビットマップに格納する際、exact と lossy モードの2種類が用いられます。exact モードが優先されます。

exact モードは行の位置を示すTIDをビットマップに格納します。このため、ビットマップから該当行のデータへ直接アクセスすることができます。

lossy モードは該当行が格納されているページ番号をビットマップに格納します。このため、ビットマップから該当行が含まれているページへアクセスすることはできますが、そのページに含まれているどの行が条件に合致するかを改めて検証する必要があります。これが、Bitmap Heap Scan のRecheck処理 "Recheck Cond" ということです。

exact モードから lossy モードへの移行

ビットマップが work_mem に収まりきらなくなると、exact モードから lossy モードへ移行します。exact モードが行単位の情報を保持するのに対し、lossy モードはページ単位の情報を保持します。lossy モードは情報の粒度が荒い分、ビットマップによるメモリ領域の消費を抑えることができますが、Recheck処理が必要になるというトレードオフが発生します。

exact モードから lossy モードへの移行はページ単位で行われます。ビットマップのサイズが十分に小さくなれば、移行処理はストップします。つまり、ビットマップ内でexact モードと lossy モードの情報が共存することになります。

実行計画での確認方法

Bitmap Heap Scan がどのモードで実行されたかは EXPLAIN ANALYZE でわかります。

Bitmap Index Scan の結果を受けて Bitmap Heap Scan が行われますが、Bitmap Heap Scan の詳細に Heap Blocks (読み込んだブロック数)が表示されます。ブロックに exact モードでアクセスすれば exact、lossy モードでアクセスすれば lossy と区別して表示されます。

なお、ページとブロックの用語の使い分けは以下の通りです。

一応、ストレージ上のリレーションのデータ切片が「ブロック」で、メモリ上のデータ切片が「ページ」「バッファ」と呼び分けるが、8 KiB は同一のデータ構造を持っているのでブロックとページ(バッファ)は論理的に同一である。

PostgreSQL のテーブルとブロックのデータ構造

Heap Blocks の表示で exact と lossy が同時に表示されることがあります。これは exact モードと lossy モードの情報が共存していることを指し示しています。

lossy モードへの移行が行われなくても、実行計画には "Recheck Cond" が出力されます。これは lossy モードへの移行が実行時に動的に行われ、実行計画の段階でRecheck処理が行われるか否かを知ることができないためです。

挙動確認編

work_mem が不足すると exact モードから lossy モードへの移行が起こることを PostgreSQL 10.3 で検証しました。

CREATE TABLE recheck_test(key serial PRIMARY KEY, value integer);

INSERT INTO recheck_test(value)
  SELECT generate_series(1, 1000000)%10;

CREATE INDEX value_idx ON recheck_test(value);

VACUUM ANALYZE;

SET enable_indexonlyscan TO off;

SET work_mem = 4096; -- 4096kB

EXPLAIN ANALYZE SELECT count(*) FROM recheck_test WHERE value=1;
                                                             QUERY PLAN
------------------------------------------------------------------------------------------------------------------------------------
 Aggregate  (cost=7788.08..7788.09 rows=1 width=8) (actual time=28.944..28.944 rows=1 loops=1)
   ->  Bitmap Heap Scan on recheck_test  (cost=1868.58..7538.99 rows=99633 width=0) (actual time=5.409..21.428 rows=100000 loops=1)
         Recheck Cond: (value = 1)
         Heap Blocks: exact=4425
         ->  Bitmap Index Scan on value_idx  (cost=0.00..1843.67 rows=99633 width=0) (actual time=4.939..4.939 rows=100000 loops=1)
               Index Cond: (value = 1)
 Planning time: 0.149 ms
 Execution time: 28.979 ms
(8 rows)

work_mem が十分にあれば exact モードのみとなります。
"Recheck Cond"が出力されていますが、Recheck処理を行いません。

SET work_mem = 64; -- 64kB

EXPLAIN ANALYZE SELECT count(*) FROM recheck_test WHERE value=1;
                                                             QUERY PLAN
------------------------------------------------------------------------------------------------------------------------------------
 Aggregate  (cost=7788.08..7788.09 rows=1 width=8) (actual time=96.134..96.134 rows=1 loops=1)
   ->  Bitmap Heap Scan on recheck_test  (cost=1868.58..7538.99 rows=99633 width=0) (actual time=5.812..88.340 rows=100000 loops=1)
         Recheck Cond: (value = 1)
         Rows Removed by Index Recheck: 733871
         Heap Blocks: exact=817 lossy=3608
         ->  Bitmap Index Scan on value_idx  (cost=0.00..1843.67 rows=99633 width=0) (actual time=5.724..5.724 rows=100000 loops=1)
               Index Cond: (value = 1)
 Planning time: 0.077 ms
 Execution time: 96.168 ms
(9 rows)

work_mem が足りないため、ビットマップの一部が lossy モードとなりました。Recheck処理が行われ、その結果削除された行数が "Rows Removed by Index Recheck" として出力されています。

work_mem が十分にある場合と比較すると、Bitmap Heap Scan で読み込んだブロック数は同じですが、処理時間は延びています。これはRecheck処理によるものと思われます。

実装確認編

ビットマップの構造を PostgreSQL 10.3 のコードで確認しました。
https://www.postgresql.org/ftp/source/v10.3/
src/backend/nodes/tidbitmap.c

PostgreSQLはテーブルとインデックスをページの集まりとして格納しています。このページの集まりがファイルとして格納されます。
物理構造の詳細は「内部構造から学ぶPostgreSQL 設計・運用計画の鉄則」の「6.1 各種ファイルのレイアウトとアクセス」を参照ください。

Bitmap Index Scan ではデータの格納にビットマップを利用します。このビットマップは PagetableEntry 構造体内に bitmapword として定義されています。ソースコードのコメントにもある通り、PagetableEntry 構造体は BlockNumber (ページ番号)をキーとしたハッシュテーブルに格納されます。

typedef struct PagetableEntry
{
	BlockNumber blockno;		/* page number (hashtable key) */
	char		status;			/* hash entry status */
	bool		ischunk;		/* T = lossy storage, F = exact */
	bool		recheck;		/* should the tuples be rechecked? */
	bitmapword	words[Max(WORDS_PER_PAGE, WORDS_PER_CHUNK)];
} PagetableEntry;

exact モードは行の位置を示すTIDをビットマップに格納します。TIDを BlockNumber と OffsetNumber に分け、同じ BlockNumber を持つ PagetableEntry 構造体の ビットマップ bitmapword に OffsetNumber の指し示すビット位置を書き込みます。

lossy モードは PagetableEntry 構造体に BlockNumber のみを格納しますが、exact モードと同じデータ構造を利用するため、以下の要領で格納します。
pageno = 該当行の存在するBlockNumber
bitno = pageno % PAGES_PER_CHUNK;
chunk_pageno = pageno - bitno;
chunk_pageno と同じ BlockNumber を持つ PagetableEntry 構造体の ビットマップ bitmapword に bitno の指し示すビット位置を書き込みます。

こうすることで同じハッシュテーブル内に exact モードと lossy モードのデータを混在させることが可能となっています。コメントから説明を引用します。

We actually store both exact pages and lossy chunks in the same hash table, using identical data structures. (This is because the memory management for hashtables doesn't easily/efficiently allow space to be transferred easily from one hashtable to another.)

簡略化した例を示します。以下の表記及び定義とします。
{BlockNumber, OffsetNumber}
PAGES_PER_CHUNK = 5

{0, 1}, {6, 0}を格納します。

blockno ischunk words[0] words[1] words[2] words[3] words[4]
0 F 0 1 0 0 0
6 F 1 0 0 0 0

{3, 4}を追加する際に lossy モードへ移行が発生したとします。
bitno = pageno % PAGES_PER_CHUNK = 3 % 5 = 3
chunk_pageno = pageno - bitno = 3 - 3 = 0

blockno ischunk words[0] words[1] words[2] words[3] words[4]
0 T 1 0 0 1 0
6 F 1 0 0 0 0

blockno=0のwords[0]が0→1に、words[1]が1→0になっています。これは lossy モードに移行することで、ビットマップの情報が行からページへと転換したためです。これは転換前に格納されていた{0, 1}のBlockNumberに対応しています。OffsetNumberの情報は失われました。
bitno = pageno % PAGES_PER_CHUNK = 0 % 5 = 0
chunk_pageno = pageno - bitno = 0 - 0 = 0

おわりに

Bitmap Heap Scan でRecheck処理が行われるか否かは実行するまで分かりません。このため、EXPLAIN ではなく EXPLAIN ANALYZE で確認する必要があります。

Recheck処理が行われると、その分パフォーマンスが落ちます。これを避けるためには適切な work_mem の値を設定します。